source: ftputil/host.py @ 1713:f146a1ea66aa

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