~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/transport/ftp.py

  • Committer: John Arbash Meinel
  • Date: 2007-08-14 19:29:56 UTC
  • mto: This revision was merged to the branch mainline in revision 2698.
  • Revision ID: john@arbash-meinel.com-20070814192956-34h336i5q3m34ods
Switch bzr.dev to 0.91 development

Show diffs side-by-side

added added

removed removed

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