27
27
from cStringIO import StringIO
34
38
from warnings import warn
37
from bzrlib.transport import Transport
38
from bzrlib.errors import (TransportNotPossible, TransportError,
39
NoSuchFile, FileExists)
40
44
from bzrlib.trace import mutter, warning
45
from bzrlib.transport import (
55
class FtpPathError(errors.PathError):
56
"""FTP failed for path: %(path)s%(extra)s"""
44
def _find_FTP(hostname, username, password, is_active):
60
def _find_FTP(hostname, port, username, password, is_active):
45
61
"""Find an ftplib.FTP instance attached to this triplet."""
46
key = "%s|%s|%s|%s" % (hostname, username, password, is_active)
62
key = (hostname, port, username, password, is_active)
63
alt_key = (hostname, port, username, '********', is_active)
47
64
if key not in _FTP_cache:
48
mutter("Constructing FTP instance against %r" % key)
49
_FTP_cache[key] = ftplib.FTP(hostname, username, password)
50
_FTP_cache[key].set_pasv(not is_active)
65
mutter("Constructing FTP instance against %r" % (alt_key,))
68
conn.connect(host=hostname, port=port)
69
if username and username != 'anonymous' and not password:
70
password = bzrlib.ui.ui_factory.get_password(
71
prompt='FTP %(user)s@%(host)s password',
72
user=username, host=hostname)
73
conn.login(user=username, passwd=password)
74
conn.set_pasv(not is_active)
76
_FTP_cache[key] = conn
51
78
return _FTP_cache[key]
54
class FtpTransportError(TransportError):
58
81
class FtpStatResult(object):
59
82
def __init__(self, f, relpath):
77
101
def __init__(self, base, _provided_instance=None):
78
102
"""Set the base path where files will be stored."""
79
103
assert base.startswith('ftp://') or base.startswith('aftp://')
80
super(FtpTransport, self).__init__(base)
81
105
self.is_active = base.startswith('aftp://')
82
106
if self.is_active:
107
# urlparse won't handle aftp://
84
(self._proto, self._host,
85
self._path, self._parameters,
86
self._query, self._fragment) = urlparse.urlparse(self.base)
109
if not base.endswith('/'):
111
(self._proto, self._username,
112
self._password, self._host,
113
self._port, self._path) = split_url(base)
114
base = self._unparse_url()
116
super(FtpTransport, self).__init__(base)
87
117
self._FTP_instance = _provided_instance
119
def _unparse_url(self, path=None):
122
path = urllib.quote(path)
123
netloc = urllib.quote(self._host)
124
if self._username is not None:
125
netloc = '%s@%s' % (urllib.quote(self._username), netloc)
126
if self._port is not None:
127
netloc = '%s:%d' % (netloc, self._port)
131
return urlparse.urlunparse((proto, netloc, path, '', '', ''))
89
133
def _get_FTP(self):
90
134
"""Return the ftplib.FTP instance for this object."""
91
135
if self._FTP_instance is not None:
92
136
return self._FTP_instance
99
username, hostname = hostname.split("@", 1)
101
username, password = username.split(":", 1)
103
self._FTP_instance = _find_FTP(hostname, username, password,
139
self._FTP_instance = _find_FTP(self._host, self._port,
140
self._username, self._password,
105
142
return self._FTP_instance
106
143
except ftplib.error_perm, e:
107
raise TransportError(msg="Error setting up connection: %s"
144
raise errors.TransportError(msg="Error setting up connection: %s"
108
145
% str(e), orig_error=e)
147
def _translate_perm_error(self, err, path, extra=None, unknown_exc=FtpPathError):
148
"""Try to translate an ftplib.error_perm exception.
150
:param err: The error to translate into a bzr error
151
:param path: The path which had problems
152
:param extra: Extra information which can be included
153
:param unknown_exc: If None, we will just raise the original exception
154
otherwise we raise unknown_exc(path, extra=extra)
160
extra += ': ' + str(err)
161
if ('no such file' in s
162
or 'could not open' in s
163
or 'no such dir' in s
164
or 'could not create file' in s # vsftpd
166
raise errors.NoSuchFile(path, extra=extra)
167
if ('file exists' in s):
168
raise errors.FileExists(path, extra=extra)
169
if ('not a directory' in s):
170
raise errors.PathError(path, extra=extra)
172
mutter('unable to understand error for path: %s: %s', path, err)
175
raise unknown_exc(path, extra=extra)
176
# TODO: jam 20060516 Consider re-raising the error wrapped in
177
# something like TransportError, but this loses the traceback
178
# Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
179
# to handle. Consider doing something like that here.
180
#raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
110
183
def should_cache(self):
111
184
"""Return True if the data pulled across should be cached locally.
150
217
# Possibly, we could use urlparse.urljoin() here, but
151
218
# I'm concerned about when it chooses to strip the last
152
219
# portion of the path, and when it doesn't.
153
return '/'.join(basepath)
221
# XXX: It seems that ftplib does not handle Unicode paths
222
# at the same time, medusa won't handle utf8 paths
223
# So if we .encode(utf8) here, then we get a Server failure.
224
# while if we use str(), we get a UnicodeError, and the test suite
225
# just skips testing UnicodePaths.
226
return str('/'.join(basepath) or '/')
155
228
def abspath(self, relpath):
156
229
"""Return the full url to the given relative path.
157
230
This can be supplied with a string or a list
159
232
path = self._abspath(relpath)
160
return urlparse.urlunparse((self._proto,
161
self._host, path, '', '', ''))
233
return self._unparse_url(path)
163
235
def has(self, relpath):
164
"""Does the target location exist?
166
XXX: I assume we're never asked has(dirname) and thus I use
167
the FTP size command and assume that if it doesn't raise,
236
"""Does the target location exist?"""
237
# FIXME jam 20060516 We *do* ask about directories in the test suite
238
# We don't seem to in the actual codebase
239
# XXX: I assume we're never asked has(dirname) and thus I use
240
# the FTP size command and assume that if it doesn't raise,
242
abspath = self._abspath(relpath)
171
244
f = self._get_FTP()
172
s = f.size(self._abspath(relpath))
173
mutter("FTP has: %s" % self._abspath(relpath))
245
mutter('FTP has check: %s => %s', relpath, abspath)
247
mutter("FTP has: %s", abspath)
175
except ftplib.error_perm:
176
mutter("FTP has not: %s" % self._abspath(relpath))
249
except ftplib.error_perm, e:
250
if ('is a directory' in str(e).lower()):
251
mutter("FTP has dir: %s: %s", abspath, e)
253
mutter("FTP has not: %s: %s", abspath, e)
179
256
def get(self, relpath, decode=False, retries=0):
186
263
We're meant to return a file-like object which bzr will
187
264
then read from. For now we do this via the magic of StringIO
266
# TODO: decode should be deprecated
190
mutter("FTP get: %s" % self._abspath(relpath))
268
mutter("FTP get: %s", self._abspath(relpath))
191
269
f = self._get_FTP()
193
271
f.retrbinary('RETR '+self._abspath(relpath), ret.write, 8192)
196
274
except ftplib.error_perm, e:
197
raise NoSuchFile(self.abspath(relpath), extra=str(e))
275
raise errors.NoSuchFile(self.abspath(relpath), extra=str(e))
198
276
except ftplib.error_temp, e:
277
if retries > _number_of_retries:
278
raise errors.TransportError(msg="FTP temporary error during GET %s. Aborting."
279
% self.abspath(relpath),
202
warning("FTP temporary error: %s. Retrying." % str(e))
282
warning("FTP temporary error: %s. Retrying.", str(e))
203
283
self._FTP_instance = None
204
284
return self.get(relpath, decode, retries+1)
206
286
if retries > _number_of_retries:
287
raise errors.TransportError("FTP control connection closed during GET %s."
288
% self.abspath(relpath),
209
291
warning("FTP control connection closed. Trying to reopen.")
292
time.sleep(_sleep_between_retries)
210
293
self._FTP_instance = None
211
294
return self.get(relpath, decode, retries+1)
213
def put(self, relpath, fp, mode=None, retries=0):
296
def put_file(self, relpath, fp, mode=None, retries=0):
214
297
"""Copy the file-like or string object into the location.
216
299
:param relpath: Location to put the contents, relative to base.
217
:param f: File-like or string object.
300
:param fp: File-like or string object.
218
301
:param retries: Number of retries after temporary failures so far
219
302
for this operation.
221
TODO: jam 20051215 This should be an atomic put, not overwritting files in place
222
304
TODO: jam 20051215 ftp as a protocol seems to support chmod, but ftplib does not
224
if not hasattr(fp, 'read'):
306
abspath = self._abspath(relpath)
307
tmp_abspath = '%s.tmp.%.9f.%d.%d' % (abspath, time.time(),
308
os.getpid(), random.randint(0,0x7FFFFFFF))
309
if getattr(fp, 'read', None) is None:
225
310
fp = StringIO(fp)
227
mutter("FTP put: %s" % self._abspath(relpath))
312
mutter("FTP put: %s", abspath)
228
313
f = self._get_FTP()
229
f.storbinary('STOR '+self._abspath(relpath), fp, 8192)
315
f.storbinary('STOR '+tmp_abspath, fp)
316
f.rename(tmp_abspath, abspath)
317
except (ftplib.error_temp,EOFError), e:
318
warning("Failure during ftp PUT. Deleting temporary file.")
320
f.delete(tmp_abspath)
322
warning("Failed to delete temporary file on the"
323
" server.\nFile: %s", tmp_abspath)
230
326
except ftplib.error_perm, e:
231
if "no such file" in str(e).lower():
232
raise NoSuchFile("Error storing %s: %s"
233
% (self.abspath(relpath), str(e)), extra=e)
235
raise FtpTransportError(orig_error=e)
327
self._translate_perm_error(e, abspath, extra='could not store')
236
328
except ftplib.error_temp, e:
237
329
if retries > _number_of_retries:
330
raise errors.TransportError("FTP temporary error during PUT %s. Aborting."
331
% self.abspath(relpath), orig_error=e)
240
warning("FTP temporary error: %s. Retrying." % str(e))
333
warning("FTP temporary error: %s. Retrying.", str(e))
241
334
self._FTP_instance = None
242
self.put(relpath, fp, mode, retries+1)
335
self.put_file(relpath, fp, mode, retries+1)
244
337
if retries > _number_of_retries:
338
raise errors.TransportError("FTP control connection closed during PUT %s."
339
% self.abspath(relpath), orig_error=e)
247
warning("FTP connection closed. Trying to reopen.")
341
warning("FTP control connection closed. Trying to reopen.")
342
time.sleep(_sleep_between_retries)
248
343
self._FTP_instance = None
249
self.put(relpath, fp, mode, retries+1)
344
self.put_file(relpath, fp, mode, retries+1)
252
346
def mkdir(self, relpath, mode=None):
253
347
"""Create a directory at the given path."""
255
mutter("FTP mkd: %s" % self._abspath(relpath))
258
f.mkd(self._abspath(relpath))
259
except ftplib.error_perm, e:
261
if 'File exists' in s:
262
raise FileExists(self.abspath(relpath), extra=s)
265
except ftplib.error_perm, e:
266
raise TransportError(orig_error=e)
268
def append(self, relpath, f):
348
abspath = self._abspath(relpath)
350
mutter("FTP mkd: %s", abspath)
353
except ftplib.error_perm, e:
354
self._translate_perm_error(e, abspath,
355
unknown_exc=errors.FileExists)
357
def rmdir(self, rel_path):
358
"""Delete the directory at rel_path"""
359
abspath = self._abspath(rel_path)
361
mutter("FTP rmd: %s", abspath)
364
except ftplib.error_perm, e:
365
self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
367
def append_file(self, relpath, f, mode=None):
269
368
"""Append the text in the file-like object into the final
272
raise TransportNotPossible('ftp does not support append()')
274
def copy(self, rel_from, rel_to):
275
"""Copy the item at rel_from to the location at rel_to"""
276
raise TransportNotPossible('ftp does not (yet) support copy()')
371
abspath = self._abspath(relpath)
372
if self.has(relpath):
373
ftp = self._get_FTP()
374
result = ftp.size(abspath)
378
mutter("FTP appe to %s", abspath)
379
self._try_append(relpath, f.read(), mode)
383
def _try_append(self, relpath, text, mode=None, retries=0):
384
"""Try repeatedly to append the given text to the file at relpath.
386
This is a recursive function. On errors, it will be called until the
387
number of retries is exceeded.
390
abspath = self._abspath(relpath)
391
mutter("FTP appe (try %d) to %s", retries, abspath)
392
ftp = self._get_FTP()
393
ftp.voidcmd("TYPE I")
394
cmd = "APPE %s" % abspath
395
conn = ftp.transfercmd(cmd)
399
self._setmode(relpath, mode)
401
except ftplib.error_perm, e:
402
self._translate_perm_error(e, abspath, extra='error appending',
403
unknown_exc=errors.NoSuchFile)
404
except ftplib.error_temp, e:
405
if retries > _number_of_retries:
406
raise errors.TransportError("FTP temporary error during APPEND %s." \
407
"Aborting." % abspath, orig_error=e)
409
warning("FTP temporary error: %s. Retrying.", str(e))
410
self._FTP_instance = None
411
self._try_append(relpath, text, mode, retries+1)
413
def _setmode(self, relpath, mode):
414
"""Set permissions on a path.
416
Only set permissions if the FTP server supports the 'SITE CHMOD'
420
mutter("FTP site chmod: setting permissions to %s on %s",
421
str(mode), self._abspath(relpath))
422
ftp = self._get_FTP()
423
cmd = "SITE CHMOD %s %s" % (self._abspath(relpath), str(mode))
425
except ftplib.error_perm, e:
426
# Command probably not available on this server
427
warning("FTP Could not set permissions to %s on %s. %s",
428
str(mode), self._abspath(relpath), str(e))
430
# TODO: jam 20060516 I believe ftp allows you to tell an ftp server
431
# to copy something to another machine. And you may be able
432
# to give it its own address as the 'to' location.
433
# So implement a fancier 'copy()'
278
435
def move(self, rel_from, rel_to):
279
436
"""Move the item at rel_from to the location at rel_to"""
437
abs_from = self._abspath(rel_from)
438
abs_to = self._abspath(rel_to)
281
mutter("FTP mv: %s => %s" % (self._abspath(rel_from),
282
self._abspath(rel_to)))
440
mutter("FTP mv: %s => %s", abs_from, abs_to)
283
441
f = self._get_FTP()
284
f.rename(self._abspath(rel_from), self._abspath(rel_to))
442
f.rename(abs_from, abs_to)
285
443
except ftplib.error_perm, e:
286
raise TransportError(orig_error=e)
444
self._translate_perm_error(e, abs_from,
445
extra='unable to rename to %r' % (rel_to,),
446
unknown_exc=errors.PathError)
288
450
def delete(self, relpath):
289
451
"""Delete the item at relpath"""
452
abspath = self._abspath(relpath)
291
mutter("FTP rm: %s" % self._abspath(relpath))
454
mutter("FTP rm: %s", abspath)
292
455
f = self._get_FTP()
293
f.delete(self._abspath(relpath))
294
457
except ftplib.error_perm, e:
295
raise TransportError(orig_error=e)
458
self._translate_perm_error(e, abspath, 'error deleting',
459
unknown_exc=errors.NoSuchFile)
297
461
def listable(self):
298
462
"""See Transport.listable."""
359
527
return self.lock_read(relpath)
530
class FtpServer(Server):
531
"""Common code for SFTP server facilities."""
535
self._ftp_server = None
537
self._async_thread = None
542
"""Calculate an ftp url to this server."""
543
return 'ftp://foo:bar@localhost:%d/' % (self._port)
545
# def get_bogus_url(self):
546
# """Return a URL which cannot be connected to."""
547
# return 'ftp://127.0.0.1:1'
549
def log(self, message):
550
"""This is used by medusa.ftp_server to log connections, etc."""
551
self.logs.append(message)
556
raise RuntimeError('Must have medusa to run the FtpServer')
558
self._root = os.getcwdu()
559
self._ftp_server = _ftp_server(
560
authorizer=_test_authorizer(root=self._root),
562
port=0, # bind to a random port
564
logger_object=self # Use FtpServer.log() for messages
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 100s, or 1000 requests
569
self._async_thread = threading.Thread(target=asyncore.loop,
570
kwargs={'timeout':0.1, 'count':1000})
571
self._async_thread.setDaemon(True)
572
self._async_thread.start()
575
"""See bzrlib.transport.Server.tearDown."""
576
# have asyncore release the channel
577
self._ftp_server.del_channel()
579
self._async_thread.join()
584
_test_authorizer = None
588
global _have_medusa, _ftp_channel, _ftp_server, _test_authorizer
591
import medusa.filesys
592
import medusa.ftp_server
598
class test_authorizer(object):
599
"""A custom Authorizer object for running the test suite.
601
The reason we cannot use dummy_authorizer, is because it sets the
602
channel to readonly, which we don't always want to do.
605
def __init__(self, root):
608
def authorize(self, channel, username, password):
609
"""Return (success, reply_string, filesystem)"""
611
return 0, 'No Medusa.', None
613
channel.persona = -1, -1
614
if username == 'anonymous':
615
channel.read_only = 1
617
channel.read_only = 0
619
return 1, 'OK.', medusa.filesys.os_filesystem(self.root)
622
class ftp_channel(medusa.ftp_server.ftp_channel):
623
"""Customized ftp channel"""
625
def log(self, message):
626
"""Redirect logging requests."""
627
mutter('_ftp_channel: %s', message)
629
def log_info(self, message, type='info'):
630
"""Redirect logging requests."""
631
mutter('_ftp_channel %s: %s', type, message)
633
def cmd_rnfr(self, line):
634
"""Prepare for renaming a file."""
635
self._renaming = line[1]
636
self.respond('350 Ready for RNTO')
637
# TODO: jam 20060516 in testing, the ftp server seems to
638
# check that the file already exists, or it sends
639
# 550 RNFR command failed
641
def cmd_rnto(self, line):
642
"""Rename a file based on the target given.
644
rnto must be called after calling rnfr.
646
if not self._renaming:
647
self.respond('503 RNFR required first.')
648
pfrom = self.filesystem.translate(self._renaming)
649
self._renaming = None
650
pto = self.filesystem.translate(line[1])
652
os.rename(pfrom, pto)
653
except (IOError, OSError), e:
654
# TODO: jam 20060516 return custom responses based on
655
# why the command failed
656
self.respond('550 RNTO failed: %s' % (e,))
658
self.respond('550 RNTO failed')
659
# For a test server, we will go ahead and just die
662
self.respond('250 Rename successful.')
664
def cmd_size(self, line):
665
"""Return the size of a file
667
This is overloaded to help the test suite determine if the
668
target is a directory.
671
if not self.filesystem.isfile(filename):
672
if self.filesystem.isdir(filename):
673
self.respond('550 "%s" is a directory' % (filename,))
675
self.respond('550 "%s" is not a file' % (filename,))
677
self.respond('213 %d'
678
% (self.filesystem.stat(filename)[stat.ST_SIZE]),)
680
def cmd_mkd(self, line):
681
"""Create a directory.
683
Overloaded because default implementation does not distinguish
684
*why* it cannot make a directory.
687
self.command_not_understood(''.join(line))
691
self.filesystem.mkdir (path)
692
self.respond ('257 MKD command successful.')
693
except (IOError, OSError), e:
694
self.respond ('550 error creating directory: %s' % (e,))
696
self.respond ('550 error creating directory.')
699
class ftp_server(medusa.ftp_server.ftp_server):
700
"""Customize the behavior of the Medusa ftp_server.
702
There are a few warts on the ftp_server, based on how it expects
706
ftp_channel_class = ftp_channel
708
def __init__(self, *args, **kwargs):
709
mutter('Initializing _ftp_server: %r, %r', args, kwargs)
710
medusa.ftp_server.ftp_server.__init__(self, *args, **kwargs)
712
def log(self, message):
713
"""Redirect logging requests."""
714
mutter('_ftp_server: %s', message)
716
def log_info(self, message, type='info'):
717
"""Override the asyncore.log_info so we don't stipple the screen."""
718
mutter('_ftp_server %s: %s', type, message)
720
_test_authorizer = test_authorizer
721
_ftp_channel = ftp_channel
722
_ftp_server = ftp_server
362
727
def get_test_permutations():
363
728
"""Return the permutations to be used in testing."""
364
warn("There are no FTP transport provider tests yet.")
729
if not _setup_medusa():
730
warn("You must install medusa (http://www.amk.ca/python/code/medusa.html) for FTP tests")
733
return [(FtpTransport, FtpServer)]