~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: Vincent Ladeuil
  • Date: 2007-05-25 17:12:52 UTC
  • mto: (2485.8.44 bzr.connection.sharing)
  • mto: This revision was merged to the branch mainline in revision 2646.
  • Revision ID: v.ladeuil+lp@free.fr-20070525171252-hsooebpkqtb3j6ef
Add test for cat and missing.

* bzrlib/tests/commands/__init__.py:
(test_suite): Add test_cat and test_missing.

* bzrlib/tests/commands/test_cat.py: 
New file.

* bzrlib/tests/commands/test_missing.py: 
New file.

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