source: ftputil/host.py @ 1506:f40b9f6738d0

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