source: ftputil.py @ 855:5afd2d6f8f4c

Last change on this file since 855:5afd2d6f8f4c was 855:5afd2d6f8f4c, checked in by Stefan Schwarzer <sschwarzer@…>, 11 years ago
Split `_TransferredFile` into two classes (local and remote).
File size: 36.9 KB
Line 
1# Copyright (C) 2002-2010, Stefan Schwarzer <sschwarzer@sschwarzer.net>
2# All rights reserved.
3#
4# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are
6# met:
7#
8# - Redistributions of source code must retain the above copyright
9#   notice, this list of conditions and the following disclaimer.
10#
11# - Redistributions in binary form must reproduce the above copyright
12#   notice, this list of conditions and the following disclaimer in the
13#   documentation and/or other materials provided with the distribution.
14#
15# - Neither the name of the above author nor the names of the
16#   contributors to the software may be used to endorse or promote
17#   products derived from this software without specific prior written
18#   permission.
19#
20# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21# ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR
24# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
25# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
26# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
27# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
28# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
29# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
32"""
33ftputil - high-level FTP client library
34
35FTPHost objects
36    This class resembles the `os` module's interface to ordinary file
37    systems. In addition, it provides a method `file` which will
38    return file-objects corresponding to remote files.
39
40    # example session
41    host = ftputil.FTPHost('ftp.domain.com', 'me', 'secret')
42    print host.getcwd()  # e. g. '/home/me'
43    source = host.file('sourcefile', 'r')
44    host.mkdir('newdir')
45    host.chdir('newdir')
46    target = host.file('targetfile', 'w')
47    host.copyfileobj(source, target)
48    source.close()
49    target.close()
50    host.remove('targetfile')
51    host.chdir(host.pardir)
52    host.rmdir('newdir')
53    host.close()
54
55    There are also shortcuts for uploads and downloads:
56
57    host.upload(local_file, remote_file)
58    host.download(remote_file, local_file)
59
60    Both accept an additional mode parameter. If it is 'b', the
61    transfer mode will be for binary files.
62
63    For even more functionality refer to the documentation in
64    `ftputil.txt`.
65
66FTPFile objects
67    `FTPFile` objects are constructed via the `file` method (`open`
68    is an alias) of `FTPHost` objects. `FTPFile` objects support the
69    usual file operations for non-seekable files (`read`, `readline`,
70    `readlines`, `xreadlines`, `write`, `writelines`, `close`).
71
72Note: ftputil currently is not threadsafe. More specifically, you can
73      use different `FTPHost` objects in different threads but not
74      using a single `FTPHost` object in different threads.
75"""
76
77import ftplib
78import os
79import stat
80import sys
81import time
82
83import ftp_error
84import ftp_file
85import ftp_path
86import ftp_stat
87import ftputil_version
88
89
90__all__ = ['FTPHost']
91
92__version__ = ftputil_version.__version__
93
94
95class _LocalFile(object):
96    """
97    Represent a file on the local side which is to be transferred or
98    is already transferred.
99    """
100
101    def __init__(self, name, mode):
102        self.name = os.path.abspath(name)
103        self.mode = mode
104
105    def exists(self):
106        """
107        Return `True` if the path representing this file exists.
108        Otherwise return `False`.
109        """
110        return os.path.exists(self.name)
111
112    def mtime(self):
113        """Return the timestamp for the last modification in seconds."""
114        return os.path.getmtime(self.name)
115
116    def mtime_precision(self):
117        """Return the precision of the last modification time in seconds."""
118        # assume modification timestamps for local filesystems are
119        #  at least precise up to a second
120        return 1.0
121
122    def fobj(self):
123        """Return a file object for the name/path in the constructor."""
124        return open(self.name, self.mode)
125
126
127class _RemoteFile(object):
128    """
129    Represent a file on the remote side which is to be transferred or
130    is already transferred.
131    """
132
133    def __init__(self, ftp_host, name, mode):
134        self._host = ftp_host
135        self._path = ftp_host.path
136        self.name = self._path.abspath(name)
137        self.mode = mode
138
139    def exists(self):
140        """
141        Return `True` if the path representing this file exists.
142        Otherwise return `False`.
143        """
144        return self._path.exists(self.name)
145
146    def mtime(self):
147        """Return the timestamp for the last modification in seconds."""
148        # convert to client time zone; see definition of time
149        #  shift in docstring of `FTPHost.set_time_shift`
150        return self._path.getmtime(self.name) - self._host.time_shift()
151
152    def mtime_precision(self):
153        """Return the precision of the last modification time in seconds."""
154        # I think using `stat` instead of `lstat` makes more sense here
155        return self._host.stat(self.name)._st_mtime_precision
156
157    def fobj(self):
158        """Return a file object for the name/path in the constructor."""
159        return self._host.file(self.name, self.mode)
160
161
162#####################################################################
163# `FTPHost` class with several methods similar to those of `os`
164
165class FTPHost(object):
166    """FTP host class."""
167
168    # Implementation notes:
169    #
170    # Upon every request of a file (`_FTPFile` object) a new FTP
171    # session is created ("cloned"), leading to a child session of
172    # the `FTPHost` object from which the file is requested.
173    #
174    # This is needed because opening an `_FTPFile` will make the
175    # local session object wait for the completion of the transfer.
176    # In fact, code like this would block indefinitely, if the `RETR`
177    # request would be made on the `_session` of the object host:
178    #
179    #   host = FTPHost(ftp_server, user, password)
180    #   f = host.file('index.html')
181    #   host.getcwd()   # would block!
182    #
183    # On the other hand, the initially constructed host object will
184    # store references to already established `_FTPFile` objects and
185    # reuse an associated connection if its associated `_FTPFile`
186    # has been closed.
187
188    def __init__(self, *args, **kwargs):
189        """Abstract initialization of `FTPHost` object."""
190        # store arguments for later operations
191        self._args = args
192        self._kwargs = kwargs
193        # make a session according to these arguments
194        self._session = self._make_session()
195        # simulate os.path
196        self.path = ftp_path._Path(self)
197        # lstat, stat, listdir services
198        self._stat = ftp_stat._Stat(self)
199        self.stat_cache = self._stat._lstat_cache
200        self.stat_cache.enable()
201        # save (cache) current directory
202        self._current_dir = ftp_error._try_with_oserror(self._session.pwd)
203        # associated `FTPHost` objects for data transfer
204        self._children = []
205        # only set if this instance represents an `_FTPFile`
206        self._file = None
207        # now opened
208        self.closed = False
209        # set curdir, pardir etc. for the remote host; RFC 959 states
210        #  that this is, strictly spoken, dependent on the server OS
211        #  but it seems to work at least with Unix and Windows
212        #  servers
213        self.curdir, self.pardir, self.sep = '.', '..', '/'
214        # set default time shift (used in `upload_if_newer` and
215        #  `download_if_newer`)
216        self.set_time_shift(0.0)
217
218    #
219    # dealing with child sessions and file-like objects
220    #  (rather low-level)
221    #
222    def _make_session(self):
223        """
224        Return a new session object according to the current state of
225        this `FTPHost` instance.
226        """
227        # use copies of the arguments
228        args = self._args[:]
229        kwargs = self._kwargs.copy()
230        # if a session factory had been given on the instantiation of
231        #  this `FTPHost` object, use the same factory for this
232        #  `FTPHost` object's child sessions
233        factory = kwargs.pop('session_factory', ftplib.FTP)
234        return ftp_error._try_with_oserror(factory, *args, **kwargs)
235
236    def _copy(self):
237        """Return a copy of this `FTPHost` object."""
238        # The copy includes a new session factory return value (aka
239        #  session) but doesn't copy the state of `self.getcwd()`.
240        return FTPHost(*self._args, **self._kwargs)
241
242    def _available_child(self):
243        """
244        Return an available (i. e. one whose `_file` object is closed)
245        child (`FTPHost` object) from the pool of children or `None`
246        if there aren't any.
247        """
248        for host in self._children:
249            if host._file.closed:
250                return host
251        # be explicit
252        return None
253
254    def file(self, path, mode='r'):
255        """
256        Return an open file(-like) object which is associated with
257        this `FTPHost` object.
258
259        This method tries to reuse a child but will generate a new one
260        if none is available.
261        """
262        host = self._available_child()
263        if host is None:
264            host = self._copy()
265            self._children.append(host)
266            host._file = ftp_file._FTPFile(host)
267        basedir = self.getcwd()
268        # prepare for changing the directory (see whitespace workaround
269        #  in method `_dir`)
270        if host.path.isabs(path):
271            effective_path = path
272        else:
273            effective_path = host.path.join(basedir, path)
274        effective_dir, effective_file = host.path.split(effective_path)
275        try:
276            # this will fail if we can't access the directory at all
277            host.chdir(effective_dir)
278        except ftp_error.PermanentError:
279            # similarly to a failed `file` in a local filesystem, we
280            #  raise an `IOError`, not an `OSError`
281            raise ftp_error.FTPIOError("remote directory '%s' doesn't exist "
282                  "or has insufficient access rights" % effective_dir)
283        host._file._open(effective_file, mode)
284        if 'w' in mode:
285            # invalidate cache entry because size and timestamps will change
286            self.stat_cache.invalidate(effective_path)
287        return host._file
288
289    # make `open` an alias
290    open = file
291
292    def close(self):
293        """Close host connection."""
294        if self.closed:
295            return
296        # close associated children
297        for host in self._children:
298            # children have a `_file` attribute which is an `_FTPFile` object
299            host._file.close()
300            host.close()
301        # now deal with ourself
302        try:
303            ftp_error._try_with_oserror(self._session.close)
304        finally:
305            # if something went wrong before, the host/session is
306            #  probably defunct and subsequent calls to `close` won't
307            #  help either, so we consider the host/session closed for
308            #  practical purposes
309            self.stat_cache.clear()
310            self._children = []
311            self.closed = True
312
313    def __del__(self):
314        # don't complain about lazy except clause
315        # pylint: disable-msg=W0702, W0704
316        try:
317            self.close()
318        except:
319            # we don't want warnings if the constructor failed
320            pass
321
322    #
323    # setting a custom directory parser
324    #
325    def set_parser(self, parser):
326        """
327        Set the parser for extracting stat results from directory
328        listings.
329
330        The parser interface is described in the documentation, but
331        here are the most important things:
332
333        - A parser should derive from `ftp_stat.Parser`.
334
335        - The parser has to implement two methods, `parse_line` and
336          `ignores_line`. For the latter, there's a probably useful
337          default in the class `ftp_stat.Parser`.
338
339        - `parse_line` should try to parse a line of a directory
340          listing and return a `ftp_stat.StatResult` instance. If
341          parsing isn't possible, raise `ftp_error.ParserError` with
342          a useful error message.
343
344        - `ignores_line` should return a true value if the line isn't
345          assumed to contain stat information.
346        """
347        # the cache contents, if any, aren't probably useful
348        self.stat_cache.clear()
349        # set the parser
350        self._stat._parser = parser
351        # just set a parser explicitly, don't allow "smart" switching anymore
352        self._stat._allow_parser_switching = False
353
354    #
355    # time shift adjustment between client (i. e. us) and server
356    #
357    def set_time_shift(self, time_shift):
358        """
359        Set the time shift value (i. e. the time difference between
360        client and server) for this `FTPHost` object. By (my)
361        definition, the time shift value is positive if the local
362        time of the server is greater than the local time of the
363        client (for the same physical time), i. e.
364
365            time_shift =def= t_server - t_client
366        <=> t_server = t_client + time_shift
367        <=> t_client = t_server - time_shift
368
369        The time shift is measured in seconds.
370        """
371        self._time_shift = time_shift
372
373    def time_shift(self):
374        """
375        Return the time shift between FTP server and client. See the
376        docstring of `set_time_shift` for more on this value.
377        """
378        return self._time_shift
379
380    def __rounded_time_shift(self, time_shift):
381        """
382        Return the given time shift in seconds, but rounded to
383        full hours. The argument is also assumed to be given in
384        seconds.
385        """
386        minute = 60.0
387        hour = 60.0 * minute
388        # avoid division by zero below
389        if time_shift == 0:
390            return 0.0
391        # use a positive value for rounding
392        absolute_time_shift = abs(time_shift)
393        signum = time_shift / absolute_time_shift
394        # round it to hours; this code should also work for later Python
395        #  versions because of the explicit `int`
396        absolute_rounded_time_shift = \
397          int( (absolute_time_shift + 30*minute) / hour ) * hour
398        # return with correct sign
399        return signum * absolute_rounded_time_shift
400
401    def __assert_valid_time_shift(self, time_shift):
402        """
403        Perform sanity checks on the time shift value (given in
404        seconds). If the value is invalid, raise a `TimeShiftError`,
405        else simply return `None`.
406        """
407        minute = 60.0
408        hour = 60.0 * minute
409        absolute_rounded_time_shift = abs(self.__rounded_time_shift(time_shift))
410        # test 1: fail if the absolute time shift is greater than
411        #  a full day (24 hours)
412        if absolute_rounded_time_shift > 24 * hour:
413            raise ftp_error.TimeShiftError(
414                  "time shift (%.2f s) > 1 day" % time_shift)
415        # test 2: fail if the deviation between given time shift and
416        #  full hours is greater than a certain limit (e. g. five minutes)
417        maximum_deviation = 5 * minute
418        if abs(time_shift - self.__rounded_time_shift(time_shift)) > \
419           maximum_deviation:
420            raise ftp_error.TimeShiftError(
421                  "time shift (%.2f s) deviates more than %d s from full hours"
422                  % (time_shift, maximum_deviation))
423
424    def synchronize_times(self):
425        """
426        Synchronize the local times of FTP client and server. This
427        is necessary to let `upload_if_newer` and `download_if_newer`
428        work correctly. If `synchronize_times` isn't applicable
429        (see below), the time shift can still be set explicitly with
430        `set_time_shift`.
431
432        This implementation of `synchronize_times` requires _all_ of
433        the following:
434
435        - The connection between server and client is established.
436        - The client has write access to the directory that is
437          current when `synchronize_times` is called.
438
439        The common usage pattern of `synchronize_times` is to call it
440        directly after the connection is established. (As can be
441        concluded from the points above, this requires write access
442        to the login directory.)
443
444        If `synchronize_times` fails, it raises a `TimeShiftError`.
445        """
446        helper_file_name = "_ftputil_sync_"
447        # open a dummy file for writing in the current directory
448        #  on the FTP host, then close it
449        try:
450            file_ = self.file(helper_file_name, 'w')
451            file_.close()
452            server_time = self.path.getmtime(helper_file_name)
453        finally:
454            # remove the just written file
455            self.unlink(helper_file_name)
456        # calculate the difference between server and client
457        time_shift = server_time - time.time()
458        # do some sanity checks
459        self.__assert_valid_time_shift(time_shift)
460        # if tests passed, store the time difference as time shift value
461        self.set_time_shift(self.__rounded_time_shift(time_shift))
462
463    #
464    # operations based on file-like objects (rather high-level),
465    #  like upload and download
466    #
467    def copyfileobj(self, source, target, length=64*1024):
468        "Copy data from file-like object source to file-like object target."
469        # inspired by `shutil.copyfileobj` (I don't use the `shutil`
470        #  code directly because it might change)
471        while True:
472            buffer_ = source.read(length)
473            if not buffer_:
474                break
475            target.write(buffer_)
476
477    def __get_modes(self, mode):
478        """Return modes for source and target file."""
479        #XXX Should we allow mode "a" at all? We don't support appending!
480        #XXX use dictionary?
481        # invalid mode values are handled when a file object is made
482        if mode == 'b':
483            return 'rb', 'wb'
484        else:
485            return 'r', 'w'
486
487    def __copy_file(self, source_file, target_file, conditional):
488        """
489        Copy a file from `source_file` to `target_file`.
490       
491        These are `_LocalFile` or `_RemoteFile` objects. Which of them
492        is a local or a remote file, respectively, is determined by
493        the arguments. If `conditional` is true, the file is only
494        copied if the target doesn't exist or is older than the
495        source. If `conditional` is false, the file is copied
496        unconditionally.
497        """
498        if conditional:
499            # evaluate condition: the target file either doesn't exist or is
500            #  older than the source file; use >= in the comparison, that is
501            #  if in doubt (due to imprecise timestamps) transfer
502            #FIXME we probably need a more complex comparison, depending
503            #  on the precision of the timestamp on the server!
504            condition = not target_file.exists() or \
505                        source_file.mtime() > target_file.mtime()
506            if not condition:
507                # we didn't transfer
508                return False
509        source_fobj = source_file.fobj()
510        try:
511            target_fobj = target_file.fobj()
512            try:
513                self.copyfileobj(source_fobj, target_fobj)
514            finally:
515                target_fobj.close()
516        finally:
517            source_fobj.close()
518        # transfer accomplished
519        return True
520
521    def _upload(self, source, target, mode, conditional):
522        """
523        Upload from `source` to `target` which are `_TransferredFile`
524        objects. The string `mode` may be "" or "b". If `conditional`
525        is true, check if file should be copied at all. See the
526        docstring of `__copy_file` for more.
527        """
528        source_mode, target_mode = self.__get_modes(mode)
529        source_file = _LocalFile(source, source_mode)
530        # passing `self` (the `FTPHost` instance) here is correct
531        target_file = _RemoteFile(self, target, target_mode)
532        # the path in the stat cache is implicitly invalidated when
533        #  the file is opened on the remote host
534        return self.__copy_file(source_file, target_file, conditional)
535
536    def upload(self, source, target, mode=''):
537        """
538        Upload a file from the local source (name) to the remote
539        target (name). The argument `mode` is an empty string or 'a' for
540        text copies, or 'b' for binary copies.
541        """
542        self._upload(source, target, mode, conditional=False)
543
544    def upload_if_newer(self, source, target, mode=''):
545        """
546        Upload a file only if it's newer than the target on the
547        remote host or if the target file does not exist. See the
548        method `upload` for the meaning of the parameters.
549
550        If an upload was necessary, return `True`, else return
551        `False`.
552        """
553        return self._upload(source, target, mode, conditional=True)
554
555    def _download(self, source, target, mode, conditional):
556        """
557        Download from `source` to `target` which are `_TransferredFile`
558        objects. The string `mode` may be "" or "b". If `conditional`
559        is true, check if file should be copied at all. See the
560        docstring of `__copy_file` for more.
561        """
562        source_mode, target_mode = self.__get_modes(mode)
563        # passing `self` (the `FTPHost` instance) here is correct
564        source_file = _RemoteFile(self, source, source_mode)
565        target_file = _LocalFile(target, target_mode)
566        return self.__copy_file(source_file, target_file, conditional)
567
568    def download(self, source, target, mode=''):
569        """
570        Download a file from the remote source (name) to the local
571        target (name). The argument mode is an empty string or 'a' for
572        text copies, or 'b' for binary copies.
573        """
574        self._download(source, target, mode, conditional=False)
575
576    def download_if_newer(self, source, target, mode=''):
577        """
578        Download a file only if it's newer than the target on the
579        local host or if the target file does not exist. See the
580        method `download` for the meaning of the parameters.
581
582        If a download was necessary, return `True`, else return
583        `False`.
584        """
585        return self._download(source, target, mode, conditional=True)
586
587    #
588    # helper methods to descend into a directory before executing a command
589    #
590    def _check_inaccessible_login_directory(self):
591        """
592        Raise an `InaccessibleLoginDirError` exception if we can't
593        change to the login directory. This test is only reliable if
594        the current directory is the login directory.
595        """
596        presumable_login_dir = self.getcwd()
597        # bail out with an internal error rather than modifying the
598        #  current directory without hope of restoration
599        try:
600            self.chdir(presumable_login_dir)
601        except ftp_error.PermanentError:
602            # `old_dir` is an inaccessible login directory
603            raise ftp_error.InaccessibleLoginDirError(
604                  "directory '%s' is not accessible" % presumable_login_dir)
605
606    def _robust_ftp_command(self, command, path, descend_deeply=False):
607        """
608        Run an FTP command on a path. The return value of the method
609        is the return value of the command.
610
611        If `descend_deeply` is true (the default is false), descend
612        deeply, i. e. change the directory to the end of the path.
613        """
614        # if we can't change to the yet-current directory, the code
615        #  below won't work (see below), so in this case rather raise
616        #  an exception than give wrong results
617        self._check_inaccessible_login_directory()
618        # Some FTP servers don't behave as expected if the directory
619        #  portion of the path contains whitespace, some even yield
620        #  strange results if the command isn't executed in the
621        #  current directory. Therefore, change to the directory
622        #  which contains the item to run the command on and invoke
623        #  the command just there.
624        # remember old working directory
625        old_dir = self.getcwd()
626        try:
627            if descend_deeply:
628                # invoke the command in (not: on) the deepest directory
629                self.chdir(path)
630                # workaround for some servers that give recursive
631                #  listings when called with a dot as path; see issue #33,
632                #  http://ftputil.sschwarzer.net/trac/ticket/33
633                return command(self, "")
634            else:
635                # invoke the command in the "next to last" directory
636                head, tail = self.path.split(path)
637                self.chdir(head)
638                return command(self, tail)
639        finally:
640            # restore the old directory
641            self.chdir(old_dir)
642
643    #
644    # miscellaneous utility methods resembling functions in `os`
645    #
646    def getcwd(self):
647        """Return the current path name."""
648        return self._current_dir
649
650    def chdir(self, path):
651        """Change the directory on the host."""
652        ftp_error._try_with_oserror(self._session.cwd, path)
653        self._current_dir = self.path.normpath(self.path.join(
654                                               # use "old" current dir
655                                               self._current_dir, path))
656
657    def mkdir(self, path, mode=None):
658        """
659        Make the directory path on the remote host. The argument
660        `mode` is ignored and only "supported" for similarity with
661        `os.mkdir`.
662        """
663        # ignore unused argument `mode`
664        # pylint: disable-msg=W0613
665        def command(self, path):
666            """Callback function."""
667            return ftp_error._try_with_oserror(self._session.mkd, path)
668        self._robust_ftp_command(command, path)
669
670    def makedirs(self, path, mode=None):
671        """
672        Make the directory `path`, but also make not yet existing
673        intermediate directories, like `os.makedirs`. The value
674        of `mode` is only accepted for compatibility with
675        `os.makedirs` but otherwise ignored.
676        """
677        # ignore unused argument `mode`
678        # pylint: disable-msg=W0613
679        path = self.path.abspath(path)
680        directories = path.split(self.sep)
681        # try to build the directory chain from the "uppermost" to
682        #  the "lowermost" directory
683        for index in range(1, len(directories)):
684            # re-insert the separator which got lost by using `path.split`
685            next_directory = self.sep + self.path.join(*directories[:index+1])
686            try:
687                self.mkdir(next_directory)
688            except ftp_error.PermanentError:
689                # find out the cause of the error; re-raise the
690                #  exception only if the directory didn't exist already;
691                #  else something went _really_ wrong, e. g. we might
692                #  have a regular file with the name of the directory
693                if not self.path.isdir(next_directory):
694                    raise
695
696    def rmdir(self, path):
697        """
698        Remove the _empty_ directory `path` on the remote host.
699
700        Compatibility note:
701
702        Previous versions of ftputil could possibly delete non-
703        empty directories as well, - if the server allowed it. This
704        is no longer supported.
705        """
706        path = self.path.abspath(path)
707        if self.listdir(path):
708            raise ftp_error.PermanentError("directory '%s' not empty" % path)
709        #XXX how will `rmd` work with links?
710        def command(self, path):
711            """Callback function."""
712            ftp_error._try_with_oserror(self._session.rmd, path)
713        self._robust_ftp_command(command, path)
714        self.stat_cache.invalidate(path)
715
716    def remove(self, path):
717        """Remove the given file or link."""
718        path = self.path.abspath(path)
719        # though `isfile` includes also links to files, `islink`
720        #  is needed to include links to directories
721        # if the path doesn't exist, let the removal command trigger
722        #  an exception with a more appropriate error message
723        if self.path.isfile(path) or self.path.islink(path) or \
724           not self.path.exists(path):
725            def command(self, path):
726                """Callback function."""
727                ftp_error._try_with_oserror(self._session.delete, path)
728            self._robust_ftp_command(command, path)
729        else:
730            raise ftp_error.PermanentError("remove/unlink can only delete "
731                                           "files and links, not directories")
732        self.stat_cache.invalidate(path)
733
734    def unlink(self, path):
735        """
736        Remove the given file given by `path`.
737
738        Raise a `PermanentError` if the path doesn't exist, raise a
739        `PermanentError`, but maybe raise other exceptions depending
740        on the state of the server (e. g. timeout).
741        """
742        self.remove(path)
743
744    def rmtree(self, path, ignore_errors=False, onerror=None):
745        """
746        Remove the given remote, possibly non-empty, directory tree.
747        The interface of this method is rather complex, in favor of
748        compatibility with `shutil.rmtree`.
749
750        If `ignore_errors` is set to a true value, errors are ignored.
751        If `ignore_errors` is a false value _and_ `onerror` isn't set,
752        all exceptions occuring during the tree iteration and
753        processing are raised. These exceptions are all of type
754        `PermanentError`.
755
756        To distinguish between error situations, pass in a callable
757        for `onerror`. This callable must accept three arguments:
758        `func`, `path` and `exc_info`). `func` is a bound method
759        object, _for example_ `your_host_object.listdir`. `path` is
760        the path that was the recent argument of the respective method
761        (`listdir`, `remove`, `rmdir`). `exc_info` is the exception
762        info as it's got from `sys.exc_info`.
763
764        Implementation note: The code is copied from `shutil.rmtree`
765        in Python 2.4 and adapted to ftputil.
766        """
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                # ignore unused arguments
773                # pylint: disable-msg=W0613
774                pass
775        elif onerror is None:
776            def new_onerror(*args):
777                """Re-raise exception."""
778                # ignore unused arguments
779                # pylint: disable-msg=W0613
780                raise
781        else:
782            new_onerror = onerror
783        names = []
784        try:
785            names = self.listdir(path)
786        except ftp_error.PermanentError:
787            new_onerror(self.listdir, path, sys.exc_info())
788        for name in names:
789            full_name = self.path.join(path, name)
790            try:
791                mode = self.lstat(full_name).st_mode
792            except ftp_error.PermanentError:
793                mode = 0
794            if stat.S_ISDIR(mode):
795                self.rmtree(full_name, ignore_errors, new_onerror)
796            else:
797                try:
798                    self.remove(full_name)
799                except ftp_error.PermanentError:
800                    new_onerror(self.remove, full_name, sys.exc_info())
801        try:
802            self.rmdir(path)
803        except ftp_error.FTPOSError:
804            new_onerror(self.rmdir, path, sys.exc_info())
805
806    def rename(self, source, target):
807        """Rename the source on the FTP host to target."""
808        # the following code is in spirit similar to the code in the
809        #  method `_robust_ftp_command`, though we don't 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                ftp_error._try_with_oserror(self._session.rename,
821                                            source_tail, target_tail)
822            finally:
823                self.chdir(old_dir)
824        else:
825            # use straightforward command
826            ftp_error._try_with_oserror(self._session.rename, source, target)
827
828    #XXX one could argue to put this method into the `_Stat` class, but
829    #  I refrained from that because then `_Stat` would have to know
830    #  about `FTPHost`'s `_session` attribute and in turn about
831    #  `_session`'s `dir` method
832    def _dir(self, path):
833        """Return a directory listing as made by FTP's `DIR` command."""
834        # we can't use `self.path.isdir` in this method because that
835        #  would cause a call of `(l)stat` and thus a call to `_dir`,
836        #  so we would end up with an infinite recursion
837        def _FTPHost_dir_command(self, path):
838            """Callback function."""
839            lines = []
840            def callback(line):
841                """Callback function."""
842                lines.append(line)
843            ftp_error._try_with_oserror(self._session.dir, path, callback)
844            return lines
845        lines = self._robust_ftp_command(_FTPHost_dir_command, path,
846                                         descend_deeply=True)
847        return lines
848
849    # the `listdir`, `lstat` and `stat` methods don't use
850    #  `_robust_ftp_command` because they implicitly already use
851    #  `_dir` which actually uses `_robust_ftp_command`
852    def listdir(self, path):
853        """
854        Return a list of directories, files etc. in the directory
855        named `path`.
856
857        If the directory listing from the server can't be parsed with
858        any of the available parsers raise a `ParserError`.
859        """
860        return self._stat.listdir(path)
861
862    def lstat(self, path, _exception_for_missing_path=True):
863        """
864        Return an object similar to that returned by `os.lstat`.
865
866        If the directory listing from the server can't be parsed with
867        any of the available parsers, raise a `ParserError`. If the
868        directory _can_ be parsed and the `path` is _not_ found, raise
869        a `PermanentError`.
870
871        (`_exception_for_missing_path` is an implementation aid and
872        _not_ intended for use by ftputil clients.)
873        """
874        return self._stat.lstat(path, _exception_for_missing_path)
875
876    def stat(self, path, _exception_for_missing_path=True):
877        """
878        Return info from a "stat" call on `path`.
879
880        If the directory containing `path` can't be parsed, raise a
881        `ParserError`. If the directory containing `path` can be
882        parsed but the `path` can't be found, raise a
883        `PermanentError`. Also raise a `PermanentError` if there's an
884        endless (cyclic) chain of symbolic links "behind" the `path`.
885
886        (`_exception_for_missing_path` is an implementation aid and
887        _not_ intended for use by ftputil clients.)
888        """
889        return self._stat.stat(path, _exception_for_missing_path)
890
891    def walk(self, top, topdown=True, onerror=None):
892        """
893        Iterate over directory tree and return a tuple (dirpath,
894        dirnames, filenames) on each iteration, like the `os.walk`
895        function (see http://docs.python.org/lib/os-file-dir.html ).
896
897        Implementation note: The code is copied from `os.walk` in
898        Python 2.4 and adapted to ftputil.
899        """
900        # code from `os.walk` ...
901        try:
902            names = self.listdir(top)
903        except ftp_error.FTPOSError, err:
904            if onerror is not None:
905                onerror(err)
906            return
907
908        dirs, nondirs = [], []
909        for name in names:
910            if self.path.isdir(self.path.join(top, name)):
911                dirs.append(name)
912            else:
913                nondirs.append(name)
914
915        if topdown:
916            yield top, dirs, nondirs
917        for name in dirs:
918            path = self.path.join(top, name)
919            if not self.path.islink(path):
920                for item in self.walk(path, topdown, onerror):
921                    yield item
922        if not topdown:
923            yield top, dirs, nondirs
924
925    def chmod(self, path, mode):
926        """
927        Change the mode of a remote `path` (a string) to the integer
928        `mode`. This integer uses the same bits as the mode value
929        returned by the `stat` and `lstat` commands.
930
931        If something goes wrong, raise a `TemporaryError` or a
932        `PermanentError`, according to the status code returned by
933        the server. In particular, a non-existent path usually
934        causes a `PermanentError`.
935        """
936        path = self.path.abspath(path)
937        def command(self, path):
938            """Callback function."""
939            ftp_error._try_with_oserror(self._session.voidcmd,
940                                        "SITE CHMOD %s %s" % (oct(mode), path))
941        self._robust_ftp_command(command, path)
942        self.stat_cache.invalidate(path)
943
944    #
945    # context manager methods
946    #
947    def __enter__(self):
948        # return `self`, so it can be accessed as the variable
949        #  component of the `with` statement.
950        return self
951
952    def __exit__(self, exc_type, exc_val, exc_tb):
953        # we don't need the `exc_*` arguments here
954        # pylint: disable-msg=W0613
955        self.close()
956        # be explicit
957        return False
958
Note: See TracBrowser for help on using the repository browser.