source: ftputil/host.py @ 1630:2a179afe3823

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