~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: Canonical.com Patch Queue Manager
  • Date: 2007-11-03 23:02:16 UTC
  • mfrom: (2951.1.1 pack)
  • Revision ID: pqm@pqm.ubuntu.com-20071103230216-mnmwuxm413lyhjdv
(robertc) Fix data-refresh logic for packs not to refresh mid-transaction when a names write lock is held. (Robert Collins)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2005, 2006, 2007 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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
16
"""Implementation of Transport over ftp.
 
17
 
 
18
Written by Daniel Silverstone <dsilvers@digital-scurf.org> with serious
 
19
cargo-culting from the sftp transport and the http transport.
 
20
 
 
21
It provides the ftp:// and aftp:// protocols where ftp:// is passive ftp
 
22
and aftp:// is active ftp. Most people will want passive ftp for traversing
 
23
NAT and other firewalls, so it's best to use it unless you explicitly want
 
24
active, in which case aftp:// will be your friend.
 
25
"""
 
26
 
 
27
from cStringIO import StringIO
 
28
import errno
 
29
import ftplib
 
30
import os
 
31
import os.path
 
32
import urlparse
 
33
import stat
 
34
import time
 
35
import random
 
36
from warnings import warn
 
37
 
 
38
from bzrlib import (
 
39
    errors,
 
40
    osutils,
 
41
    urlutils,
 
42
    )
 
43
from bzrlib.trace import mutter, warning
 
44
from bzrlib.transport import (
 
45
    AppendBasedFileStream,
 
46
    _file_streams,
 
47
    Server,
 
48
    ConnectedTransport,
 
49
    )
 
50
from bzrlib.transport.local import LocalURLServer
 
51
import bzrlib.ui
 
52
 
 
53
_have_medusa = False
 
54
 
 
55
 
 
56
class FtpPathError(errors.PathError):
 
57
    """FTP failed for path: %(path)s%(extra)s"""
 
58
 
 
59
 
 
60
class FtpStatResult(object):
 
61
    def __init__(self, f, relpath):
 
62
        try:
 
63
            self.st_size = f.size(relpath)
 
64
            self.st_mode = stat.S_IFREG
 
65
        except ftplib.error_perm:
 
66
            pwd = f.pwd()
 
67
            try:
 
68
                f.cwd(relpath)
 
69
                self.st_mode = stat.S_IFDIR
 
70
            finally:
 
71
                f.cwd(pwd)
 
72
 
 
73
 
 
74
_number_of_retries = 2
 
75
_sleep_between_retries = 5
 
76
 
 
77
# FIXME: there are inconsistencies in the way temporary errors are
 
78
# handled. Sometimes we reconnect, sometimes we raise an exception. Care should
 
79
# be taken to analyze the implications for write operations (read operations
 
80
# are safe to retry). Overall even some read operations are never
 
81
# retried. --vila 20070720 (Bug #127164)
 
82
class FtpTransport(ConnectedTransport):
 
83
    """This is the transport agent for ftp:// access."""
 
84
 
 
85
    def __init__(self, base, _from_transport=None):
 
86
        """Set the base path where files will be stored."""
 
87
        assert base.startswith('ftp://') or base.startswith('aftp://')
 
88
        super(FtpTransport, self).__init__(base,
 
89
                                           _from_transport=_from_transport)
 
90
        self._unqualified_scheme = 'ftp'
 
91
        if self._scheme == 'aftp':
 
92
            self.is_active = True
 
93
        else:
 
94
            self.is_active = False
 
95
 
 
96
    def _get_FTP(self):
 
97
        """Return the ftplib.FTP instance for this object."""
 
98
        # Ensures that a connection is established
 
99
        connection = self._get_connection()
 
100
        if connection is None:
 
101
            # First connection ever
 
102
            connection, credentials = self._create_connection()
 
103
            self._set_connection(connection, credentials)
 
104
        return connection
 
105
 
 
106
    def _create_connection(self, credentials=None):
 
107
        """Create a new connection with the provided credentials.
 
108
 
 
109
        :param credentials: The credentials needed to establish the connection.
 
110
 
 
111
        :return: The created connection and its associated credentials.
 
112
 
 
113
        The credentials are only the password as it may have been entered
 
114
        interactively by the user and may be different from the one provided
 
115
        in base url at transport creation time.
 
116
        """
 
117
        if credentials is None:
 
118
            password = self._password
 
119
        else:
 
120
            password = credentials
 
121
 
 
122
        mutter("Constructing FTP instance against %r" %
 
123
               ((self._host, self._port, self._user, '********',
 
124
                self.is_active),))
 
125
        try:
 
126
            connection = ftplib.FTP()
 
127
            connection.connect(host=self._host, port=self._port)
 
128
            if self._user and self._user != 'anonymous' and \
 
129
                    password is None: # '' is a valid password
 
130
                get_password = bzrlib.ui.ui_factory.get_password
 
131
                password = get_password(prompt='FTP %(user)s@%(host)s password',
 
132
                                        user=self._user, host=self._host)
 
133
            connection.login(user=self._user, passwd=password)
 
134
            connection.set_pasv(not self.is_active)
 
135
        except ftplib.error_perm, e:
 
136
            raise errors.TransportError(msg="Error setting up connection:"
 
137
                                        " %s" % str(e), orig_error=e)
 
138
        return connection, password
 
139
 
 
140
    def _reconnect(self):
 
141
        """Create a new connection with the previously used credentials"""
 
142
        credentials = self._get_credentials()
 
143
        connection, credentials = self._create_connection(credentials)
 
144
        self._set_connection(connection, credentials)
 
145
 
 
146
    def _translate_perm_error(self, err, path, extra=None,
 
147
                              unknown_exc=FtpPathError):
 
148
        """Try to translate an ftplib.error_perm exception.
 
149
 
 
150
        :param err: The error to translate into a bzr error
 
151
        :param path: The path which had problems
 
152
        :param extra: Extra information which can be included
 
153
        :param unknown_exc: If None, we will just raise the original exception
 
154
                    otherwise we raise unknown_exc(path, extra=extra)
 
155
        """
 
156
        s = str(err).lower()
 
157
        if not extra:
 
158
            extra = str(err)
 
159
        else:
 
160
            extra += ': ' + str(err)
 
161
        if ('no such file' in s
 
162
            or 'could not open' in s
 
163
            or 'no such dir' in s
 
164
            or 'could not create file' in s # vsftpd
 
165
            or 'file doesn\'t exist' in s
 
166
            or 'file/directory not found' in s # filezilla server
 
167
            ):
 
168
            raise errors.NoSuchFile(path, extra=extra)
 
169
        if ('file exists' in s):
 
170
            raise errors.FileExists(path, extra=extra)
 
171
        if ('not a directory' in s):
 
172
            raise errors.PathError(path, extra=extra)
 
173
 
 
174
        mutter('unable to understand error for path: %s: %s', path, err)
 
175
 
 
176
        if unknown_exc:
 
177
            raise unknown_exc(path, extra=extra)
 
178
        # TODO: jam 20060516 Consider re-raising the error wrapped in 
 
179
        #       something like TransportError, but this loses the traceback
 
180
        #       Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
 
181
        #       to handle. Consider doing something like that here.
 
182
        #raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
 
183
        raise
 
184
 
 
185
    def _remote_path(self, relpath):
 
186
        # XXX: It seems that ftplib does not handle Unicode paths
 
187
        # at the same time, medusa won't handle utf8 paths So if
 
188
        # we .encode(utf8) here (see ConnectedTransport
 
189
        # implementation), then we get a Server failure.  while
 
190
        # if we use str(), we get a UnicodeError, and the test
 
191
        # suite just skips testing UnicodePaths.
 
192
        relative = str(urlutils.unescape(relpath))
 
193
        remote_path = self._combine_paths(self._path, relative)
 
194
        return remote_path
 
195
 
 
196
    def has(self, relpath):
 
197
        """Does the target location exist?"""
 
198
        # FIXME jam 20060516 We *do* ask about directories in the test suite
 
199
        #       We don't seem to in the actual codebase
 
200
        # XXX: I assume we're never asked has(dirname) and thus I use
 
201
        # the FTP size command and assume that if it doesn't raise,
 
202
        # all is good.
 
203
        abspath = self._remote_path(relpath)
 
204
        try:
 
205
            f = self._get_FTP()
 
206
            mutter('FTP has check: %s => %s', relpath, abspath)
 
207
            s = f.size(abspath)
 
208
            mutter("FTP has: %s", abspath)
 
209
            return True
 
210
        except ftplib.error_perm, e:
 
211
            if ('is a directory' in str(e).lower()):
 
212
                mutter("FTP has dir: %s: %s", abspath, e)
 
213
                return True
 
214
            mutter("FTP has not: %s: %s", abspath, e)
 
215
            return False
 
216
 
 
217
    def get(self, relpath, decode=False, retries=0):
 
218
        """Get the file at the given relative path.
 
219
 
 
220
        :param relpath: The relative path to the file
 
221
        :param retries: Number of retries after temporary failures so far
 
222
                        for this operation.
 
223
 
 
224
        We're meant to return a file-like object which bzr will
 
225
        then read from. For now we do this via the magic of StringIO
 
226
        """
 
227
        # TODO: decode should be deprecated
 
228
        try:
 
229
            mutter("FTP get: %s", self._remote_path(relpath))
 
230
            f = self._get_FTP()
 
231
            ret = StringIO()
 
232
            f.retrbinary('RETR '+self._remote_path(relpath), ret.write, 8192)
 
233
            ret.seek(0)
 
234
            return ret
 
235
        except ftplib.error_perm, e:
 
236
            raise errors.NoSuchFile(self.abspath(relpath), extra=str(e))
 
237
        except ftplib.error_temp, e:
 
238
            if retries > _number_of_retries:
 
239
                raise errors.TransportError(msg="FTP temporary error during GET %s. Aborting."
 
240
                                     % self.abspath(relpath),
 
241
                                     orig_error=e)
 
242
            else:
 
243
                warning("FTP temporary error: %s. Retrying.", str(e))
 
244
                self._reconnect()
 
245
                return self.get(relpath, decode, retries+1)
 
246
        except EOFError, e:
 
247
            if retries > _number_of_retries:
 
248
                raise errors.TransportError("FTP control connection closed during GET %s."
 
249
                                     % self.abspath(relpath),
 
250
                                     orig_error=e)
 
251
            else:
 
252
                warning("FTP control connection closed. Trying to reopen.")
 
253
                time.sleep(_sleep_between_retries)
 
254
                self._reconnect()
 
255
                return self.get(relpath, decode, retries+1)
 
256
 
 
257
    def put_file(self, relpath, fp, mode=None, retries=0):
 
258
        """Copy the file-like or string object into the location.
 
259
 
 
260
        :param relpath: Location to put the contents, relative to base.
 
261
        :param fp:       File-like or string object.
 
262
        :param retries: Number of retries after temporary failures so far
 
263
                        for this operation.
 
264
 
 
265
        TODO: jam 20051215 ftp as a protocol seems to support chmod, but
 
266
        ftplib does not
 
267
        """
 
268
        abspath = self._remote_path(relpath)
 
269
        tmp_abspath = '%s.tmp.%.9f.%d.%d' % (abspath, time.time(),
 
270
                        os.getpid(), random.randint(0,0x7FFFFFFF))
 
271
        bytes = None
 
272
        if getattr(fp, 'read', None) is None:
 
273
            # hand in a string IO
 
274
            bytes = fp
 
275
            fp = StringIO(bytes)
 
276
        else:
 
277
            # capture the byte count; .read() may be read only so
 
278
            # decorate it.
 
279
            class byte_counter(object):
 
280
                def __init__(self, fp):
 
281
                    self.fp = fp
 
282
                    self.counted_bytes = 0
 
283
                def read(self, count):
 
284
                    result = self.fp.read(count)
 
285
                    self.counted_bytes += len(result)
 
286
                    return result
 
287
            fp = byte_counter(fp)
 
288
        try:
 
289
            mutter("FTP put: %s", abspath)
 
290
            f = self._get_FTP()
 
291
            try:
 
292
                f.storbinary('STOR '+tmp_abspath, fp)
 
293
                self._rename_and_overwrite(tmp_abspath, abspath, f)
 
294
                if bytes is not None:
 
295
                    return len(bytes)
 
296
                else:
 
297
                    return fp.counted_bytes
 
298
            except (ftplib.error_temp,EOFError), e:
 
299
                warning("Failure during ftp PUT. Deleting temporary file.")
 
300
                try:
 
301
                    f.delete(tmp_abspath)
 
302
                except:
 
303
                    warning("Failed to delete temporary file on the"
 
304
                            " server.\nFile: %s", tmp_abspath)
 
305
                    raise e
 
306
                raise
 
307
        except ftplib.error_perm, e:
 
308
            self._translate_perm_error(e, abspath, extra='could not store',
 
309
                                       unknown_exc=errors.NoSuchFile)
 
310
        except ftplib.error_temp, e:
 
311
            if retries > _number_of_retries:
 
312
                raise errors.TransportError("FTP temporary error during PUT %s. Aborting."
 
313
                                     % self.abspath(relpath), orig_error=e)
 
314
            else:
 
315
                warning("FTP temporary error: %s. Retrying.", str(e))
 
316
                self._reconnect()
 
317
                self.put_file(relpath, fp, mode, retries+1)
 
318
        except EOFError:
 
319
            if retries > _number_of_retries:
 
320
                raise errors.TransportError("FTP control connection closed during PUT %s."
 
321
                                     % self.abspath(relpath), orig_error=e)
 
322
            else:
 
323
                warning("FTP control connection closed. Trying to reopen.")
 
324
                time.sleep(_sleep_between_retries)
 
325
                self._reconnect()
 
326
                self.put_file(relpath, fp, mode, retries+1)
 
327
 
 
328
    def mkdir(self, relpath, mode=None):
 
329
        """Create a directory at the given path."""
 
330
        abspath = self._remote_path(relpath)
 
331
        try:
 
332
            mutter("FTP mkd: %s", abspath)
 
333
            f = self._get_FTP()
 
334
            f.mkd(abspath)
 
335
        except ftplib.error_perm, e:
 
336
            self._translate_perm_error(e, abspath,
 
337
                unknown_exc=errors.FileExists)
 
338
 
 
339
    def open_write_stream(self, relpath, mode=None):
 
340
        """See Transport.open_write_stream."""
 
341
        self.put_bytes(relpath, "", mode)
 
342
        result = AppendBasedFileStream(self, relpath)
 
343
        _file_streams[self.abspath(relpath)] = result
 
344
        return result
 
345
 
 
346
    def recommended_page_size(self):
 
347
        """See Transport.recommended_page_size().
 
348
 
 
349
        For FTP we suggest a large page size to reduce the overhead
 
350
        introduced by latency.
 
351
        """
 
352
        return 64 * 1024
 
353
 
 
354
    def rmdir(self, rel_path):
 
355
        """Delete the directory at rel_path"""
 
356
        abspath = self._remote_path(rel_path)
 
357
        try:
 
358
            mutter("FTP rmd: %s", abspath)
 
359
            f = self._get_FTP()
 
360
            f.rmd(abspath)
 
361
        except ftplib.error_perm, e:
 
362
            self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
 
363
 
 
364
    def append_file(self, relpath, f, mode=None):
 
365
        """Append the text in the file-like object into the final
 
366
        location.
 
367
        """
 
368
        abspath = self._remote_path(relpath)
 
369
        if self.has(relpath):
 
370
            ftp = self._get_FTP()
 
371
            result = ftp.size(abspath)
 
372
        else:
 
373
            result = 0
 
374
 
 
375
        mutter("FTP appe to %s", abspath)
 
376
        self._try_append(relpath, f.read(), mode)
 
377
 
 
378
        return result
 
379
 
 
380
    def _try_append(self, relpath, text, mode=None, retries=0):
 
381
        """Try repeatedly to append the given text to the file at relpath.
 
382
        
 
383
        This is a recursive function. On errors, it will be called until the
 
384
        number of retries is exceeded.
 
385
        """
 
386
        try:
 
387
            abspath = self._remote_path(relpath)
 
388
            mutter("FTP appe (try %d) to %s", retries, abspath)
 
389
            ftp = self._get_FTP()
 
390
            ftp.voidcmd("TYPE I")
 
391
            cmd = "APPE %s" % abspath
 
392
            conn = ftp.transfercmd(cmd)
 
393
            conn.sendall(text)
 
394
            conn.close()
 
395
            if mode:
 
396
                self._setmode(relpath, mode)
 
397
            ftp.getresp()
 
398
        except ftplib.error_perm, e:
 
399
            self._translate_perm_error(e, abspath, extra='error appending',
 
400
                unknown_exc=errors.NoSuchFile)
 
401
        except ftplib.error_temp, e:
 
402
            if retries > _number_of_retries:
 
403
                raise errors.TransportError("FTP temporary error during APPEND %s." \
 
404
                        "Aborting." % abspath, orig_error=e)
 
405
            else:
 
406
                warning("FTP temporary error: %s. Retrying.", str(e))
 
407
                self._reconnect()
 
408
                self._try_append(relpath, text, mode, retries+1)
 
409
 
 
410
    def _setmode(self, relpath, mode):
 
411
        """Set permissions on a path.
 
412
 
 
413
        Only set permissions if the FTP server supports the 'SITE CHMOD'
 
414
        extension.
 
415
        """
 
416
        try:
 
417
            mutter("FTP site chmod: setting permissions to %s on %s",
 
418
                str(mode), self._remote_path(relpath))
 
419
            ftp = self._get_FTP()
 
420
            cmd = "SITE CHMOD %s %s" % (self._remote_path(relpath), str(mode))
 
421
            ftp.sendcmd(cmd)
 
422
        except ftplib.error_perm, e:
 
423
            # Command probably not available on this server
 
424
            warning("FTP Could not set permissions to %s on %s. %s",
 
425
                    str(mode), self._remote_path(relpath), str(e))
 
426
 
 
427
    # TODO: jam 20060516 I believe ftp allows you to tell an ftp server
 
428
    #       to copy something to another machine. And you may be able
 
429
    #       to give it its own address as the 'to' location.
 
430
    #       So implement a fancier 'copy()'
 
431
 
 
432
    def rename(self, rel_from, rel_to):
 
433
        abs_from = self._remote_path(rel_from)
 
434
        abs_to = self._remote_path(rel_to)
 
435
        mutter("FTP rename: %s => %s", abs_from, abs_to)
 
436
        f = self._get_FTP()
 
437
        return self._rename(abs_from, abs_to, f)
 
438
 
 
439
    def _rename(self, abs_from, abs_to, f):
 
440
        try:
 
441
            f.rename(abs_from, abs_to)
 
442
        except ftplib.error_perm, e:
 
443
            self._translate_perm_error(e, abs_from,
 
444
                ': unable to rename to %r' % (abs_to))
 
445
 
 
446
    def move(self, rel_from, rel_to):
 
447
        """Move the item at rel_from to the location at rel_to"""
 
448
        abs_from = self._remote_path(rel_from)
 
449
        abs_to = self._remote_path(rel_to)
 
450
        try:
 
451
            mutter("FTP mv: %s => %s", abs_from, abs_to)
 
452
            f = self._get_FTP()
 
453
            self._rename_and_overwrite(abs_from, abs_to, f)
 
454
        except ftplib.error_perm, e:
 
455
            self._translate_perm_error(e, abs_from,
 
456
                extra='unable to rename to %r' % (rel_to,), 
 
457
                unknown_exc=errors.PathError)
 
458
 
 
459
    def _rename_and_overwrite(self, abs_from, abs_to, f):
 
460
        """Do a fancy rename on the remote server.
 
461
 
 
462
        Using the implementation provided by osutils.
 
463
        """
 
464
        osutils.fancy_rename(abs_from, abs_to,
 
465
            rename_func=lambda p1, p2: self._rename(p1, p2, f),
 
466
            unlink_func=lambda p: self._delete(p, f))
 
467
 
 
468
    def delete(self, relpath):
 
469
        """Delete the item at relpath"""
 
470
        abspath = self._remote_path(relpath)
 
471
        f = self._get_FTP()
 
472
        self._delete(abspath, f)
 
473
 
 
474
    def _delete(self, abspath, f):
 
475
        try:
 
476
            mutter("FTP rm: %s", abspath)
 
477
            f.delete(abspath)
 
478
        except ftplib.error_perm, e:
 
479
            self._translate_perm_error(e, abspath, 'error deleting',
 
480
                unknown_exc=errors.NoSuchFile)
 
481
 
 
482
    def external_url(self):
 
483
        """See bzrlib.transport.Transport.external_url."""
 
484
        # FTP URL's are externally usable.
 
485
        return self.base
 
486
 
 
487
    def listable(self):
 
488
        """See Transport.listable."""
 
489
        return True
 
490
 
 
491
    def list_dir(self, relpath):
 
492
        """See Transport.list_dir."""
 
493
        basepath = self._remote_path(relpath)
 
494
        mutter("FTP nlst: %s", basepath)
 
495
        f = self._get_FTP()
 
496
        try:
 
497
            paths = f.nlst(basepath)
 
498
        except ftplib.error_perm, e:
 
499
            self._translate_perm_error(e, relpath, extra='error with list_dir')
 
500
        # If FTP.nlst returns paths prefixed by relpath, strip 'em
 
501
        if paths and paths[0].startswith(basepath):
 
502
            entries = [path[len(basepath)+1:] for path in paths]
 
503
        else:
 
504
            entries = paths
 
505
        # Remove . and .. if present
 
506
        return [urlutils.escape(entry) for entry in entries
 
507
                if entry not in ('.', '..')]
 
508
 
 
509
    def iter_files_recursive(self):
 
510
        """See Transport.iter_files_recursive.
 
511
 
 
512
        This is cargo-culted from the SFTP transport"""
 
513
        mutter("FTP iter_files_recursive")
 
514
        queue = list(self.list_dir("."))
 
515
        while queue:
 
516
            relpath = queue.pop(0)
 
517
            st = self.stat(relpath)
 
518
            if stat.S_ISDIR(st.st_mode):
 
519
                for i, basename in enumerate(self.list_dir(relpath)):
 
520
                    queue.insert(i, relpath+"/"+basename)
 
521
            else:
 
522
                yield relpath
 
523
 
 
524
    def stat(self, relpath):
 
525
        """Return the stat information for a file."""
 
526
        abspath = self._remote_path(relpath)
 
527
        try:
 
528
            mutter("FTP stat: %s", abspath)
 
529
            f = self._get_FTP()
 
530
            return FtpStatResult(f, abspath)
 
531
        except ftplib.error_perm, e:
 
532
            self._translate_perm_error(e, abspath, extra='error w/ stat')
 
533
 
 
534
    def lock_read(self, relpath):
 
535
        """Lock the given file for shared (read) access.
 
536
        :return: A lock object, which should be passed to Transport.unlock()
 
537
        """
 
538
        # The old RemoteBranch ignore lock for reading, so we will
 
539
        # continue that tradition and return a bogus lock object.
 
540
        class BogusLock(object):
 
541
            def __init__(self, path):
 
542
                self.path = path
 
543
            def unlock(self):
 
544
                pass
 
545
        return BogusLock(relpath)
 
546
 
 
547
    def lock_write(self, relpath):
 
548
        """Lock the given file for exclusive (write) access.
 
549
        WARNING: many transports do not support this, so trying avoid using it
 
550
 
 
551
        :return: A lock object, which should be passed to Transport.unlock()
 
552
        """
 
553
        return self.lock_read(relpath)
 
554
 
 
555
 
 
556
def get_test_permutations():
 
557
    """Return the permutations to be used in testing."""
 
558
    from bzrlib import tests
 
559
    if tests.FTPServerFeature.available():
 
560
        from bzrlib.tests import ftp_server
 
561
        return [(FtpTransport, ftp_server.FTPServer)]
 
562
    else:
 
563
        # Dummy server to have the test suite report the number of tests
 
564
        # needing that feature.
 
565
        class UnavailableFTPServer(object):
 
566
            def setUp(self):
 
567
                raise tests.UnavailableFeature(tests.FTPServerFeature)
 
568
 
 
569
        return [(FtpTransport, UnavailableFTPServer)]