source: ftputil/host.py @ 1711:766c15f83205

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