/brz/remove-bazaar

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/brz/remove-bazaar

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: Alexander Belchenko
  • Date: 2007-04-16 20:42:59 UTC
  • mto: This revision was merged to the branch mainline in revision 2422.
  • Revision ID: bialix@ukr.net-20070416204259-tyi3ptqe80gpkwcl
forget to return tree

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