source: ftputil/host.py @ 1449:22d7f6132882

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