source: ftputil.py @ 736:27623d5a9237

Last change on this file since 736:27623d5a9237 was 736:27623d5a9237, checked in by Stefan Schwarzer <sschwarzer@…>, 12 years ago
Improved comments.
File size: 34.5 KB
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"""
35ftputil - high-level FTP client library
36
37FTPHost 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
68FTPFile 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
74Note: 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
79import ftplib
80import os
81import stat
82import sys
83import time
84
85import ftp_error
86import ftp_file
87import ftp_path
88import ftp_stat
89import 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
93from 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
110class 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 in the same directory
759            old_dir = self.getcwd()
760            try:
761                self.chdir(source_head)
762                ftp_error._try_with_oserror(self._session.rename,
763                                            source_tail, target_tail)
764            finally:
765                self.chdir(old_dir)
766        else:
767            # use straightforward command
768            ftp_error._try_with_oserror(self._session.rename, source, target)
769
770    #XXX one could argue to put this method into the `_Stat` class, but
771    #  I refrained from that because then `_Stat` would have to know
772    #  about `FTPHost`'s `_session` attribute and in turn about
773    #  `_session`'s `dir` method
774    def _dir(self, path):
775        """Return a directory listing as made by FTP's `DIR` command."""
776        # we can't use `self.path.isdir` in this method because that
777        #  would cause a call of `(l)stat` and thus a call to `_dir`,
778        #  so we would end up with an infinite recursion
779        def command(self, path):
780            """Callback function."""
781            lines = []
782            def callback(line):
783                """Callback function."""
784                lines.append(line)
785            ftp_error._try_with_oserror(self._session.dir, path, callback)
786            return lines
787        lines = self._robust_ftp_command(command, path, descend_deeply=True)
788        return lines
789
790    # the `listdir`, `lstat` and `stat` methods don't use
791    #  `_robust_ftp_command` because they implicitly already use
792    #  `_dir` which actually uses `_robust_ftp_command`
793    def listdir(self, path):
794        """
795        Return a list of directories, files etc. in the directory
796        named `path`.
797
798        If the directory listing from the server can't be parsed with
799        any of the available parsers raise a `ParserError`.
800        """
801        return self._stat.listdir(path)
802
803    def lstat(self, path, _exception_for_missing_path=True):
804        """
805        Return an object similar to that returned by `os.lstat`.
806
807        If the directory listing from the server can't be parsed with
808        any of the available parsers, raise a `ParserError`. If the
809        directory _can_ be parsed and the `path` is _not_ found, raise
810        a `PermanentError`.
811
812        (`_exception_for_missing_path` is an implementation aid and
813        _not_ intended for use by ftputil clients.)
814        """
815        return self._stat.lstat(path, _exception_for_missing_path)
816
817    def stat(self, path, _exception_for_missing_path=True):
818        """
819        Return info from a "stat" call on `path`.
820
821        If the directory containing `path` can't be parsed, raise a
822        `ParserError`. If the directory containing `path` can be
823        parsed but the `path` can't be found, raise a
824        `PermanentError`. Also raise a `PermanentError` if there's an
825        endless (cyclic) chain of symbolic links "behind" the `path`.
826
827        (`_exception_for_missing_path` is an implementation aid and
828        _not_ intended for use by ftputil clients.)
829        """
830        return self._stat.stat(path, _exception_for_missing_path)
831
832    def walk(self, top, topdown=True, onerror=None):
833        """
834        Iterate over directory tree and return a tuple (dirpath,
835        dirnames, filenames) on each iteration, like the `os.walk`
836        function (see http://docs.python.org/lib/os-file-dir.html ).
837
838        Implementation note: The code is copied from `os.walk` in
839        Python 2.4 and adapted to ftputil.
840        """
841        # code from `os.walk` ...
842        try:
843            names = self.listdir(top)
844        except ftp_error.FTPOSError, err:
845            if onerror is not None:
846                onerror(err)
847            return
848
849        dirs, nondirs = [], []
850        for name in names:
851            if self.path.isdir(self.path.join(top, name)):
852                dirs.append(name)
853            else:
854                nondirs.append(name)
855
856        if topdown:
857            yield top, dirs, nondirs
858        for name in dirs:
859            path = self.path.join(top, name)
860            if not self.path.islink(path):
861                for item in self.walk(path, topdown, onerror):
862                    yield item
863        if not topdown:
864            yield top, dirs, nondirs
865
866    def chmod(self, path, mode):
867        """
868        Change the mode of a remote `path` (a string) to the integer
869        `mode`. This integer uses the same bits as the mode value
870        returned by the `stat` and `lstat` commands.
871
872        If something goes wrong, raise a `TemporaryError` or a
873        `PermanentError`, according to the status code returned by
874        the server. In particular, a non-existent path usually
875        causes a `PermanentError`.
876        """
877        path = self.path.abspath(path)
878        def command(self, path):
879            """Callback function."""
880            ftp_error._try_with_oserror(self._session.voidcmd,
881                                        "SITE CHMOD %s %s" % (oct(mode), path))
882        self._robust_ftp_command(command, path)
883        self.stat_cache.invalidate(path)
884
885    #
886    # context manager methods
887    #
888    def __enter__(self):
889        # return `self`, so it can be accessed as the variable
890        #  component of the `with` statement.
891        return self
892
893    def __exit__(self, exc_type, exc_val, exc_tb):
894        # we don't need the `exc_*` arguments here
895        # pylint: disable-msg=W0613
896        self.close()
897        # be explicit
898        return False
899
Note: See TracBrowser for help on using the repository browser.