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
165
raise errors.NoSuchFile(path, extra=extra)
166
if ('file exists' in s):
167
raise errors.FileExists(path, extra=extra)
168
if ('not a directory' in s):
169
raise errors.PathError(path, extra=extra)
171
mutter('unable to understand error for path: %s: %s', path, err)
174
raise unknown_exc(path, extra=extra)
175
# TODO: jam 20060516 Consider re-raising the error wrapped in
176
# something like TransportError, but this loses the traceback
177
# Also, 'sftp' has a generic 'Failure' mode, which we use failure_exc
178
# to handle. Consider doing something like that here.
179
#raise TransportError(msg='Error for path: %s' % (path,), orig_error=e)
110
182
def should_cache(self):
111
183
"""Return True if the data pulled across should be cached locally.
150
216
# Possibly, we could use urlparse.urljoin() here, but
151
217
# I'm concerned about when it chooses to strip the last
152
218
# portion of the path, and when it doesn't.
153
return '/'.join(basepath)
220
# XXX: It seems that ftplib does not handle Unicode paths
221
# at the same time, medusa won't handle utf8 paths
222
# So if we .encode(utf8) here, then we get a Server failure.
223
# while if we use str(), we get a UnicodeError, and the test suite
224
# just skips testing UnicodePaths.
225
return str('/'.join(basepath) or '/')
155
227
def abspath(self, relpath):
156
228
"""Return the full url to the given relative path.
157
229
This can be supplied with a string or a list
159
231
path = self._abspath(relpath)
160
return urlparse.urlunparse((self._proto,
161
self._host, path, '', '', ''))
232
return self._unparse_url(path)
163
234
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,
235
"""Does the target location exist?"""
236
# FIXME jam 20060516 We *do* ask about directories in the test suite
237
# We don't seem to in the actual codebase
238
# XXX: I assume we're never asked has(dirname) and thus I use
239
# the FTP size command and assume that if it doesn't raise,
241
abspath = self._abspath(relpath)
171
243
f = self._get_FTP()
172
s = f.size(self._abspath(relpath))
173
mutter("FTP has: %s" % self._abspath(relpath))
244
mutter('FTP has check: %s => %s', relpath, abspath)
246
mutter("FTP has: %s", abspath)
175
except ftplib.error_perm:
176
mutter("FTP has not: %s" % self._abspath(relpath))
248
except ftplib.error_perm, e:
249
if ('is a directory' in str(e).lower()):
250
mutter("FTP has dir: %s: %s", abspath, e)
252
mutter("FTP has not: %s: %s", abspath, e)
179
255
def get(self, relpath, decode=False, retries=0):
186
262
We're meant to return a file-like object which bzr will
187
263
then read from. For now we do this via the magic of StringIO
265
# TODO: decode should be deprecated
190
mutter("FTP get: %s" % self._abspath(relpath))
267
mutter("FTP get: %s", self._abspath(relpath))
191
268
f = self._get_FTP()
193
270
f.retrbinary('RETR '+self._abspath(relpath), ret.write, 8192)
196
273
except ftplib.error_perm, e:
197
raise NoSuchFile(self.abspath(relpath), extra=str(e))
274
raise errors.NoSuchFile(self.abspath(relpath), extra=str(e))
198
275
except ftplib.error_temp, e:
276
if retries > _number_of_retries:
277
raise errors.TransportError(msg="FTP temporary error during GET %s. Aborting."
278
% self.abspath(relpath),
202
warning("FTP temporary error: %s. Retrying." % str(e))
281
warning("FTP temporary error: %s. Retrying.", str(e))
203
282
self._FTP_instance = None
204
283
return self.get(relpath, decode, retries+1)
206
285
if retries > _number_of_retries:
286
raise errors.TransportError("FTP control connection closed during GET %s."
287
% self.abspath(relpath),
209
290
warning("FTP control connection closed. Trying to reopen.")
291
time.sleep(_sleep_between_retries)
210
292
self._FTP_instance = None
211
293
return self.get(relpath, decode, retries+1)
213
def put(self, relpath, fp, mode=None, retries=0):
295
def put_file(self, relpath, fp, mode=None, retries=0):
214
296
"""Copy the file-like or string object into the location.
216
298
:param relpath: Location to put the contents, relative to base.
217
:param f: File-like or string object.
299
:param fp: File-like or string object.
218
300
:param retries: Number of retries after temporary failures so far
219
301
for this operation.
221
TODO: jam 20051215 This should be an atomic put, not overwritting files in place
222
303
TODO: jam 20051215 ftp as a protocol seems to support chmod, but ftplib does not
224
if not hasattr(fp, 'read'):
305
abspath = self._abspath(relpath)
306
tmp_abspath = '%s.tmp.%.9f.%d.%d' % (abspath, time.time(),
307
os.getpid(), random.randint(0,0x7FFFFFFF))
308
if getattr(fp, 'read', None) is None:
225
309
fp = StringIO(fp)
227
mutter("FTP put: %s" % self._abspath(relpath))
311
mutter("FTP put: %s", abspath)
228
312
f = self._get_FTP()
229
f.storbinary('STOR '+self._abspath(relpath), fp, 8192)
314
f.storbinary('STOR '+tmp_abspath, fp)
315
f.rename(tmp_abspath, abspath)
316
except (ftplib.error_temp,EOFError), e:
317
warning("Failure during ftp PUT. Deleting temporary file.")
319
f.delete(tmp_abspath)
321
warning("Failed to delete temporary file on the"
322
" server.\nFile: %s", tmp_abspath)
230
325
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)
326
self._translate_perm_error(e, abspath, extra='could not store')
236
327
except ftplib.error_temp, e:
237
328
if retries > _number_of_retries:
329
raise errors.TransportError("FTP temporary error during PUT %s. Aborting."
330
% self.abspath(relpath), orig_error=e)
240
warning("FTP temporary error: %s. Retrying." % str(e))
332
warning("FTP temporary error: %s. Retrying.", str(e))
241
333
self._FTP_instance = None
242
self.put(relpath, fp, mode, retries+1)
334
self.put_file(relpath, fp, mode, retries+1)
244
336
if retries > _number_of_retries:
337
raise errors.TransportError("FTP control connection closed during PUT %s."
338
% self.abspath(relpath), orig_error=e)
247
warning("FTP connection closed. Trying to reopen.")
340
warning("FTP control connection closed. Trying to reopen.")
341
time.sleep(_sleep_between_retries)
248
342
self._FTP_instance = None
249
self.put(relpath, fp, mode, retries+1)
343
self.put_file(relpath, fp, mode, retries+1)
252
345
def mkdir(self, relpath, mode=None):
253
346
"""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):
347
abspath = self._abspath(relpath)
349
mutter("FTP mkd: %s", abspath)
352
except ftplib.error_perm, e:
353
self._translate_perm_error(e, abspath,
354
unknown_exc=errors.FileExists)
356
def rmdir(self, rel_path):
357
"""Delete the directory at rel_path"""
358
abspath = self._abspath(rel_path)
360
mutter("FTP rmd: %s", abspath)
363
except ftplib.error_perm, e:
364
self._translate_perm_error(e, abspath, unknown_exc=errors.PathError)
366
def append_file(self, relpath, f, mode=None):
269
367
"""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()')
370
abspath = self._abspath(relpath)
371
if self.has(relpath):
372
ftp = self._get_FTP()
373
result = ftp.size(abspath)
377
mutter("FTP appe to %s", abspath)
378
self._try_append(relpath, f.read(), mode)
382
def _try_append(self, relpath, text, mode=None, retries=0):
383
"""Try repeatedly to append the given text to the file at relpath.
385
This is a recursive function. On errors, it will be called until the
386
number of retries is exceeded.
389
abspath = self._abspath(relpath)
390
mutter("FTP appe (try %d) to %s", retries, abspath)
391
ftp = self._get_FTP()
392
ftp.voidcmd("TYPE I")
393
cmd = "APPE %s" % abspath
394
conn = ftp.transfercmd(cmd)
398
self._setmode(relpath, mode)
400
except ftplib.error_perm, e:
401
self._translate_perm_error(e, abspath, extra='error appending',
402
unknown_exc=errors.NoSuchFile)
403
except ftplib.error_temp, e:
404
if retries > _number_of_retries:
405
raise errors.TransportError("FTP temporary error during APPEND %s." \
406
"Aborting." % abspath, orig_error=e)
408
warning("FTP temporary error: %s. Retrying.", str(e))
409
self._FTP_instance = None
410
self._try_append(relpath, text, mode, retries+1)
412
def _setmode(self, relpath, mode):
413
"""Set permissions on a path.
415
Only set permissions if the FTP server supports the 'SITE CHMOD'
419
mutter("FTP site chmod: setting permissions to %s on %s",
420
str(mode), self._abspath(relpath))
421
ftp = self._get_FTP()
422
cmd = "SITE CHMOD %s %s" % (self._abspath(relpath), str(mode))
424
except ftplib.error_perm, e:
425
# Command probably not available on this server
426
warning("FTP Could not set permissions to %s on %s. %s",
427
str(mode), self._abspath(relpath), str(e))
429
# TODO: jam 20060516 I believe ftp allows you to tell an ftp server
430
# to copy something to another machine. And you may be able
431
# to give it its own address as the 'to' location.
432
# So implement a fancier 'copy()'
278
434
def move(self, rel_from, rel_to):
279
435
"""Move the item at rel_from to the location at rel_to"""
436
abs_from = self._abspath(rel_from)
437
abs_to = self._abspath(rel_to)
281
mutter("FTP mv: %s => %s" % (self._abspath(rel_from),
282
self._abspath(rel_to)))
439
mutter("FTP mv: %s => %s", abs_from, abs_to)
283
440
f = self._get_FTP()
284
f.rename(self._abspath(rel_from), self._abspath(rel_to))
441
f.rename(abs_from, abs_to)
285
442
except ftplib.error_perm, e:
286
raise TransportError(orig_error=e)
443
self._translate_perm_error(e, abs_from,
444
extra='unable to rename to %r' % (rel_to,),
445
unknown_exc=errors.PathError)
288
449
def delete(self, relpath):
289
450
"""Delete the item at relpath"""
451
abspath = self._abspath(relpath)
291
mutter("FTP rm: %s" % self._abspath(relpath))
453
mutter("FTP rm: %s", abspath)
292
454
f = self._get_FTP()
293
f.delete(self._abspath(relpath))
294
456
except ftplib.error_perm, e:
295
raise TransportError(orig_error=e)
457
self._translate_perm_error(e, abspath, 'error deleting',
458
unknown_exc=errors.NoSuchFile)
297
460
def listable(self):
298
461
"""See Transport.listable."""
359
526
return self.lock_read(relpath)
529
class FtpServer(Server):
530
"""Common code for SFTP server facilities."""
534
self._ftp_server = None
536
self._async_thread = None
541
"""Calculate an ftp url to this server."""
542
return 'ftp://foo:bar@localhost:%d/' % (self._port)
544
# def get_bogus_url(self):
545
# """Return a URL which cannot be connected to."""
546
# return 'ftp://127.0.0.1:1'
548
def log(self, message):
549
"""This is used by medusa.ftp_server to log connections, etc."""
550
self.logs.append(message)
555
raise RuntimeError('Must have medusa to run the FtpServer')
557
self._root = os.getcwdu()
558
self._ftp_server = _ftp_server(
559
authorizer=_test_authorizer(root=self._root),
561
port=0, # bind to a random port
563
logger_object=self # Use FtpServer.log() for messages
565
self._port = self._ftp_server.getsockname()[1]
566
# Don't let it loop forever, or handle an infinite number of requests.
567
# In this case it will run for 100s, or 1000 requests
568
self._async_thread = threading.Thread(target=asyncore.loop,
569
kwargs={'timeout':0.1, 'count':1000})
570
self._async_thread.setDaemon(True)
571
self._async_thread.start()
574
"""See bzrlib.transport.Server.tearDown."""
575
# have asyncore release the channel
576
self._ftp_server.del_channel()
578
self._async_thread.join()
583
_test_authorizer = None
587
global _have_medusa, _ftp_channel, _ftp_server, _test_authorizer
590
import medusa.filesys
591
import medusa.ftp_server
597
class test_authorizer(object):
598
"""A custom Authorizer object for running the test suite.
600
The reason we cannot use dummy_authorizer, is because it sets the
601
channel to readonly, which we don't always want to do.
604
def __init__(self, root):
607
def authorize(self, channel, username, password):
608
"""Return (success, reply_string, filesystem)"""
610
return 0, 'No Medusa.', None
612
channel.persona = -1, -1
613
if username == 'anonymous':
614
channel.read_only = 1
616
channel.read_only = 0
618
return 1, 'OK.', medusa.filesys.os_filesystem(self.root)
621
class ftp_channel(medusa.ftp_server.ftp_channel):
622
"""Customized ftp channel"""
624
def log(self, message):
625
"""Redirect logging requests."""
626
mutter('_ftp_channel: %s', message)
628
def log_info(self, message, type='info'):
629
"""Redirect logging requests."""
630
mutter('_ftp_channel %s: %s', type, message)
632
def cmd_rnfr(self, line):
633
"""Prepare for renaming a file."""
634
self._renaming = line[1]
635
self.respond('350 Ready for RNTO')
636
# TODO: jam 20060516 in testing, the ftp server seems to
637
# check that the file already exists, or it sends
638
# 550 RNFR command failed
640
def cmd_rnto(self, line):
641
"""Rename a file based on the target given.
643
rnto must be called after calling rnfr.
645
if not self._renaming:
646
self.respond('503 RNFR required first.')
647
pfrom = self.filesystem.translate(self._renaming)
648
self._renaming = None
649
pto = self.filesystem.translate(line[1])
651
os.rename(pfrom, pto)
652
except (IOError, OSError), e:
653
# TODO: jam 20060516 return custom responses based on
654
# why the command failed
655
self.respond('550 RNTO failed: %s' % (e,))
657
self.respond('550 RNTO failed')
658
# For a test server, we will go ahead and just die
661
self.respond('250 Rename successful.')
663
def cmd_size(self, line):
664
"""Return the size of a file
666
This is overloaded to help the test suite determine if the
667
target is a directory.
670
if not self.filesystem.isfile(filename):
671
if self.filesystem.isdir(filename):
672
self.respond('550 "%s" is a directory' % (filename,))
674
self.respond('550 "%s" is not a file' % (filename,))
676
self.respond('213 %d'
677
% (self.filesystem.stat(filename)[stat.ST_SIZE]),)
679
def cmd_mkd(self, line):
680
"""Create a directory.
682
Overloaded because default implementation does not distinguish
683
*why* it cannot make a directory.
686
self.command_not_understood(''.join(line))
690
self.filesystem.mkdir (path)
691
self.respond ('257 MKD command successful.')
692
except (IOError, OSError), e:
693
self.respond ('550 error creating directory: %s' % (e,))
695
self.respond ('550 error creating directory.')
698
class ftp_server(medusa.ftp_server.ftp_server):
699
"""Customize the behavior of the Medusa ftp_server.
701
There are a few warts on the ftp_server, based on how it expects
705
ftp_channel_class = ftp_channel
707
def __init__(self, *args, **kwargs):
708
mutter('Initializing _ftp_server: %r, %r', args, kwargs)
709
medusa.ftp_server.ftp_server.__init__(self, *args, **kwargs)
711
def log(self, message):
712
"""Redirect logging requests."""
713
mutter('_ftp_server: %s', message)
715
def log_info(self, message, type='info'):
716
"""Override the asyncore.log_info so we don't stipple the screen."""
717
mutter('_ftp_server %s: %s', type, message)
719
_test_authorizer = test_authorizer
720
_ftp_channel = ftp_channel
721
_ftp_server = ftp_server
362
726
def get_test_permutations():
363
727
"""Return the permutations to be used in testing."""
364
warn("There are no FTP transport provider tests yet.")
728
if not _setup_medusa():
729
warn("You must install medusa (http://www.amk.ca/python/code/medusa.html) for FTP tests")
732
return [(FtpTransport, FtpServer)]