~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

Add source index to the index iteration API to allow mapping back to the origin of retrieved data.

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)]