root/ftputil.py @ 843:d887d0aa8e84

Revision 843:d887d0aa8e84, 34.0 kB (checked in by Stefan Schwarzer <sschwarzer@…>, 7 months ago)
Removed now-unused Subversion keywords (migration to Mercurial).
Line 
1# Copyright (C) 2002-2010, 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"""
33ftputil - high-level FTP client library
34
35FTPHost objects
36    This class resembles the `os` module's interface to ordinary file
37    systems. In addition, it provides a method `file` which will
38    return file-objects corresponding to remote files.
39
40    # example session
41    host = ftputil.FTPHost('ftp.domain.com', 'me', 'secret')
42    print host.getcwd()  # e. g. '/home/me'
43    source = host.file('sourcefile', 'r')
44    host.mkdir('newdir')
45    host.chdir('newdir')
46    target = host.file('targetfile', 'w')
47    host.copyfileobj(source, target)
48    source.close()
49    target.close()
50    host.remove('targetfile')
51    host.chdir(host.pardir)
52    host.rmdir('newdir')
53    host.close()
54
55    There are also shortcuts for uploads and downloads:
56
57    host.upload(local_file, remote_file)
58    host.download(remote_file, local_file)
59
60    Both accept an additional mode parameter. If it is 'b', the
61    transfer mode will be for binary files.
62
63    For even more functionality refer to the documentation in
64    `ftputil.txt`.
65
66FTPFile objects
67    `FTPFile` objects are constructed via the `file` method (`open`
68    is an alias) of `FTPHost` objects. `FTPFile` objects support the
69    usual file operations for non-seekable files (`read`, `readline`,
70    `readlines`, `xreadlines`, `write`, `writelines`, `close`).
71
72Note: ftputil currently is not threadsafe. More specifically, you can
73      use different `FTPHost` objects in different threads but not
74      using a single `FTPHost` object in different threads.
75"""
76
77import ftplib
78import os
79import stat
80import sys
81import time
82
83import ftp_error
84import ftp_file
85import ftp_path
86import ftp_stat
87import ftputil_version
88
89
90__all__ = ['FTPHost']
91
92__version__ = ftputil_version.__version__
93
94
95#####################################################################
96# `FTPHost` class with several methods similar to those of `os`
97
98class FTPHost(object):
99    """FTP host class."""
100
101    # Implementation notes:
102    #
103    # Upon every request of a file (`_FTPFile` object) a new FTP
104    # session is created ("cloned"), leading to a child session of
105    # the `FTPHost` object from which the file is requested.
106    #
107    # This is needed because opening an `_FTPFile` will make the
108    # local session object wait for the completion of the transfer.
109    # In fact, code like this would block indefinitely, if the `RETR`
110    # request would be made on the `_session` of the object host:
111    #
112    #   host = FTPHost(ftp_server, user, password)
113    #   f = host.file('index.html')
114    #   host.getcwd()   # would block!
115    #
116    # On the other hand, the initially constructed host object will
117    # store references to already established `_FTPFile` objects and
118    # reuse an associated connection if its associated `_FTPFile`
119    # has been closed.
120
121    def __init__(self, *args, **kwargs):
122        """Abstract initialization of `FTPHost` object."""
123        # store arguments for later operations
124        self._args = args
125        self._kwargs = kwargs
126        # make a session according to these arguments
127        self._session = self._make_session()
128        # simulate os.path
129        self.path = ftp_path._Path(self)
130        # lstat, stat, listdir services
131        self._stat = ftp_stat._Stat(self)
132        self.stat_cache = self._stat._lstat_cache
133        self.stat_cache.enable()
134        # save (cache) current directory
135        self._current_dir = ftp_error._try_with_oserror(self._session.pwd)
136        # associated `FTPHost` objects for data transfer
137        self._children = []
138        # only set if this instance represents an `_FTPFile`
139        self._file = None
140        # now opened
141        self.closed = False
142        # set curdir, pardir etc. for the remote host; RFC 959 states
143        #  that this is, strictly spoken, dependent on the server OS
144        #  but it seems to work at least with Unix and Windows
145        #  servers
146        self.curdir, self.pardir, self.sep = '.', '..', '/'
147        # set default time shift (used in `upload_if_newer` and
148        #  `download_if_newer`)
149        self.set_time_shift(0.0)
150
151    #
152    # dealing with child sessions and file-like objects
153    #  (rather low-level)
154    #
155    def _make_session(self):
156        """
157        Return a new session object according to the current state of
158        this `FTPHost` instance.
159        """
160        # use copies of the arguments
161        args = self._args[:]
162        kwargs = self._kwargs.copy()
163        # if a session factory had been given on the instantiation of
164        #  this `FTPHost` object, use the same factory for this
165        #  `FTPHost` object's child sessions
166        factory = kwargs.pop('session_factory', ftplib.FTP)
167        return ftp_error._try_with_oserror(factory, *args, **kwargs)
168
169    def _copy(self):
170        """Return a copy of this `FTPHost` object."""
171        # The copy includes a new session factory return value (aka
172        #  session) but doesn't copy the state of `self.getcwd()`.
173        return FTPHost(*self._args, **self._kwargs)
174
175    def _available_child(self):
176        """
177        Return an available (i. e. one whose `_file` object is closed)
178        child (`FTPHost` object) from the pool of children or `None`
179        if there aren't any.
180        """
181        for host in self._children:
182            if host._file.closed:
183                return host
184        # be explicit
185        return None
186
187    def file(self, path, mode='r'):
188        """
189        Return an open file(-like) object which is associated with
190        this `FTPHost` object.
191
192        This method tries to reuse a child but will generate a new one
193        if none is available.
194        """
195        host = self._available_child()
196        if host is None:
197            host = self._copy()
198            self._children.append(host)
199            host._file = ftp_file._FTPFile(host)
200        basedir = self.getcwd()
201        # prepare for changing the directory (see whitespace workaround
202        #  in method `_dir`)
203        if host.path.isabs(path):
204            effective_path = path
205        else:
206            effective_path = host.path.join(basedir, path)
207        effective_dir, effective_file = host.path.split(effective_path)
208        try:
209            # this will fail if we can't access the directory at all
210            host.chdir(effective_dir)
211        except ftp_error.PermanentError:
212            # similarly to a failed `file` in a local filesystem, we
213            #  raise an `IOError`, not an `OSError`
214            raise ftp_error.FTPIOError("remote directory '%s' doesn't exist "
215                  "or has insufficient access rights" % effective_dir)
216        host._file._open(effective_file, mode)
217        if 'w' in mode:
218            # invalidate cache entry because size and timestamps will change
219            self.stat_cache.invalidate(effective_path)
220        return host._file
221
222    # make `open` an alias
223    open = file
224
225    def close(self):
226        """Close host connection."""
227        if self.closed:
228            return
229        # close associated children
230        for host in self._children:
231            # children have a `_file` attribute which is an `_FTPFile` object
232            host._file.close()
233            host.close()
234        # now deal with ourself
235        try:
236            ftp_error._try_with_oserror(self._session.close)
237        finally:
238            # if something went wrong before, the host/session is
239            #  probably defunct and subsequent calls to `close` won't
240            #  help either, so we consider the host/session closed for
241            #  practical purposes
242            self.stat_cache.clear()
243            self._children = []
244            self.closed = True
245
246    def __del__(self):
247        # don't complain about lazy except clause
248        # pylint: disable-msg=W0702, W0704
249        try:
250            self.close()
251        except:
252            # we don't want warnings if the constructor failed
253            pass
254
255    #
256    # setting a custom directory parser
257    #
258    def set_parser(self, parser):
259        """
260        Set the parser for extracting stat results from directory
261        listings.
262
263        The parser interface is described in the documentation, but
264        here are the most important things:
265
266        - A parser should derive from `ftp_stat.Parser`.
267
268        - The parser has to implement two methods, `parse_line` and
269          `ignores_line`. For the latter, there's a probably useful
270          default in the class `ftp_stat.Parser`.
271
272        - `parse_line` should try to parse a line of a directory
273          listing and return a `ftp_stat.StatResult` instance. If
274          parsing isn't possible, raise `ftp_error.ParserError` with
275          a useful error message.
276
277        - `ignores_line` should return a true value if the line isn't
278          assumed to contain stat information.
279        """
280        # the cache contents, if any, aren't probably useful
281        self.stat_cache.clear()
282        # set the parser
283        self._stat._parser = parser
284        # just set a parser explicitly, don't allow "smart" switching anymore
285        self._stat._allow_parser_switching = False
286
287    #
288    # time shift adjustment between client (i. e. us) and server
289    #
290    def set_time_shift(self, time_shift):
291        """
292        Set the time shift value (i. e. the time difference between
293        client and server) for this `FTPHost` object. By (my)
294        definition, the time shift value is positive if the local
295        time of the server is greater than the local time of the
296        client (for the same physical time), i. e.
297
298            time_shift =def= t_server - t_client
299        <=> t_server = t_client + time_shift
300        <=> t_client = t_server - time_shift
301
302        The time shift is measured in seconds.
303        """
304        self._time_shift = time_shift
305
306    def time_shift(self):
307        """
308        Return the time shift between FTP server and client. See the
309        docstring of `set_time_shift` for more on this value.
310        """
311        return self._time_shift
312
313    def __rounded_time_shift(self, time_shift):
314        """
315        Return the given time shift in seconds, but rounded to
316        full hours. The argument is also assumed to be given in
317        seconds.
318        """
319        minute = 60.0
320        hour = 60.0 * minute
321        # avoid division by zero below
322        if time_shift == 0:
323            return 0.0
324        # use a positive value for rounding
325        absolute_time_shift = abs(time_shift)
326        signum = time_shift / absolute_time_shift
327        # round it to hours; this code should also work for later Python
328        #  versions because of the explicit `int`
329        absolute_rounded_time_shift = \
330          int( (absolute_time_shift + 30*minute) / hour ) * hour
331        # return with correct sign
332        return signum * absolute_rounded_time_shift
333
334    def __assert_valid_time_shift(self, time_shift):
335        """
336        Perform sanity checks on the time shift value (given in
337        seconds). If the value is invalid, raise a `TimeShiftError`,
338        else simply return `None`.
339        """
340        minute = 60.0
341        hour = 60.0 * minute
342        absolute_rounded_time_shift = abs(self.__rounded_time_shift(time_shift))
343        # test 1: fail if the absolute time shift is greater than
344        #  a full day (24 hours)
345        if absolute_rounded_time_shift > 24 * hour:
346            raise ftp_error.TimeShiftError(
347                  "time shift (%.2f s) > 1 day" % time_shift)
348        # test 2: fail if the deviation between given time shift and
349        #  full hours is greater than a certain limit (e. g. five minutes)
350        maximum_deviation = 5 * minute
351        if abs(time_shift - self.__rounded_time_shift(time_shift)) > \
352           maximum_deviation:
353            raise ftp_error.TimeShiftError(
354                  "time shift (%.2f s) deviates more than %d s from full hours"
355                  % (time_shift, maximum_deviation))
356
357    def synchronize_times(self):
358        """
359        Synchronize the local times of FTP client and server. This
360        is necessary to let `upload_if_newer` and `download_if_newer`
361        work correctly. If `synchronize_times` isn't applicable
362        (see below), the time shift can still be set explicitly with
363        `set_time_shift`.
364
365        This implementation of `synchronize_times` requires _all_ of
366        the following:
367
368        - The connection between server and client is established.
369        - The client has write access to the directory that is
370          current when `synchronize_times` is called.
371
372        The common usage pattern of `synchronize_times` is to call it
373        directly after the connection is established. (As can be
374        concluded from the points above, this requires write access
375        to the login directory.)
376
377        If `synchronize_times` fails, it raises a `TimeShiftError`.
378        """
379        helper_file_name = "_ftputil_sync_"
380        # open a dummy file for writing in the current directory
381        #  on the FTP host, then close it
382        try:
383            file_ = self.file(helper_file_name, 'w')
384            file_.close()
385            server_time = self.path.getmtime(helper_file_name)
386        finally:
387            # remove the just written file
388            self.unlink(helper_file_name)
389        # calculate the difference between server and client
390        time_shift = server_time - time.time()
391        # do some sanity checks
392        self.__assert_valid_time_shift(time_shift)
393        # if tests passed, store the time difference as time shift value
394        self.set_time_shift(self.__rounded_time_shift(time_shift))
395
396    #
397    # operations based on file-like objects (rather high-level)
398    #
399    def copyfileobj(self, source, target, length=64*1024):
400        "Copy data from file-like object source to file-like object target."
401        # inspired by `shutil.copyfileobj` (I don't use the `shutil`
402        #  code directly because it might change)
403        while True:
404            buffer_ = source.read(length)
405            if not buffer_:
406                break
407            target.write(buffer_)
408
409    def __get_modes(self, mode):
410        """Return modes for source and target file."""
411        if mode == 'b':
412            return 'rb', 'wb'
413        else:
414            return 'r', 'w'
415
416    def __copy_file(self, source, target, mode, source_open, target_open):
417        """
418        Copy a file from source to target. Which of both is a local
419        or a remote file, respectively, is determined by the arguments.
420        """
421        source_mode, target_mode = self.__get_modes(mode)
422        source = source_open(source, source_mode)
423        try:
424            target = target_open(target, target_mode)
425            try:
426                self.copyfileobj(source, target)
427            finally:
428                target.close()
429        finally:
430            source.close()
431
432    def upload(self, source, target, mode=''):
433        """
434        Upload a file from the local source (name) to the remote
435        target (name). The argument mode is an empty string or 'a' for
436        text copies, or 'b' for binary copies.
437        """
438        self.__copy_file(source, target, mode, open, self.file)
439        # the path in the stat cache is implicitly invalidated when
440        #  the file is opened on the remote host
441
442    def download(self, source, target, mode=''):
443        """
444        Download a file from the remote source (name) to the local
445        target (name). The argument mode is an empty string or 'a' for
446        text copies, or 'b' for binary copies.
447        """
448        self.__copy_file(source, target, mode, self.file, open)
449
450    #XXX the use of the `copy_method` seems less-than-ideal
451    #  factoring; can we handle it in another way?
452
453    def __copy_file_if_newer(self, source, target, mode,
454      source_mtime, target_mtime, target_exists, copy_method):
455        """
456        Copy a source file only if it's newer than the target. The
457        direction of the copy operation is determined by the
458        arguments. See methods `upload_if_newer` and
459        `download_if_newer` for examples.
460
461        If the copy was necessary, return `True`, else return `False`.
462        """
463        source_timestamp = source_mtime(source)
464        if target_exists(target):
465            target_timestamp = target_mtime(target)
466        else:
467            # every timestamp is newer than this one
468            target_timestamp = 0.0
469        if source_timestamp > target_timestamp:
470            copy_method(source, target, mode)
471            return True
472        else:
473            return False
474
475    def __shifted_local_mtime(self, file_name):
476        """
477        Return last modification of a local file, corrected with
478        respect to the time shift between client and server.
479        """
480        local_mtime = os.path.getmtime(file_name)
481        # transform to server time
482        return local_mtime + self.time_shift()
483
484    def upload_if_newer(self, source, target, mode=''):
485        """
486        Upload a file only if it's newer than the target on the
487        remote host or if the target file does not exist. See the
488        method `upload` for the meaning of the parameters.
489
490        If an upload was necessary, return `True`, else return
491        `False`.
492        """
493        return self.__copy_file_if_newer(source, target, mode,
494          self.__shifted_local_mtime, self.path.getmtime,
495          self.path.exists, self.upload)
496
497    def download_if_newer(self, source, target, mode=''):
498        """
499        Download a file only if it's newer than the target on the
500        local host or if the target file does not exist. See the
501        method `download` for the meaning of the parameters.
502
503        If a download was necessary, return `True`, else return
504        `False`.
505        """
506        return self.__copy_file_if_newer(source, target, mode,
507          self.path.getmtime, self.__shifted_local_mtime,
508          os.path.exists, self.download)
509
510    #
511    # helper methods to descend into a directory before executing a command
512    #
513    def _check_inaccessible_login_directory(self):
514        """
515        Raise an `InaccessibleLoginDirError` exception if we can't
516        change to the login directory. This test is only reliable if
517        the current directory is the login directory.
518        """
519        presumable_login_dir = self.getcwd()
520        # bail out with an internal error rather than modifying the
521        #  current directory without hope of restoration
522        try:
523            self.chdir(presumable_login_dir)
524        except ftp_error.PermanentError:
525            # `old_dir` is an inaccessible login directory
526            raise ftp_error.InaccessibleLoginDirError(
527                  "directory '%s' is not accessible" % presumable_login_dir)
528
529    def _robust_ftp_command(self, command, path, descend_deeply=False):
530        """
531        Run an FTP command on a path. The return value of the method
532        is the return value of the command.
533
534        If `descend_deeply` is true (the default is false), descend
535        deeply, i. e. change the directory to the end of the path.
536        """
537        # if we can't change to the yet-current directory, the code
538        #  below won't work (see below), so in this case rather raise
539        #  an exception than give wrong results
540        self._check_inaccessible_login_directory()
541        # Some FTP servers don't behave as expected if the directory
542        #  portion of the path contains whitespace, some even yield
543        #  strange results if the command isn't executed in the
544        #  current directory. Therefore, change to the directory
545        #  which contains the item to run the command on and invoke
546        #  the command just there.
547        # remember old working directory
548        old_dir = self.getcwd()
549        try:
550            if descend_deeply:
551                # invoke the command in (not: on) the deepest directory
552                self.chdir(path)
553                # workaround for some servers that give recursive
554                #  listings when called with a dot as path; see issue #33,
555                #  http://ftputil.sschwarzer.net/trac/ticket/33
556                return command(self, "")
557            else:
558                # invoke the command in the "next to last" directory
559                head, tail = self.path.split(path)
560                self.chdir(head)
561                return command(self, tail)
562        finally:
563            # restore the old directory
564            self.chdir(old_dir)
565
566    #
567    # miscellaneous utility methods resembling functions in `os`
568    #
569    def getcwd(self):
570        """Return the current path name."""
571        return self._current_dir
572
573    def chdir(self, path):
574        """Change the directory on the host."""
575        ftp_error._try_with_oserror(self._session.cwd, path)
576        self._current_dir = self.path.normpath(self.path.join(
577                                               # use "old" current dir
578                                               self._current_dir, path))
579
580    def mkdir(self, path, mode=None):
581        """
582        Make the directory path on the remote host. The argument
583        `mode` is ignored and only "supported" for similarity with
584        `os.mkdir`.
585        """
586        # ignore unused argument `mode`
587        # pylint: disable-msg=W0613
588        def command(self, path):
589            """Callback function."""
590            return ftp_error._try_with_oserror(self._session.mkd, path)
591        self._robust_ftp_command(command, path)
592
593    def makedirs(self, path, mode=None):
594        """
595        Make the directory `path`, but also make not yet existing
596        intermediate directories, like `os.makedirs`. The value
597        of `mode` is only accepted for compatibility with
598        `os.makedirs` but otherwise ignored.
599        """
600        # ignore unused argument `mode`
601        # pylint: disable-msg=W0613
602        path = self.path.abspath(path)
603        directories = path.split(self.sep)
604        # try to build the directory chain from the "uppermost" to
605        #  the "lowermost" directory
606        for index in range(1, len(directories)):
607            # re-insert the separator which got lost by using `path.split`
608            next_directory = self.sep + self.path.join(*directories[:index+1])
609            try:
610                self.mkdir(next_directory)
611            except ftp_error.PermanentError:
612                # find out the cause of the error; re-raise the
613                #  exception only if the directory didn't exist already;
614                #  else something went _really_ wrong, e. g. we might
615                #  have a regular file with the name of the directory
616                if not self.path.isdir(next_directory):
617                    raise
618
619    def rmdir(self, path):
620        """
621        Remove the _empty_ directory `path` on the remote host.
622
623        Compatibility note:
624
625        Previous versions of ftputil could possibly delete non-
626        empty directories as well, - if the server allowed it. This
627        is no longer supported.
628        """
629        path = self.path.abspath(path)
630        if self.listdir(path):
631            raise ftp_error.PermanentError("directory '%s' not empty" % path)
632        #XXX how will `rmd` work with links?
633        def command(self, path):
634            """Callback function."""
635            ftp_error._try_with_oserror(self._session.rmd, path)
636        self._robust_ftp_command(command, path)
637        self.stat_cache.invalidate(path)
638
639    def remove(self, path):
640        """Remove the given file or link."""
641        path = self.path.abspath(path)
642        # though `isfile` includes also links to files, `islink`
643        #  is needed to include links to directories
644        # if the path doesn't exist, let the removal command trigger
645        #  an exception with a more appropriate error message
646        if self.path.isfile(path) or self.path.islink(path) or \
647           not self.path.exists(path):
648            def command(self, path):
649                """Callback function."""
650                ftp_error._try_with_oserror(self._session.delete, path)
651            self._robust_ftp_command(command, path)
652        else:
653            raise ftp_error.PermanentError("remove/unlink can only delete "
654                                           "files and links, not directories")
655        self.stat_cache.invalidate(path)
656
657    def unlink(self, path):
658        """
659        Remove the given file given by `path`.
660
661        Raise a `PermanentError` if the path doesn't exist, raise a
662        `PermanentError`, but maybe raise other exceptions depending
663        on the state of the server (e. g. timeout).
664        """
665        self.remove(path)
666
667    def rmtree(self, path, ignore_errors=False, onerror=None):
668        """
669        Remove the given remote, possibly non-empty, directory tree.
670        The interface of this method is rather complex, in favor of
671        compatibility with `shutil.rmtree`.
672
673        If `ignore_errors` is set to a true value, errors are ignored.
674        If `ignore_errors` is a false value _and_ `onerror` isn't set,
675        all exceptions occuring during the tree iteration and
676        processing are raised. These exceptions are all of type
677        `PermanentError`.
678
679        To distinguish between error situations, pass in a callable
680        for `onerror`. This callable must accept three arguments:
681        `func`, `path` and `exc_info`). `func` is a bound method
682        object, _for example_ `your_host_object.listdir`. `path` is
683        the path that was the recent argument of the respective method
684        (`listdir`, `remove`, `rmdir`). `exc_info` is the exception
685        info as it's got from `sys.exc_info`.
686
687        Implementation note: The code is copied from `shutil.rmtree`
688        in Python 2.4 and adapted to ftputil.
689        """
690        # the following code is an adapted version of Python 2.4's
691        #  `shutil.rmtree` function
692        if ignore_errors:
693            def new_onerror(*args):
694                """Do nothing."""
695                # ignore unused arguments
696                # pylint: disable-msg=W0613
697                pass
698        elif onerror is None:
699            def new_onerror(*args):
700                """Re-raise exception."""
701                # ignore unused arguments
702                # pylint: disable-msg=W0613
703                raise
704        else:
705            new_onerror = onerror
706        names = []
707        try:
708            names = self.listdir(path)
709        except ftp_error.PermanentError:
710            new_onerror(self.listdir, path, sys.exc_info())
711        for name in names:
712            full_name = self.path.join(path, name)
713            try:
714                mode = self.lstat(full_name).st_mode
715            except ftp_error.PermanentError:
716                mode = 0
717            if stat.S_ISDIR(mode):
718                self.rmtree(full_name, ignore_errors, new_onerror)
719            else:
720                try:
721                    self.remove(full_name)
722                except ftp_error.PermanentError:
723                    new_onerror(self.remove, full_name, sys.exc_info())
724        try:
725            self.rmdir(path)
726        except ftp_error.FTPOSError:
727            new_onerror(self.rmdir, path, sys.exc_info())
728
729    def rename(self, source, target):
730        """Rename the source on the FTP host to target."""
731        # the following code is in spirit similar to the code in the
732        #  method `_robust_ftp_command`, though we don't do
733        #  _everything_ imaginable
734        self._check_inaccessible_login_directory()
735        source_head, source_tail = self.path.split(source)
736        target_head, target_tail = self.path.split(target)
737        paths_contain_whitespace = (" " in source_head) or (" " in target_head)
738        if paths_contain_whitespace and source_head == target_head:
739            # both items are in the same directory
740            old_dir = self.getcwd()
741            try:
742                self.chdir(source_head)
743                ftp_error._try_with_oserror(self._session.rename,
744                                            source_tail, target_tail)
745            finally:
746                self.chdir(old_dir)
747        else:
748            # use straightforward command
749            ftp_error._try_with_oserror(self._session.rename, source, target)
750
751    #XXX one could argue to put this method into the `_Stat` class, but
752    #  I refrained from that because then `_Stat` would have to know
753    #  about `FTPHost`'s `_session` attribute and in turn about
754    #  `_session`'s `dir` method
755    def _dir(self, path):
756        """Return a directory listing as made by FTP's `DIR` command."""
757        # we can't use `self.path.isdir` in this method because that
758        #  would cause a call of `(l)stat` and thus a call to `_dir`,
759        #  so we would end up with an infinite recursion
760        def _FTPHost_dir_command(self, path):
761            """Callback function."""
762            lines = []
763            def callback(line):
764                """Callback function."""
765                lines.append(line)
766            ftp_error._try_with_oserror(self._session.dir, path, callback)
767            return lines
768        lines = self._robust_ftp_command(_FTPHost_dir_command, path,
769                                         descend_deeply=True)
770        return lines
771
772    # the `listdir`, `lstat` and `stat` methods don't use
773    #  `_robust_ftp_command` because they implicitly already use
774    #  `_dir` which actually uses `_robust_ftp_command`
775    def listdir(self, path):
776        """
777        Return a list of directories, files etc. in the directory
778        named `path`.
779
780        If the directory listing from the server can't be parsed with
781        any of the available parsers raise a `ParserError`.
782        """
783        return self._stat.listdir(path)
784
785    def lstat(self, path, _exception_for_missing_path=True):
786        """
787        Return an object similar to that returned by `os.lstat`.
788
789        If the directory listing from the server can't be parsed with
790        any of the available parsers, raise a `ParserError`. If the
791        directory _can_ be parsed and the `path` is _not_ found, raise
792        a `PermanentError`.
793
794        (`_exception_for_missing_path` is an implementation aid and
795        _not_ intended for use by ftputil clients.)
796        """
797        return self._stat.lstat(path, _exception_for_missing_path)
798
799    def stat(self, path, _exception_for_missing_path=True):
800        """
801        Return info from a "stat" call on `path`.
802
803        If the directory containing `path` can't be parsed, raise a
804        `ParserError`. If the directory containing `path` can be
805        parsed but the `path` can't be found, raise a
806        `PermanentError`. Also raise a `PermanentError` if there's an
807        endless (cyclic) chain of symbolic links "behind" the `path`.
808
809        (`_exception_for_missing_path` is an implementation aid and
810        _not_ intended for use by ftputil clients.)
811        """
812        return self._stat.stat(path, _exception_for_missing_path)
813
814    def walk(self, top, topdown=True, onerror=None):
815        """
816        Iterate over directory tree and return a tuple (dirpath,
817        dirnames, filenames) on each iteration, like the `os.walk`
818        function (see http://docs.python.org/lib/os-file-dir.html ).
819
820        Implementation note: The code is copied from `os.walk` in
821        Python 2.4 and adapted to ftputil.
822        """
823        # code from `os.walk` ...
824        try:
825            names = self.listdir(top)
826        except ftp_error.FTPOSError, err:
827            if onerror is not None:
828                onerror(err)
829            return
830
831        dirs, nondirs = [], []
832        for name in names:
833            if self.path.isdir(self.path.join(top, name)):
834                dirs.append(name)
835            else:
836                nondirs.append(name)
837
838        if topdown:
839            yield top, dirs, nondirs
840        for name in dirs:
841            path = self.path.join(top, name)
842            if not self.path.islink(path):
843                for item in self.walk(path, topdown, onerror):
844                    yield item
845        if not topdown:
846            yield top, dirs, nondirs
847
848    def chmod(self, path, mode):
849        """
850        Change the mode of a remote `path` (a string) to the integer
851        `mode`. This integer uses the same bits as the mode value
852        returned by the `stat` and `lstat` commands.
853
854        If something goes wrong, raise a `TemporaryError` or a
855        `PermanentError`, according to the status code returned by
856        the server. In particular, a non-existent path usually
857        causes a `PermanentError`.
858        """
859        path = self.path.abspath(path)
860        def command(self, path):
861            """Callback function."""
862            ftp_error._try_with_oserror(self._session.voidcmd,
863                                        "SITE CHMOD %s %s" % (oct(mode), path))
864        self._robust_ftp_command(command, path)
865        self.stat_cache.invalidate(path)
866
867    #
868    # context manager methods
869    #
870    def __enter__(self):
871        # return `self`, so it can be accessed as the variable
872        #  component of the `with` statement.
873        return self
874
875    def __exit__(self, exc_type, exc_val, exc_tb):
876        # we don't need the `exc_*` arguments here
877        # pylint: disable-msg=W0613
878        self.close()
879        # be explicit
880        return False
Note: See TracBrowser for help on using the browser.