source: ftputil/host.py @ 1610:618b30604061

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