1
# Copyright (C) 2005 Canonical Ltd
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
16
"""Implementation of Transport over ftp.
18
Written by Daniel Silverstone <dsilvers@digital-scurf.org> with serious
19
cargo-culting from the sftp transport and the http transport.
21
It provides the ftp:// and aftp:// protocols where ftp:// is passive ftp
22
and aftp:// is active ftp. Most people will want passive ftp for traversing
23
NAT and other firewalls, so it's best to use it unless you explicitly want
24
active, in which case aftp:// will be your friend.
27
from cStringIO import StringIO
36
from warnings import warn
39
from bzrlib.transport import Transport
40
from bzrlib.errors import (TransportNotPossible, TransportError,
41
NoSuchFile, FileExists, DirectoryNotEmpty)
42
from bzrlib.trace import mutter, warning
46
def _find_FTP(hostname, username, password, is_active):
47
"""Find an ftplib.FTP instance attached to this triplet."""
48
key = "%s|%s|%s|%s" % (hostname, username, password, is_active)
49
if key not in _FTP_cache:
50
mutter("Constructing FTP instance against %r" % key)
51
_FTP_cache[key] = ftplib.FTP(hostname, username, password)
52
_FTP_cache[key].set_pasv(not is_active)
53
return _FTP_cache[key]
56
class FtpTransportError(TransportError):
60
class FtpStatResult(object):
61
def __init__(self, f, relpath):
63
self.st_size = f.size(relpath)
64
self.st_mode = stat.S_IFREG
65
except ftplib.error_perm:
69
self.st_mode = stat.S_IFDIR
74
_number_of_retries = 2
75
_sleep_between_retries = 5
77
class FtpTransport(Transport):
78
"""This is the transport agent for ftp:// access."""
80
def __init__(self, base, _provided_instance=None):
81
"""Set the base path where files will be stored."""
82
assert base.startswith('ftp://') or base.startswith('aftp://')
83
super(FtpTransport, self).__init__(base)
84
self.is_active = base.startswith('aftp://')
87
(self._proto, self._host,
88
self._path, self._parameters,
89
self._query, self._fragment) = urlparse.urlparse(self.base)
90
self._FTP_instance = _provided_instance
93
"""Return the ftplib.FTP instance for this object."""
94
if self._FTP_instance is not None:
95
return self._FTP_instance
100
hostname = self._host
102
username, hostname = hostname.split("@", 1)
104
username, password = username.split(":", 1)
106
self._FTP_instance = _find_FTP(hostname, username, password,
108
return self._FTP_instance
109
except ftplib.error_perm, e:
110
raise TransportError(msg="Error setting up connection: %s"
111
% str(e), orig_error=e)
113
def should_cache(self):
114
"""Return True if the data pulled across should be cached locally.
118
def clone(self, offset=None):
119
"""Return a new FtpTransport with root at self.base + offset.
123
return FtpTransport(self.base, self._FTP_instance)
125
return FtpTransport(self.abspath(offset), self._FTP_instance)
127
def _abspath(self, relpath):
128
assert isinstance(relpath, basestring)
129
relpath = urllib.unquote(relpath)
130
if isinstance(relpath, basestring):
131
relpath_parts = relpath.split('/')
133
# TODO: Don't call this with an array - no magic interfaces
134
relpath_parts = relpath[:]
135
if len(relpath_parts) > 1:
136
if relpath_parts[0] == '':
137
raise ValueError("path %r within branch %r seems to be absolute"
138
% (relpath, self._path))
139
basepath = self._path.split('/')
140
if len(basepath) > 0 and basepath[-1] == '':
141
basepath = basepath[:-1]
142
for p in relpath_parts:
144
if len(basepath) == 0:
145
# In most filesystems, a request for the parent
146
# of root, just returns root.
149
elif p == '.' or p == '':
153
# Possibly, we could use urlparse.urljoin() here, but
154
# I'm concerned about when it chooses to strip the last
155
# portion of the path, and when it doesn't.
156
return '/'.join(basepath)
158
def abspath(self, relpath):
159
"""Return the full url to the given relative path.
160
This can be supplied with a string or a list
162
path = self._abspath(relpath)
163
return urlparse.urlunparse((self._proto,
164
self._host, path, '', '', ''))
166
def has(self, relpath):
167
"""Does the target location exist?
169
XXX: I assume we're never asked has(dirname) and thus I use
170
the FTP size command and assume that if it doesn't raise,
175
s = f.size(self._abspath(relpath))
176
mutter("FTP has: %s" % self._abspath(relpath))
178
except ftplib.error_perm:
179
mutter("FTP has not: %s" % self._abspath(relpath))
182
def get(self, relpath, decode=False, retries=0):
183
"""Get the file at the given relative path.
185
:param relpath: The relative path to the file
186
:param retries: Number of retries after temporary failures so far
189
We're meant to return a file-like object which bzr will
190
then read from. For now we do this via the magic of StringIO
192
# TODO: decode should be deprecated
194
mutter("FTP get: %s" % self._abspath(relpath))
197
f.retrbinary('RETR '+self._abspath(relpath), ret.write, 8192)
200
except ftplib.error_perm, e:
201
raise NoSuchFile(self.abspath(relpath), extra=str(e))
202
except ftplib.error_temp, e:
203
if retries > _number_of_retries:
204
raise TransportError(msg="FTP temporary error during GET %s. Aborting."
205
% self.abspath(relpath),
208
warning("FTP temporary error: %s. Retrying." % str(e))
209
self._FTP_instance = None
210
return self.get(relpath, decode, retries+1)
212
if retries > _number_of_retries:
213
raise TransportError("FTP control connection closed during GET %s."
214
% self.abspath(relpath),
217
warning("FTP control connection closed. Trying to reopen.")
218
time.sleep(_sleep_between_retries)
219
self._FTP_instance = None
220
return self.get(relpath, decode, retries+1)
222
def put(self, relpath, fp, mode=None, retries=0):
223
"""Copy the file-like or string object into the location.
225
:param relpath: Location to put the contents, relative to base.
226
:param fp: File-like or string object.
227
:param retries: Number of retries after temporary failures so far
230
TODO: jam 20051215 ftp as a protocol seems to support chmod, but ftplib does not
232
tmp_abspath = '%s.tmp.%.9f.%d.%d' % (self._abspath(relpath), time.time(),
233
os.getpid(), random.randint(0,0x7FFFFFFF))
234
if not hasattr(fp, 'read'):
237
mutter("FTP put: %s" % self._abspath(relpath))
240
f.storbinary('STOR '+tmp_abspath, fp)
241
f.rename(tmp_abspath, self._abspath(relpath))
242
except (ftplib.error_temp,EOFError), e:
243
warning("Failure during ftp PUT. Deleting temporary file.")
245
f.delete(tmp_abspath)
247
warning("Failed to delete temporary file on the server.\nFile: %s"
251
except ftplib.error_perm, e:
252
if "no such file" in str(e).lower():
253
raise NoSuchFile("Error storing %s: %s"
254
% (self.abspath(relpath), str(e)), extra=e)
256
raise FtpTransportError(orig_error=e)
257
except ftplib.error_temp, e:
258
if retries > _number_of_retries:
259
raise TransportError("FTP temporary error during PUT %s. Aborting."
260
% self.abspath(relpath), orig_error=e)
262
warning("FTP temporary error: %s. Retrying." % str(e))
263
self._FTP_instance = None
264
self.put(relpath, fp, mode, retries+1)
266
if retries > _number_of_retries:
267
raise TransportError("FTP control connection closed during PUT %s."
268
% self.abspath(relpath), orig_error=e)
270
warning("FTP control connection closed. Trying to reopen.")
271
time.sleep(_sleep_between_retries)
272
self._FTP_instance = None
273
self.put(relpath, fp, mode, retries+1)
276
def mkdir(self, relpath, mode=None):
277
"""Create a directory at the given path."""
279
mutter("FTP mkd: %s" % self._abspath(relpath))
282
f.mkd(self._abspath(relpath))
283
except ftplib.error_perm, e:
285
if 'File exists' in s:
286
raise FileExists(self.abspath(relpath), extra=s)
289
except ftplib.error_perm, e:
290
raise TransportError(orig_error=e)
292
def rmdir(self, rel_path):
293
"""Delete the directory at rel_path"""
295
mutter("FTP rmd: %s" % self._abspath(rel_path))
298
f.rmd(self._abspath(rel_path))
299
except ftplib.error_perm, e:
300
if str(e).endswith("Directory not empty"):
301
raise DirectoryNotEmpty(self._abspath(rel_path), extra=str(e))
303
raise TransportError(msg="Cannot remove directory at %s" % \
304
self._abspath(rel_path), extra=str(e))
306
def append(self, relpath, f):
307
"""Append the text in the file-like object into the final
310
raise TransportNotPossible('ftp does not support append()')
312
def copy(self, rel_from, rel_to):
313
"""Copy the item at rel_from to the location at rel_to"""
314
raise TransportNotPossible('ftp does not (yet) support copy()')
316
def move(self, rel_from, rel_to):
317
"""Move the item at rel_from to the location at rel_to"""
319
mutter("FTP mv: %s => %s" % (self._abspath(rel_from),
320
self._abspath(rel_to)))
322
f.rename(self._abspath(rel_from), self._abspath(rel_to))
323
except ftplib.error_perm, e:
324
raise TransportError(orig_error=e)
328
def delete(self, relpath):
329
"""Delete the item at relpath"""
331
mutter("FTP rm: %s" % self._abspath(relpath))
333
f.delete(self._abspath(relpath))
334
except ftplib.error_perm, e:
335
if str(e).endswith("No such file or directory"):
336
raise NoSuchFile(self._abspath(relpath), extra=str(e))
338
raise TransportError(orig_error=e)
341
"""See Transport.listable."""
344
def list_dir(self, relpath):
345
"""See Transport.list_dir."""
347
mutter("FTP nlst: %s" % self._abspath(relpath))
349
basepath = self._abspath(relpath)
350
# FTP.nlst returns paths prefixed by relpath, strip 'em
351
the_list = f.nlst(basepath)
352
stripped = [path[len(basepath)+1:] for path in the_list]
353
# Remove . and .. if present, and return
354
return [path for path in stripped if path not in (".", "..")]
355
except ftplib.error_perm, e:
356
raise TransportError(orig_error=e)
358
def iter_files_recursive(self):
359
"""See Transport.iter_files_recursive.
361
This is cargo-culted from the SFTP transport"""
362
mutter("FTP iter_files_recursive")
363
queue = list(self.list_dir("."))
365
relpath = urllib.quote(queue.pop(0))
366
st = self.stat(relpath)
367
if stat.S_ISDIR(st.st_mode):
368
for i, basename in enumerate(self.list_dir(relpath)):
369
queue.insert(i, relpath+"/"+basename)
373
def stat(self, relpath):
374
"""Return the stat information for a file.
377
mutter("FTP stat: %s" % self._abspath(relpath))
379
return FtpStatResult(f, self._abspath(relpath))
380
except ftplib.error_perm, e:
381
if "no such file" in str(e).lower():
382
raise NoSuchFile("Error storing %s: %s"
383
% (self.abspath(relpath), str(e)), extra=e)
385
raise FtpTransportError(orig_error=e)
387
def lock_read(self, relpath):
388
"""Lock the given file for shared (read) access.
389
:return: A lock object, which should be passed to Transport.unlock()
391
# The old RemoteBranch ignore lock for reading, so we will
392
# continue that tradition and return a bogus lock object.
393
class BogusLock(object):
394
def __init__(self, path):
398
return BogusLock(relpath)
400
def lock_write(self, relpath):
401
"""Lock the given file for exclusive (write) access.
402
WARNING: many transports do not support this, so trying avoid using it
404
:return: A lock object, which should be passed to Transport.unlock()
406
return self.lock_read(relpath)
409
def get_test_permutations():
410
"""Return the permutations to be used in testing."""
411
warn("There are no FTP transport provider tests yet.")