source: ftputil/host.py @ 1278:831382d92a2d

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