source: ftputil/stat.py @ 1682:ec059f9d9dc8

Last change on this file since 1682:ec059f9d9dc8 was 1682:ec059f9d9dc8, checked in by Stefan Schwarzer <sschwarzer@…>, 2 years ago
Handle infinite link chains in `isdir` and `isfile` If `FTPHost.path.isdir` and `FTPHost.path.isfile` run into an infinite link chain, return `False`, as the corresponding functions in `os.path` do. Before, `isdir` and `isfile` would raise a `PermanentError` with the information that a recursive link chain was detected. This behavior, in turn, led to a failure in `FTPHost.walk` (see ticket #107). ticket: 107
File size: 30.3 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"""
6ftputil.stat - stat result, parsers, and FTP stat'ing for `ftputil`
7"""
8
9from __future__ import absolute_import
10from __future__ import unicode_literals
11
12import datetime
13import math
14import re
15import stat
16import time
17
18import ftputil.error
19import ftputil.stat_cache
20
21
22# These can be used to write custom parsers.
23__all__ = ["StatResult", "Parser", "UnixParser", "MSParser"]
24
25
26# Datetime precision values in seconds.
27MINUTE_PRECISION  = 60
28DAY_PRECISION     = 24 * 60 * 60
29UNKNOWN_PRECISION = None
30
31
32class StatResult(tuple):
33    """
34    Support class resembling a tuple like that returned from
35    `os.(l)stat`.
36    """
37
38    _index_mapping = {
39      "st_mode":  0, "st_ino":   1, "st_dev":    2, "st_nlink":    3,
40      "st_uid":   4, "st_gid":   5, "st_size":   6, "st_atime":    7,
41      "st_mtime": 8, "st_ctime": 9, "_st_name": 10, "_st_target": 11}
42
43    def __init__(self, sequence):
44        # Don't call `__init__` via `super`. Construction from a
45        # sequence is implicitly handled by `tuple.__new__`, not
46        # `tuple.__init__`. As a by-product, this avoids a
47        # `DeprecationWarning` in Python 2.6+ .
48        # pylint: disable=super-init-not-called
49        #
50        # Use `sequence` parameter to remain compatible to `__new__`
51        # interface.
52        # pylint: disable=unused-argument
53        #
54        # These may be overwritten in a `Parser.parse_line` method.
55        self._st_name = ""
56        self._st_target = None
57        self._st_mtime_precision = UNKNOWN_PRECISION
58
59    def __getattr__(self, attr_name):
60        if attr_name in self._index_mapping:
61            return self[self._index_mapping[attr_name]]
62        else:
63            raise AttributeError("'StatResult' object has no attribute '{0}'".
64                                 format(attr_name))
65
66    def __repr__(self):
67        # "Invert" `_index_mapping` so that we can look up the names
68        # for the tuple indices.
69        index_to_name = dict((v, k) for k, v in self._index_mapping.items())
70        argument_strings = []
71        for index, item in enumerate(self):
72            argument_strings.append("{0}={1!r}".format(index_to_name[index],
73                                                       item))
74        return "{0}({1})".format(type(self).__name__,
75                                 ", ".join(argument_strings))
76
77
78#
79# FTP directory parsers
80#
81class Parser(object):
82    """
83    Represent a parser for directory lines. Parsers for specific
84    directory formats inherit from this class.
85    """
86
87    # Map month abbreviations to month numbers.
88    _month_numbers = {
89      "jan":  1, "feb":  2, "mar":  3, "apr":  4,
90      "may":  5, "jun":  6, "jul":  7, "aug":  8,
91      "sep":  9, "oct": 10, "nov": 11, "dec": 12}
92
93    _total_regex = re.compile(r"^total\s+\d+")
94
95    def ignores_line(self, line):
96        """
97        Return a true value if the line should be ignored, i. e. is
98        assumed to _not_ contain actual directory/file/link data.
99        A typical example are summary lines like "total 23" which
100        are emitted by some FTP servers.
101
102        If the line should be used to extract stat data from it,
103        return a false value.
104        """
105        # Ignore empty lines stemming from only a line break.
106        if not line.strip():
107            # Yes, ignore the line if it's empty.
108            return True
109        # Either a `_SRE_Match` instance or `None`
110        match = self._total_regex.search(line)
111        return bool(match)
112
113    def parse_line(self, line, time_shift=0.0):
114        """
115        Return a `StatResult` object as derived from the string
116        `line`. The parser code to use depends on the directory format
117        the FTP server delivers (also see examples at end of file).
118
119        If the given text line can't be parsed, raise a `ParserError`.
120
121        For the definition of `time_shift` see the docstring of
122        `FTPHost.set_time_shift` in `ftputil.py`. Not all parsers
123        use the `time_shift` parameter.
124        """
125        raise NotImplementedError("must be defined by subclass")
126
127    #
128    # Helper methods for parts of a directory listing line
129    #
130    def parse_unix_mode(self, mode_string):
131        """
132        Return an integer from the `mode_string`, compatible with
133        the `st_mode` value in stat results. Such a mode string
134        may look like "drwxr-xr-x".
135
136        If the mode string can't be parsed, raise an
137        `ftputil.error.ParserError`.
138        """
139        # Allow derived classes to make use of `self`.
140        # pylint: disable=no-self-use
141        if len(mode_string) != 10:
142            raise ftputil.error.ParserError("invalid mode string '{0}'".
143                                            format(mode_string))
144        st_mode = 0
145        #TODO Add support for "S" and sticky bit ("t", "T").
146        for bit in mode_string[1:10]:
147            bit = (bit != "-")
148            st_mode = (st_mode << 1) + bit
149        if mode_string[3] == "s":
150            st_mode = st_mode | stat.S_ISUID
151        if mode_string[6] == "s":
152            st_mode = st_mode | stat.S_ISGID
153        file_type_to_mode = {"b": stat.S_IFBLK, "c": stat.S_IFCHR,
154                             "d": stat.S_IFDIR, "l": stat.S_IFLNK,
155                             "p": stat.S_IFIFO, "s": stat.S_IFSOCK,
156                             "-": stat.S_IFREG,
157                             # Ignore types which `ls` can't make sense of
158                             # (assuming the FTP server returns listings
159                             # like `ls` does).
160                             "?": 0,
161                            }
162        file_type = mode_string[0]
163        if file_type in file_type_to_mode:
164            st_mode = st_mode | file_type_to_mode[file_type]
165        else:
166            raise ftputil.error.ParserError(
167                  "unknown file type character '{0}'".format(file_type))
168        return st_mode
169
170    def _as_int(self, int_string, int_description):
171        """
172        Return `int_string` converted to an integer.
173
174        If it can't be converted, raise a `ParserError`, using
175        `int_description` in the error message. For example, if the
176        integer value is a day, pass "day" for `int_description`.
177        """
178        try:
179            return int(int_string)
180        except ValueError:
181            raise ftputil.error.ParserError("non-integer {0} value {1!r}".
182                                            format(int_description,
183                                                   int_string))
184
185    def _mktime(self, mktime_tuple):
186        """
187        Return a float value like `time.mktime` does, but ...
188
189        - Raise a `ParserError` if parts of `mktime_tuple` are
190          invalid (say, a day is 32).
191
192        - If the resulting float value would be smaller than 0.0
193          (indicating a time before the "epoch") return a sentinel
194          value of 0.0. Do this also if the native `mktime`
195          implementation would raise an `OverflowError`.
196        """
197        datetime_tuple = mktime_tuple[:6]
198        try:
199            # Only for sanity checks, we're not interested in the
200            # return value.
201            datetime.datetime(*datetime_tuple)
202        # For example, day == 32. Not all implementations of `mktime`
203        # catch this kind of error.
204        except ValueError:
205            invalid_datetime = ("%04d-%02d-%02d %02d:%02d:%02d" %
206                                datetime_tuple)
207            raise ftputil.error.ParserError("invalid datetime {0!r}".
208                                            format(invalid_datetime))
209        try:
210            time_float = time.mktime(mktime_tuple)
211        except (OverflowError, ValueError):
212            # Sentinel for times before the epoch, see ticket #83.
213            time_float = 0.0
214        # Don't allow float values smaller than 0.0 because, according
215        # to https://docs.python.org/3/library/time.html#module-time ,
216        # these might be undefined for some platforms.
217        return max(0.0, time_float)
218
219    def parse_unix_time(self, month_abbreviation, day, year_or_time,
220                        time_shift, with_precision=False):
221        """
222        Return a floating point number, like from `time.mktime`, by
223        parsing the string arguments `month_abbreviation`, `day` and
224        `year_or_time`. The parameter `time_shift` is the difference
225        "time on server" - "time on client" and is available as the
226        `time_shift` parameter in the `parse_line` interface.
227
228        If `with_precision` is true (default: false), return a
229        two-element tuple consisting of the floating point number as
230        described in the previous paragraph and the precision of the
231        time in seconds. The default is `False` for backward
232        compatibility with custom parsers.
233
234        The precision value takes into account that, for example, a
235        time string like "May 26  2005" has only a precision of one
236        day. This information is important for the `upload_if_newer`
237        and `download_if_newer` methods in the `FTPHost` class.
238
239        Times in Unix-style directory listings typically have one of
240        these formats:
241
242        - "Nov 23 02:33" (month name, day of month, time)
243
244        - "May 26  2005" (month name, day of month, year)
245
246        If this method can't make sense of the given arguments, it
247        raises an `ftputil.error.ParserError`.
248        """
249        try:
250            month = self._month_numbers[month_abbreviation.lower()]
251        except KeyError:
252            raise ftputil.error.ParserError("invalid month abbreviation {0!r}".
253                                            format(month_abbreviation))
254        day = self._as_int(day, "day")
255        if ":" not in year_or_time:
256            # `year_or_time` is really a year.
257            year, hour, minute = self._as_int(year_or_time, "year"), 0, 0
258            st_mtime = self._mktime( (year, month, day,
259                                      hour, minute, 0, 0, 0, -1) )
260            st_mtime_precision = DAY_PRECISION
261        else:
262            # `year_or_time` is a time hh:mm.
263            hour, minute = year_or_time.split(":")
264            year, hour, minute = (
265              None, self._as_int(hour, "hour"), self._as_int(minute, "minute"))
266            # Try the current year
267            year = time.localtime()[0]
268            st_mtime = self._mktime( (year, month, day,
269                                      hour, minute, 0, 0, 0, -1) )
270            st_mtime_precision = MINUTE_PRECISION
271            # Rhs of comparison: Transform client time to server time
272            # (as on the lhs), so both can be compared with respect
273            # to the set time shift (see the definition of the time
274            # shift in `FTPHost.set_time_shift`'s docstring). The
275            # last addend allows for small deviations between the
276            # supposed (rounded) and the actual time shift.
277            #
278            # XXX The downside of this "correction" is that there is
279            # a one-minute time interval exactly one year ago that
280            # may cause that datetime to be recognized as the current
281            # datetime, but after all the datetime from the server
282            # can only be exact up to a minute.
283            if st_mtime > time.time() + time_shift + st_mtime_precision:
284                # If it's in the future, use previous year.
285                st_mtime = self._mktime( (year-1, month, day,
286                                          hour, minute, 0, 0, 0, -1) )
287        # If we had a datetime before the epoch, the resulting value
288        # 0.0 doesn't tell us anything about the precision.
289        if st_mtime == 0.0:
290            st_mtime_precision = UNKNOWN_PRECISION
291        #
292        if with_precision:
293            return st_mtime, st_mtime_precision
294        else:
295            return st_mtime
296
297    def parse_ms_time(self, date, time_, time_shift):
298        """
299        Return a floating point number, like from `time.mktime`, by
300        parsing the string arguments `date` and `time_`. The parameter
301        `time_shift` is the difference
302
303            "time on server" - "time on client"
304
305        and can be set as the `time_shift` parameter in the
306        `parse_line` interface.
307
308        Times in MS-style directory listings typically have the
309        format "10-23-01 03:25PM" (month-day_of_month-two_digit_year,
310        hour:minute, am/pm).
311
312        If this method can't make sense of the given arguments, it
313        raises an `ftputil.error.ParserError`.
314        """
315        # Derived classes might want to use `self`.
316        # pylint: disable=no-self-use
317        #
318        # Derived classes may need access to `time_shift`.
319        # pylint: disable=unused-argument
320        #
321        # For the time being, I don't add a `with_precision`
322        # parameter as in the Unix parser because the precision for
323        # the DOS format is always a minute and can be set in
324        # `MSParser.parse_line`. Should you find yourself needing
325        # support for `with_precision` for a derived class, please
326        # send a mail (see ftputil.txt/html).
327        month, day, year = [self._as_int(part, "year/month/day")
328                            for part in date.split("-")]
329        if year >= 1000:
330            # We have a four-digit year, so no need for heuristics.
331            pass
332        elif year >= 70:
333            year = 1900 + year
334        else:
335            year = 2000 + year
336        try:
337            hour, minute, am_pm = time_[0:2], time_[3:5], time_[5]
338        except IndexError:
339            raise ftputil.error.ParserError("invalid time string '{0}'".
340                                            format(time_))
341        hour, minute = (
342          self._as_int(hour, "hour"), self._as_int(minute, "minute"))
343        if hour == 12 and am_pm == "A":
344            hour = 0
345        if hour != 12 and am_pm == "P":
346            hour += 12
347        st_mtime = self._mktime( (year, month, day,
348                                  hour, minute, 0, 0, 0, -1) )
349        return st_mtime
350
351
352class UnixParser(Parser):
353    """`Parser` class for Unix-specific directory format."""
354
355    @staticmethod
356    def _split_line(line):
357        """
358        Split a line in metadata, nlink, user, group, size, month,
359        day, year_or_time and name and return the result as an
360        nine-element list of these values. If the name is a link,
361        it will be encoded as a string "link_name -> link_target".
362        """
363        # This method encapsulates the recognition of an unusual
364        # Unix format variant (see ticket
365        # http://ftputil.sschwarzer.net/trac/ticket/12 ).
366        line_parts = line.split()
367        FIELD_COUNT_WITHOUT_USERID = 8
368        FIELD_COUNT_WITH_USERID = FIELD_COUNT_WITHOUT_USERID + 1
369        if len(line_parts) < FIELD_COUNT_WITHOUT_USERID:
370            # No known Unix-style format
371            raise ftputil.error.ParserError("line '{0}' can't be parsed".
372                                            format(line))
373        # If we have a valid format (either with or without user id field),
374        # the field with index 5 is either the month abbreviation or a day.
375        try:
376            int(line_parts[5])
377        except ValueError:
378            # Month abbreviation, "invalid literal for int"
379            line_parts = line.split(None, FIELD_COUNT_WITH_USERID-1)
380        else:
381            # Day
382            line_parts = line.split(None, FIELD_COUNT_WITHOUT_USERID-1)
383            USER_FIELD_INDEX = 2
384            line_parts.insert(USER_FIELD_INDEX, None)
385        return line_parts
386
387    def parse_line(self, line, time_shift=0.0):
388        """
389        Return a `StatResult` instance corresponding to the given
390        text line. The `time_shift` value is needed to determine
391        to which year a datetime without an explicit year belongs.
392
393        If the line can't be parsed, raise a `ParserError`.
394        """
395        # The local variables are rather simple.
396        # pylint: disable=too-many-locals
397        try:
398            mode_string, nlink, user, group, size, month, day, \
399              year_or_time, name = self._split_line(line)
400        # We can get a `ValueError` here if the name is blank (see
401        # ticket #69). This is a strange use case, but at least we
402        # should raise the exception the docstring mentions.
403        except ValueError as exc:
404            raise ftputil.error.ParserError(str(exc))
405        # st_mode
406        st_mode = self.parse_unix_mode(mode_string)
407        # st_ino, st_dev, st_nlink, st_uid, st_gid, st_size, st_atime
408        st_ino = None
409        st_dev = None
410        st_nlink = int(nlink)
411        st_uid = user
412        st_gid = group
413        st_size = int(size)
414        st_atime = None
415        # st_mtime
416        st_mtime, st_mtime_precision = \
417          self.parse_unix_time(month, day, year_or_time, time_shift,
418                               with_precision=True)
419        # st_ctime
420        st_ctime = None
421        # st_name
422        if name.count(" -> ") > 1:
423            # If we have more than one arrow we can't tell where the link
424            # name ends and the target name starts.
425            raise ftputil.error.ParserError(
426                    '''name '{0}' contains more than one "->"'''.format(name))
427        elif name.count(" -> ") == 1:
428            st_name, st_target = name.split(" -> ")
429        else:
430            st_name, st_target = name, None
431        stat_result = StatResult(
432                      (st_mode, st_ino, st_dev, st_nlink, st_uid,
433                       st_gid, st_size, st_atime, st_mtime, st_ctime) )
434        # These attributes are kind of "half-official". I'm not
435        # sure whether they should be used by ftputil client code.
436        # pylint: disable=protected-access
437        stat_result._st_mtime_precision = st_mtime_precision
438        stat_result._st_name = st_name
439        stat_result._st_target = st_target
440        return stat_result
441
442
443class MSParser(Parser):
444    """`Parser` class for MS-specific directory format."""
445
446    def parse_line(self, line, time_shift=0.0):
447        """
448        Return a `StatResult` instance corresponding to the given
449        text line from a FTP server which emits "Microsoft format"
450        (see end of file).
451
452        If the line can't be parsed, raise a `ParserError`.
453
454        The parameter `time_shift` isn't used in this method but is
455        listed for compatibility with the base class.
456        """
457        # The local variables are rather simple.
458        # pylint: disable=too-many-locals
459        try:
460            date, time_, dir_or_size, name = line.split(None, 3)
461        except ValueError:
462            # "unpack list of wrong size"
463            raise ftputil.error.ParserError("line '{0}' can't be parsed".
464                                            format(line))
465        # st_mode
466        #  Default to read access only; in fact, we can't tell.
467        st_mode = 0o400
468        if dir_or_size == "<DIR>":
469            st_mode = st_mode | stat.S_IFDIR
470        else:
471            st_mode = st_mode | stat.S_IFREG
472        # st_ino, st_dev, st_nlink, st_uid, st_gid
473        st_ino = None
474        st_dev = None
475        st_nlink = None
476        st_uid = None
477        st_gid = None
478        # st_size
479        if dir_or_size != "<DIR>":
480            try:
481                st_size = int(dir_or_size)
482            except ValueError:
483                raise ftputil.error.ParserError("invalid size {0}".
484                                                format(dir_or_size))
485        else:
486            st_size = None
487        # st_atime
488        st_atime = None
489        # st_mtime
490        st_mtime = self.parse_ms_time(date, time_, time_shift)
491        # st_ctime
492        st_ctime = None
493        stat_result = StatResult(
494                      (st_mode, st_ino, st_dev, st_nlink, st_uid,
495                       st_gid, st_size, st_atime, st_mtime, st_ctime) )
496        # These attributes are kind of "half-official". I'm not
497        # sure whether they should be used by ftputil client code.
498        # pylint: disable=protected-access
499        # _st_name and _st_target
500        stat_result._st_name = name
501        stat_result._st_target = None
502        # mtime precision in seconds
503        #  If we had a datetime before the epoch, the resulting value
504        #  0.0 doesn't tell us anything about the precision.
505        if st_mtime == 0.0:
506            stat_result._st_mtime_precision = UNKNOWN_PRECISION
507        else:
508            stat_result._st_mtime_precision = MINUTE_PRECISION
509        return stat_result
510
511#
512# Stat'ing operations for files on an FTP server
513#
514class _Stat(object):
515    """Methods for stat'ing directories, links and regular files."""
516
517    def __init__(self, host):
518        self._host = host
519        self._path = host.path
520        # Use the Unix directory parser by default.
521        self._parser = UnixParser()
522        # Allow one chance to switch to another parser if the default
523        # doesn't work.
524        self._allow_parser_switching = True
525        # Cache only lstat results. `stat` works locally on `lstat` results.
526        self._lstat_cache = ftputil.stat_cache.StatCache()
527
528    def _host_dir(self, path):
529        """
530        Return a list of lines, as fetched by FTP's `LIST` command,
531        when applied to `path`.
532        """
533        return self._host._dir(path)
534
535    def _stat_results_from_dir(self, path):
536        """
537        Yield stat results extracted from the directory listing `path`.
538        Omit the special entries for the directory itself and its parent
539        directory.
540        """
541        lines = self._host_dir(path)
542        # `cache` is the "high-level" `StatCache` object whereas
543        # `cache._cache` is the "low-level" `LRUCache` object.
544        cache = self._lstat_cache
545        # Auto-grow cache if the cache up to now can't hold as many
546        # entries as there are in the directory `path`.
547        if cache._enabled and len(lines) >= cache._cache.size:
548            new_size = int(math.ceil(1.1 * len(lines)))
549            cache.resize(new_size)
550        # Yield stat results from lines.
551        for line in lines:
552            if self._parser.ignores_line(line):
553                continue
554            # For `listdir`, we are interested in just the names,
555            # but we use the `time_shift` parameter to have the
556            # correct timestamp values in the cache.
557            stat_result = self._parser.parse_line(line,
558                                                  self._host.time_shift())
559            if stat_result._st_name in [self._host.curdir, self._host.pardir]:
560                continue
561            loop_path = self._path.join(path, stat_result._st_name)
562            self._lstat_cache[loop_path] = stat_result
563            yield stat_result
564
565    def _real_listdir(self, path):
566        """
567        Return a list of directories, files etc. in the directory
568        named `path`.
569
570        Like `os.listdir` the returned list elements have the type
571        of the path argument.
572
573        If the directory listing from the server can't be parsed,
574        raise a `ParserError`.
575        """
576        # We _can't_ put this check into `FTPHost._dir`; see its docstring.
577        path = self._path.abspath(path)
578        # `listdir` should only be allowed for directories and links to them.
579        if not self._path.isdir(path):
580            raise ftputil.error.PermanentError(
581                  "550 {0}: no such directory or wrong directory parser used".
582                  format(path))
583        # Set up for `for` loop.
584        names = []
585        for stat_result in self._stat_results_from_dir(path):
586            st_name = stat_result._st_name
587            names.append(st_name)
588        return names
589
590    def _real_lstat(self, path, _exception_for_missing_path=True):
591        """
592        Return an object similar to that returned by `os.lstat`.
593
594        If the directory listing from the server can't be parsed,
595        raise a `ParserError`. If the directory can be parsed and the
596        `path` is not found, raise a `PermanentError`. That means that
597        if the directory containing `path` can't be parsed we get a
598        `ParserError`, independent on the presence of `path` on the
599        server.
600
601        (`_exception_for_missing_path` is an implementation aid and
602        _not_ intended for use by ftputil clients.)
603        """
604        path = self._path.abspath(path)
605        # If the path is in the cache, return the lstat result.
606        if path in self._lstat_cache:
607            return self._lstat_cache[path]
608        # Note: (l)stat works by going one directory up and parsing
609        # the output of an FTP `LIST` command. Unfortunately, it is
610        # not possible to do this for the root directory `/`.
611        if path == "/":
612            raise ftputil.error.RootDirError(
613                  "can't stat remote root directory")
614        dirname, basename = self._path.split(path)
615        # If even the directory doesn't exist and we don't want the
616        # exception, treat it the same as if the path wasn't found in the
617        # directory's contents (compare below). The use of `isdir` here
618        # causes a recursion but that should be ok because that will at
619        # the latest stop when we've gotten to the root directory.
620        if not self._path.isdir(dirname) and not _exception_for_missing_path:
621            return None
622        # Loop through all lines of the directory listing. We
623        # probably won't need all lines for the particular path but
624        # we want to collect as many stat results in the cache as
625        # possible.
626        lstat_result_for_path = None
627        for stat_result in self._stat_results_from_dir(dirname):
628            # Needed to work without cache or with disabled cache.
629            if stat_result._st_name == basename:
630                lstat_result_for_path = stat_result
631        if lstat_result_for_path is not None:
632            return lstat_result_for_path
633        # Path was not found during the loop.
634        if _exception_for_missing_path:
635            #TODO Use FTP `LIST` command on the file to implicitly use
636            # the usual status code of the server for missing files
637            # (450 vs. 550).
638            raise ftputil.error.PermanentError(
639                  "550 {0}: no such file or directory".format(path))
640        else:
641            # Be explicit. Returning `None` is a signal for
642            # `_Path.exists/isfile/isdir/islink` that the path was
643            # not found. If we would raise an exception, there would
644            # be no distinction between a missing path or a more
645            # severe error in the code above.
646            return None
647
648    def _real_stat(self, path, _exception_for_missing_path=True):
649        """
650        Return info from a "stat" call on `path`.
651
652        If the directory containing `path` can't be parsed, raise
653        a `ParserError`. If the listing can be parsed but the
654        `path` can't be found, raise a `PermanentError`. Also raise
655        a `PermanentError` if there's an endless (cyclic) chain of
656        symbolic links "behind" the `path`.
657
658        (`_exception_for_missing_path` is an implementation aid and
659        _not_ intended for use by ftputil clients.)
660        """
661        # Save for error message.
662        original_path = path
663        # Most code in this method is used to detect recursive
664        # link structures.
665        visited_paths = set()
666        while True:
667            # Stat the link if it is one, else the file/directory.
668            lstat_result = self._real_lstat(path, _exception_for_missing_path)
669            if lstat_result is None:
670                return None
671            # If the file is not a link, the `stat` result is the
672            # same as the `lstat` result.
673            if not stat.S_ISLNK(lstat_result.st_mode):
674                return lstat_result
675            # If we stat'ed a link, calculate a normalized path for
676            # the file the link points to.
677            dirname, _ = self._path.split(path)
678            path = self._path.join(dirname, lstat_result._st_target)
679            path = self._path.abspath(self._path.normpath(path))
680            # Check for cyclic structure.
681            if path in visited_paths:
682                # We had seen this path already.
683                raise ftputil.error.RecursiveLinksError(
684                  "recursive link structure detected for remote path '{0}'".
685                  format(original_path))
686            # Remember the path we have encountered.
687            visited_paths.add(path)
688
689    def __call_with_parser_retry(self, method, *args, **kwargs):
690        """
691        Call `method` with the `args` and `kwargs` once. If that
692        results in a `ParserError` and only one parser has been
693        used yet, try the other parser. If that still fails,
694        propagate the `ParserError`.
695        """
696        # Do _not_ set `_allow_parser_switching` in a `finally` clause!
697        # This would cause a `PermanentError` due to a not-found
698        # file in an empty directory to finally establish the
699        # parser - which is wrong.
700        try:
701            result = method(*args, **kwargs)
702            # If a `listdir` call didn't find anything, we can't
703            # say anything about the usefulness of the parser.
704            if (method is not self._real_listdir) and result:
705                self._allow_parser_switching = False
706            return result
707        except ftputil.error.ParserError:
708            if self._allow_parser_switching:
709                self._allow_parser_switching = False
710                self._parser = MSParser()
711                return method(*args, **kwargs)
712            else:
713                raise
714
715    # Don't use these methods, but instead the corresponding methods
716    # in the `FTPHost` class.
717    def _listdir(self, path):
718        """
719        Return a list of items in `path`.
720
721        Raise a `PermanentError` if the path doesn't exist, but
722        maybe raise other exceptions depending on the state of
723        the server (e. g. timeout).
724        """
725        return self.__call_with_parser_retry(self._real_listdir, path)
726
727    def _lstat(self, path, _exception_for_missing_path=True):
728        """
729        Return a `StatResult` without following links.
730
731        Raise a `PermanentError` if the path doesn't exist, but
732        maybe raise other exceptions depending on the state of
733        the server (e. g. timeout).
734        """
735        return self.__call_with_parser_retry(self._real_lstat, path,
736                                             _exception_for_missing_path)
737
738    def _stat(self, path, _exception_for_missing_path=True):
739        """
740        Return a `StatResult` with following links.
741
742        Raise a `PermanentError` if the path doesn't exist, but
743        maybe raise other exceptions depending on the state of
744        the server (e. g. timeout).
745        """
746        return self.__call_with_parser_retry(self._real_stat, path,
747                                             _exception_for_missing_path)
Note: See TracBrowser for help on using the repository browser.