/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

Merge from bzr.dev

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