~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: v.ladeuil+lp at free
  • Date: 2007-05-15 17:40:32 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-20070515174032-qzdkangpv29l9e7g
Add a test that check that init connect only once. It fails.

* __init__.py:
(test_suite): Register the new test class.

* test_init.py: 
(InstrumentedTransport): A transport that can track connections.
(TransportHooks): Transport specific hooks.
(TestInit): Iniit command behavior tests.

* ftp.py:
(FtpTransport.__init__): Mark place that need fixing regarding
transport connection sharing

* builtins.py:
(cmd_init.run): Mark places that need fixing regarding transport
connection sharing.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2005-2010 Canonical Ltd
 
1
# Copyright (C) 2005, 2006, 2007 Canonical Ltd
2
2
#
3
3
# This program is free software; you can redistribute it and/or modify
4
4
# it under the terms of the GNU General Public License as published by
12
12
#
13
13
# You should have received a copy of the GNU General Public License
14
14
# along with this program; if not, write to the Free Software
15
 
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16
 
 
 
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17
16
"""Implementation of Transport over ftp.
18
17
 
19
18
Written by Daniel Silverstone <dsilvers@digital-scurf.org> with serious
26
25
"""
27
26
 
28
27
from cStringIO import StringIO
 
28
import asyncore
 
29
import errno
29
30
import ftplib
30
 
import getpass
31
31
import os
32
 
import random
33
 
import socket
 
32
import os.path
 
33
import urllib
 
34
import urlparse
 
35
import select
34
36
import stat
 
37
import threading
35
38
import time
 
39
import random
 
40
from warnings import warn
36
41
 
37
42
from bzrlib import (
38
 
    config,
39
43
    errors,
40
44
    osutils,
41
45
    urlutils,
42
46
    )
43
 
from bzrlib.symbol_versioning import (
44
 
    DEPRECATED_PARAMETER,
45
 
    deprecated_in,
46
 
    deprecated_passed,
47
 
    warn,
48
 
    )
49
47
from bzrlib.trace import mutter, warning
50
48
from bzrlib.transport import (
51
 
    AppendBasedFileStream,
52
 
    ConnectedTransport,
53
 
    _file_streams,
54
 
    register_urlparse_netloc_protocol,
55
49
    Server,
 
50
    split_url,
 
51
    Transport,
56
52
    )
57
 
 
58
 
 
59
 
register_urlparse_netloc_protocol('aftp')
 
53
from bzrlib.transport.local import LocalURLServer
 
54
import bzrlib.ui
 
55
 
 
56
_have_medusa = False
60
57
 
61
58
 
62
59
class FtpPathError(errors.PathError):
63
60
    """FTP failed for path: %(path)s%(extra)s"""
64
61
 
65
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
 
66
85
class FtpStatResult(object):
67
 
 
68
 
    def __init__(self, f, abspath):
 
86
    def __init__(self, f, relpath):
69
87
        try:
70
 
            self.st_size = f.size(abspath)
 
88
            self.st_size = f.size(relpath)
71
89
            self.st_mode = stat.S_IFREG
72
90
        except ftplib.error_perm:
73
91
            pwd = f.pwd()
74
92
            try:
75
 
                f.cwd(abspath)
 
93
                f.cwd(relpath)
76
94
                self.st_mode = stat.S_IFDIR
77
95
            finally:
78
96
                f.cwd(pwd)
81
99
_number_of_retries = 2
82
100
_sleep_between_retries = 5
83
101
 
84
 
# FIXME: there are inconsistencies in the way temporary errors are
85
 
# handled. Sometimes we reconnect, sometimes we raise an exception. Care should
86
 
# be taken to analyze the implications for write operations (read operations
87
 
# are safe to retry). Overall even some read operations are never
88
 
# retried. --vila 20070720 (Bug #127164)
89
 
class FtpTransport(ConnectedTransport):
 
102
class FtpTransport(Transport):
90
103
    """This is the transport agent for ftp:// access."""
91
104
 
92
 
    def __init__(self, base, _from_transport=None):
 
105
    def __init__(self, base, _provided_instance=None):
93
106
        """Set the base path where files will be stored."""
94
 
        if not (base.startswith('ftp://') or base.startswith('aftp://')):
95
 
            raise ValueError(base)
96
 
        super(FtpTransport, self).__init__(base,
97
 
                                           _from_transport=_from_transport)
98
 
        self._unqualified_scheme = 'ftp'
99
 
        if self._parsed_url.scheme == 'aftp':
100
 
            self.is_active = True
101
 
        else:
102
 
            self.is_active = False
103
 
 
104
 
        # Most modern FTP servers support the APPE command. If ours doesn't, we
105
 
        # (re)set this flag accordingly later.
106
 
        self._has_append = True
 
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
 
 
113
            # FIXME: This breaks even hopes of connection sharing
 
114
            # by 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, '', '', ''))
107
139
 
108
140
    def _get_FTP(self):
109
141
        """Return the ftplib.FTP instance for this object."""
110
 
        # Ensures that a connection is established
111
 
        connection = self._get_connection()
112
 
        if connection is None:
113
 
            # First connection ever
114
 
            connection, credentials = self._create_connection()
115
 
            self._set_connection(connection, credentials)
116
 
        return connection
117
 
 
118
 
    connection_class = ftplib.FTP
119
 
 
120
 
    def _create_connection(self, credentials=None):
121
 
        """Create a new connection with the provided credentials.
122
 
 
123
 
        :param credentials: The credentials needed to establish the connection.
124
 
 
125
 
        :return: The created connection and its associated credentials.
126
 
 
127
 
        The input credentials are only the password as it may have been
128
 
        entered interactively by the user and may be different from the one
129
 
        provided in base url at transport creation time.  The returned
130
 
        credentials are username, password.
131
 
        """
132
 
        if credentials is None:
133
 
            user, password = self._user, self._password
134
 
        else:
135
 
            user, password = credentials
136
 
 
137
 
        auth = config.AuthenticationConfig()
138
 
        if user is None:
139
 
            user = auth.get_user('ftp', self._host, port=self._port,
140
 
                                 default=getpass.getuser())
141
 
        mutter("Constructing FTP instance against %r" %
142
 
               ((self._host, self._port, user, '********',
143
 
                self.is_active),))
 
142
        if self._FTP_instance is not None:
 
143
            return self._FTP_instance
 
144
        
144
145
        try:
145
 
            connection = self.connection_class()
146
 
            connection.connect(host=self._host, port=self._port)
147
 
            self._login(connection, auth, user, password)
148
 
            connection.set_pasv(not self.is_active)
149
 
            # binary mode is the default
150
 
            connection.voidcmd('TYPE I')
151
 
        except socket.error, e:
152
 
            raise errors.SocketConnectionError(self._host, self._port,
153
 
                                               msg='Unable to connect to',
154
 
                                               orig_error= e)
 
146
            self._FTP_instance = _find_FTP(self._host, self._port,
 
147
                                           self._username, self._password,
 
148
                                           self.is_active)
 
149
            return self._FTP_instance
155
150
        except ftplib.error_perm, e:
156
 
            raise errors.TransportError(msg="Error setting up connection:"
157
 
                                        " %s" % str(e), orig_error=e)
158
 
        return connection, (user, password)
159
 
 
160
 
    def _login(self, connection, auth, user, password):
161
 
        # '' is a valid password
162
 
        if user and user != 'anonymous' and password is None:
163
 
            password = auth.get_password('ftp', self._host,
164
 
                                         user, port=self._port)
165
 
        connection.login(user=user, passwd=password)
166
 
 
167
 
    def _reconnect(self):
168
 
        """Create a new connection with the previously used credentials"""
169
 
        credentials = self._get_credentials()
170
 
        connection, credentials = self._create_connection(credentials)
171
 
        self._set_connection(connection, credentials)
172
 
 
173
 
    def disconnect(self):
174
 
        connection = self._get_connection()
175
 
        if connection is not None:
176
 
            connection.close()
177
 
 
178
 
    def _translate_ftp_error(self, err, path, extra=None,
179
 
                              unknown_exc=FtpPathError):
180
 
        """Try to translate an ftplib exception to a bzrlib exception.
 
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.
181
156
 
182
157
        :param err: The error to translate into a bzr error
183
158
        :param path: The path which had problems
185
160
        :param unknown_exc: If None, we will just raise the original exception
186
161
                    otherwise we raise unknown_exc(path, extra=extra)
187
162
        """
188
 
        # ftp error numbers are very generic, like "451: Requested action aborted,
189
 
        # local error in processing" so unfortunately we have to match by
190
 
        # strings.
191
163
        s = str(err).lower()
192
164
        if not extra:
193
165
            extra = str(err)
197
169
            or 'could not open' in s
198
170
            or 'no such dir' in s
199
171
            or 'could not create file' in s # vsftpd
200
 
            or 'file doesn\'t exist' in s
201
 
            or 'rnfr command failed.' in s # vsftpd RNFR reply if file not found
202
 
            or 'file/directory not found' in s # filezilla server
203
 
            # Microsoft FTP-Service RNFR reply if file not found
204
 
            or (s.startswith('550 ') and 'unable to rename to' in extra)
205
 
            # if containing directory doesn't exist, suggested by
206
 
            # <https://bugs.launchpad.net/bzr/+bug/224373>
207
 
            or (s.startswith('550 ') and "can't find folder" in s)
208
172
            ):
209
173
            raise errors.NoSuchFile(path, extra=extra)
210
 
        elif ('file exists' in s):
 
174
        if ('file exists' in s):
211
175
            raise errors.FileExists(path, extra=extra)
212
 
        elif ('not a directory' in s):
 
176
        if ('not a directory' in s):
213
177
            raise errors.PathError(path, extra=extra)
214
 
        elif 'directory not empty' in s:
215
 
            raise errors.DirectoryNotEmpty(path, extra=extra)
216
178
 
217
179
        mutter('unable to understand error for path: %s: %s', path, err)
218
180
 
219
181
        if unknown_exc:
220
182
            raise unknown_exc(path, extra=extra)
221
 
        # TODO: jam 20060516 Consider re-raising the error wrapped in
 
183
        # TODO: jam 20060516 Consider re-raising the error wrapped in 
222
184
        #       something like TransportError, but this loses the traceback
223
185
        #       Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
224
186
        #       to handle. Consider doing something like that here.
225
187
        #raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
226
188
        raise
227
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
 
228
242
    def has(self, relpath):
229
243
        """Does the target location exist?"""
230
244
        # FIXME jam 20060516 We *do* ask about directories in the test suite
232
246
        # XXX: I assume we're never asked has(dirname) and thus I use
233
247
        # the FTP size command and assume that if it doesn't raise,
234
248
        # all is good.
235
 
        abspath = self._remote_path(relpath)
 
249
        abspath = self._abspath(relpath)
236
250
        try:
237
251
            f = self._get_FTP()
238
252
            mutter('FTP has check: %s => %s', relpath, abspath)
246
260
            mutter("FTP has not: %s: %s", abspath, e)
247
261
            return False
248
262
 
249
 
    def get(self, relpath, decode=DEPRECATED_PARAMETER, retries=0):
 
263
    def get(self, relpath, decode=False, retries=0):
250
264
        """Get the file at the given relative path.
251
265
 
252
266
        :param relpath: The relative path to the file
256
270
        We're meant to return a file-like object which bzr will
257
271
        then read from. For now we do this via the magic of StringIO
258
272
        """
259
 
        if deprecated_passed(decode):
260
 
            warn(deprecated_in((2,3,0)) %
261
 
                 '"decode" parameter to FtpTransport.get()',
262
 
                 DeprecationWarning, stacklevel=2)
 
273
        # TODO: decode should be deprecated
263
274
        try:
264
 
            mutter("FTP get: %s", self._remote_path(relpath))
 
275
            mutter("FTP get: %s", self._abspath(relpath))
265
276
            f = self._get_FTP()
266
277
            ret = StringIO()
267
 
            f.retrbinary('RETR '+self._remote_path(relpath), ret.write, 8192)
 
278
            f.retrbinary('RETR '+self._abspath(relpath), ret.write, 8192)
268
279
            ret.seek(0)
269
280
            return ret
270
281
        except ftplib.error_perm, e:
276
287
                                     orig_error=e)
277
288
            else:
278
289
                warning("FTP temporary error: %s. Retrying.", str(e))
279
 
                self._reconnect()
 
290
                self._FTP_instance = None
280
291
                return self.get(relpath, decode, retries+1)
281
292
        except EOFError, e:
282
293
            if retries > _number_of_retries:
286
297
            else:
287
298
                warning("FTP control connection closed. Trying to reopen.")
288
299
                time.sleep(_sleep_between_retries)
289
 
                self._reconnect()
 
300
                self._FTP_instance = None
290
301
                return self.get(relpath, decode, retries+1)
291
302
 
292
303
    def put_file(self, relpath, fp, mode=None, retries=0):
300
311
        TODO: jam 20051215 ftp as a protocol seems to support chmod, but
301
312
        ftplib does not
302
313
        """
303
 
        abspath = self._remote_path(relpath)
 
314
        abspath = self._abspath(relpath)
304
315
        tmp_abspath = '%s.tmp.%.9f.%d.%d' % (abspath, time.time(),
305
316
                        os.getpid(), random.randint(0,0x7FFFFFFF))
306
 
        bytes = None
307
317
        if getattr(fp, 'read', None) is None:
308
 
            # hand in a string IO
309
 
            bytes = fp
310
 
            fp = StringIO(bytes)
311
 
        else:
312
 
            # capture the byte count; .read() may be read only so
313
 
            # decorate it.
314
 
            class byte_counter(object):
315
 
                def __init__(self, fp):
316
 
                    self.fp = fp
317
 
                    self.counted_bytes = 0
318
 
                def read(self, count):
319
 
                    result = self.fp.read(count)
320
 
                    self.counted_bytes += len(result)
321
 
                    return result
322
 
            fp = byte_counter(fp)
 
318
            fp = StringIO(fp)
323
319
        try:
324
320
            mutter("FTP put: %s", abspath)
325
321
            f = self._get_FTP()
326
322
            try:
327
323
                f.storbinary('STOR '+tmp_abspath, fp)
328
324
                self._rename_and_overwrite(tmp_abspath, abspath, f)
329
 
                self._setmode(relpath, mode)
330
 
                if bytes is not None:
331
 
                    return len(bytes)
332
 
                else:
333
 
                    return fp.counted_bytes
334
 
            except (ftplib.error_temp, EOFError), e:
335
 
                warning("Failure during ftp PUT of %s: %s. Deleting temporary file."
336
 
                    % (tmp_abspath, e, ))
 
325
            except (ftplib.error_temp,EOFError), e:
 
326
                warning("Failure during ftp PUT. Deleting temporary file.")
337
327
                try:
338
328
                    f.delete(tmp_abspath)
339
329
                except:
342
332
                    raise e
343
333
                raise
344
334
        except ftplib.error_perm, e:
345
 
            self._translate_ftp_error(e, abspath, extra='could not store',
346
 
                                       unknown_exc=errors.NoSuchFile)
 
335
            self._translate_perm_error(e, abspath, extra='could not store')
347
336
        except ftplib.error_temp, e:
348
337
            if retries > _number_of_retries:
349
 
                raise errors.TransportError(
350
 
                    "FTP temporary error during PUT %s: %s. Aborting."
351
 
                    % (self.abspath(relpath), e), orig_error=e)
 
338
                raise errors.TransportError("FTP temporary error during PUT %s. Aborting."
 
339
                                     % self.abspath(relpath), orig_error=e)
352
340
            else:
353
341
                warning("FTP temporary error: %s. Retrying.", str(e))
354
 
                self._reconnect()
 
342
                self._FTP_instance = None
355
343
                self.put_file(relpath, fp, mode, retries+1)
356
344
        except EOFError:
357
345
            if retries > _number_of_retries:
360
348
            else:
361
349
                warning("FTP control connection closed. Trying to reopen.")
362
350
                time.sleep(_sleep_between_retries)
363
 
                self._reconnect()
 
351
                self._FTP_instance = None
364
352
                self.put_file(relpath, fp, mode, retries+1)
365
353
 
366
354
    def mkdir(self, relpath, mode=None):
367
355
        """Create a directory at the given path."""
368
 
        abspath = self._remote_path(relpath)
 
356
        abspath = self._abspath(relpath)
369
357
        try:
370
358
            mutter("FTP mkd: %s", abspath)
371
359
            f = self._get_FTP()
372
 
            try:
373
 
                f.mkd(abspath)
374
 
            except ftplib.error_reply, e:
375
 
                # <https://bugs.launchpad.net/bzr/+bug/224373> Microsoft FTP
376
 
                # server returns "250 Directory created." which is kind of
377
 
                # reasonable, 250 meaning "requested file action OK", but not what
378
 
                # Python's ftplib expects.
379
 
                if e[0][:3] == '250':
380
 
                    pass
381
 
                else:
382
 
                    raise
383
 
            self._setmode(relpath, mode)
 
360
            f.mkd(abspath)
384
361
        except ftplib.error_perm, e:
385
 
            self._translate_ftp_error(e, abspath,
 
362
            self._translate_perm_error(e, abspath,
386
363
                unknown_exc=errors.FileExists)
387
364
 
388
 
    def open_write_stream(self, relpath, mode=None):
389
 
        """See Transport.open_write_stream."""
390
 
        self.put_bytes(relpath, "", mode)
391
 
        result = AppendBasedFileStream(self, relpath)
392
 
        _file_streams[self.abspath(relpath)] = result
393
 
        return result
394
 
 
395
 
    def recommended_page_size(self):
396
 
        """See Transport.recommended_page_size().
397
 
 
398
 
        For FTP we suggest a large page size to reduce the overhead
399
 
        introduced by latency.
400
 
        """
401
 
        return 64 * 1024
402
 
 
403
365
    def rmdir(self, rel_path):
404
366
        """Delete the directory at rel_path"""
405
 
        abspath = self._remote_path(rel_path)
 
367
        abspath = self._abspath(rel_path)
406
368
        try:
407
369
            mutter("FTP rmd: %s", abspath)
408
370
            f = self._get_FTP()
409
371
            f.rmd(abspath)
410
372
        except ftplib.error_perm, e:
411
 
            self._translate_ftp_error(e, abspath, unknown_exc=errors.PathError)
 
373
            self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
412
374
 
413
375
    def append_file(self, relpath, f, mode=None):
414
376
        """Append the text in the file-like object into the final
415
377
        location.
416
378
        """
417
 
        text = f.read()
418
 
        abspath = self._remote_path(relpath)
 
379
        abspath = self._abspath(relpath)
419
380
        if self.has(relpath):
420
381
            ftp = self._get_FTP()
421
382
            result = ftp.size(abspath)
422
383
        else:
423
384
            result = 0
424
385
 
425
 
        if self._has_append:
426
 
            mutter("FTP appe to %s", abspath)
427
 
            self._try_append(relpath, text, mode)
428
 
        else:
429
 
            self._fallback_append(relpath, text, mode)
 
386
        mutter("FTP appe to %s", abspath)
 
387
        self._try_append(relpath, f.read(), mode)
430
388
 
431
389
        return result
432
390
 
433
391
    def _try_append(self, relpath, text, mode=None, retries=0):
434
392
        """Try repeatedly to append the given text to the file at relpath.
435
 
 
 
393
        
436
394
        This is a recursive function. On errors, it will be called until the
437
395
        number of retries is exceeded.
438
396
        """
439
397
        try:
440
 
            abspath = self._remote_path(relpath)
 
398
            abspath = self._abspath(relpath)
441
399
            mutter("FTP appe (try %d) to %s", retries, abspath)
442
400
            ftp = self._get_FTP()
 
401
            ftp.voidcmd("TYPE I")
443
402
            cmd = "APPE %s" % abspath
444
403
            conn = ftp.transfercmd(cmd)
445
404
            conn.sendall(text)
446
405
            conn.close()
447
 
            self._setmode(relpath, mode)
 
406
            if mode:
 
407
                self._setmode(relpath, mode)
448
408
            ftp.getresp()
449
409
        except ftplib.error_perm, e:
450
 
            # Check whether the command is not supported (reply code 502)
451
 
            if str(e).startswith('502 '):
452
 
                warning("FTP server does not support file appending natively. "
453
 
                        "Performance may be severely degraded! (%s)", e)
454
 
                self._has_append = False
455
 
                self._fallback_append(relpath, text, mode)
456
 
            else:
457
 
                self._translate_ftp_error(e, abspath, extra='error appending',
458
 
                    unknown_exc=errors.NoSuchFile)
 
410
            self._translate_perm_error(e, abspath, extra='error appending',
 
411
                unknown_exc=errors.NoSuchFile)
459
412
        except ftplib.error_temp, e:
460
413
            if retries > _number_of_retries:
461
 
                raise errors.TransportError(
462
 
                    "FTP temporary error during APPEND %s. Aborting."
463
 
                    % abspath, orig_error=e)
 
414
                raise errors.TransportError("FTP temporary error during APPEND %s." \
 
415
                        "Aborting." % abspath, orig_error=e)
464
416
            else:
465
417
                warning("FTP temporary error: %s. Retrying.", str(e))
466
 
                self._reconnect()
 
418
                self._FTP_instance = None
467
419
                self._try_append(relpath, text, mode, retries+1)
468
420
 
469
 
    def _fallback_append(self, relpath, text, mode = None):
470
 
        remote = self.get(relpath)
471
 
        remote.seek(0, os.SEEK_END)
472
 
        remote.write(text)
473
 
        remote.seek(0)
474
 
        return self.put_file(relpath, remote, mode)
475
 
 
476
421
    def _setmode(self, relpath, mode):
477
422
        """Set permissions on a path.
478
423
 
479
424
        Only set permissions if the FTP server supports the 'SITE CHMOD'
480
425
        extension.
481
426
        """
482
 
        if mode:
483
 
            try:
484
 
                mutter("FTP site chmod: setting permissions to %s on %s",
485
 
                       oct(mode), self._remote_path(relpath))
486
 
                ftp = self._get_FTP()
487
 
                cmd = "SITE CHMOD %s %s" % (oct(mode),
488
 
                                            self._remote_path(relpath))
489
 
                ftp.sendcmd(cmd)
490
 
            except ftplib.error_perm, e:
491
 
                # Command probably not available on this server
492
 
                warning("FTP Could not set permissions to %s on %s. %s",
493
 
                        oct(mode), self._remote_path(relpath), str(e))
 
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))
494
437
 
495
438
    # TODO: jam 20060516 I believe ftp allows you to tell an ftp server
496
439
    #       to copy something to another machine. And you may be able
498
441
    #       So implement a fancier 'copy()'
499
442
 
500
443
    def rename(self, rel_from, rel_to):
501
 
        abs_from = self._remote_path(rel_from)
502
 
        abs_to = self._remote_path(rel_to)
 
444
        abs_from = self._abspath(rel_from)
 
445
        abs_to = self._abspath(rel_to)
503
446
        mutter("FTP rename: %s => %s", abs_from, abs_to)
504
447
        f = self._get_FTP()
505
448
        return self._rename(abs_from, abs_to, f)
507
450
    def _rename(self, abs_from, abs_to, f):
508
451
        try:
509
452
            f.rename(abs_from, abs_to)
510
 
        except (ftplib.error_temp, ftplib.error_perm), e:
511
 
            self._translate_ftp_error(e, abs_from,
 
453
        except ftplib.error_perm, e:
 
454
            self._translate_perm_error(e, abs_from,
512
455
                ': unable to rename to %r' % (abs_to))
513
456
 
514
457
    def move(self, rel_from, rel_to):
515
458
        """Move the item at rel_from to the location at rel_to"""
516
 
        abs_from = self._remote_path(rel_from)
517
 
        abs_to = self._remote_path(rel_to)
 
459
        abs_from = self._abspath(rel_from)
 
460
        abs_to = self._abspath(rel_to)
518
461
        try:
519
462
            mutter("FTP mv: %s => %s", abs_from, abs_to)
520
463
            f = self._get_FTP()
521
464
            self._rename_and_overwrite(abs_from, abs_to, f)
522
465
        except ftplib.error_perm, e:
523
 
            self._translate_ftp_error(e, abs_from,
524
 
                extra='unable to rename to %r' % (rel_to,),
 
466
            self._translate_perm_error(e, abs_from,
 
467
                extra='unable to rename to %r' % (rel_to,), 
525
468
                unknown_exc=errors.PathError)
526
469
 
527
470
    def _rename_and_overwrite(self, abs_from, abs_to, f):
535
478
 
536
479
    def delete(self, relpath):
537
480
        """Delete the item at relpath"""
538
 
        abspath = self._remote_path(relpath)
 
481
        abspath = self._abspath(relpath)
539
482
        f = self._get_FTP()
540
483
        self._delete(abspath, f)
541
484
 
544
487
            mutter("FTP rm: %s", abspath)
545
488
            f.delete(abspath)
546
489
        except ftplib.error_perm, e:
547
 
            self._translate_ftp_error(e, abspath, 'error deleting',
 
490
            self._translate_perm_error(e, abspath, 'error deleting',
548
491
                unknown_exc=errors.NoSuchFile)
549
492
 
550
 
    def external_url(self):
551
 
        """See bzrlib.transport.Transport.external_url."""
552
 
        # FTP URL's are externally usable.
553
 
        return self.base
554
 
 
555
493
    def listable(self):
556
494
        """See Transport.listable."""
557
495
        return True
558
496
 
559
497
    def list_dir(self, relpath):
560
498
        """See Transport.list_dir."""
561
 
        basepath = self._remote_path(relpath)
 
499
        basepath = self._abspath(relpath)
562
500
        mutter("FTP nlst: %s", basepath)
563
501
        f = self._get_FTP()
564
502
        try:
565
 
            try:
566
 
                paths = f.nlst(basepath)
567
 
            except ftplib.error_perm, e:
568
 
                self._translate_ftp_error(e, relpath,
569
 
                                           extra='error with list_dir')
570
 
            except ftplib.error_temp, e:
571
 
                # xs4all's ftp server raises a 450 temp error when listing an
572
 
                # empty directory. Check for that and just return an empty list
573
 
                # in that case. See bug #215522
574
 
                if str(e).lower().startswith('450 no files found'):
575
 
                    mutter('FTP Server returned "%s" for nlst.'
576
 
                           ' Assuming it means empty directory',
577
 
                           str(e))
578
 
                    return []
579
 
                raise
580
 
        finally:
581
 
            # Restore binary mode as nlst switch to ascii mode to retrieve file
582
 
            # list
583
 
            f.voidcmd('TYPE I')
584
 
 
 
503
            paths = f.nlst(basepath)
 
504
        except ftplib.error_perm, e:
 
505
            self._translate_perm_error(e, relpath, extra='error with list_dir')
585
506
        # If FTP.nlst returns paths prefixed by relpath, strip 'em
586
507
        if paths and paths[0].startswith(basepath):
587
508
            entries = [path[len(basepath)+1:] for path in paths]
608
529
 
609
530
    def stat(self, relpath):
610
531
        """Return the stat information for a file."""
611
 
        abspath = self._remote_path(relpath)
 
532
        abspath = self._abspath(relpath)
612
533
        try:
613
534
            mutter("FTP stat: %s", abspath)
614
535
            f = self._get_FTP()
615
536
            return FtpStatResult(f, abspath)
616
537
        except ftplib.error_perm, e:
617
 
            self._translate_ftp_error(e, abspath, extra='error w/ stat')
 
538
            self._translate_perm_error(e, abspath, extra='error w/ stat')
618
539
 
619
540
    def lock_read(self, relpath):
620
541
        """Lock the given file for shared (read) access.
638
559
        return self.lock_read(relpath)
639
560
 
640
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':1000})
 
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
        except select.error, e:
 
626
            if e.args[0] != errno.EBADF:
 
627
                raise
 
628
 
 
629
 
 
630
_ftp_channel = None
 
631
_ftp_server = None
 
632
_test_authorizer = None
 
633
 
 
634
 
 
635
def _setup_medusa():
 
636
    global _have_medusa, _ftp_channel, _ftp_server, _test_authorizer
 
637
    try:
 
638
        import medusa
 
639
        import medusa.filesys
 
640
        import medusa.ftp_server
 
641
    except ImportError:
 
642
        return False
 
643
 
 
644
    _have_medusa = True
 
645
 
 
646
    class test_authorizer(object):
 
647
        """A custom Authorizer object for running the test suite.
 
648
 
 
649
        The reason we cannot use dummy_authorizer, is because it sets the
 
650
        channel to readonly, which we don't always want to do.
 
651
        """
 
652
 
 
653
        def __init__(self, root):
 
654
            self.root = root
 
655
 
 
656
        def authorize(self, channel, username, password):
 
657
            """Return (success, reply_string, filesystem)"""
 
658
            if not _have_medusa:
 
659
                return 0, 'No Medusa.', None
 
660
 
 
661
            channel.persona = -1, -1
 
662
            if username == 'anonymous':
 
663
                channel.read_only = 1
 
664
            else:
 
665
                channel.read_only = 0
 
666
 
 
667
            return 1, 'OK.', medusa.filesys.os_filesystem(self.root)
 
668
 
 
669
 
 
670
    class ftp_channel(medusa.ftp_server.ftp_channel):
 
671
        """Customized ftp channel"""
 
672
 
 
673
        def log(self, message):
 
674
            """Redirect logging requests."""
 
675
            mutter('_ftp_channel: %s', message)
 
676
            
 
677
        def log_info(self, message, type='info'):
 
678
            """Redirect logging requests."""
 
679
            mutter('_ftp_channel %s: %s', type, message)
 
680
            
 
681
        def cmd_rnfr(self, line):
 
682
            """Prepare for renaming a file."""
 
683
            self._renaming = line[1]
 
684
            self.respond('350 Ready for RNTO')
 
685
            # TODO: jam 20060516 in testing, the ftp server seems to
 
686
            #       check that the file already exists, or it sends
 
687
            #       550 RNFR command failed
 
688
 
 
689
        def cmd_rnto(self, line):
 
690
            """Rename a file based on the target given.
 
691
 
 
692
            rnto must be called after calling rnfr.
 
693
            """
 
694
            if not self._renaming:
 
695
                self.respond('503 RNFR required first.')
 
696
            pfrom = self.filesystem.translate(self._renaming)
 
697
            self._renaming = None
 
698
            pto = self.filesystem.translate(line[1])
 
699
            if os.path.exists(pto):
 
700
                self.respond('550 RNTO failed: file exists')
 
701
                return
 
702
            try:
 
703
                os.rename(pfrom, pto)
 
704
            except (IOError, OSError), e:
 
705
                # TODO: jam 20060516 return custom responses based on
 
706
                #       why the command failed
 
707
                # (bialix 20070418) str(e) on Python 2.5 @ Windows
 
708
                # sometimes don't provide expected error message;
 
709
                # so we obtain such message via os.strerror()
 
710
                self.respond('550 RNTO failed: %s' % os.strerror(e.errno))
 
711
            except:
 
712
                self.respond('550 RNTO failed')
 
713
                # For a test server, we will go ahead and just die
 
714
                raise
 
715
            else:
 
716
                self.respond('250 Rename successful.')
 
717
 
 
718
        def cmd_size(self, line):
 
719
            """Return the size of a file
 
720
 
 
721
            This is overloaded to help the test suite determine if the 
 
722
            target is a directory.
 
723
            """
 
724
            filename = line[1]
 
725
            if not self.filesystem.isfile(filename):
 
726
                if self.filesystem.isdir(filename):
 
727
                    self.respond('550 "%s" is a directory' % (filename,))
 
728
                else:
 
729
                    self.respond('550 "%s" is not a file' % (filename,))
 
730
            else:
 
731
                self.respond('213 %d' 
 
732
                    % (self.filesystem.stat(filename)[stat.ST_SIZE]),)
 
733
 
 
734
        def cmd_mkd(self, line):
 
735
            """Create a directory.
 
736
 
 
737
            Overloaded because default implementation does not distinguish
 
738
            *why* it cannot make a directory.
 
739
            """
 
740
            if len (line) != 2:
 
741
                self.command_not_understood(''.join(line))
 
742
            else:
 
743
                path = line[1]
 
744
                try:
 
745
                    self.filesystem.mkdir (path)
 
746
                    self.respond ('257 MKD command successful.')
 
747
                except (IOError, OSError), e:
 
748
                    # (bialix 20070418) str(e) on Python 2.5 @ Windows
 
749
                    # sometimes don't provide expected error message;
 
750
                    # so we obtain such message via os.strerror()
 
751
                    self.respond ('550 error creating directory: %s' %
 
752
                                  os.strerror(e.errno))
 
753
                except:
 
754
                    self.respond ('550 error creating directory.')
 
755
 
 
756
 
 
757
    class ftp_server(medusa.ftp_server.ftp_server):
 
758
        """Customize the behavior of the Medusa ftp_server.
 
759
 
 
760
        There are a few warts on the ftp_server, based on how it expects
 
761
        to be used.
 
762
        """
 
763
        _renaming = None
 
764
        ftp_channel_class = ftp_channel
 
765
 
 
766
        def __init__(self, *args, **kwargs):
 
767
            mutter('Initializing _ftp_server: %r, %r', args, kwargs)
 
768
            medusa.ftp_server.ftp_server.__init__(self, *args, **kwargs)
 
769
 
 
770
        def log(self, message):
 
771
            """Redirect logging requests."""
 
772
            mutter('_ftp_server: %s', message)
 
773
 
 
774
        def log_info(self, message, type='info'):
 
775
            """Override the asyncore.log_info so we don't stipple the screen."""
 
776
            mutter('_ftp_server %s: %s', type, message)
 
777
 
 
778
    _test_authorizer = test_authorizer
 
779
    _ftp_channel = ftp_channel
 
780
    _ftp_server = ftp_server
 
781
 
 
782
    return True
 
783
 
 
784
 
641
785
def get_test_permutations():
642
786
    """Return the permutations to be used in testing."""
643
 
    from bzrlib.tests import ftp_server
644
 
    return [(FtpTransport, ftp_server.FTPTestServer)]
 
787
    if not _setup_medusa():
 
788
        warn("You must install medusa (http://www.amk.ca/python/code/medusa.html) for FTP tests")
 
789
        return []
 
790
    else:
 
791
        return [(FtpTransport, FtpServer)]