~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: Martin Pool
  • Date: 2008-05-02 07:31:24 UTC
  • mto: (3408.2.1 integration)
  • mto: This revision was merged to the branch mainline in revision 3410.
  • Revision ID: mbp@sourcefrog.net-20080502073124-nxmeqrzkji6u2m76
Remove code deprecated prior to 1.1 and its tests

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 errno
 
29
import ftplib
 
30
import getpass
 
31
import os
 
32
import os.path
 
33
import urlparse
 
34
import socket
 
35
import stat
 
36
import time
 
37
import random
 
38
from warnings import warn
 
39
 
 
40
from bzrlib import (
 
41
    config,
 
42
    errors,
 
43
    osutils,
 
44
    urlutils,
 
45
    )
 
46
from bzrlib.trace import mutter, warning
 
47
from bzrlib.transport import (
 
48
    AppendBasedFileStream,
 
49
    ConnectedTransport,
 
50
    _file_streams,
 
51
    register_urlparse_netloc_protocol,
 
52
    Server,
 
53
    )
 
54
from bzrlib.transport.local import LocalURLServer
 
55
import bzrlib.ui
 
56
 
 
57
 
 
58
register_urlparse_netloc_protocol('aftp')
 
59
 
 
60
 
 
61
class FtpPathError(errors.PathError):
 
62
    """FTP failed for path: %(path)s%(extra)s"""
 
63
 
 
64
 
 
65
class FtpStatResult(object):
 
66
    def __init__(self, f, relpath):
 
67
        try:
 
68
            self.st_size = f.size(relpath)
 
69
            self.st_mode = stat.S_IFREG
 
70
        except ftplib.error_perm:
 
71
            pwd = f.pwd()
 
72
            try:
 
73
                f.cwd(relpath)
 
74
                self.st_mode = stat.S_IFDIR
 
75
            finally:
 
76
                f.cwd(pwd)
 
77
 
 
78
 
 
79
_number_of_retries = 2
 
80
_sleep_between_retries = 5
 
81
 
 
82
# FIXME: there are inconsistencies in the way temporary errors are
 
83
# handled. Sometimes we reconnect, sometimes we raise an exception. Care should
 
84
# be taken to analyze the implications for write operations (read operations
 
85
# are safe to retry). Overall even some read operations are never
 
86
# retried. --vila 20070720 (Bug #127164)
 
87
class FtpTransport(ConnectedTransport):
 
88
    """This is the transport agent for ftp:// access."""
 
89
 
 
90
    def __init__(self, base, _from_transport=None):
 
91
        """Set the base path where files will be stored."""
 
92
        assert base.startswith('ftp://') or base.startswith('aftp://')
 
93
        super(FtpTransport, self).__init__(base,
 
94
                                           _from_transport=_from_transport)
 
95
        self._unqualified_scheme = 'ftp'
 
96
        if self._scheme == 'aftp':
 
97
            self.is_active = True
 
98
        else:
 
99
            self.is_active = False
 
100
 
 
101
    def _get_FTP(self):
 
102
        """Return the ftplib.FTP instance for this object."""
 
103
        # Ensures that a connection is established
 
104
        connection = self._get_connection()
 
105
        if connection is None:
 
106
            # First connection ever
 
107
            connection, credentials = self._create_connection()
 
108
            self._set_connection(connection, credentials)
 
109
        return connection
 
110
 
 
111
    def _create_connection(self, credentials=None):
 
112
        """Create a new connection with the provided credentials.
 
113
 
 
114
        :param credentials: The credentials needed to establish the connection.
 
115
 
 
116
        :return: The created connection and its associated credentials.
 
117
 
 
118
        The credentials are only the password as it may have been entered
 
119
        interactively by the user and may be different from the one provided
 
120
        in base url at transport creation time.
 
121
        """
 
122
        if credentials is None:
 
123
            user, password = self._user, self._password
 
124
        else:
 
125
            user, password = credentials
 
126
 
 
127
        auth = config.AuthenticationConfig()
 
128
        if user is None:
 
129
            user = auth.get_user('ftp', self._host, port=self._port)
 
130
            if user is None:
 
131
                # Default to local user
 
132
                user = getpass.getuser()
 
133
 
 
134
        mutter("Constructing FTP instance against %r" %
 
135
               ((self._host, self._port, user, '********',
 
136
                self.is_active),))
 
137
        try:
 
138
            connection = ftplib.FTP()
 
139
            connection.connect(host=self._host, port=self._port)
 
140
            if user and user != 'anonymous' and \
 
141
                    password is None: # '' is a valid password
 
142
                password = auth.get_password('ftp', self._host, user,
 
143
                                             port=self._port)
 
144
            connection.login(user=user, passwd=password)
 
145
            connection.set_pasv(not self.is_active)
 
146
        except socket.error, e:
 
147
            raise errors.SocketConnectionError(self._host, self._port,
 
148
                                               msg='Unable to connect to',
 
149
                                               orig_error= e)
 
150
        except ftplib.error_perm, e:
 
151
            raise errors.TransportError(msg="Error setting up connection:"
 
152
                                        " %s" % str(e), orig_error=e)
 
153
        return connection, (user, password)
 
154
 
 
155
    def _reconnect(self):
 
156
        """Create a new connection with the previously used credentials"""
 
157
        credentials = self._get_credentials()
 
158
        connection, credentials = self._create_connection(credentials)
 
159
        self._set_connection(connection, credentials)
 
160
 
 
161
    def _translate_perm_error(self, err, path, extra=None,
 
162
                              unknown_exc=FtpPathError):
 
163
        """Try to translate an ftplib.error_perm exception.
 
164
 
 
165
        :param err: The error to translate into a bzr error
 
166
        :param path: The path which had problems
 
167
        :param extra: Extra information which can be included
 
168
        :param unknown_exc: If None, we will just raise the original exception
 
169
                    otherwise we raise unknown_exc(path, extra=extra)
 
170
        """
 
171
        s = str(err).lower()
 
172
        if not extra:
 
173
            extra = str(err)
 
174
        else:
 
175
            extra += ': ' + str(err)
 
176
        if ('no such file' in s
 
177
            or 'could not open' in s
 
178
            or 'no such dir' in s
 
179
            or 'could not create file' in s # vsftpd
 
180
            or 'file doesn\'t exist' in s
 
181
            or 'rnfr command failed.' in s # vsftpd RNFR reply if file not found
 
182
            or 'file/directory not found' in s # filezilla server
 
183
            # Microsoft FTP-Service RNFR reply if file not found
 
184
            or (s.startswith('550 ') and 'unable to rename to' in extra)
 
185
            ):
 
186
            raise errors.NoSuchFile(path, extra=extra)
 
187
        if ('file exists' in s):
 
188
            raise errors.FileExists(path, extra=extra)
 
189
        if ('not a directory' in s):
 
190
            raise errors.PathError(path, extra=extra)
 
191
 
 
192
        mutter('unable to understand error for path: %s: %s', path, err)
 
193
 
 
194
        if unknown_exc:
 
195
            raise unknown_exc(path, extra=extra)
 
196
        # TODO: jam 20060516 Consider re-raising the error wrapped in 
 
197
        #       something like TransportError, but this loses the traceback
 
198
        #       Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
 
199
        #       to handle. Consider doing something like that here.
 
200
        #raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
 
201
        raise
 
202
 
 
203
    def _remote_path(self, relpath):
 
204
        # XXX: It seems that ftplib does not handle Unicode paths
 
205
        # at the same time, medusa won't handle utf8 paths So if
 
206
        # we .encode(utf8) here (see ConnectedTransport
 
207
        # implementation), then we get a Server failure.  while
 
208
        # if we use str(), we get a UnicodeError, and the test
 
209
        # suite just skips testing UnicodePaths.
 
210
        relative = str(urlutils.unescape(relpath))
 
211
        remote_path = self._combine_paths(self._path, relative)
 
212
        return remote_path
 
213
 
 
214
    def has(self, relpath):
 
215
        """Does the target location exist?"""
 
216
        # FIXME jam 20060516 We *do* ask about directories in the test suite
 
217
        #       We don't seem to in the actual codebase
 
218
        # XXX: I assume we're never asked has(dirname) and thus I use
 
219
        # the FTP size command and assume that if it doesn't raise,
 
220
        # all is good.
 
221
        abspath = self._remote_path(relpath)
 
222
        try:
 
223
            f = self._get_FTP()
 
224
            mutter('FTP has check: %s => %s', relpath, abspath)
 
225
            s = f.size(abspath)
 
226
            mutter("FTP has: %s", abspath)
 
227
            return True
 
228
        except ftplib.error_perm, e:
 
229
            if ('is a directory' in str(e).lower()):
 
230
                mutter("FTP has dir: %s: %s", abspath, e)
 
231
                return True
 
232
            mutter("FTP has not: %s: %s", abspath, e)
 
233
            return False
 
234
 
 
235
    def get(self, relpath, decode=False, retries=0):
 
236
        """Get the file at the given relative path.
 
237
 
 
238
        :param relpath: The relative path to the file
 
239
        :param retries: Number of retries after temporary failures so far
 
240
                        for this operation.
 
241
 
 
242
        We're meant to return a file-like object which bzr will
 
243
        then read from. For now we do this via the magic of StringIO
 
244
        """
 
245
        # TODO: decode should be deprecated
 
246
        try:
 
247
            mutter("FTP get: %s", self._remote_path(relpath))
 
248
            f = self._get_FTP()
 
249
            ret = StringIO()
 
250
            f.retrbinary('RETR '+self._remote_path(relpath), ret.write, 8192)
 
251
            ret.seek(0)
 
252
            return ret
 
253
        except ftplib.error_perm, e:
 
254
            raise errors.NoSuchFile(self.abspath(relpath), extra=str(e))
 
255
        except ftplib.error_temp, e:
 
256
            if retries > _number_of_retries:
 
257
                raise errors.TransportError(msg="FTP temporary error during GET %s. Aborting."
 
258
                                     % self.abspath(relpath),
 
259
                                     orig_error=e)
 
260
            else:
 
261
                warning("FTP temporary error: %s. Retrying.", str(e))
 
262
                self._reconnect()
 
263
                return self.get(relpath, decode, retries+1)
 
264
        except EOFError, e:
 
265
            if retries > _number_of_retries:
 
266
                raise errors.TransportError("FTP control connection closed during GET %s."
 
267
                                     % self.abspath(relpath),
 
268
                                     orig_error=e)
 
269
            else:
 
270
                warning("FTP control connection closed. Trying to reopen.")
 
271
                time.sleep(_sleep_between_retries)
 
272
                self._reconnect()
 
273
                return self.get(relpath, decode, retries+1)
 
274
 
 
275
    def put_file(self, relpath, fp, mode=None, retries=0):
 
276
        """Copy the file-like or string object into the location.
 
277
 
 
278
        :param relpath: Location to put the contents, relative to base.
 
279
        :param fp:       File-like or string object.
 
280
        :param retries: Number of retries after temporary failures so far
 
281
                        for this operation.
 
282
 
 
283
        TODO: jam 20051215 ftp as a protocol seems to support chmod, but
 
284
        ftplib does not
 
285
        """
 
286
        abspath = self._remote_path(relpath)
 
287
        tmp_abspath = '%s.tmp.%.9f.%d.%d' % (abspath, time.time(),
 
288
                        os.getpid(), random.randint(0,0x7FFFFFFF))
 
289
        bytes = None
 
290
        if getattr(fp, 'read', None) is None:
 
291
            # hand in a string IO
 
292
            bytes = fp
 
293
            fp = StringIO(bytes)
 
294
        else:
 
295
            # capture the byte count; .read() may be read only so
 
296
            # decorate it.
 
297
            class byte_counter(object):
 
298
                def __init__(self, fp):
 
299
                    self.fp = fp
 
300
                    self.counted_bytes = 0
 
301
                def read(self, count):
 
302
                    result = self.fp.read(count)
 
303
                    self.counted_bytes += len(result)
 
304
                    return result
 
305
            fp = byte_counter(fp)
 
306
        try:
 
307
            mutter("FTP put: %s", abspath)
 
308
            f = self._get_FTP()
 
309
            try:
 
310
                f.storbinary('STOR '+tmp_abspath, fp)
 
311
                self._rename_and_overwrite(tmp_abspath, abspath, f)
 
312
                if bytes is not None:
 
313
                    return len(bytes)
 
314
                else:
 
315
                    return fp.counted_bytes
 
316
            except (ftplib.error_temp,EOFError), e:
 
317
                warning("Failure during ftp PUT. Deleting temporary file.")
 
318
                try:
 
319
                    f.delete(tmp_abspath)
 
320
                except:
 
321
                    warning("Failed to delete temporary file on the"
 
322
                            " server.\nFile: %s", tmp_abspath)
 
323
                    raise e
 
324
                raise
 
325
        except ftplib.error_perm, e:
 
326
            self._translate_perm_error(e, abspath, extra='could not store',
 
327
                                       unknown_exc=errors.NoSuchFile)
 
328
        except ftplib.error_temp, e:
 
329
            if retries > _number_of_retries:
 
330
                raise errors.TransportError("FTP temporary error during PUT %s. Aborting."
 
331
                                     % self.abspath(relpath), orig_error=e)
 
332
            else:
 
333
                warning("FTP temporary error: %s. Retrying.", str(e))
 
334
                self._reconnect()
 
335
                self.put_file(relpath, fp, mode, retries+1)
 
336
        except EOFError:
 
337
            if retries > _number_of_retries:
 
338
                raise errors.TransportError("FTP control connection closed during PUT %s."
 
339
                                     % self.abspath(relpath), orig_error=e)
 
340
            else:
 
341
                warning("FTP control connection closed. Trying to reopen.")
 
342
                time.sleep(_sleep_between_retries)
 
343
                self._reconnect()
 
344
                self.put_file(relpath, fp, mode, retries+1)
 
345
 
 
346
    def mkdir(self, relpath, mode=None):
 
347
        """Create a directory at the given path."""
 
348
        abspath = self._remote_path(relpath)
 
349
        try:
 
350
            mutter("FTP mkd: %s", abspath)
 
351
            f = self._get_FTP()
 
352
            f.mkd(abspath)
 
353
        except ftplib.error_perm, e:
 
354
            self._translate_perm_error(e, abspath,
 
355
                unknown_exc=errors.FileExists)
 
356
 
 
357
    def open_write_stream(self, relpath, mode=None):
 
358
        """See Transport.open_write_stream."""
 
359
        self.put_bytes(relpath, "", mode)
 
360
        result = AppendBasedFileStream(self, relpath)
 
361
        _file_streams[self.abspath(relpath)] = result
 
362
        return result
 
363
 
 
364
    def recommended_page_size(self):
 
365
        """See Transport.recommended_page_size().
 
366
 
 
367
        For FTP we suggest a large page size to reduce the overhead
 
368
        introduced by latency.
 
369
        """
 
370
        return 64 * 1024
 
371
 
 
372
    def rmdir(self, rel_path):
 
373
        """Delete the directory at rel_path"""
 
374
        abspath = self._remote_path(rel_path)
 
375
        try:
 
376
            mutter("FTP rmd: %s", abspath)
 
377
            f = self._get_FTP()
 
378
            f.rmd(abspath)
 
379
        except ftplib.error_perm, e:
 
380
            self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
 
381
 
 
382
    def append_file(self, relpath, f, mode=None):
 
383
        """Append the text in the file-like object into the final
 
384
        location.
 
385
        """
 
386
        abspath = self._remote_path(relpath)
 
387
        if self.has(relpath):
 
388
            ftp = self._get_FTP()
 
389
            result = ftp.size(abspath)
 
390
        else:
 
391
            result = 0
 
392
 
 
393
        mutter("FTP appe to %s", abspath)
 
394
        self._try_append(relpath, f.read(), mode)
 
395
 
 
396
        return result
 
397
 
 
398
    def _try_append(self, relpath, text, mode=None, retries=0):
 
399
        """Try repeatedly to append the given text to the file at relpath.
 
400
        
 
401
        This is a recursive function. On errors, it will be called until the
 
402
        number of retries is exceeded.
 
403
        """
 
404
        try:
 
405
            abspath = self._remote_path(relpath)
 
406
            mutter("FTP appe (try %d) to %s", retries, abspath)
 
407
            ftp = self._get_FTP()
 
408
            ftp.voidcmd("TYPE I")
 
409
            cmd = "APPE %s" % abspath
 
410
            conn = ftp.transfercmd(cmd)
 
411
            conn.sendall(text)
 
412
            conn.close()
 
413
            if mode:
 
414
                self._setmode(relpath, mode)
 
415
            ftp.getresp()
 
416
        except ftplib.error_perm, e:
 
417
            self._translate_perm_error(e, abspath, extra='error appending',
 
418
                unknown_exc=errors.NoSuchFile)
 
419
        except ftplib.error_temp, e:
 
420
            if retries > _number_of_retries:
 
421
                raise errors.TransportError("FTP temporary error during APPEND %s." \
 
422
                        "Aborting." % abspath, orig_error=e)
 
423
            else:
 
424
                warning("FTP temporary error: %s. Retrying.", str(e))
 
425
                self._reconnect()
 
426
                self._try_append(relpath, text, mode, retries+1)
 
427
 
 
428
    def _setmode(self, relpath, mode):
 
429
        """Set permissions on a path.
 
430
 
 
431
        Only set permissions if the FTP server supports the 'SITE CHMOD'
 
432
        extension.
 
433
        """
 
434
        try:
 
435
            mutter("FTP site chmod: setting permissions to %s on %s",
 
436
                str(mode), self._remote_path(relpath))
 
437
            ftp = self._get_FTP()
 
438
            cmd = "SITE CHMOD %s %s" % (self._remote_path(relpath), str(mode))
 
439
            ftp.sendcmd(cmd)
 
440
        except ftplib.error_perm, e:
 
441
            # Command probably not available on this server
 
442
            warning("FTP Could not set permissions to %s on %s. %s",
 
443
                    str(mode), self._remote_path(relpath), str(e))
 
444
 
 
445
    # TODO: jam 20060516 I believe ftp allows you to tell an ftp server
 
446
    #       to copy something to another machine. And you may be able
 
447
    #       to give it its own address as the 'to' location.
 
448
    #       So implement a fancier 'copy()'
 
449
 
 
450
    def rename(self, rel_from, rel_to):
 
451
        abs_from = self._remote_path(rel_from)
 
452
        abs_to = self._remote_path(rel_to)
 
453
        mutter("FTP rename: %s => %s", abs_from, abs_to)
 
454
        f = self._get_FTP()
 
455
        return self._rename(abs_from, abs_to, f)
 
456
 
 
457
    def _rename(self, abs_from, abs_to, f):
 
458
        try:
 
459
            f.rename(abs_from, abs_to)
 
460
        except ftplib.error_perm, e:
 
461
            self._translate_perm_error(e, abs_from,
 
462
                ': unable to rename to %r' % (abs_to))
 
463
 
 
464
    def move(self, rel_from, rel_to):
 
465
        """Move the item at rel_from to the location at rel_to"""
 
466
        abs_from = self._remote_path(rel_from)
 
467
        abs_to = self._remote_path(rel_to)
 
468
        try:
 
469
            mutter("FTP mv: %s => %s", abs_from, abs_to)
 
470
            f = self._get_FTP()
 
471
            self._rename_and_overwrite(abs_from, abs_to, f)
 
472
        except ftplib.error_perm, e:
 
473
            self._translate_perm_error(e, abs_from,
 
474
                extra='unable to rename to %r' % (rel_to,), 
 
475
                unknown_exc=errors.PathError)
 
476
 
 
477
    def _rename_and_overwrite(self, abs_from, abs_to, f):
 
478
        """Do a fancy rename on the remote server.
 
479
 
 
480
        Using the implementation provided by osutils.
 
481
        """
 
482
        osutils.fancy_rename(abs_from, abs_to,
 
483
            rename_func=lambda p1, p2: self._rename(p1, p2, f),
 
484
            unlink_func=lambda p: self._delete(p, f))
 
485
 
 
486
    def delete(self, relpath):
 
487
        """Delete the item at relpath"""
 
488
        abspath = self._remote_path(relpath)
 
489
        f = self._get_FTP()
 
490
        self._delete(abspath, f)
 
491
 
 
492
    def _delete(self, abspath, f):
 
493
        try:
 
494
            mutter("FTP rm: %s", abspath)
 
495
            f.delete(abspath)
 
496
        except ftplib.error_perm, e:
 
497
            self._translate_perm_error(e, abspath, 'error deleting',
 
498
                unknown_exc=errors.NoSuchFile)
 
499
 
 
500
    def external_url(self):
 
501
        """See bzrlib.transport.Transport.external_url."""
 
502
        # FTP URL's are externally usable.
 
503
        return self.base
 
504
 
 
505
    def listable(self):
 
506
        """See Transport.listable."""
 
507
        return True
 
508
 
 
509
    def list_dir(self, relpath):
 
510
        """See Transport.list_dir."""
 
511
        basepath = self._remote_path(relpath)
 
512
        mutter("FTP nlst: %s", basepath)
 
513
        f = self._get_FTP()
 
514
        try:
 
515
            paths = f.nlst(basepath)
 
516
        except ftplib.error_perm, e:
 
517
            self._translate_perm_error(e, relpath, extra='error with list_dir')
 
518
        # If FTP.nlst returns paths prefixed by relpath, strip 'em
 
519
        if paths and paths[0].startswith(basepath):
 
520
            entries = [path[len(basepath)+1:] for path in paths]
 
521
        else:
 
522
            entries = paths
 
523
        # Remove . and .. if present
 
524
        return [urlutils.escape(entry) for entry in entries
 
525
                if entry not in ('.', '..')]
 
526
 
 
527
    def iter_files_recursive(self):
 
528
        """See Transport.iter_files_recursive.
 
529
 
 
530
        This is cargo-culted from the SFTP transport"""
 
531
        mutter("FTP iter_files_recursive")
 
532
        queue = list(self.list_dir("."))
 
533
        while queue:
 
534
            relpath = queue.pop(0)
 
535
            st = self.stat(relpath)
 
536
            if stat.S_ISDIR(st.st_mode):
 
537
                for i, basename in enumerate(self.list_dir(relpath)):
 
538
                    queue.insert(i, relpath+"/"+basename)
 
539
            else:
 
540
                yield relpath
 
541
 
 
542
    def stat(self, relpath):
 
543
        """Return the stat information for a file."""
 
544
        abspath = self._remote_path(relpath)
 
545
        try:
 
546
            mutter("FTP stat: %s", abspath)
 
547
            f = self._get_FTP()
 
548
            return FtpStatResult(f, abspath)
 
549
        except ftplib.error_perm, e:
 
550
            self._translate_perm_error(e, abspath, extra='error w/ stat')
 
551
 
 
552
    def lock_read(self, relpath):
 
553
        """Lock the given file for shared (read) access.
 
554
        :return: A lock object, which should be passed to Transport.unlock()
 
555
        """
 
556
        # The old RemoteBranch ignore lock for reading, so we will
 
557
        # continue that tradition and return a bogus lock object.
 
558
        class BogusLock(object):
 
559
            def __init__(self, path):
 
560
                self.path = path
 
561
            def unlock(self):
 
562
                pass
 
563
        return BogusLock(relpath)
 
564
 
 
565
    def lock_write(self, relpath):
 
566
        """Lock the given file for exclusive (write) access.
 
567
        WARNING: many transports do not support this, so trying avoid using it
 
568
 
 
569
        :return: A lock object, which should be passed to Transport.unlock()
 
570
        """
 
571
        return self.lock_read(relpath)
 
572
 
 
573
 
 
574
def get_test_permutations():
 
575
    """Return the permutations to be used in testing."""
 
576
    from bzrlib import tests
 
577
    if tests.FTPServerFeature.available():
 
578
        from bzrlib.tests import ftp_server
 
579
        return [(FtpTransport, ftp_server.FTPServer)]
 
580
    else:
 
581
        # Dummy server to have the test suite report the number of tests
 
582
        # needing that feature. We raise UnavailableFeature from methods before
 
583
        # the test server is being used. Doing so in the setUp method has bad
 
584
        # side-effects (tearDown is never called).
 
585
        class UnavailableFTPServer(object):
 
586
 
 
587
            def setUp(self):
 
588
                pass
 
589
 
 
590
            def tearDown(self):
 
591
                pass
 
592
 
 
593
            def get_url(self):
 
594
                raise tests.UnavailableFeature(tests.FTPServerFeature)
 
595
 
 
596
            def get_bogus_url(self):
 
597
                raise tests.UnavailableFeature(tests.FTPServerFeature)
 
598
 
 
599
        return [(FtpTransport, UnavailableFTPServer)]