source: ftputil/host.py @ 1421:a229eabbb7ef

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