source: ftputil/host.py @ 1675:1ce0dd183c17

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