source: ftputil/host.py @ 1603:3bdb8233c7f2

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