~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: John Arbash Meinel
  • Date: 2006-06-10 14:53:51 UTC
  • mto: (1711.7.2 win32)
  • mto: This revision was merged to the branch mainline in revision 1796.
  • Revision ID: john@arbash-meinel.com-20060610145351-9da0c1f8ba8a57e0
the _posix_* routines should use posixpath not os.path, so tests pass on win32

Show diffs side-by-side

added added

removed removed

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