source: ftputil/stat.py @ 1743:4b773b472e7e

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