/brz/remove-bazaar

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/brz/remove-bazaar

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: John Arbash Meinel
  • Date: 2007-04-17 21:19:29 UTC
  • mto: This revision was merged to the branch mainline in revision 2452.
  • Revision ID: john@arbash-meinel.com-20070417211929-rchob1jeahy30cj0
Update annotate.py to use the new helper function.

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