A GIO transport

Mattias Eriksson snaggen at acc.umu.se
Sat May 1 15:06:49 BST 2010


Hi, 

Here is a first attempt att creating a gio transport for bzr. The reason
I wrote this is that I'd lite to store a bzr repo on my Apple
TimeCapsule that only provides afp and smb file access. So by using gio
as a transport it should be possible to use all protocols supported by
the gnome gio library (currently ssh/sftp, ftp, dav, smb, obex I think),
but I have only tested it with smb and ssh. Note that this is not a
merge request since I think there are still some work to do.

Some things I need help with:
     1. Authentication, currently this is handled with no interaction
        with the built in authentication mechanisms, since I do not know
        how that handles asking for domain for protocols that requires
        this (smb). I'm also thinking that i might be good to hook in to
        gnome-keyring if available (since the gio transport already
        depends on gio and gtk), does this sound like a good idea or
        just crazy?
     2. This module should only be loaded if gio is available I think,
        how can this be done (or only if the url starts with gio+)? or
        is this how things work already?
     3. Tests, I can't seem to figure out how to create a working
        testsuite. I tried to base it on the sftp tests but the
        authentication doesn't work for some reason. 
     4. The mounting of the volume is done asynchronously, so I
        currently have a not to pretty loop waiting for this to be done.
        Is there some fancy way to do this in python?

so what do you think? 
Also, I'm quite new to python so any feedback on codingstyle aso is also
welcome.

//Mattias

-------------- next part --------------
# Bazaar merge directive format 2 (Bazaar 0.90)
# revision_id: snaggen at acc.umu.se-20100501133422-yk7zqgci3qnp39v6
# target_branch: lp:bzr
# testament_sha1: fc94ad06584d9b8d7a2a682fc6618f4f697c8efe
# timestamp: 2010-05-01 15:52:20 +0200
# source_branch: lp:~snaggen/bzr/gio-transport
# base_revision_id: pqm at pqm.ubuntu.com-20100429073011-5f4kzm2wpojq9we1
# 
# Begin patch
=== modified file 'bzrlib/transport/__init__.py'
--- bzrlib/transport/__init__.py	2010-04-16 07:56:51 +0000
+++ bzrlib/transport/__init__.py	2010-05-01 12:50:48 +0000
@@ -1740,6 +1740,19 @@
 register_transport_proto('aftp://', help="Access using active FTP.")
 register_lazy_transport('aftp://', 'bzrlib.transport.ftp', 'FtpTransport')
 
+register_transport_proto('gio+smb://', help="Access using SMB through GIO.")
+register_lazy_transport('gio+smb://', 'bzrlib.transport.gio', 'GioTransport')
+register_transport_proto('gio+dav://', help="Access using DAV through GIO.")
+register_lazy_transport('gio+dav://', 'bzrlib.transport.gio', 'GioTransport')
+register_transport_proto('gio+obex://', help="Access using OBEX through GIO.")
+register_lazy_transport('gio+obex://', 'bzrlib.transport.gio', 'GioTransport')
+register_transport_proto('gio+ftp://', help="Access using FTP through GIO.")
+register_lazy_transport('gio+ftp://', 'bzrlib.transport.gio', 'GioTransport')
+register_transport_proto('gio+ssh://', help="Access using SSH through GIO.")
+register_lazy_transport('gio+ssh://', 'bzrlib.transport.gio', 'GioTransport')
+register_transport_proto('gio+sftp://', help="Access using SFTP through GIO.")
+register_lazy_transport('gio+sftp://', 'bzrlib.transport.gio', 'GioTransport')
+
 try:
     import kerberos
     kerberos_available = True

=== added directory 'bzrlib/transport/gio'
=== added file 'bzrlib/transport/gio/__init__.py'
--- bzrlib/transport/gio/__init__.py	1970-01-01 00:00:00 +0000
+++ bzrlib/transport/gio/__init__.py	2010-05-01 13:34:22 +0000
@@ -0,0 +1,517 @@
+# Copyright (C) 2010 Mattias Eriksson, Canonical
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+"""Implementation of Transport over gio.
+
+Written by Mattias Eriksson <snaggen at acc.umu.se> based on the ftp transport.
+
+It provides the gio+XXX:// protocols where XXX is any of the protocols 
+supported by gio.
+"""
+
+from cStringIO import StringIO
+import getpass
+import os
+import random
+import socket
+import stat
+
+import time
+import gio
+import gtk
+import sys
+import getpass
+import urlparse
+
+from bzrlib import (
+    config,
+    errors,
+    osutils,
+    urlutils,
+    )
+from bzrlib.trace import mutter, warning
+from bzrlib.transport import (
+    FileStream,
+    ConnectedTransport,
+    _file_streams,
+    Server,
+    )
+
+class GioFileStream(FileStream):
+    """A file stream object returned by open_write_stream.
+
+    This version uses GIO to perform writes.
+    """
+
+    def __init__(self, transport, relpath):
+        FileStream.__init__(self, transport, relpath)
+        self.gio_file = transport._get_GIO(relpath)
+        self.stream = self.gio_file.create()
+
+    def _close(self):
+        self.stream.close()
+
+    def write(self, bytes):
+        try:
+            #Using pump_string_file seems to make things crash
+            osutils.pumpfile(StringIO(bytes), self.stream)
+        except gio.Error, e:
+            #self.transport._translate_gio_error(e,self.relpath)
+            raise errors.BzrError(str(e))
+
+class GioStatResult(object):
+
+    def __init__(self, f):
+       info = f.query_info('standard::size,standard::type')
+       self.st_size = info.get_size()
+       #mutter("stat size is %d bytes" % self.st_size)
+       type = info.get_file_type();
+       if (type == gio.FILE_TYPE_REGULAR):
+           self.st_mode = stat.S_IFREG
+       elif type == gio.FILE_TYPE_DIRECTORY:
+           self.st_mode = stat.S_IFDIR
+
+
+class GioTransport(ConnectedTransport):
+    """This is the transport agent for gio+XXX:// access."""
+
+    def __init__(self, base, _from_transport=None):
+        self.mounted = 0;
+        """Set the base path where files will be stored."""
+        if not base.startswith('gio+'):
+            raise ValueError(base)
+
+        super(GioTransport, self).__init__(base,
+            _from_transport=_from_transport)
+       
+        #Remove the username and password from the url we send to GIO
+        (scheme, user, password, host, port, path) = urlutils.parse_url(base[len('gio+'):])
+        netloc = host
+        if port:
+            netloc = "%s:%s" % (host,port)
+        u = (scheme, netloc, path,'','','')
+        self.url = urlparse.urlunparse(u)
+
+    def _relpath_to_url(self, relpath):
+        full_url = urlutils.join(self.url, relpath)
+        return full_url
+
+    def _get_GIO(self, relpath):
+        """Return the ftplib.GIO instance for this object."""
+        # Ensures that a connection is established
+        connection = self._get_connection()
+        if connection is None:
+            # First connection ever
+            connection, credentials = self._create_connection()
+            self._set_connection(connection, credentials)
+        fileurl = self._relpath_to_url(relpath)
+        file = gio.File(fileurl);
+        return file
+
+    #really use bzrlib.auth get_password for this
+    #or possibly better gnome-keyring?
+    def _ask_password_cb(self, op, message, default_user, default_domain, flags):
+        print message
+        if flags & gio.ASK_PASSWORD_NEED_USERNAME:
+            print "Username: "
+            user = sys.stdin.readline()
+            if user[-1] == '\n':
+                user = user[:-1]
+            op.set_username(user)
+        if flags & gio.ASK_PASSWORD_NEED_DOMAIN:
+            print "Domain: ";
+            domain = sys.stdin.readline()
+            if domain[-1] == '\n':
+                domain = domain[:-1]
+            op.set_domain(domain)
+        if flags & gio.ASK_PASSWORD_NEED_PASSWORD:
+            print "Password: ";
+            isatty = getattr(sys.stdin, 'isatty', None)
+            if isatty is not None and isatty():
+                # getpass() ensure the password is not echoed and other
+                # cross-platform niceties
+                password = getpass.getpass('')
+            else:
+                # echo doesn't make sense without a terminal
+                password = sys.stdin.readline()
+                if not password:
+                    password = None
+                elif password[-1] == '\n':
+                    password = password[:-1]
+            op.set_password(password)
+        op.reply(gio.MOUNT_OPERATION_HANDLED)
+    
+    def _mount_done_cb(self, obj, res):
+        try:
+            obj.mount_enclosing_volume_finish(res)
+            self.mounted = 1;
+        except gio.Error, e:
+            print "ERROR: ", e
+            self.mounted = -1
+    
+    def _create_connection(self, credentials=None):
+        
+        if credentials is None:
+            user, password = self._user, self._password
+        else:
+            user, password = credentials
+
+        try:
+            connection = gio.File(self.url);
+            mount = None
+            try:
+                mount = connection.find_enclosing_mount()
+                if mount != None:
+                    self.mounted = 1
+            except gio.Error, e:
+                if (e.code == gio.ERROR_NOT_MOUNTED):
+                    op = gio.MountOperation()
+                    if user:
+                        op.set_username(user)
+                    if password:
+                        op.set_password(password)
+                    op.connect('ask-password', self._ask_password_cb)  
+                    m = connection.mount_enclosing_volume(op, self._mount_done_cb)
+                    while self.mounted==0:
+                        if gtk.events_pending():
+                            gtk.main_iteration()
+                        time.sleep(0.1)
+                else:
+                    mounted = 1
+        except gio.Error, e:
+            raise errors.TransportError(msg="Error setting up connection:"
+                                        " %s" % str(e), orig_error=e)
+        return connection, (user, password)
+
+    def _reconnect(self):
+        """Create a new connection with the previously used credentials"""
+        credentials = self._get_credentials()
+        connection, credentials = self._create_connection(credentials)
+        self._set_connection(connection, credentials)
+
+    def _remote_path(self, relpath):
+        relative = urlutils.unescape(relpath).encode('utf-8')
+        remote_path = self._combine_paths(self._path, relative)
+        return remote_path
+
+    def has(self, relpath):
+        """Does the target location exist?"""
+        try:
+            mutter('GIO has check: %s' % relpath)
+            f = self._get_GIO(relpath)
+            st = GioStatResult(f)
+            if stat.S_ISREG(st.st_mode) or stat.S_ISDIR(st.st_mode) :
+                return True
+            return False
+        except gio.Error, e:
+            if e.code == gio.ERROR_NOT_FOUND:
+                return False
+            else:
+                self._translate_gio_error(e, relpath)
+                
+
+    def get(self, relpath, decode=False, retries=0):
+        """Get the file at the given relative path.
+
+        :param relpath: The relative path to the file
+        :param retries: Number of retries after temporary failures so far
+                        for this operation.
+
+        We're meant to return a file-like object which bzr will
+        then read from. For now we do this via the magic of StringIO
+        """
+        try:
+            mutter("GIO get: %s" % relpath)
+            f = self._get_GIO(relpath)
+            fin = f.read()
+            buf = fin.read()
+            fin.close()
+            ret = StringIO(buf)
+            return ret
+        except gio.Error, e:
+            #Currently no retries code is implemented, don't 
+            #know if that is needed or is gio makes things more 
+            #reliable
+            self._translate_gio_error(e, relpath)
+
+    def put_file(self, relpath, fp, mode=None):
+        """Copy the file-like object into the location.
+
+        :param relpath: Location to put the contents, relative to base.
+        :param fp:       File-like or string object.
+        """
+        mutter("GIO put_file %s" % relpath)
+        tmppath = '%s.tmp.%.9f.%d.%d' % (relpath, time.time(),
+                    os.getpid(), random.randint(0,0x7FFFFFFF))
+        f = None
+        fout = None
+        try:
+            try:
+                f = self._get_GIO(tmppath)
+                fout = f.create()
+                closed = False
+                length = self._pump(fp,fout)
+                fout.close()
+                closed = True
+                self.stat(tmppath)
+                dest = self._get_GIO(relpath)
+                f.move(dest, flags=gio.FILE_COPY_OVERWRITE)
+
+                if mode is not None:
+                    self._setmode(relpath, mode)
+                #mutter("GIO put_file wrote %d bytes", length);
+                return length
+            except gio.Error, e:
+                self._translate_gio_error(e, relpath)
+        except Exception, e:
+            import traceback
+            mutter(traceback.format_exc())
+            
+            try:
+                if not closed and fout is not None:
+                    fout.close()
+                if f is not None:
+                    f.delete()
+            except:
+                # raise the saved except
+                raise e
+            # raise the original with its traceback if we can.
+            raise
+
+    def mkdir(self, relpath, mode=None):
+        """Create a directory at the given path."""
+        try:
+            mutter("GIO mkdir: %s" % relpath)
+            f = self._get_GIO(relpath)
+            f.make_directory()
+            self._setmode(relpath, mode)
+        except gio.Error, e:
+            self._translate_gio_error(e, relpath)
+
+    def open_write_stream(self, relpath, mode=None):
+        """See Transport.open_write_stream."""
+        mutter("GIO open_write_stream %s" % relpath)
+        if mode is not None:
+            self._setmode(relpath, mode)
+        result = GioFileStream(self, relpath)
+        _file_streams[self.abspath(relpath)] = result
+        return result
+
+    def recommended_page_size(self):
+        """See Transport.recommended_page_size().
+
+        For FTP we suggest a large page size to reduce the overhead
+        introduced by latency.
+        """
+        mutter("GIO recommended_page")
+        return 64 * 1024
+
+    def rmdir(self, relpath):
+        """Delete the directory at rel_path"""
+        try:
+            mutter("GIO rmdir %s" % relpath)
+            st = self.stat(relpath)
+            if stat.S_ISDIR(st.st_mode):
+                f = self._get_GIO(relpath)
+                f.delete()
+            else:
+                raise errors.NotADirectory(relpath)
+        except gio.Error, e:
+            self._translate_gio_error(e, relpath)
+        except errors.NotADirectory, e:
+            #just pass it forward
+            raise e
+        except Exception, e:
+            mutter('failed to rmdir %s: %s' % (relpath,e))
+            raise errors.PathError(relpath)
+
+    def append_file(self, relpath, file, mode=None):
+        """Append the text in the file-like object into the final
+        location.
+        """
+        #GIO append_to seems not to append but to truncate
+        #Work around this.
+        mutter("GIO append_file: %s" % relpath)
+        tmppath = '%s.tmp.%.9f.%d.%d' % (relpath, time.time(),
+                    os.getpid(), random.randint(0,0x7FFFFFFF))
+        try:
+            result = 0
+            fo = self._get_GIO(tmppath)
+            fi = self._get_GIO(relpath)
+            fout = fo.create()
+            try: 
+                info = GioStatResult(fi)
+                result = info.st_size
+                fin = fi.read()
+                length = self._pump(fin,fout)
+                fin.close()
+            except gio.Error, e:
+                if e.code != gio.ERROR_NOT_FOUND:
+                    self._translate_gio_error(e, relpath)
+            length = self._pump(file,fout)
+            fout.close()
+            fo.move(fi, flags=gio.FILE_COPY_OVERWRITE)
+            info = GioStatResult(fi)
+            if info.st_size != result + length: 
+                raise errors.BzrError("Failed to append size after (%d) is not beforfe (%d) + written (%d) total (%d)" % (info.st_size, result, length, result + length));
+            return result
+        except gio.Error, e:
+            self._translate_gio_error(e, relpath)
+
+    def _setmode(self, relpath, mode):
+        """Set permissions on a path.
+
+        Only set permissions on Unix systems
+        """
+        mutter("GIO _setmode %s" % relpath)
+        if mode:
+            try:
+                f = self._get_GIO(relpath)
+                f.set_attribute_uint32(gio.FILE_ATTRIBUTE_UNIX_MODE,mode)
+            except gio.Error, e:
+                if e.code == gio.ERROR_NOT_SUPPORTED:
+                    # Command probably not available on this server
+                    mutter("GIO Could not set permissions to %s on %s. %s",
+                        oct(mode), self._remote_path(relpath), str(e))
+                else:
+                    self._translate_gio_error(e, relpath)
+
+    def rename(self, rel_from, rel_to):
+        """Rename without special overwriting"""
+        try:
+            mutter("GIO move (rename): %s => %s", rel_from, rel_to)
+            f = self._get_GIO(rel_from)
+            t = self._get_GIO(rel_to)
+            f.move(t)
+        except gio.Error, e:
+            self._translate_gio_error(e, relfrom)
+
+    def move(self, rel_from, rel_to):
+        """Move the item at rel_from to the location at rel_to"""
+        try:
+            mutter("GIO move: %s => %s", rel_from, rel_to)
+            f = self._get_GIO(rel_from)
+            t = self._get_GIO(rel_to)
+            f.move(t, flags=gio.FILE_COPY_OVERWRITE)
+        except gio.Error, e:
+            self._translate_gio_error(e, relfrom)
+
+    def delete(self, relpath):
+        """Delete the item at relpath"""
+        try:
+            mutter("GIO delete: %s", relpath)
+            f = self._get_GIO(relpath)
+            f.delete()
+        except gio.Error, e:
+            self._translate_gio_error(e, relpath)
+
+    def external_url(self):
+        """See bzrlib.transport.Transport.external_url."""
+        mutter("GIO external_url", self.base)
+        # GIO external url
+        return self.base
+
+    def listable(self):
+        """See Transport.listable."""
+        mutter("GIO listable")
+        return True
+
+    def list_dir(self, relpath):
+        """See Transport.list_dir."""
+        mutter("GIO list_dir")
+        try:
+            entries = []
+            f = self._get_GIO(relpath);
+            children = f.enumerate_children(gio.FILE_ATTRIBUTE_STANDARD_NAME)
+            for child in children:
+                entries.append(urlutils.escape(child.get_name()))
+            return entries
+        except gio.Error, e:
+            self._translate_gio_error(e, relpath)
+
+    def iter_files_recursive(self):
+        """See Transport.iter_files_recursive.
+
+        This is cargo-culted from the SFTP transport"""
+        mutter("GIO iter_files_recursive")
+        queue = list(self.list_dir("."))
+        while queue:
+            relpath = queue.pop(0)
+            st = self.stat(relpath)
+            if stat.S_ISDIR(st.st_mode):
+                for i, basename in enumerate(self.list_dir(relpath)):
+                    queue.insert(i, relpath+"/"+basename)
+            else:
+                yield relpath
+
+    def stat(self, relpath):
+        """Return the stat information for a file."""
+        try:
+            mutter("GIO stat: %s", relpath)
+            f = self._get_GIO(relpath)
+            return GioStatResult(f)
+        except gio.Error, e:
+            self._translate_gio_error(e, relpath, extra='error w/ stat')
+
+    def lock_read(self, relpath):
+        """Lock the given file for shared (read) access.
+        :return: A lock object, which should be passed to Transport.unlock()
+        """
+        mutter("GIO lock_read", relpath)
+        # The old RemoteBranch ignore lock for reading, so we will
+        # continue that tradition and return a bogus lock object.
+        class BogusLock(object):
+            def __init__(self, path):
+                self.path = path
+            def unlock(self):
+                pass
+        return BogusLock(relpath)
+
+    def lock_write(self, relpath):
+        """Lock the given file for exclusive (write) access.
+        WARNING: many transports do not support this, so trying avoid using it
+
+        :return: A lock object, which should be passed to Transport.unlock()
+        """
+        mutter("GIO lock_write", relpath)
+        return self.lock_read(relpath)
+
+    def _translate_gio_error(self, err, path, extra=None):
+        mutter("GIO Error: %s %s" % (str(err), path))
+        if extra is None:
+            extra = str(err)
+        if err.code == gio.ERROR_NOT_FOUND:
+            raise errors.NoSuchFile(path, extra=extra)
+        elif err.code == gio.ERROR_EXISTS:
+            raise errors.FileExists(path, extra=extra)
+        elif err.code == gio.ERROR_NOT_DIRECTORY:
+            raise errors.NotADirectory(path, extra=extra)
+        elif err.code == gio.ERROR_NOT_EMPTY:
+            raise errors.DirectoryNotEmpty(path, extra=extra)
+        elif err.code == gio.ERROR_BUSY:
+            raise errors.ResourceBusy(path, extra=extra)
+        elif err.code == gio.ERROR_PERMISSION_DENIED:
+            raise errors.PermissionDenied(path, extra=extra)
+        else:
+            mutter('unable to understand error for path: %s: %s', path, err)
+            raise err
+
+#def get_test_permutations():
+#    """Return the permutations to be used in testing."""
+#    from bzrlib.tests import stub_sftp
+#    return [(GioTransport, stub_sftp.SFTPAbsoluteServer),
+#            (GioTransport, stub_sftp.SFTPHomeDirServer),
+#            (GioTransport, stub_sftp.SFTPSiblingAbsoluteServer),
+#            ]

# Begin bundle
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


More information about the bazaar mailing list