source: ftputil/host.py @ 1459:defd9fc5cd71

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