~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/gio_transport.py

Merge cleanup into texinfo

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2010 Canonical Ltd.
 
2
#
 
3
# This program is free software; you can redistribute it and/or modify
 
4
# it under the terms of the GNU General Public License as published by
 
5
# the Free Software Foundation; either version 2 of the License, or
 
6
# (at your option) any later version.
 
7
#
 
8
# This program is distributed in the hope that it will be useful,
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
# GNU General Public License for more details.
 
12
#
 
13
# You should have received a copy of the GNU General Public License
 
14
# along with this program; if not, write to the Free Software
 
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
 
16
#
 
17
# Author: Mattias Eriksson
 
18
 
 
19
"""Implementation of Transport over gio.
 
20
 
 
21
Written by Mattias Eriksson <snaggen@acc.umu.se> based on the ftp transport.
 
22
 
 
23
It provides the gio+XXX:// protocols where XXX is any of the protocols
 
24
supported by gio.
 
25
"""
 
26
from cStringIO import StringIO
 
27
import getpass
 
28
import os
 
29
import random
 
30
import socket
 
31
import stat
 
32
import urllib
 
33
import time
 
34
import sys
 
35
import getpass
 
36
import urlparse
 
37
 
 
38
from bzrlib import (
 
39
    config,
 
40
    errors,
 
41
    osutils,
 
42
    urlutils,
 
43
    debug,
 
44
    ui,
 
45
    )
 
46
from bzrlib.trace import mutter, warning
 
47
from bzrlib.transport import (
 
48
    FileStream,
 
49
    ConnectedTransport,
 
50
    _file_streams,
 
51
    Server,
 
52
    )
 
53
 
 
54
from bzrlib.tests.test_server import TestServer
 
55
 
 
56
try:
 
57
    import glib
 
58
except ImportError, e:
 
59
    raise errors.DependencyNotPresent('glib', e)
 
60
try:
 
61
    import gio
 
62
except ImportError, e:
 
63
    raise errors.DependencyNotPresent('gio', e)
 
64
 
 
65
 
 
66
class GioLocalURLServer(TestServer):
 
67
    """A pretend server for local transports, using file:// urls.
 
68
 
 
69
    Of course no actual server is required to access the local filesystem, so
 
70
    this just exists to tell the test code how to get to it.
 
71
    """
 
72
 
 
73
    def start_server(self):
 
74
        pass
 
75
 
 
76
    def get_url(self):
 
77
        """See Transport.Server.get_url."""
 
78
        return "gio+" + urlutils.local_path_to_url('')
 
79
 
 
80
 
 
81
class GioFileStream(FileStream):
 
82
    """A file stream object returned by open_write_stream.
 
83
 
 
84
    This version uses GIO to perform writes.
 
85
    """
 
86
 
 
87
    def __init__(self, transport, relpath):
 
88
        FileStream.__init__(self, transport, relpath)
 
89
        self.gio_file = transport._get_GIO(relpath)
 
90
        self.stream = self.gio_file.create()
 
91
 
 
92
    def _close(self):
 
93
        self.stream.close()
 
94
 
 
95
    def write(self, bytes):
 
96
        try:
 
97
            #Using pump_string_file seems to make things crash
 
98
            osutils.pumpfile(StringIO(bytes), self.stream)
 
99
        except gio.Error, e:
 
100
            #self.transport._translate_gio_error(e,self.relpath)
 
101
            raise errors.BzrError(str(e))
 
102
 
 
103
 
 
104
class GioStatResult(object):
 
105
 
 
106
    def __init__(self, f):
 
107
        info = f.query_info('standard::size,standard::type')
 
108
        self.st_size = info.get_size()
 
109
        type = info.get_file_type()
 
110
        if (type == gio.FILE_TYPE_REGULAR):
 
111
            self.st_mode = stat.S_IFREG
 
112
        elif type == gio.FILE_TYPE_DIRECTORY:
 
113
            self.st_mode = stat.S_IFDIR
 
114
 
 
115
 
 
116
class GioTransport(ConnectedTransport):
 
117
    """This is the transport agent for gio+XXX:// access."""
 
118
 
 
119
    def __init__(self, base, _from_transport=None):
 
120
        """Initialize the GIO transport and make sure the url is correct."""
 
121
 
 
122
        if not base.startswith('gio+'):
 
123
            raise ValueError(base)
 
124
 
 
125
        (scheme, netloc, path, params, query, fragment) = \
 
126
                urlparse.urlparse(base[len('gio+'):], allow_fragments=False)
 
127
        if '@' in netloc:
 
128
            user, netloc = netloc.rsplit('@', 1)
 
129
        #Seems it is not possible to list supported backends for GIO
 
130
        #so a hardcoded list it is then.
 
131
        gio_backends = ['dav', 'file', 'ftp', 'obex', 'sftp', 'ssh', 'smb']
 
132
        if scheme not in gio_backends:
 
133
            raise errors.InvalidURL(base,
 
134
                    extra="GIO support is only available for " + \
 
135
                    ', '.join(gio_backends))
 
136
 
 
137
        #Remove the username and password from the url we send to GIO
 
138
        #by rebuilding the url again.
 
139
        u = (scheme, netloc, path, '', '', '')
 
140
        self.url = urlparse.urlunparse(u)
 
141
 
 
142
        # And finally initialize super
 
143
        super(GioTransport, self).__init__(base,
 
144
            _from_transport=_from_transport)
 
145
 
 
146
    def _relpath_to_url(self, relpath):
 
147
        full_url = urlutils.join(self.url, relpath)
 
148
        if isinstance(full_url, unicode):
 
149
            raise errors.InvalidURL(full_url)
 
150
        return full_url
 
151
 
 
152
    def _get_GIO(self, relpath):
 
153
        """Return the ftplib.GIO instance for this object."""
 
154
        # Ensures that a connection is established
 
155
        connection = self._get_connection()
 
156
        if connection is None:
 
157
            # First connection ever
 
158
            connection, credentials = self._create_connection()
 
159
            self._set_connection(connection, credentials)
 
160
        fileurl = self._relpath_to_url(relpath)
 
161
        file = gio.File(fileurl)
 
162
        return file
 
163
 
 
164
    def _auth_cb(self, op, message, default_user, default_domain, flags):
 
165
        #really use bzrlib.auth get_password for this
 
166
        #or possibly better gnome-keyring?
 
167
        auth = config.AuthenticationConfig()
 
168
        (scheme, urluser, urlpassword, host, port, urlpath) = \
 
169
           urlutils.parse_url(self.url)
 
170
        user = None
 
171
        if (flags & gio.ASK_PASSWORD_NEED_USERNAME and
 
172
                flags & gio.ASK_PASSWORD_NEED_DOMAIN):
 
173
            prompt = scheme.upper() + ' %(host)s DOMAIN\username'
 
174
            user_and_domain = auth.get_user(scheme, host,
 
175
                    port=port, ask=True, prompt=prompt)
 
176
            (domain, user) = user_and_domain.split('\\', 1)
 
177
            op.set_username(user)
 
178
            op.set_domain(domain)
 
179
        elif flags & gio.ASK_PASSWORD_NEED_USERNAME:
 
180
            user = auth.get_user(scheme, host,
 
181
                    port=port, ask=True)
 
182
            op.set_username(user)
 
183
        elif flags & gio.ASK_PASSWORD_NEED_DOMAIN:
 
184
            #Don't know how common this case is, but anyway
 
185
            #a DOMAIN and a username prompt should be the
 
186
            #same so I will missuse the ui_factory get_username
 
187
            #a little bit here.
 
188
            prompt = scheme.upper() + ' %(host)s DOMAIN'
 
189
            domain = ui.ui_factory.get_username(prompt=prompt)
 
190
            op.set_domain(domain)
 
191
 
 
192
        if flags & gio.ASK_PASSWORD_NEED_PASSWORD:
 
193
            if user is None:
 
194
                user = op.get_username()
 
195
            password = auth.get_password(scheme, host,
 
196
                    user, port=port)
 
197
            op.set_password(password)
 
198
        op.reply(gio.MOUNT_OPERATION_HANDLED)
 
199
 
 
200
    def _mount_done_cb(self, obj, res):
 
201
        try:
 
202
            obj.mount_enclosing_volume_finish(res)
 
203
            self.loop.quit()
 
204
        except gio.Error, e:
 
205
            self.loop.quit()
 
206
            raise errors.BzrError("Failed to mount the given location: " + str(e));
 
207
 
 
208
    def _create_connection(self, credentials=None):
 
209
        if credentials is None:
 
210
            user, password = self._user, self._password
 
211
        else:
 
212
            user, password = credentials
 
213
 
 
214
        try:
 
215
            connection = gio.File(self.url)
 
216
            mount = None
 
217
            try:
 
218
                mount = connection.find_enclosing_mount()
 
219
            except gio.Error, e:
 
220
                if (e.code == gio.ERROR_NOT_MOUNTED):
 
221
                    self.loop = glib.MainLoop()
 
222
                    ui.ui_factory.show_message('Mounting %s using GIO' % \
 
223
                            self.url)
 
224
                    op = gio.MountOperation()
 
225
                    if user:
 
226
                        op.set_username(user)
 
227
                    if password:
 
228
                        op.set_password(password)
 
229
                    op.connect('ask-password', self._auth_cb)
 
230
                    m = connection.mount_enclosing_volume(op,
 
231
                            self._mount_done_cb)
 
232
                    self.loop.run()
 
233
        except gio.Error, e:
 
234
            raise errors.TransportError(msg="Error setting up connection:"
 
235
                                        " %s" % str(e), orig_error=e)
 
236
        return connection, (user, password)
 
237
 
 
238
    def _reconnect(self):
 
239
        """Create a new connection with the previously used credentials"""
 
240
        credentials = self._get_credentials()
 
241
        connection, credentials = self._create_connection(credentials)
 
242
        self._set_connection(connection, credentials)
 
243
 
 
244
    def _remote_path(self, relpath):
 
245
        relative = urlutils.unescape(relpath).encode('utf-8')
 
246
        remote_path = self._combine_paths(self._path, relative)
 
247
        return remote_path
 
248
 
 
249
    def has(self, relpath):
 
250
        """Does the target location exist?"""
 
251
        try:
 
252
            if 'gio' in debug.debug_flags:
 
253
                mutter('GIO has check: %s' % relpath)
 
254
            f = self._get_GIO(relpath)
 
255
            st = GioStatResult(f)
 
256
            if stat.S_ISREG(st.st_mode) or stat.S_ISDIR(st.st_mode):
 
257
                return True
 
258
            return False
 
259
        except gio.Error, e:
 
260
            if e.code == gio.ERROR_NOT_FOUND:
 
261
                return False
 
262
            else:
 
263
                self._translate_gio_error(e, relpath)
 
264
 
 
265
    def get(self, relpath, decode=False, retries=0):
 
266
        """Get the file at the given relative path.
 
267
 
 
268
        :param relpath: The relative path to the file
 
269
        :param retries: Number of retries after temporary failures so far
 
270
                        for this operation.
 
271
 
 
272
        We're meant to return a file-like object which bzr will
 
273
        then read from. For now we do this via the magic of StringIO
 
274
        """
 
275
        try:
 
276
            if 'gio' in debug.debug_flags:
 
277
                mutter("GIO get: %s" % relpath)
 
278
            f = self._get_GIO(relpath)
 
279
            fin = f.read()
 
280
            buf = fin.read()
 
281
            fin.close()
 
282
            ret = StringIO(buf)
 
283
            return ret
 
284
        except gio.Error, e:
 
285
            #If we get a not mounted here it might mean
 
286
            #that a bad path has been entered (or that mount failed)
 
287
            if (e.code == gio.ERROR_NOT_MOUNTED):
 
288
                raise errors.PathError(relpath,
 
289
                  extra='Failed to get file, make sure the path is correct. ' \
 
290
                  + str(e))
 
291
            else:
 
292
                self._translate_gio_error(e, relpath)
 
293
 
 
294
    def put_file(self, relpath, fp, mode=None):
 
295
        """Copy the file-like object into the location.
 
296
 
 
297
        :param relpath: Location to put the contents, relative to base.
 
298
        :param fp:       File-like or string object.
 
299
        """
 
300
        if 'gio' in debug.debug_flags:
 
301
            mutter("GIO put_file %s" % relpath)
 
302
        tmppath = '%s.tmp.%.9f.%d.%d' % (relpath, time.time(),
 
303
                    os.getpid(), random.randint(0, 0x7FFFFFFF))
 
304
        f = None
 
305
        fout = None
 
306
        try:
 
307
            closed = True
 
308
            try:
 
309
                f = self._get_GIO(tmppath)
 
310
                fout = f.create()
 
311
                closed = False
 
312
                length = self._pump(fp, fout)
 
313
                fout.close()
 
314
                closed = True
 
315
                self.stat(tmppath)
 
316
                dest = self._get_GIO(relpath)
 
317
                f.move(dest, flags=gio.FILE_COPY_OVERWRITE)
 
318
                f = None
 
319
                if mode is not None:
 
320
                    self._setmode(relpath, mode)
 
321
                return length
 
322
            except gio.Error, e:
 
323
                self._translate_gio_error(e, relpath)
 
324
        finally:
 
325
            if not closed and fout is not None:
 
326
                fout.close()
 
327
            if f is not None and f.query_exists():
 
328
                f.delete()
 
329
 
 
330
    def mkdir(self, relpath, mode=None):
 
331
        """Create a directory at the given path."""
 
332
        try:
 
333
            if 'gio' in debug.debug_flags:
 
334
                mutter("GIO mkdir: %s" % relpath)
 
335
            f = self._get_GIO(relpath)
 
336
            f.make_directory()
 
337
            self._setmode(relpath, mode)
 
338
        except gio.Error, e:
 
339
            self._translate_gio_error(e, relpath)
 
340
 
 
341
    def open_write_stream(self, relpath, mode=None):
 
342
        """See Transport.open_write_stream."""
 
343
        if 'gio' in debug.debug_flags:
 
344
            mutter("GIO open_write_stream %s" % relpath)
 
345
        if mode is not None:
 
346
            self._setmode(relpath, mode)
 
347
        result = GioFileStream(self, relpath)
 
348
        _file_streams[self.abspath(relpath)] = result
 
349
        return result
 
350
 
 
351
    def recommended_page_size(self):
 
352
        """See Transport.recommended_page_size().
 
353
 
 
354
        For FTP we suggest a large page size to reduce the overhead
 
355
        introduced by latency.
 
356
        """
 
357
        if 'gio' in debug.debug_flags:
 
358
            mutter("GIO recommended_page")
 
359
        return 64 * 1024
 
360
 
 
361
    def rmdir(self, relpath):
 
362
        """Delete the directory at rel_path"""
 
363
        try:
 
364
            if 'gio' in debug.debug_flags:
 
365
                mutter("GIO rmdir %s" % relpath)
 
366
            st = self.stat(relpath)
 
367
            if stat.S_ISDIR(st.st_mode):
 
368
                f = self._get_GIO(relpath)
 
369
                f.delete()
 
370
            else:
 
371
                raise errors.NotADirectory(relpath)
 
372
        except gio.Error, e:
 
373
            self._translate_gio_error(e, relpath)
 
374
        except errors.NotADirectory, e:
 
375
            #just pass it forward
 
376
            raise e
 
377
        except Exception, e:
 
378
            mutter('failed to rmdir %s: %s' % (relpath, e))
 
379
            raise errors.PathError(relpath)
 
380
 
 
381
    def append_file(self, relpath, file, mode=None):
 
382
        """Append the text in the file-like object into the final
 
383
        location.
 
384
        """
 
385
        #GIO append_to seems not to append but to truncate
 
386
        #Work around this.
 
387
        if 'gio' in debug.debug_flags:
 
388
            mutter("GIO append_file: %s" % relpath)
 
389
        tmppath = '%s.tmp.%.9f.%d.%d' % (relpath, time.time(),
 
390
                    os.getpid(), random.randint(0, 0x7FFFFFFF))
 
391
        try:
 
392
            result = 0
 
393
            fo = self._get_GIO(tmppath)
 
394
            fi = self._get_GIO(relpath)
 
395
            fout = fo.create()
 
396
            try:
 
397
                info = GioStatResult(fi)
 
398
                result = info.st_size
 
399
                fin = fi.read()
 
400
                self._pump(fin, fout)
 
401
                fin.close()
 
402
            #This separate except is to catch and ignore the
 
403
            #gio.ERROR_NOT_FOUND for the already existing file.
 
404
            #It is valid to open a non-existing file for append.
 
405
            #This is caused by the broken gio append_to...
 
406
            except gio.Error, e:
 
407
                if e.code != gio.ERROR_NOT_FOUND:
 
408
                    self._translate_gio_error(e, relpath)
 
409
            length = self._pump(file, fout)
 
410
            fout.close()
 
411
            info = GioStatResult(fo)
 
412
            if info.st_size != result + length:
 
413
                raise errors.BzrError("Failed to append size after " \
 
414
                      "(%d) is not original (%d) + written (%d) total (%d)" % \
 
415
                      (info.st_size, result, length, result + length))
 
416
            fo.move(fi, flags=gio.FILE_COPY_OVERWRITE)
 
417
            return result
 
418
        except gio.Error, e:
 
419
            self._translate_gio_error(e, relpath)
 
420
 
 
421
    def _setmode(self, relpath, mode):
 
422
        """Set permissions on a path.
 
423
 
 
424
        Only set permissions on Unix systems
 
425
        """
 
426
        if 'gio' in debug.debug_flags:
 
427
            mutter("GIO _setmode %s" % relpath)
 
428
        if mode:
 
429
            try:
 
430
                f = self._get_GIO(relpath)
 
431
                f.set_attribute_uint32(gio.FILE_ATTRIBUTE_UNIX_MODE, mode)
 
432
            except gio.Error, e:
 
433
                if e.code == gio.ERROR_NOT_SUPPORTED:
 
434
                    # Command probably not available on this server
 
435
                    mutter("GIO Could not set permissions to %s on %s. %s",
 
436
                        oct(mode), self._remote_path(relpath), str(e))
 
437
                else:
 
438
                    self._translate_gio_error(e, relpath)
 
439
 
 
440
    def rename(self, rel_from, rel_to):
 
441
        """Rename without special overwriting"""
 
442
        try:
 
443
            if 'gio' in debug.debug_flags:
 
444
                mutter("GIO move (rename): %s => %s", rel_from, rel_to)
 
445
            f = self._get_GIO(rel_from)
 
446
            t = self._get_GIO(rel_to)
 
447
            f.move(t)
 
448
        except gio.Error, e:
 
449
            self._translate_gio_error(e, rel_from)
 
450
 
 
451
    def move(self, rel_from, rel_to):
 
452
        """Move the item at rel_from to the location at rel_to"""
 
453
        try:
 
454
            if 'gio' in debug.debug_flags:
 
455
                mutter("GIO move: %s => %s", rel_from, rel_to)
 
456
            f = self._get_GIO(rel_from)
 
457
            t = self._get_GIO(rel_to)
 
458
            f.move(t, flags=gio.FILE_COPY_OVERWRITE)
 
459
        except gio.Error, e:
 
460
            self._translate_gio_error(e, relfrom)
 
461
 
 
462
    def delete(self, relpath):
 
463
        """Delete the item at relpath"""
 
464
        try:
 
465
            if 'gio' in debug.debug_flags:
 
466
                mutter("GIO delete: %s", relpath)
 
467
            f = self._get_GIO(relpath)
 
468
            f.delete()
 
469
        except gio.Error, e:
 
470
            self._translate_gio_error(e, relpath)
 
471
 
 
472
    def external_url(self):
 
473
        """See bzrlib.transport.Transport.external_url."""
 
474
        if 'gio' in debug.debug_flags:
 
475
            mutter("GIO external_url", self.base)
 
476
        # GIO external url
 
477
        return self.base
 
478
 
 
479
    def listable(self):
 
480
        """See Transport.listable."""
 
481
        if 'gio' in debug.debug_flags:
 
482
            mutter("GIO listable")
 
483
        return True
 
484
 
 
485
    def list_dir(self, relpath):
 
486
        """See Transport.list_dir."""
 
487
        if 'gio' in debug.debug_flags:
 
488
            mutter("GIO list_dir")
 
489
        try:
 
490
            entries = []
 
491
            f = self._get_GIO(relpath)
 
492
            children = f.enumerate_children(gio.FILE_ATTRIBUTE_STANDARD_NAME)
 
493
            for child in children:
 
494
                entries.append(urlutils.escape(child.get_name()))
 
495
            return entries
 
496
        except gio.Error, e:
 
497
            self._translate_gio_error(e, relpath)
 
498
 
 
499
    def iter_files_recursive(self):
 
500
        """See Transport.iter_files_recursive.
 
501
 
 
502
        This is cargo-culted from the SFTP transport"""
 
503
        if 'gio' in debug.debug_flags:
 
504
            mutter("GIO iter_files_recursive")
 
505
        queue = list(self.list_dir("."))
 
506
        while queue:
 
507
            relpath = queue.pop(0)
 
508
            st = self.stat(relpath)
 
509
            if stat.S_ISDIR(st.st_mode):
 
510
                for i, basename in enumerate(self.list_dir(relpath)):
 
511
                    queue.insert(i, relpath + "/" + basename)
 
512
            else:
 
513
                yield relpath
 
514
 
 
515
    def stat(self, relpath):
 
516
        """Return the stat information for a file."""
 
517
        try:
 
518
            if 'gio' in debug.debug_flags:
 
519
                mutter("GIO stat: %s", relpath)
 
520
            f = self._get_GIO(relpath)
 
521
            return GioStatResult(f)
 
522
        except gio.Error, e:
 
523
            self._translate_gio_error(e, relpath, extra='error w/ stat')
 
524
 
 
525
    def lock_read(self, relpath):
 
526
        """Lock the given file for shared (read) access.
 
527
        :return: A lock object, which should be passed to Transport.unlock()
 
528
        """
 
529
        if 'gio' in debug.debug_flags:
 
530
            mutter("GIO lock_read", relpath)
 
531
 
 
532
        class BogusLock(object):
 
533
            # The old RemoteBranch ignore lock for reading, so we will
 
534
            # continue that tradition and return a bogus lock object.
 
535
 
 
536
            def __init__(self, path):
 
537
                self.path = path
 
538
 
 
539
            def unlock(self):
 
540
                pass
 
541
 
 
542
        return BogusLock(relpath)
 
543
 
 
544
    def lock_write(self, relpath):
 
545
        """Lock the given file for exclusive (write) access.
 
546
        WARNING: many transports do not support this, so trying avoid using it
 
547
 
 
548
        :return: A lock object, whichshould be passed to Transport.unlock()
 
549
        """
 
550
        if 'gio' in debug.debug_flags:
 
551
            mutter("GIO lock_write", relpath)
 
552
        return self.lock_read(relpath)
 
553
 
 
554
    def _translate_gio_error(self, err, path, extra=None):
 
555
        if 'gio' in debug.debug_flags:
 
556
            mutter("GIO Error: %s %s" % (str(err), path))
 
557
        if extra is None:
 
558
            extra = str(err)
 
559
        if err.code == gio.ERROR_NOT_FOUND:
 
560
            raise errors.NoSuchFile(path, extra=extra)
 
561
        elif err.code == gio.ERROR_EXISTS:
 
562
            raise errors.FileExists(path, extra=extra)
 
563
        elif err.code == gio.ERROR_NOT_DIRECTORY:
 
564
            raise errors.NotADirectory(path, extra=extra)
 
565
        elif err.code == gio.ERROR_NOT_EMPTY:
 
566
            raise errors.DirectoryNotEmpty(path, extra=extra)
 
567
        elif err.code == gio.ERROR_BUSY:
 
568
            raise errors.ResourceBusy(path, extra=extra)
 
569
        elif err.code == gio.ERROR_PERMISSION_DENIED:
 
570
            raise errors.PermissionDenied(path, extra=extra)
 
571
        elif err.code == gio.ERROR_HOST_NOT_FOUND:
 
572
            raise errors.PathError(path, extra=extra)
 
573
        elif err.code == gio.ERROR_IS_DIRECTORY:
 
574
            raise errors.PathError(path, extra=extra)
 
575
        else:
 
576
            mutter('unable to understand error for path: %s: %s', path, err)
 
577
            raise errors.PathError(path,
 
578
                    extra="Unhandled gio error: " + str(err))
 
579
 
 
580
 
 
581
def get_test_permutations():
 
582
    """Return the permutations to be used in testing."""
 
583
    from bzrlib.tests import test_server
 
584
    return [(GioTransport, GioLocalURLServer)]