source: ftputil.py @ 854:bb371fc05f5b

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