source: ftputil/host.py @ 1343:bcadc5b1504f

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