root/trunk/ftputil.py

Revision 771, 34.5 kB (checked in by schwa, 2 weeks ago)
Improved comments.
  • Property svn:mime-type set to text/x-python
  • Property svn:eol-style set to native
  • Property svn:keywords set to Id
Line 
1 # Copyright (C) 2002-2008, Stefan Schwarzer <sschwarzer@sschwarzer.net>
2 # All rights reserved.
3 #
4 # Redistribution and use in source and binary forms, with or without
5 # modification, are permitted provided that the following conditions are
6 # met:
7 #
8 # - Redistributions of source code must retain the above copyright
9 #   notice, this list of conditions and the following disclaimer.
10 #
11 # - Redistributions in binary form must reproduce the above copyright
12 #   notice, this list of conditions and the following disclaimer in the
13 #   documentation and/or other materials provided with the distribution.
14 #
15 # - Neither the name of the above author nor the names of the
16 #   contributors to the software may be used to endorse or promote
17 #   products derived from this software without specific prior written
18 #   permission.
19 #
20 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 # ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR
24 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
25 # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
26 # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
27 # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
28 # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
29 # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
32 # $Id$
33
34 """
35 ftputil - high-level FTP client library
36
37 FTPHost objects
38     This class resembles the `os` module's interface to ordinary file
39     systems. In addition, it provides a method `file` which will
40     return file-objects corresponding to remote files.
41
42     # example session
43     host = ftputil.FTPHost('ftp.domain.com', 'me', 'secret')
44     print host.getcwd()  # e. g. '/home/me'
45     source = host.file('sourcefile', 'r')
46     host.mkdir('newdir')
47     host.chdir('newdir')
48     target = host.file('targetfile', 'w')
49     host.copyfileobj(source, target)
50     source.close()
51     target.close()
52     host.remove('targetfile')
53     host.chdir(host.pardir)
54     host.rmdir('newdir')
55     host.close()
56
57     There are also shortcuts for uploads and downloads:
58
59     host.upload(local_file, remote_file)
60     host.download(remote_file, local_file)
61
62     Both accept an additional mode parameter. If it is 'b', the
63     transfer mode will be for binary files.
64
65     For even more functionality refer to the documentation in
66     `ftputil.txt`.
67
68 FTPFile objects
69     `FTPFile` objects are constructed via the `file` method (`open`
70     is an alias) of `FTPHost` objects. `FTPFile` objects support the
71     usual file operations for non-seekable files (`read`, `readline`,
72     `readlines`, `xreadlines`, `write`, `writelines`, `close`).
73
74 Note: ftputil currently is not threadsafe. More specifically, you can
75       use different `FTPHost` objects in different threads but not
76       using a single `FTPHost` object in different threads.
77 """
78
79 import ftplib
80 import os
81 import stat
82 import sys
83 import time
84
85 import ftp_error
86 import ftp_file
87 import ftp_path
88 import ftp_stat
89 import ftputil_version
90
91 # make exceptions available in this module for backwards compatibilty;
92 #  you really should access them via the `ftp_error` module, not from here
93 from ftp_error import FTPError, FTPIOError, FTPOSError, \
94                       InaccessibleLoginDirError, InternalError, \
95                       ParserError, PermanentError, RootDirError, \
96                       TemporaryError, TimeShiftError
97
98 # it's recommended to use the error classes via the `ftp_error` module;
99 #  they're only here for backward compatibility
100 __all__ = ['FTPError', 'FTPOSError', 'TemporaryError',
101            'PermanentError', 'ParserError', 'FTPIOError',
102            'RootDirError', 'FTPHost']
103
104 __version__ = ftputil_version.__version__
105
106
107 #####################################################################
108 # `FTPHost` class with several methods similar to those of `os`
109
110 class FTPHost(object):
111     """FTP host class."""
112
113     # Implementation notes:
114     #
115     # Upon every request of a file (`_FTPFile` object) a new FTP
116     # session is created ("cloned"), leading to a child session of
117     # the `FTPHost` object from which the file is requested.
118     #
119     # This is needed because opening an `_FTPFile` will make the
120     # local session object wait for the completion of the transfer.
121     # In fact, code like this would block indefinitely, if the `RETR`
122     # request would be made on the `_session` of the object host:
123     #
124     #   host = FTPHost(ftp_server, user, password)
125     #   f = host.file('index.html')
126     #   host.getcwd()   # would block!
127     #
128     # On the other hand, the initially constructed host object will
129     # store references to already established `_FTPFile` objects and
130     # reuse an associated connection if its associated `_FTPFile`
131     # has been closed.
132
133     def __init__(self, *args, **kwargs):
134         """Abstract initialization of `FTPHost` object."""
135         # store arguments for later operations
136         self._args = args
137         self._kwargs = kwargs
138         # make a session according to these arguments
139         self._session = self._make_session()
140         # simulate os.path
141         self.path = ftp_path._Path(self)
142         # lstat, stat, listdir services
143         self._stat = ftp_stat._Stat(self)
144         self.stat_cache = self._stat._lstat_cache
145         self.stat_cache.enable()
146         # save (cache) current directory
147         self._current_dir = ftp_error._try_with_oserror(self._session.pwd)
148         # associated `FTPHost` objects for data transfer
149         self._children = []
150         # only set if this instance represents an `_FTPFile`
151         self._file = None
152         # now opened
153         self.closed = False
154         # set curdir, pardir etc. for the remote host; RFC 959 states
155         #  that this is, strictly spoken, dependent on the server OS
156         #  but it seems to work at least with Unix and Windows
157         #  servers
158         self.curdir, self.pardir, self.sep = '.', '..', '/'
159         # set default time shift (used in `upload_if_newer` and
160         #  `download_if_newer`)
161         self.set_time_shift(0.0)
162
163     #
164     # dealing with child sessions and file-like objects
165     #  (rather low-level)
166     #
167     def _make_session(self):
168         """
169         Return a new session object according to the current state of
170         this `FTPHost` instance.
171         """
172         # use copies of the arguments
173         args = self._args[:]
174         kwargs = self._kwargs.copy()
175         # if a session factory had been given on the instantiation of
176         #  this `FTPHost` object, use the same factory for this
177         #  `FTPHost` object's child sessions
178         factory = kwargs.pop('session_factory', ftplib.FTP)
179         return ftp_error._try_with_oserror(factory, *args, **kwargs)
180
181     def _copy(self):
182         """Return a copy of this `FTPHost` object."""
183         # The copy includes a new session factory return value (aka
184         #  session) but doesn't copy the state of `self.getcwd()`.
185         return FTPHost(*self._args, **self._kwargs)
186
187     def _available_child(self):
188         """
189         Return an available (i. e. one whose `_file` object is closed)
190         child (`FTPHost` object) from the pool of children or `None`
191         if there aren't any.
192         """
193         for host in self._children:
194             if host._file.closed:
195                 return host
196         # be explicit
197         return None
198
199     def file(self, path, mode='r'):
200         """
201         Return an open file(-like) object which is associated with
202         this `FTPHost` object.
203
204         This method tries to reuse a child but will generate a new one
205         if none is available.
206         """
207         host = self._available_child()
208         if host is None:
209             host = self._copy()
210             self._children.append(host)
211             host._file = ftp_file._FTPFile(host)
212         basedir = self.getcwd()
213         # prepare for changing the directory (see whitespace workaround
214         #  in method `_dir`)
215         if host.path.isabs(path):
216             effective_path = path
217         else:
218             effective_path = host.path.join(basedir, path)
219         effective_dir, effective_file = host.path.split(effective_path)
220         try:
221             # this will fail if we can't access the directory at all
222             host.chdir(effective_dir)
223         except ftp_error.PermanentError:
224             # similarly to a failed `file` in a local filesystem, we
225             #  raise an `IOError`, not an `OSError`
226             raise ftp_error.FTPIOError("remote directory '%s' doesn't exist "
227                   "or has insufficient access rights" % effective_dir)
228         host._file._open(effective_file, mode)
229         if 'w' in mode:
230             self.stat_cache.invalidate(effective_path)
231         return host._file
232
233     # make `open` an alias
234     open = file
235
236     def close(self):
237         """Close host connection."""
238         if self.closed:
239             return
240         # close associated children
241         for host in self._children:
242             # children have a `_file` attribute which is an `_FTPFile` object
243             host._file.close()
244             host.close()
245         # now deal with ourself
246         try:
247             ftp_error._try_with_oserror(self._session.close)
248         finally:
249             # if something went wrong before, the host/session is
250             #  probably defunct and subsequent calls to `close` won't
251             #  help either, so we consider the host/session closed for
252             #  practical purposes
253             self.stat_cache.clear()
254             self._children = []
255             self.closed = True
256
257     def __del__(self):
258         # don't complain about lazy except clause
259         # pylint: disable-msg=W0702, W0704
260         try:
261             self.close()
262         except:
263             # we don't want warnings if the constructor failed
264             pass
265
266     #
267     # setting a custom directory parser
268     #
269     def set_parser(self, parser):
270         """
271         Set the parser for extracting stat results from directory
272         listings.
273
274         The parser interface is described in the documentation, but
275         here are the most important things:
276
277         - A parser should derive from `ftp_stat.Parser`.
278
279         - The parser has to implement two methods, `parse_line` and
280           `ignores_line`. For the latter, there's a probably useful
281           default in the class `ftp_stat.Parser`.
282
283         - `parse_line` should try to parse a line of a directory
284           listing and return a `ftp_stat.StatResult` instance. If
285           parsing isn't possible, raise `ftp_error.ParserError` with
286           a useful error message.
287
288         - `ignores_line` should return a true value if the line isn't
289           assumed to contain stat information.
290         """
291         # the cache contents, if any, aren't probably useful
292         self.stat_cache.clear()
293         # set the parser
294         self._stat._parser = parser
295         # just set a parser explicitly, don't allow "smart" switching anymore
296         self._stat._allow_parser_switching = False
297
298     #
299     # time shift adjustment between client (i. e. us) and server
300     #
301     def set_time_shift(self, time_shift):
302         """
303         Set the time shift value (i. e. the time difference between
304         client and server) for this `FTPHost` object. By (my)
305         definition, the time shift value is positive if the local
306         time of the server is greater than the local time of the
307         client (for the same physical time), i. e.
308
309             time_shift =def= t_server - t_client
310         <=> t_server = t_client + time_shift
311         <=> t_client = t_server - time_shift
312
313         The time shift is measured in seconds.
314         """
315         self._time_shift = time_shift
316
317     def time_shift(self):
318         """
319         Return the time shift between FTP server and client. See the
320         docstring of `set_time_shift` for more on this value.
321         """
322         return self._time_shift
323
324     def __rounded_time_shift(self, time_shift):
325         """
326         Return the given time shift in seconds, but rounded to
327         full hours. The argument is also assumed to be given in
328         seconds.
329         """
330         minute = 60.0
331         hour = 60.0 * minute
332         # avoid division by zero below
333         if time_shift == 0:
334             return 0.0
335         # use a positive value for rounding
336         absolute_time_shift = abs(time_shift)
337         signum = time_shift / absolute_time_shift
338         # round it to hours; this code should also work for later Python
339         #  versions because of the explicit `int`
340         absolute_rounded_time_shift = \
341           int( (absolute_time_shift + 30*minute) / hour ) * hour
342         # return with correct sign
343         return signum * absolute_rounded_time_shift
344
345     def __assert_valid_time_shift(self, time_shift):
346         """
347         Perform sanity checks on the time shift value (given in
348         seconds). If the value is invalid, raise a `TimeShiftError`,
349         else simply return `None`.
350         """
351         minute = 60.0
352         hour = 60.0 * minute
353         absolute_rounded_time_shift = abs(self.__rounded_time_shift(time_shift))
354         # test 1: fail if the absolute time shift is greater than
355         #  a full day (24 hours)
356         if absolute_rounded_time_shift > 24 * hour:
357             raise ftp_error.TimeShiftError(
358                   "time shift (%.2f s) > 1 day" % time_shift)
359         # test 2: fail if the deviation between given time shift and
360         #  full hours is greater than a certain limit (e. g. five minutes)
361         maximum_deviation = 5 * minute
362         if abs(time_shift - self.__rounded_time_shift(time_shift)) > \
363            maximum_deviation:
364             raise ftp_error.TimeShiftError(
365                   "time shift (%.2f s) deviates more than %d s from full hours"
366                   % (time_shift, maximum_deviation))
367
368     def synchronize_times(self):
369         """
370         Synchronize the local times of FTP client and server. This
371         is necessary to let `upload_if_newer` and `download_if_newer`
372         work correctly.
373
374         This implementation of `synchronize_times` requires _all_ of
375         the following:
376
377         - The connection between server and client is established.
378         - The client has write access to the directory that is
379           current when `synchronize_times` is called.
380
381         The usual usage pattern of `synchronize_times` is to call it
382         directly after the connection is established. (As can be
383         concluded from the points above, this requires write access
384         to the login directory.)
385
386         If `synchronize_times` fails, it raises a `TimeShiftError`.
387         """
388         helper_file_name = "_ftputil_sync_"
389         # open a dummy file for writing in the current directory
390         #  on the FTP host, then close it
391         try:
392             file_ = self.file(helper_file_name, 'w')
393             file_.close()
394             server_time = self.path.getmtime(helper_file_name)
395         finally:
396             # remove the just written file
397             self.unlink(helper_file_name)
398         # calculate the difference between server and client
399         time_shift = server_time - time.time()
400         # do some sanity checks
401         self.__assert_valid_time_shift(time_shift)
402         # if tests passed, store the time difference as time shift value
403         self.set_time_shift(self.__rounded_time_shift(time_shift))
404
405     #
406     # operations based on file-like objects (rather high-level)
407     #
408     def copyfileobj(self, source, target, length=64*1024):
409         "Copy data from file-like object source to file-like object target."
410         # inspired by `shutil.copyfileobj` (I don't use the `shutil`
411         #  code directly because it might change)
412         while True:
413             buffer_ = source.read(length)
414             if not buffer_:
415                 break
416             target.write(buffer_)
417
418     def __get_modes(self, mode):
419         """Return modes for source and target file."""
420         if mode == 'b':
421             return 'rb', 'wb'
422         else:
423             return 'r', 'w'
424
425     def __copy_file(self, source, target, mode, source_open, target_open):
426         """
427         Copy a file from source to target. Which of both is a local
428         or a remote file, respectively, is determined by the arguments.
429         """
430         source_mode, target_mode = self.__get_modes(mode)
431         source = source_open(source, source_mode)
432         try:
433             target = target_open(target, target_mode)
434             try:
435                 self.copyfileobj(source, target)
436             finally:
437                 target.close()
438         finally:
439             source.close()
440
441     def upload(self, source, target, mode=''):
442         """
443         Upload a file from the local source (name) to the remote
444         target (name). The argument mode is an empty string or 'a' for
445         text copies, or 'b' for binary copies.
446         """
447         self.__copy_file(source, target, mode, open, self.file)
448         # the path in the stat cache is implicitly invalidated when
449         #  the file is opened on the remote host
450
451     def download(self, source, target, mode=''):
452         """
453         Download a file from the remote source (name) to the local
454         target (name). The argument mode is an empty string or 'a' for
455         text copies, or 'b' for binary copies.
456         """
457         self.__copy_file(source, target, mode, self.file, open)
458
459     #XXX the use of the `copy_method` seems less-than-ideal
460     #  factoring; can we handle it in another way?
461
462     def __copy_file_if_newer(self, source, target, mode,
463       source_mtime, target_mtime, target_exists, copy_method):
464         """
465         Copy a source file only if it's newer than the target. The
466         direction of the copy operation is determined by the
467         arguments. See methods `upload_if_newer` and
468         `download_if_newer` for examples.
469
470         If the copy was necessary, return `True`, else return `False`.
471         """
472         source_timestamp = source_mtime(source)
473         if target_exists(target):
474             target_timestamp = target_mtime(target)
475         else:
476             # every timestamp is newer than this one
477             target_timestamp = 0.0
478         if source_timestamp > target_timestamp:
479             copy_method(source, target, mode)
480             return True
481         else:
482             return False
483
484     def __shifted_local_mtime(self, file_name):
485         """
486         Return last modification of a local file, corrected with
487         respect to the time shift between client and server.
488         """
489         local_mtime = os.path.getmtime(file_name)
490         # transform to server time
491         return local_mtime + self.time_shift()
492
493     def upload_if_newer(self, source, target, mode=''):
494         """
495         Upload a file only if it's newer than the target on the
496         remote host or if the target file does not exist. See the
497         method `upload` for the meaning of the parameters.
498
499         If an upload was necessary, return `True`, else return
500         `False`.
501         """
502         return self.__copy_file_if_newer(source, target, mode,
503           self.__shifted_local_mtime, self.path.getmtime,
504           self.path.exists, self.upload)
505
506     def download_if_newer(self, source, target, mode=''):
507         """
508         Download a file only if it's newer than the target on the
509         local host or if the target file does not exist. See the
510         method `download` for the meaning of the parameters.
511
512         If a download was necessary, return `True`, else return
513         `False`.
514         """
515         return self.__copy_file_if_newer(source, target, mode,
516           self.path.getmtime, self.__shifted_local_mtime,
517           os.path.exists, self.download)
518
519     #
520     # helper methods to descend into a directory before executing a command
521     #
522     def _check_inaccessible_login_directory(self):
523         """
524         Raise an `InaccessibleLoginDirError` exception if we can't
525         change to the login directory. This test is only reliable if
526         the current directory is the login directory.
527         """
528         presumable_login_dir = self.getcwd()
529         # bail out with an internal error rather than modifying the
530         #  current directory without hope of restoration
531         try:
532             self.chdir(presumable_login_dir)
533         except ftp_error.PermanentError:
534             # `old_dir` is an inaccessible login directory
535             raise ftp_error.InaccessibleLoginDirError(
536                   "directory '%s' is not accessible" % presumable_login_dir)
537
538     def _robust_ftp_command(self, command, path, descend_deeply=False):
539         """
540         Run an FTP command on a path.
541
542         If the path doesn't contain whitespace, run it (the
543         overwritten `_command` method) with the instance (`self`) as
544         first and the `path` as second argument.
545
546         If the path contains whitespace, split it into a head and a
547         tail part where the tail is the last component of the path.
548         Change into the head directory, then execute the command on
549         the tail component.
550
551         The return value of the method is the return value of the
552         command.
553
554         If `descend_deeply` is true (the default is false), descend
555         deeply, i. e. change the directory to the end of the path.
556         """
557         head, tail = self.path.split(path)
558         if descend_deeply:
559             special_case = " " in path
560         else:
561             special_case = " " in head
562         if not special_case:
563             # nothing special, just apply the command
564             return command(self, path)
565         else:
566             self._check_inaccessible_login_directory()
567             # because of a bug in `ftplib` (or even in FTP servers?)
568             #  the straightforward code
569             #    command(self, path)
570             #  fails if some of the path components contain whitespace;
571             #  changing to the directory first and then applying the
572             #  command works, though
573             # remember old working directory
574             old_dir = self.getcwd()
575             try:
576                 if descend_deeply:
577                     # invoke the command in (not: on) the deepest directory
578                     self.chdir(path)
579                     return command(self, self.curdir)
580                 else:
581                     # invoke the command in the "next-to-last" directory
582                     self.chdir(head)
583                     return command(self, tail)
584             finally:
585                 # restore the old directory
586                 self.chdir(old_dir)
587
588     #
589     # miscellaneous utility methods resembling functions in `os`
590     #
591     def getcwd(self):
592         """Return the current path name."""
593         return self._current_dir
594
595     def chdir(self, path):
596         """Change the directory on the host."""
597         ftp_error._try_with_oserror(self._session.cwd, path)
598         self._current_dir = self.path.normpath(self.path.join(
599                                                # use "old" current dir
600                                                self._current_dir, path))
601
602     def mkdir(self, path, mode=None):
603         """
604         Make the directory path on the remote host. The argument
605         `mode` is ignored and only "supported" for similarity with
606         `os.mkdir`.
607         """
608         # ignore unused argument `mode`
609         # pylint: disable-msg=W0613
610         def command(self, path):
611             """Callback function."""
612             return ftp_error._try_with_oserror(self._session.mkd, path)
613         self._robust_ftp_command(command, path)
614
615     def makedirs(self, path, mode=None):
616         """
617         Make the directory `path`, but also make not yet existing
618         intermediate directories, like `os.makedirs`. The value
619         of `mode` is only accepted for compatibility with
620         `os.makedirs` but otherwise ignored.
621         """
622         # ignore unused argument `mode`
623         # pylint: disable-msg=W0613
624         path = self.path.abspath(path)
625         directories = path.split(self.sep)
626         # try to build the directory chain from the "uppermost" to
627         #  the "lowermost" directory
628         for index in range(1, len(directories)):
629             # re-insert the separator which got lost by using `path.split`
630             next_directory = self.sep + self.path.join(*directories[:index+1])
631             try:
632                 self.mkdir(next_directory)
633             except ftp_error.PermanentError:
634                 # find out the cause of the error; re-raise the
635                 #  exception only if the directory didn't exist already;
636                 #  else something went _really_ wrong, e. g. we might
637                 #  have a regular file with the name of the directory
638                 if not self.path.isdir(next_directory):
639                     raise
640
641     def rmdir(self, path):
642         """
643         Remove the _empty_ directory `path` on the remote host.
644
645         Compatibility note:
646
647         Previous versions of ftputil could possibly delete non-
648         empty directories as well, - if the server allowed it. This
649         is no longer supported.
650         """
651         path = self.path.abspath(path)
652         if self.listdir(path):
653             raise ftp_error.PermanentError("directory '%s' not empty" % path)
654         #XXX how will `rmd` work with links?
655         def command(self, path):
656             """Callback function."""
657             ftp_error._try_with_oserror(self._session.rmd, path)
658         self._robust_ftp_command(command, path)
659         self.stat_cache.invalidate(path)
660
661     def remove(self, path):
662         """Remove the given file or link."""
663         path = self.path.abspath(path)
664         # though `isfile` includes also links to files, `islink`
665         #  is needed to include links to directories
666         if self.path.isfile(path) or self.path.islink(path):
667             def command(self, path):
668                 """Callback function."""
669                 ftp_error._try_with_oserror(self._session.delete, path)
670             self._robust_ftp_command(command, path)
671         else:
672             raise ftp_error.PermanentError("remove/unlink can only delete "
673                                            "files and links, not directories")
674         self.stat_cache.invalidate(path)
675
676     def unlink(self, path):
677         """
678         Remove the given file given by `path`.
679
680         Raise a `PermanentError` if the path doesn't exist, raise a
681         `PermanentError`, but maybe raise other exceptions depending
682         on the state of the server (e. g. timeout).
683         """
684         self.remove(path)
685
686     def rmtree(self, path, ignore_errors=False, onerror=None):
687         """
688         Remove the given remote, possibly non-empty, directory tree.
689         The interface of this method is rather complex, in favor of
690         compatibility with `shutil.rmtree`.
691
692         If `ignore_errors` is set to a true value, errors are ignored.
693         If `ignore_errors` is a false value _and_ `onerror` isn't set,
694         all exceptions occuring during the tree iteration and
695         processing are raised. These exceptions are all of type
696         `PermanentError`.
697
698         To distinguish between error situations, pass in a callable
699         for `onerror`. This callable must accept three arguments:
700         `func`, `path` and `exc_info`). `func` is a bound method
701         object, _for example_ `your_host_object.listdir`. `path` is
702         the path that was the recent argument of the respective method
703         (`listdir`, `remove`, `rmdir`). `exc_info` is the exception
704         info as it's got from `sys.exc_info`.
705
706         Implementation note: The code is copied from `shutil.rmtree`
707         in Python 2.4 and adapted to ftputil.
708         """
709         # the following code is an adapted version of Python 2.4's
710         #  `shutil.rmtree` function
711         if ignore_errors:
712             def new_onerror(*args):
713                 """Do nothing."""
714                 # ignore unused arguments
715                 # pylint: disable-msg=W0613
716                 pass
717         elif onerror is None:
718             def new_onerror(*args):
719                 """Re-raise exception."""
720                 # ignore unused arguments
721                 # pylint: disable-msg=W0613
722                 raise
723         else:
724             new_onerror = onerror
725         names = []
726         try:
727             names = self.listdir(path)
728         except ftp_error.PermanentError:
729             new_onerror(self.listdir, path, sys.exc_info())
730         for name in names:
731             full_name = self.path.join(path, name)
732             try:
733                 mode = self.lstat(full_name).st_mode
734             except ftp_error.PermanentError:
735                 mode = 0
736             if stat.S_ISDIR(mode):
737                 self.rmtree(full_name, ignore_errors, new_onerror)
738             else:
739                 try:
740                     self.remove(full_name)
741                 except ftp_error.PermanentError:
742                     new_onerror(self.remove, full_name, sys.exc_info())
743         try:
744             self.rmdir(path)
745         except ftp_error.FTPOSError:
746             new_onerror(self.rmdir, path, sys.exc_info())
747
748     def rename(self, source, target):
749         """Rename the source on the FTP host to target."""
750         # the following code is in spirit similar to the code in the
751         #  method `_robust_ftp_command`, though we don't do
752         #  _everything_ imaginable
753         self._check_inaccessible_login_directory()
754         source_head, source_tail = self.path.split(source)
755         target_head, target_tail = self.path.split(target)
756         paths_contain_whitespace = (" " in source_head) or (" " in target_head)
757         if paths_contain_whitespace and source_head == target_head:
758             # both items are