~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: Martin Pool
  • Date: 2005-03-14 07:07:24 UTC
  • Revision ID: mbp@sourcefrog.net-20050314070724-ba6c85db7d96c508
- add setup.py and install instructions
- rename main script to just bzr

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 asyncore
29
 
import errno
30
 
import ftplib
31
 
import os
32
 
import os.path
33
 
import urllib
34
 
import urlparse
35
 
import select
36
 
import stat
37
 
import threading
38
 
import time
39
 
import random
40
 
from warnings import warn
41
 
 
42
 
from bzrlib import (
43
 
    errors,
44
 
    osutils,
45
 
    urlutils,
46
 
    )
47
 
from bzrlib.trace import mutter, warning
48
 
from bzrlib.transport import (
49
 
    Server,
50
 
    ConnectedTransport,
51
 
    )
52
 
from bzrlib.transport.local import LocalURLServer
53
 
import bzrlib.ui
54
 
 
55
 
_have_medusa = False
56
 
 
57
 
 
58
 
class FtpPathError(errors.PathError):
59
 
    """FTP failed for path: %(path)s%(extra)s"""
60
 
 
61
 
 
62
 
class FtpStatResult(object):
63
 
    def __init__(self, f, relpath):
64
 
        try:
65
 
            self.st_size = f.size(relpath)
66
 
            self.st_mode = stat.S_IFREG
67
 
        except ftplib.error_perm:
68
 
            pwd = f.pwd()
69
 
            try:
70
 
                f.cwd(relpath)
71
 
                self.st_mode = stat.S_IFDIR
72
 
            finally:
73
 
                f.cwd(pwd)
74
 
 
75
 
 
76
 
_number_of_retries = 2
77
 
_sleep_between_retries = 5
78
 
 
79
 
# FIXME: there are inconsistencies in the way temporary errors are
80
 
# handled. Sometimes we reconnect, sometimes we raise an exception. Care should
81
 
# be taken to analyze the implications for write operations (read operations
82
 
# are safe to retry). Overall even some read operations are never
83
 
# retried. --vila 20070720 (Bug #127164)
84
 
class FtpTransport(ConnectedTransport):
85
 
    """This is the transport agent for ftp:// access."""
86
 
 
87
 
    def __init__(self, base, _from_transport=None):
88
 
        """Set the base path where files will be stored."""
89
 
        assert base.startswith('ftp://') or base.startswith('aftp://')
90
 
        super(FtpTransport, self).__init__(base,
91
 
                                           _from_transport=_from_transport)
92
 
        self._unqualified_scheme = 'ftp'
93
 
        if self._scheme == 'aftp':
94
 
            self.is_active = True
95
 
        else:
96
 
            self.is_active = False
97
 
 
98
 
    def _get_FTP(self):
99
 
        """Return the ftplib.FTP instance for this object."""
100
 
        # Ensures that a connection is established
101
 
        connection = self._get_connection()
102
 
        if connection is None:
103
 
            # First connection ever
104
 
            connection, credentials = self._create_connection()
105
 
            self._set_connection(connection, credentials)
106
 
        return connection
107
 
 
108
 
    def _create_connection(self, credentials=None):
109
 
        """Create a new connection with the provided credentials.
110
 
 
111
 
        :param credentials: The credentials needed to establish the connection.
112
 
 
113
 
        :return: The created connection and its associated credentials.
114
 
 
115
 
        The credentials are only the password as it may have been entered
116
 
        interactively by the user and may be different from the one provided
117
 
        in base url at transport creation time.
118
 
        """
119
 
        if credentials is None:
120
 
            password = self._password
121
 
        else:
122
 
            password = credentials
123
 
 
124
 
        mutter("Constructing FTP instance against %r" %
125
 
               ((self._host, self._port, self._user, '********',
126
 
                self.is_active),))
127
 
        try:
128
 
            connection = ftplib.FTP()
129
 
            connection.connect(host=self._host, port=self._port)
130
 
            if self._user and self._user != 'anonymous' and \
131
 
                    password is not None: # '' is a valid password
132
 
                get_password = bzrlib.ui.ui_factory.get_password
133
 
                password = get_password(prompt='FTP %(user)s@%(host)s password',
134
 
                                        user=self._user, host=self._host)
135
 
            connection.login(user=self._user, passwd=password)
136
 
            connection.set_pasv(not self.is_active)
137
 
        except ftplib.error_perm, e:
138
 
            raise errors.TransportError(msg="Error setting up connection:"
139
 
                                        " %s" % str(e), orig_error=e)
140
 
        return connection, password
141
 
 
142
 
    def _reconnect(self):
143
 
        """Create a new connection with the previously used credentials"""
144
 
        credentials = self.get_credentials()
145
 
        connection, credentials = self._create_connection(credentials)
146
 
        self._set_connection(connection, credentials)
147
 
 
148
 
    def _translate_perm_error(self, err, path, extra=None,
149
 
                              unknown_exc=FtpPathError):
150
 
        """Try to translate an ftplib.error_perm exception.
151
 
 
152
 
        :param err: The error to translate into a bzr error
153
 
        :param path: The path which had problems
154
 
        :param extra: Extra information which can be included
155
 
        :param unknown_exc: If None, we will just raise the original exception
156
 
                    otherwise we raise unknown_exc(path, extra=extra)
157
 
        """
158
 
        s = str(err).lower()
159
 
        if not extra:
160
 
            extra = str(err)
161
 
        else:
162
 
            extra += ': ' + str(err)
163
 
        if ('no such file' in s
164
 
            or 'could not open' in s
165
 
            or 'no such dir' in s
166
 
            or 'could not create file' in s # vsftpd
167
 
            or 'file doesn\'t exist' in s
168
 
            ):
169
 
            raise errors.NoSuchFile(path, extra=extra)
170
 
        if ('file exists' in s):
171
 
            raise errors.FileExists(path, extra=extra)
172
 
        if ('not a directory' in s):
173
 
            raise errors.PathError(path, extra=extra)
174
 
 
175
 
        mutter('unable to understand error for path: %s: %s', path, err)
176
 
 
177
 
        if unknown_exc:
178
 
            raise unknown_exc(path, extra=extra)
179
 
        # TODO: jam 20060516 Consider re-raising the error wrapped in 
180
 
        #       something like TransportError, but this loses the traceback
181
 
        #       Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
182
 
        #       to handle. Consider doing something like that here.
183
 
        #raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
184
 
        raise
185
 
 
186
 
    def should_cache(self):
187
 
        """Return True if the data pulled across should be cached locally.
188
 
        """
189
 
        return True
190
 
 
191
 
    def _remote_path(self, relpath):
192
 
        # XXX: It seems that ftplib does not handle Unicode paths
193
 
        # at the same time, medusa won't handle utf8 paths So if
194
 
        # we .encode(utf8) here (see ConnectedTransport
195
 
        # implementation), then we get a Server failure.  while
196
 
        # if we use str(), we get a UnicodeError, and the test
197
 
        # suite just skips testing UnicodePaths.
198
 
        relative = str(urlutils.unescape(relpath))
199
 
        remote_path = self._combine_paths(self._path, relative)
200
 
        return remote_path
201
 
 
202
 
    def has(self, relpath):
203
 
        """Does the target location exist?"""
204
 
        # FIXME jam 20060516 We *do* ask about directories in the test suite
205
 
        #       We don't seem to in the actual codebase
206
 
        # XXX: I assume we're never asked has(dirname) and thus I use
207
 
        # the FTP size command and assume that if it doesn't raise,
208
 
        # all is good.
209
 
        abspath = self._remote_path(relpath)
210
 
        try:
211
 
            f = self._get_FTP()
212
 
            mutter('FTP has check: %s => %s', relpath, abspath)
213
 
            s = f.size(abspath)
214
 
            mutter("FTP has: %s", abspath)
215
 
            return True
216
 
        except ftplib.error_perm, e:
217
 
            if ('is a directory' in str(e).lower()):
218
 
                mutter("FTP has dir: %s: %s", abspath, e)
219
 
                return True
220
 
            mutter("FTP has not: %s: %s", abspath, e)
221
 
            return False
222
 
 
223
 
    def get(self, relpath, decode=False, retries=0):
224
 
        """Get the file at the given relative path.
225
 
 
226
 
        :param relpath: The relative path to the file
227
 
        :param retries: Number of retries after temporary failures so far
228
 
                        for this operation.
229
 
 
230
 
        We're meant to return a file-like object which bzr will
231
 
        then read from. For now we do this via the magic of StringIO
232
 
        """
233
 
        # TODO: decode should be deprecated
234
 
        try:
235
 
            mutter("FTP get: %s", self._remote_path(relpath))
236
 
            f = self._get_FTP()
237
 
            ret = StringIO()
238
 
            f.retrbinary('RETR '+self._remote_path(relpath), ret.write, 8192)
239
 
            ret.seek(0)
240
 
            return ret
241
 
        except ftplib.error_perm, e:
242
 
            raise errors.NoSuchFile(self.abspath(relpath), extra=str(e))
243
 
        except ftplib.error_temp, e:
244
 
            if retries > _number_of_retries:
245
 
                raise errors.TransportError(msg="FTP temporary error during GET %s. Aborting."
246
 
                                     % self.abspath(relpath),
247
 
                                     orig_error=e)
248
 
            else:
249
 
                warning("FTP temporary error: %s. Retrying.", str(e))
250
 
                self._reconnect()
251
 
                return self.get(relpath, decode, retries+1)
252
 
        except EOFError, e:
253
 
            if retries > _number_of_retries:
254
 
                raise errors.TransportError("FTP control connection closed during GET %s."
255
 
                                     % self.abspath(relpath),
256
 
                                     orig_error=e)
257
 
            else:
258
 
                warning("FTP control connection closed. Trying to reopen.")
259
 
                time.sleep(_sleep_between_retries)
260
 
                self._reconnect()
261
 
                return self.get(relpath, decode, retries+1)
262
 
 
263
 
    def put_file(self, relpath, fp, mode=None, retries=0):
264
 
        """Copy the file-like or string object into the location.
265
 
 
266
 
        :param relpath: Location to put the contents, relative to base.
267
 
        :param fp:       File-like or string object.
268
 
        :param retries: Number of retries after temporary failures so far
269
 
                        for this operation.
270
 
 
271
 
        TODO: jam 20051215 ftp as a protocol seems to support chmod, but
272
 
        ftplib does not
273
 
        """
274
 
        abspath = self._remote_path(relpath)
275
 
        tmp_abspath = '%s.tmp.%.9f.%d.%d' % (abspath, time.time(),
276
 
                        os.getpid(), random.randint(0,0x7FFFFFFF))
277
 
        if getattr(fp, 'read', None) is None:
278
 
            fp = StringIO(fp)
279
 
        try:
280
 
            mutter("FTP put: %s", abspath)
281
 
            f = self._get_FTP()
282
 
            try:
283
 
                f.storbinary('STOR '+tmp_abspath, fp)
284
 
                self._rename_and_overwrite(tmp_abspath, abspath, f)
285
 
            except (ftplib.error_temp,EOFError), e:
286
 
                warning("Failure during ftp PUT. Deleting temporary file.")
287
 
                try:
288
 
                    f.delete(tmp_abspath)
289
 
                except:
290
 
                    warning("Failed to delete temporary file on the"
291
 
                            " server.\nFile: %s", tmp_abspath)
292
 
                    raise e
293
 
                raise
294
 
        except ftplib.error_perm, e:
295
 
            self._translate_perm_error(e, abspath, extra='could not store',
296
 
                                       unknown_exc=errors.NoSuchFile)
297
 
        except ftplib.error_temp, e:
298
 
            if retries > _number_of_retries:
299
 
                raise errors.TransportError("FTP temporary error during PUT %s. Aborting."
300
 
                                     % self.abspath(relpath), orig_error=e)
301
 
            else:
302
 
                warning("FTP temporary error: %s. Retrying.", str(e))
303
 
                self._reconnect()
304
 
                self.put_file(relpath, fp, mode, retries+1)
305
 
        except EOFError:
306
 
            if retries > _number_of_retries:
307
 
                raise errors.TransportError("FTP control connection closed during PUT %s."
308
 
                                     % self.abspath(relpath), orig_error=e)
309
 
            else:
310
 
                warning("FTP control connection closed. Trying to reopen.")
311
 
                time.sleep(_sleep_between_retries)
312
 
                self._reconnect()
313
 
                self.put_file(relpath, fp, mode, retries+1)
314
 
 
315
 
    def mkdir(self, relpath, mode=None):
316
 
        """Create a directory at the given path."""
317
 
        abspath = self._remote_path(relpath)
318
 
        try:
319
 
            mutter("FTP mkd: %s", abspath)
320
 
            f = self._get_FTP()
321
 
            f.mkd(abspath)
322
 
        except ftplib.error_perm, e:
323
 
            self._translate_perm_error(e, abspath,
324
 
                unknown_exc=errors.FileExists)
325
 
 
326
 
    def rmdir(self, rel_path):
327
 
        """Delete the directory at rel_path"""
328
 
        abspath = self._remote_path(rel_path)
329
 
        try:
330
 
            mutter("FTP rmd: %s", abspath)
331
 
            f = self._get_FTP()
332
 
            f.rmd(abspath)
333
 
        except ftplib.error_perm, e:
334
 
            self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
335
 
 
336
 
    def append_file(self, relpath, f, mode=None):
337
 
        """Append the text in the file-like object into the final
338
 
        location.
339
 
        """
340
 
        abspath = self._remote_path(relpath)
341
 
        if self.has(relpath):
342
 
            ftp = self._get_FTP()
343
 
            result = ftp.size(abspath)
344
 
        else:
345
 
            result = 0
346
 
 
347
 
        mutter("FTP appe to %s", abspath)
348
 
        self._try_append(relpath, f.read(), mode)
349
 
 
350
 
        return result
351
 
 
352
 
    def _try_append(self, relpath, text, mode=None, retries=0):
353
 
        """Try repeatedly to append the given text to the file at relpath.
354
 
        
355
 
        This is a recursive function. On errors, it will be called until the
356
 
        number of retries is exceeded.
357
 
        """
358
 
        try:
359
 
            abspath = self._remote_path(relpath)
360
 
            mutter("FTP appe (try %d) to %s", retries, abspath)
361
 
            ftp = self._get_FTP()
362
 
            ftp.voidcmd("TYPE I")
363
 
            cmd = "APPE %s" % abspath
364
 
            conn = ftp.transfercmd(cmd)
365
 
            conn.sendall(text)
366
 
            conn.close()
367
 
            if mode:
368
 
                self._setmode(relpath, mode)
369
 
            ftp.getresp()
370
 
        except ftplib.error_perm, e:
371
 
            self._translate_perm_error(e, abspath, extra='error appending',
372
 
                unknown_exc=errors.NoSuchFile)
373
 
        except ftplib.error_temp, e:
374
 
            if retries > _number_of_retries:
375
 
                raise errors.TransportError("FTP temporary error during APPEND %s." \
376
 
                        "Aborting." % abspath, orig_error=e)
377
 
            else:
378
 
                warning("FTP temporary error: %s. Retrying.", str(e))
379
 
                self._reconnect()
380
 
                self._try_append(relpath, text, mode, retries+1)
381
 
 
382
 
    def _setmode(self, relpath, mode):
383
 
        """Set permissions on a path.
384
 
 
385
 
        Only set permissions if the FTP server supports the 'SITE CHMOD'
386
 
        extension.
387
 
        """
388
 
        try:
389
 
            mutter("FTP site chmod: setting permissions to %s on %s",
390
 
                str(mode), self._remote_path(relpath))
391
 
            ftp = self._get_FTP()
392
 
            cmd = "SITE CHMOD %s %s" % (self._remote_path(relpath), str(mode))
393
 
            ftp.sendcmd(cmd)
394
 
        except ftplib.error_perm, e:
395
 
            # Command probably not available on this server
396
 
            warning("FTP Could not set permissions to %s on %s. %s",
397
 
                    str(mode), self._remote_path(relpath), str(e))
398
 
 
399
 
    # TODO: jam 20060516 I believe ftp allows you to tell an ftp server
400
 
    #       to copy something to another machine. And you may be able
401
 
    #       to give it its own address as the 'to' location.
402
 
    #       So implement a fancier 'copy()'
403
 
 
404
 
    def rename(self, rel_from, rel_to):
405
 
        abs_from = self._remote_path(rel_from)
406
 
        abs_to = self._remote_path(rel_to)
407
 
        mutter("FTP rename: %s => %s", abs_from, abs_to)
408
 
        f = self._get_FTP()
409
 
        return self._rename(abs_from, abs_to, f)
410
 
 
411
 
    def _rename(self, abs_from, abs_to, f):
412
 
        try:
413
 
            f.rename(abs_from, abs_to)
414
 
        except ftplib.error_perm, e:
415
 
            self._translate_perm_error(e, abs_from,
416
 
                ': unable to rename to %r' % (abs_to))
417
 
 
418
 
    def move(self, rel_from, rel_to):
419
 
        """Move the item at rel_from to the location at rel_to"""
420
 
        abs_from = self._remote_path(rel_from)
421
 
        abs_to = self._remote_path(rel_to)
422
 
        try:
423
 
            mutter("FTP mv: %s => %s", abs_from, abs_to)
424
 
            f = self._get_FTP()
425
 
            self._rename_and_overwrite(abs_from, abs_to, f)
426
 
        except ftplib.error_perm, e:
427
 
            self._translate_perm_error(e, abs_from,
428
 
                extra='unable to rename to %r' % (rel_to,), 
429
 
                unknown_exc=errors.PathError)
430
 
 
431
 
    def _rename_and_overwrite(self, abs_from, abs_to, f):
432
 
        """Do a fancy rename on the remote server.
433
 
 
434
 
        Using the implementation provided by osutils.
435
 
        """
436
 
        osutils.fancy_rename(abs_from, abs_to,
437
 
            rename_func=lambda p1, p2: self._rename(p1, p2, f),
438
 
            unlink_func=lambda p: self._delete(p, f))
439
 
 
440
 
    def delete(self, relpath):
441
 
        """Delete the item at relpath"""
442
 
        abspath = self._remote_path(relpath)
443
 
        f = self._get_FTP()
444
 
        self._delete(abspath, f)
445
 
 
446
 
    def _delete(self, abspath, f):
447
 
        try:
448
 
            mutter("FTP rm: %s", abspath)
449
 
            f.delete(abspath)
450
 
        except ftplib.error_perm, e:
451
 
            self._translate_perm_error(e, abspath, 'error deleting',
452
 
                unknown_exc=errors.NoSuchFile)
453
 
 
454
 
    def external_url(self):
455
 
        """See bzrlib.transport.Transport.external_url."""
456
 
        # FTP URL's are externally usable.
457
 
        return self.base
458
 
 
459
 
    def listable(self):
460
 
        """See Transport.listable."""
461
 
        return True
462
 
 
463
 
    def list_dir(self, relpath):
464
 
        """See Transport.list_dir."""
465
 
        basepath = self._remote_path(relpath)
466
 
        mutter("FTP nlst: %s", basepath)
467
 
        f = self._get_FTP()
468
 
        try:
469
 
            paths = f.nlst(basepath)
470
 
        except ftplib.error_perm, e:
471
 
            self._translate_perm_error(e, relpath, extra='error with list_dir')
472
 
        # If FTP.nlst returns paths prefixed by relpath, strip 'em
473
 
        if paths and paths[0].startswith(basepath):
474
 
            entries = [path[len(basepath)+1:] for path in paths]
475
 
        else:
476
 
            entries = paths
477
 
        # Remove . and .. if present
478
 
        return [urlutils.escape(entry) for entry in entries
479
 
                if entry not in ('.', '..')]
480
 
 
481
 
    def iter_files_recursive(self):
482
 
        """See Transport.iter_files_recursive.
483
 
 
484
 
        This is cargo-culted from the SFTP transport"""
485
 
        mutter("FTP iter_files_recursive")
486
 
        queue = list(self.list_dir("."))
487
 
        while queue:
488
 
            relpath = queue.pop(0)
489
 
            st = self.stat(relpath)
490
 
            if stat.S_ISDIR(st.st_mode):
491
 
                for i, basename in enumerate(self.list_dir(relpath)):
492
 
                    queue.insert(i, relpath+"/"+basename)
493
 
            else:
494
 
                yield relpath
495
 
 
496
 
    def stat(self, relpath):
497
 
        """Return the stat information for a file."""
498
 
        abspath = self._remote_path(relpath)
499
 
        try:
500
 
            mutter("FTP stat: %s", abspath)
501
 
            f = self._get_FTP()
502
 
            return FtpStatResult(f, abspath)
503
 
        except ftplib.error_perm, e:
504
 
            self._translate_perm_error(e, abspath, extra='error w/ stat')
505
 
 
506
 
    def lock_read(self, relpath):
507
 
        """Lock the given file for shared (read) access.
508
 
        :return: A lock object, which should be passed to Transport.unlock()
509
 
        """
510
 
        # The old RemoteBranch ignore lock for reading, so we will
511
 
        # continue that tradition and return a bogus lock object.
512
 
        class BogusLock(object):
513
 
            def __init__(self, path):
514
 
                self.path = path
515
 
            def unlock(self):
516
 
                pass
517
 
        return BogusLock(relpath)
518
 
 
519
 
    def lock_write(self, relpath):
520
 
        """Lock the given file for exclusive (write) access.
521
 
        WARNING: many transports do not support this, so trying avoid using it
522
 
 
523
 
        :return: A lock object, which should be passed to Transport.unlock()
524
 
        """
525
 
        return self.lock_read(relpath)
526
 
 
527
 
 
528
 
class FtpServer(Server):
529
 
    """Common code for FTP server facilities."""
530
 
 
531
 
    def __init__(self):
532
 
        self._root = None
533
 
        self._ftp_server = None
534
 
        self._port = None
535
 
        self._async_thread = None
536
 
        # ftp server logs
537
 
        self.logs = []
538
 
 
539
 
    def get_url(self):
540
 
        """Calculate an ftp url to this server."""
541
 
        return 'ftp://foo:bar@localhost:%d/' % (self._port)
542
 
 
543
 
#    def get_bogus_url(self):
544
 
#        """Return a URL which cannot be connected to."""
545
 
#        return 'ftp://127.0.0.1:1'
546
 
 
547
 
    def log(self, message):
548
 
        """This is used by medusa.ftp_server to log connections, etc."""
549
 
        self.logs.append(message)
550
 
 
551
 
    def setUp(self, vfs_server=None):
552
 
        if not _have_medusa:
553
 
            raise RuntimeError('Must have medusa to run the FtpServer')
554
 
 
555
 
        assert vfs_server is None or isinstance(vfs_server, LocalURLServer), \
556
 
            "FtpServer currently assumes local transport, got %s" % vfs_server
557
 
 
558
 
        self._root = os.getcwdu()
559
 
        self._ftp_server = _ftp_server(
560
 
            authorizer=_test_authorizer(root=self._root),
561
 
            ip='localhost',
562
 
            port=0, # bind to a random port
563
 
            resolver=None,
564
 
            logger_object=self # Use FtpServer.log() for messages
565
 
            )
566
 
        self._port = self._ftp_server.getsockname()[1]
567
 
        # Don't let it loop forever, or handle an infinite number of requests.
568
 
        # In this case it will run for 1000s, or 10000 requests
569
 
        self._async_thread = threading.Thread(
570
 
                target=FtpServer._asyncore_loop_ignore_EBADF,
571
 
                kwargs={'timeout':0.1, 'count':10000})
572
 
        self._async_thread.setDaemon(True)
573
 
        self._async_thread.start()
574
 
 
575
 
    def tearDown(self):
576
 
        """See bzrlib.transport.Server.tearDown."""
577
 
        # have asyncore release the channel
578
 
        self._ftp_server.del_channel()
579
 
        asyncore.close_all()
580
 
        self._async_thread.join()
581
 
 
582
 
    @staticmethod
583
 
    def _asyncore_loop_ignore_EBADF(*args, **kwargs):
584
 
        """Ignore EBADF during server shutdown.
585
 
 
586
 
        We close the socket to get the server to shutdown, but this causes
587
 
        select.select() to raise EBADF.
588
 
        """
589
 
        try:
590
 
            asyncore.loop(*args, **kwargs)
591
 
            # FIXME: If we reach that point, we should raise an exception
592
 
            # explaining that the 'count' parameter in setUp is too low or
593
 
            # testers may wonder why their test just sits there waiting for a
594
 
            # server that is already dead. Note that if the tester waits too
595
 
            # long under pdb the server will also die.
596
 
        except select.error, e:
597
 
            if e.args[0] != errno.EBADF:
598
 
                raise
599
 
 
600
 
 
601
 
_ftp_channel = None
602
 
_ftp_server = None
603
 
_test_authorizer = None
604
 
 
605
 
 
606
 
def _setup_medusa():
607
 
    global _have_medusa, _ftp_channel, _ftp_server, _test_authorizer
608
 
    try:
609
 
        import medusa
610
 
        import medusa.filesys
611
 
        import medusa.ftp_server
612
 
    except ImportError:
613
 
        return False
614
 
 
615
 
    _have_medusa = True
616
 
 
617
 
    class test_authorizer(object):
618
 
        """A custom Authorizer object for running the test suite.
619
 
 
620
 
        The reason we cannot use dummy_authorizer, is because it sets the
621
 
        channel to readonly, which we don't always want to do.
622
 
        """
623
 
 
624
 
        def __init__(self, root):
625
 
            self.root = root
626
 
 
627
 
        def authorize(self, channel, username, password):
628
 
            """Return (success, reply_string, filesystem)"""
629
 
            if not _have_medusa:
630
 
                return 0, 'No Medusa.', None
631
 
 
632
 
            channel.persona = -1, -1
633
 
            if username == 'anonymous':
634
 
                channel.read_only = 1
635
 
            else:
636
 
                channel.read_only = 0
637
 
 
638
 
            return 1, 'OK.', medusa.filesys.os_filesystem(self.root)
639
 
 
640
 
 
641
 
    class ftp_channel(medusa.ftp_server.ftp_channel):
642
 
        """Customized ftp channel"""
643
 
 
644
 
        def log(self, message):
645
 
            """Redirect logging requests."""
646
 
            mutter('_ftp_channel: %s', message)
647
 
 
648
 
        def log_info(self, message, type='info'):
649
 
            """Redirect logging requests."""
650
 
            mutter('_ftp_channel %s: %s', type, message)
651
 
 
652
 
        def cmd_rnfr(self, line):
653
 
            """Prepare for renaming a file."""
654
 
            self._renaming = line[1]
655
 
            self.respond('350 Ready for RNTO')
656
 
            # TODO: jam 20060516 in testing, the ftp server seems to
657
 
            #       check that the file already exists, or it sends
658
 
            #       550 RNFR command failed
659
 
 
660
 
        def cmd_rnto(self, line):
661
 
            """Rename a file based on the target given.
662
 
 
663
 
            rnto must be called after calling rnfr.
664
 
            """
665
 
            if not self._renaming:
666
 
                self.respond('503 RNFR required first.')
667
 
            pfrom = self.filesystem.translate(self._renaming)
668
 
            self._renaming = None
669
 
            pto = self.filesystem.translate(line[1])
670
 
            if os.path.exists(pto):
671
 
                self.respond('550 RNTO failed: file exists')
672
 
                return
673
 
            try:
674
 
                os.rename(pfrom, pto)
675
 
            except (IOError, OSError), e:
676
 
                # TODO: jam 20060516 return custom responses based on
677
 
                #       why the command failed
678
 
                # (bialix 20070418) str(e) on Python 2.5 @ Windows
679
 
                # sometimes don't provide expected error message;
680
 
                # so we obtain such message via os.strerror()
681
 
                self.respond('550 RNTO failed: %s' % os.strerror(e.errno))
682
 
            except:
683
 
                self.respond('550 RNTO failed')
684
 
                # For a test server, we will go ahead and just die
685
 
                raise
686
 
            else:
687
 
                self.respond('250 Rename successful.')
688
 
 
689
 
        def cmd_size(self, line):
690
 
            """Return the size of a file
691
 
 
692
 
            This is overloaded to help the test suite determine if the 
693
 
            target is a directory.
694
 
            """
695
 
            filename = line[1]
696
 
            if not self.filesystem.isfile(filename):
697
 
                if self.filesystem.isdir(filename):
698
 
                    self.respond('550 "%s" is a directory' % (filename,))
699
 
                else:
700
 
                    self.respond('550 "%s" is not a file' % (filename,))
701
 
            else:
702
 
                self.respond('213 %d' 
703
 
                    % (self.filesystem.stat(filename)[stat.ST_SIZE]),)
704
 
 
705
 
        def cmd_mkd(self, line):
706
 
            """Create a directory.
707
 
 
708
 
            Overloaded because default implementation does not distinguish
709
 
            *why* it cannot make a directory.
710
 
            """
711
 
            if len (line) != 2:
712
 
                self.command_not_understood(''.join(line))
713
 
            else:
714
 
                path = line[1]
715
 
                try:
716
 
                    self.filesystem.mkdir (path)
717
 
                    self.respond ('257 MKD command successful.')
718
 
                except (IOError, OSError), e:
719
 
                    # (bialix 20070418) str(e) on Python 2.5 @ Windows
720
 
                    # sometimes don't provide expected error message;
721
 
                    # so we obtain such message via os.strerror()
722
 
                    self.respond ('550 error creating directory: %s' %
723
 
                                  os.strerror(e.errno))
724
 
                except:
725
 
                    self.respond ('550 error creating directory.')
726
 
 
727
 
 
728
 
    class ftp_server(medusa.ftp_server.ftp_server):
729
 
        """Customize the behavior of the Medusa ftp_server.
730
 
 
731
 
        There are a few warts on the ftp_server, based on how it expects
732
 
        to be used.
733
 
        """
734
 
        _renaming = None
735
 
        ftp_channel_class = ftp_channel
736
 
 
737
 
        def __init__(self, *args, **kwargs):
738
 
            mutter('Initializing _ftp_server: %r, %r', args, kwargs)
739
 
            medusa.ftp_server.ftp_server.__init__(self, *args, **kwargs)
740
 
 
741
 
        def log(self, message):
742
 
            """Redirect logging requests."""
743
 
            mutter('_ftp_server: %s', message)
744
 
 
745
 
        def log_info(self, message, type='info'):
746
 
            """Override the asyncore.log_info so we don't stipple the screen."""
747
 
            mutter('_ftp_server %s: %s', type, message)
748
 
 
749
 
    _test_authorizer = test_authorizer
750
 
    _ftp_channel = ftp_channel
751
 
    _ftp_server = ftp_server
752
 
 
753
 
    return True
754
 
 
755
 
 
756
 
def get_test_permutations():
757
 
    """Return the permutations to be used in testing."""
758
 
    if not _setup_medusa():
759
 
        warn("You must install medusa (http://www.amk.ca/python/code/medusa.html) for FTP tests")
760
 
        return []
761
 
    else:
762
 
        return [(FtpTransport, FtpServer)]