source: ftputil/host.py @ 1284:25228b207849

Last change on this file since 1284:25228b207849 was 1284:25228b207849, checked in by Stefan Schwarzer <sschwarzer@…>, 7 years ago
Added tests for string type handling for `chdir` and `listdir`. TODO: A bytes argument for `listdir` doesn't work yet. TODO: String type tests for `path.join` and `path.split*`.
File size: 37.5 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.file('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 = 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 file(self, path, mode='r'):
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        # Fail early if we get a unicode path which can't be encoded.
166        path = str(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 filesystem,
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)
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    open = 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(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.file(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, **kwargs):
402        """
403        Copy data from file-like object `source` to file-like object
404        `target`.
405        """
406        if 'length' in kwargs:
407            max_chunk_size = kwargs['length']
408            warnings.warn(("Parameter name `length` will be removed in "
409                           "ftputil 2.6, use `max_chunk_size` instead"),
410                          DeprecationWarning, stacklevel=2)
411        ftputil.file_transfer.copyfileobj(source, target, max_chunk_size,
412                                          callback)
413
414    def __get_modes(self, mode):
415        """Return modes for source and target file."""
416        #XXX Should we allow mode "a" at all? We don't support appending!
417        # Invalid mode values are handled when a file object is made.
418        if mode == 'b':
419            return 'rb', 'wb'
420        else:
421            return 'r', 'w'
422
423    def _upload_files(self, source_path, target_path, mode):
424        """
425        Return a `LocalFile` and `RemoteFile` as source and target,
426        respectively.
427
428        The strings `source_path` and `target_path` are the (absolute
429        or relative) paths of the local and the remote file, respectively.
430        """
431        source_mode, target_mode = self.__get_modes(mode)
432        source_file = ftputil.file_transfer.LocalFile(source_path, source_mode)
433        # Passing `self` (the `FTPHost` instance) here is correct.
434        target_file = ftputil.file_transfer.RemoteFile(self, target_path,
435                                                       target_mode)
436        return source_file, target_file
437
438    def upload(self, source, target, mode='', callback=None):
439        """
440        Upload a file from the local source (name) to the remote
441        target (name). The argument `mode` is an empty string or 'a' for
442        text copies, or 'b' for binary copies.
443        """
444        # Fail early if we get a unicode path which can't be encoded.
445        # Only attempt to convert the remote `target` name to a
446        # bytestring. Leave it to the local filesystem whether it
447        # wants to support unicode filenames or not.
448        target = str(target)
449        source_file, target_file = self._upload_files(source, target, mode)
450        ftputil.file_transfer.copy_file(source_file, target_file,
451                                        conditional=False, callback=callback)
452
453    def upload_if_newer(self, source, target, mode='', callback=None):
454        """
455        Upload a file only if it's newer than the target on the
456        remote host or if the target file does not exist. See the
457        method `upload` for the meaning of the parameters.
458
459        If an upload was necessary, return `True`, else return `False`.
460        """
461        # See comment in `upload`.
462        target = str(target)
463        source_file, target_file = self._upload_files(source, target, mode)
464        return ftputil.file_transfer.copy_file(source_file, target_file,
465                                               conditional=True,
466                                               callback=callback)
467
468    def _download_files(self, source_path, target_path, mode):
469        """
470        Return a `RemoteFile` and `LocalFile` as source and target,
471        respectively.
472
473        The strings `source_path` and `target_path` are the (absolute
474        or relative) paths of the remote and the local file, respectively.
475        """
476        source_mode, target_mode = self.__get_modes(mode)
477        source_file = ftputil.file_transfer.RemoteFile(self, source_path,
478                                                       source_mode)
479        target_file = ftputil.file_transfer.LocalFile(target_path, target_mode)
480        return source_file, target_file
481
482    def download(self, source, target, mode='', callback=None):
483        """
484        Download a file from the remote source (name) to the local
485        target (name). The argument mode is an empty string or 'a' for
486        text copies, or 'b' for binary copies.
487        """
488        # Fail early if we get a unicode path which can't be encoded.
489        # Only attempt to convert the remote `source` name to a
490        # bytestring. We leave it to the local filesystem whether it
491        # wants to support unicode filenames or not.
492        source = str(source)
493        source_file, target_file = self._download_files(source, target, mode)
494        ftputil.file_transfer.copy_file(source_file, target_file,
495                                        conditional=False, callback=callback)
496
497    def download_if_newer(self, source, target, mode='', callback=None):
498        """
499        Download a file only if it's newer than the target on the
500        local host or if the target file does not exist. See the
501        method `download` for the meaning of the parameters.
502
503        If a download was necessary, return `True`, else return
504        `False`.
505        """
506        # See comment in `download`.
507        source = str(source)
508        source_file, target_file = self._download_files(source, target, mode)
509        return ftputil.file_transfer.copy_file(source_file, target_file,
510                                               conditional=True,
511                                               callback=callback)
512
513    #
514    # Helper methods to descend into a directory before executing a command
515    #
516    def _check_inaccessible_login_directory(self):
517        """
518        Raise an `InaccessibleLoginDirError` exception if we can't
519        change to the login directory. This test is only reliable if
520        the current directory is the login directory.
521        """
522        presumable_login_dir = self.getcwd()
523        # Bail out with an internal error rather than modify the
524        # current directory without hope of restoration.
525        try:
526            self.chdir(presumable_login_dir)
527        except ftputil.error.PermanentError:
528            raise ftputil.error.InaccessibleLoginDirError(
529                  "directory '{0}' is not accessible".
530                  format(presumable_login_dir))
531
532    def _robust_ftp_command(self, command, path, descend_deeply=False):
533        """
534        Run an FTP command on a path. The return value of the method
535        is the return value of the command.
536
537        If `descend_deeply` is true (the default is false), descend
538        deeply, i. e. change the directory to the end of the path.
539        """
540        # If we can't change to the yet-current directory, the code
541        # below won't work (see below), so in this case rather raise
542        # an exception than giving wrong results.
543        self._check_inaccessible_login_directory()
544        # Some FTP servers don't behave as expected if the directory
545        # portion of the path contains whitespace; some even yield
546        # strange results if the command isn't executed in the
547        # current directory. Therefore, change to the directory
548        # which contains the item to run the command on and invoke
549        # the command just there.
550        #
551        # Remember old working directory.
552        old_dir = self.getcwd()
553        try:
554            if descend_deeply:
555                # Invoke the command in (not: on) the deepest directory.
556                self.chdir(path)
557                # Workaround for some servers that give recursive
558                # listings when called with a dot as path; see issue #33,
559                # http://ftputil.sschwarzer.net/trac/ticket/33
560                return command(self, "")
561            else:
562                # Invoke the command in the "next to last" directory.
563                head, tail = self.path.split(path)
564                self.chdir(head)
565                return command(self, tail)
566        finally:
567            self.chdir(old_dir)
568
569    #
570    # Miscellaneous utility methods resembling functions in `os`
571    #
572    def getcwd(self):
573        """Return the current path name."""
574        return self._cached_current_dir
575
576    def chdir(self, path):
577        """Change the directory on the host."""
578        path = ftputil.tool.as_bytes(path)
579        with ftputil.error.ftplib_error_to_ftp_os_error:
580            self._session.cwd(path)
581        # The path given as the argument is relative to the old current
582        # directory, therefore join them.
583        self._cached_current_dir = \
584          self.path.normpath(self.path.join(self._cached_current_dir, path))
585
586    # Ignore unused argument `mode`
587    # pylint: disable=W0613
588    def mkdir(self, path, mode=None):
589        """
590        Make the directory path on the remote host. The argument
591        `mode` is ignored and only "supported" for similarity with
592        `os.mkdir`.
593        """
594        # Fail early if we get a unicode path which can't be encoded.
595        path = str(path)
596        def command(self, path):
597            """Callback function."""
598            with ftputil.error.ftplib_error_to_ftp_os_error:
599                self._session.mkd(path)
600        self._robust_ftp_command(command, path)
601
602    # Ignore unused argument `mode`
603    # pylint: disable=W0613
604    def makedirs(self, path, mode=None):
605        """
606        Make the directory `path`, but also make not yet existing
607        intermediate directories, like `os.makedirs`. The value
608        of `mode` is only accepted for compatibility with
609        `os.makedirs` but otherwise ignored.
610        """
611        # Fail early if we get a unicode path which can't be encoded.
612        path = str(path)
613        path = self.path.abspath(path)
614        directories = path.split(self.sep)
615        # Try to build the directory chain from the "uppermost" to
616        # the "lowermost" directory.
617        for index in range(1, len(directories)):
618            # Re-insert the separator which got lost by using `path.split`.
619            next_directory = self.sep + self.path.join(*directories[:index+1])
620            try:
621                self.mkdir(next_directory)
622            except ftputil.error.PermanentError:
623                # Find out the cause of the error. Re-raise the
624                # exception only if the directory didn't exist already,
625                # else something went _really_ wrong, e. g. there's a
626                # regular file with the name of the directory.
627                if not self.path.isdir(next_directory):
628                    raise
629
630    def rmdir(self, path):
631        """
632        Remove the _empty_ directory `path` on the remote host.
633
634        Compatibility note:
635
636        Previous versions of ftputil could possibly delete non-
637        empty directories as well, - if the server allowed it. This
638        is no longer supported.
639        """
640        # Fail early if we get a unicode path which can't be encoded.
641        path = str(path)
642        path = self.path.abspath(path)
643        if self.listdir(path):
644            raise ftputil.error.PermanentError("directory '{0}' not empty".
645                                               format(path))
646        #XXX How does `rmd` work with links?
647        def command(self, path):
648            """Callback function."""
649            with ftputil.error.ftplib_error_to_ftp_os_error:
650                self._session.rmd(path)
651        self._robust_ftp_command(command, path)
652        self.stat_cache.invalidate(path)
653
654    def remove(self, path):
655        """Remove the given file or link."""
656        # Fail early if we get a unicode path which can't be encoded.
657        path = str(path)
658        path = self.path.abspath(path)
659        # Though `isfile` includes also links to files, `islink`
660        # is needed to include links to directories.
661        if self.path.isfile(path) or self.path.islink(path) or \
662           not self.path.exists(path):
663            # If the path doesn't exist, let the removal command trigger
664            # an exception with a more appropriate error message.
665            def command(self, path):
666                """Callback function."""
667                with ftputil.error.ftplib_error_to_ftp_os_error:
668                    self._session.delete(path)
669            self._robust_ftp_command(command, path)
670        else:
671            raise ftputil.error.PermanentError(
672                  "remove/unlink can only delete files and links, "
673                  "not directories")
674        self.stat_cache.invalidate(path)
675
676    def unlink(self, path):
677        """
678        Remove the given file given by `path`.
679
680        Raise a `PermanentError` if the path doesn't exist, raise a
681        `PermanentError`, but maybe raise other exceptions depending
682        on the state of the server (e. g. timeout).
683        """
684        self.remove(path)
685
686    def rmtree(self, path, ignore_errors=False, onerror=None):
687        """
688        Remove the given remote, possibly non-empty, directory tree.
689        The interface of this method is rather complex, in favor of
690        compatibility with `shutil.rmtree`.
691
692        If `ignore_errors` is set to a true value, errors are ignored.
693        If `ignore_errors` is a false value _and_ `onerror` isn't set,
694        all exceptions occuring during the tree iteration and
695        processing are raised. These exceptions are all of type
696        `PermanentError`.
697
698        To distinguish between error situations, pass in a callable
699        for `onerror`. This callable must accept three arguments:
700        `func`, `path` and `exc_info`. `func` is a bound method
701        object, _for example_ `your_host_object.listdir`. `path` is
702        the path that was the recent argument of the respective method
703        (`listdir`, `remove`, `rmdir`). `exc_info` is the exception
704        info as it's got from `sys.exc_info`.
705
706        Implementation note: The code is copied from `shutil.rmtree`
707        in Python 2.4 and adapted to ftputil.
708        """
709        # Fail early if we get a unicode path which can't be encoded.
710        path = str(path)
711        # The following code is an adapted version of Python 2.4's
712        # `shutil.rmtree` function.
713        if ignore_errors:
714            def new_onerror(*args):
715                """Do nothing."""
716                # Ignore unused arguments
717                # pylint: disable=W0613
718                pass
719        elif onerror is None:
720            def new_onerror(*args):
721                """Re-raise exception."""
722                # Ignore unused arguments
723                # pylint: disable=W0613
724                raise
725        else:
726            new_onerror = onerror
727        names = []
728        try:
729            names = self.listdir(path)
730        except ftputil.error.PermanentError:
731            new_onerror(self.listdir, path, sys.exc_info())
732        for name in names:
733            full_name = self.path.join(path, name)
734            try:
735                mode = self.lstat(full_name).st_mode
736            except ftputil.error.PermanentError:
737                mode = 0
738            if stat.S_ISDIR(mode):
739                self.rmtree(full_name, ignore_errors, new_onerror)
740            else:
741                try:
742                    self.remove(full_name)
743                except ftputil.error.PermanentError:
744                    new_onerror(self.remove, full_name, sys.exc_info())
745        try:
746            self.rmdir(path)
747        except ftputil.error.FTPOSError:
748            new_onerror(self.rmdir, path, sys.exc_info())
749
750    def rename(self, source, target):
751        """Rename the source on the FTP host to target."""
752        # Fail early if we get a unicode path which can't be encoded.
753        source = str(source)
754        target = str(target)
755        # The following code is in spirit similar to the code in the
756        # method `_robust_ftp_command`, though we do _not_ do
757        # _everything_ imaginable.
758        self._check_inaccessible_login_directory()
759        source_head, source_tail = self.path.split(source)
760        target_head, target_tail = self.path.split(target)
761        paths_contain_whitespace = (" " in source_head) or (" " in target_head)
762        if paths_contain_whitespace and source_head == target_head:
763            # Both items are in the same directory.
764            old_dir = self.getcwd()
765            try:
766                self.chdir(source_head)
767                with ftputil.error.ftplib_error_to_ftp_os_error:
768                    self._session.rename(source_tail, target_tail)
769            finally:
770                self.chdir(old_dir)
771        else:
772            # Use straightforward command.
773            with ftputil.error.ftplib_error_to_ftp_os_error:
774                self._session.rename(source, target)
775
776    #XXX One could argue to put this method into the `_Stat` class, but
777    # I refrained from that because then `_Stat` would have to know
778    # about `FTPHost`'s `_session` attribute and in turn about
779    # `_session`'s `dir` method.
780    def _dir(self, path):
781        """Return a directory listing as made by FTP's `LIST` command."""
782        # Don't use `self.path.isdir` in this method because that
783        # would cause a call of `(l)stat` and thus a call to `_dir`,
784        # so we would end up with an infinite recursion.
785        def _FTPHost_dir_command(self, path):
786            """Callback function."""
787            lines = []
788            def callback(line):
789                """Callback function."""
790                lines.append(ftputil.tool.as_unicode(line))
791            # pylint: disable=W0142
792            with ftputil.error.ftplib_error_to_ftp_os_error:
793                if self.use_list_a_option:
794                    self._session.dir("-a", path, callback)
795                else:
796                    self._session.dir(path, callback)
797            return lines
798        lines = self._robust_ftp_command(_FTPHost_dir_command, path,
799                                         descend_deeply=True)
800        return lines
801
802    # The `listdir`, `lstat` and `stat` methods don't use
803    # `_robust_ftp_command` because they implicitly already use
804    # `_dir` which actually uses `_robust_ftp_command`.
805    def listdir(self, path):
806        """
807        Return a list of directories, files etc. in the directory
808        named `path`.
809
810        If the directory listing from the server can't be parsed with
811        any of the available parsers raise a `ParserError`.
812        """
813        return self._stat._listdir(path)
814
815    def lstat(self, path, _exception_for_missing_path=True):
816        """
817        Return an object similar to that returned by `os.lstat`.
818
819        If the directory listing from the server can't be parsed with
820        any of the available parsers, raise a `ParserError`. If the
821        directory _can_ be parsed and the `path` is _not_ found, raise
822        a `PermanentError`.
823
824        (`_exception_for_missing_path` is an implementation aid and
825        _not_ intended for use by ftputil clients.)
826        """
827        return self._stat._lstat(path, _exception_for_missing_path)
828
829    def stat(self, path, _exception_for_missing_path=True):
830        """
831        Return info from a "stat" call on `path`.
832
833        If the directory containing `path` can't be parsed, raise a
834        `ParserError`. If the directory containing `path` can be
835        parsed but the `path` can't be found, raise a
836        `PermanentError`. Also raise a `PermanentError` if there's an
837        endless (cyclic) chain of symbolic links "behind" the `path`.
838
839        (`_exception_for_missing_path` is an implementation aid and
840        _not_ intended for use by ftputil clients.)
841        """
842        return self._stat._stat(path, _exception_for_missing_path)
843
844    def walk(self, top, topdown=True, onerror=None):
845        """
846        Iterate over directory tree and return a tuple (dirpath,
847        dirnames, filenames) on each iteration, like the `os.walk`
848        function (see http://docs.python.org/lib/os-file-dir.html ).
849        """
850        # Fail early if we get a unicode path which can't be encoded.
851        top = str(top)
852        # The following code is copied from `os.walk` in Python 2.4
853        # and adapted to ftputil.
854        try:
855            names = self.listdir(top)
856        except ftputil.error.FTPOSError as err:
857            if onerror is not None:
858                onerror(err)
859            return
860        dirs, nondirs = [], []
861        for name in names:
862            if self.path.isdir(self.path.join(top, name)):
863                dirs.append(name)
864            else:
865                nondirs.append(name)
866        if topdown:
867            yield top, dirs, nondirs
868        for name in dirs:
869            path = self.path.join(top, name)
870            if not self.path.islink(path):
871                for item in self.walk(path, topdown, onerror):
872                    yield item
873        if not topdown:
874            yield top, dirs, nondirs
875
876    def chmod(self, path, mode):
877        """
878        Change the mode of a remote `path` (a string) to the integer
879        `mode`. This integer uses the same bits as the mode value
880        returned by the `stat` and `lstat` commands.
881
882        If something goes wrong, raise a `TemporaryError` or a
883        `PermanentError`, according to the status code returned by
884        the server. In particular, a non-existent path usually
885        causes a `PermanentError`.
886        """
887        # Fail early if we get a unicode path which can't be encoded.
888        path = str(path)
889        path = self.path.abspath(path)
890        def command(self, path):
891            """Callback function."""
892            with ftputil.error.ftplib_error_to_ftp_os_error:
893                self._session.voidcmd("SITE CHMOD 0{0:o} {1}".
894                                      format(mode, path))
895        self._robust_ftp_command(command, path)
896        self.stat_cache.invalidate(path)
897
898    #
899    # Context manager methods
900    #
901    def __enter__(self):
902        # Return `self`, so it can be accessed as the variable
903        # component of the `with` statement.
904        return self
905
906    def __exit__(self, exc_type, exc_val, exc_tb):
907        # We don't need the `exc_*` arguments here
908        # pylint: disable=W0613
909        self.close()
910        # Be explicit.
911        return False
Note: See TracBrowser for help on using the repository browser.