source: ftputil/stat.py @ 1744:194d13209ff4

Last change on this file since 1744:194d13209ff4 was 1744:194d13209ff4, checked in by Stefan Schwarzer <sschwarzer@…>, 4 months ago
Remove remark about Python 2.6 Python 2 support will be removed in ftputil 4.0.0.
File size: 30.2 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__`.
44        # pylint: disable=super-init-not-called
45        #
46        # Use `sequence` parameter to remain compatible to `__new__`
47        # interface.
48        # pylint: disable=unused-argument
49        #
50        # These may be overwritten in a `Parser.parse_line` method.
51        self._st_name = ""
52        self._st_target = None
53        self._st_mtime_precision = UNKNOWN_PRECISION
54
55    def __getattr__(self, attr_name):
56        if attr_name in self._index_mapping:
57            return self[self._index_mapping[attr_name]]
58        else:
59            raise AttributeError("'StatResult' object has no attribute '{}'".
60                                 format(attr_name))
61
62    def __repr__(self):
63        # "Invert" `_index_mapping` so that we can look up the names
64        # for the tuple indices.
65        index_to_name = dict((v, k) for k, v in self._index_mapping.items())
66        argument_strings = []
67        for index, item in enumerate(self):
68            argument_strings.append("{}={!r}".format(index_to_name[index],
69                                                     item))
70        return "{}({})".format(type(self).__name__,
71                               ", ".join(argument_strings))
72
73
74#
75# FTP directory parsers
76#
77class Parser:
78    """
79    Represent a parser for directory lines. Parsers for specific
80    directory formats inherit from this class.
81    """
82
83    # Map month abbreviations to month numbers.
84    _month_numbers = {
85      "jan":  1, "feb":  2, "mar":  3, "apr":  4,
86      "may":  5, "jun":  6, "jul":  7, "aug":  8,
87      "sep":  9, "oct": 10, "nov": 11, "dec": 12}
88
89    _total_regex = re.compile(r"^total\s+\d+")
90
91    def ignores_line(self, line):
92        """
93        Return a true value if the line should be ignored, i. e. is
94        assumed to _not_ contain actual directory/file/link data.
95        A typical example are summary lines like "total 23" which
96        are emitted by some FTP servers.
97
98        If the line should be used to extract stat data from it,
99        return a false value.
100        """
101        # Ignore empty lines stemming from only a line break.
102        if not line.strip():
103            # Yes, ignore the line if it's empty.
104            return True
105        # Either a `_SRE_Match` instance or `None`
106        match = self._total_regex.search(line)
107        return bool(match)
108
109    def parse_line(self, line, time_shift=0.0):
110        """
111        Return a `StatResult` object as derived from the string
112        `line`. The parser code to use depends on the directory format
113        the FTP server delivers (also see examples at end of file).
114
115        If the given text line can't be parsed, raise a `ParserError`.
116
117        For the definition of `time_shift` see the docstring of
118        `FTPHost.set_time_shift` in `ftputil.py`. Not all parsers
119        use the `time_shift` parameter.
120        """
121        raise NotImplementedError("must be defined by subclass")
122
123    #
124    # Helper methods for parts of a directory listing line
125    #
126    def parse_unix_mode(self, mode_string):
127        """
128        Return an integer from the `mode_string`, compatible with
129        the `st_mode` value in stat results. Such a mode string
130        may look like "drwxr-xr-x".
131
132        If the mode string can't be parsed, raise an
133        `ftputil.error.ParserError`.
134        """
135        # Allow derived classes to make use of `self`.
136        # pylint: disable=no-self-use
137        if len(mode_string) != 10:
138            raise ftputil.error.ParserError("invalid mode string '{}'".
139                                            format(mode_string))
140        st_mode = 0
141        #TODO Add support for "S" and sticky bit ("t", "T").
142        for bit in mode_string[1:10]:
143            bit = (bit != "-")
144            st_mode = (st_mode << 1) + bit
145        if mode_string[3] == "s":
146            st_mode = st_mode | stat.S_ISUID
147        if mode_string[6] == "s":
148            st_mode = st_mode | stat.S_ISGID
149        file_type_to_mode = {"b": stat.S_IFBLK, "c": stat.S_IFCHR,
150                             "d": stat.S_IFDIR, "l": stat.S_IFLNK,
151                             "p": stat.S_IFIFO, "s": stat.S_IFSOCK,
152                             "-": stat.S_IFREG,
153                             # Ignore types which `ls` can't make sense of
154                             # (assuming the FTP server returns listings
155                             # like `ls` does).
156                             "?": 0,
157                            }
158        file_type = mode_string[0]
159        if file_type in file_type_to_mode:
160            st_mode = st_mode | file_type_to_mode[file_type]
161        else:
162            raise ftputil.error.ParserError(
163                    "unknown file type character '{}'".format(file_type))
164        return st_mode
165
166    # pylint: disable=no-self-use
167    def _as_int(self, int_string, int_description):
168        """
169        Return `int_string` converted to an integer.
170
171        If it can't be converted, raise a `ParserError`, using
172        `int_description` in the error message. For example, if the
173        integer value is a day, pass "day" for `int_description`.
174        """
175        try:
176            return int(int_string)
177        except ValueError:
178            raise ftputil.error.ParserError("non-integer {} value {!r}".
179                                            format(int_description,
180                                                   int_string))
181
182    # pylint: disable=no-self-use
183    def _mktime(self, mktime_tuple):
184        """
185        Return a float value like `time.mktime` does, but ...
186
187        - Raise a `ParserError` if parts of `mktime_tuple` are
188          invalid (say, a day is 32).
189
190        - If the resulting float value would be smaller than 0.0
191          (indicating a time before the "epoch") return a sentinel
192          value of 0.0. Do this also if the native `mktime`
193          implementation would raise an `OverflowError`.
194        """
195        datetime_tuple = mktime_tuple[:6]
196        try:
197            # Only for sanity checks, we're not interested in the
198            # return value.
199            datetime.datetime(*datetime_tuple)
200        # For example, day == 32. Not all implementations of `mktime`
201        # catch this kind of error.
202        except ValueError:
203            invalid_datetime = ("%04d-%02d-%02d %02d:%02d:%02d" %
204                                datetime_tuple)
205            raise ftputil.error.ParserError("invalid datetime {0!r}".
206                                            format(invalid_datetime))
207        try:
208            time_float = time.mktime(mktime_tuple)
209        except (OverflowError, ValueError):
210            # Sentinel for times before the epoch, see ticket #83.
211            time_float = 0.0
212        # Don't allow float values smaller than 0.0 because, according
213        # to https://docs.python.org/3/library/time.html#module-time ,
214        # these might be undefined for some platforms.
215        return max(0.0, time_float)
216
217    def parse_unix_time(self, month_abbreviation, day, year_or_time,
218                        time_shift, with_precision=False):
219        """
220        Return a floating point number, like from `time.mktime`, by
221        parsing the string arguments `month_abbreviation`, `day` and
222        `year_or_time`. The parameter `time_shift` is the difference
223        "time on server" - "time on client" and is available as the
224        `time_shift` parameter in the `parse_line` interface.
225
226        If `with_precision` is true (default: false), return a
227        two-element tuple consisting of the floating point number as
228        described in the previous paragraph and the precision of the
229        time in seconds. The default is `False` for backward
230        compatibility with custom parsers.
231
232        The precision value takes into account that, for example, a
233        time string like "May 26  2005" has only a precision of one
234        day. This information is important for the `upload_if_newer`
235        and `download_if_newer` methods in the `FTPHost` class.
236
237        Times in Unix-style directory listings typically have one of
238        these formats:
239
240        - "Nov 23 02:33" (month name, day of month, time)
241
242        - "May 26  2005" (month name, day of month, year)
243
244        If this method can't make sense of the given arguments, it
245        raises an `ftputil.error.ParserError`.
246        """
247        try:
248            month = self._month_numbers[month_abbreviation.lower()]
249        except KeyError:
250            raise ftputil.error.ParserError("invalid month abbreviation {0!r}".
251                                            format(month_abbreviation))
252        day = self._as_int(day, "day")
253        if ":" not in year_or_time:
254            # `year_or_time` is really a year.
255            year, hour, minute = self._as_int(year_or_time, "year"), 0, 0
256            st_mtime = self._mktime( (year, month, day,
257                                      hour, minute, 0, 0, 0, -1) )
258            st_mtime_precision = DAY_PRECISION
259        else:
260            # `year_or_time` is a time hh:mm.
261            hour, minute = year_or_time.split(":")
262            year, hour, minute = (
263              None, self._as_int(hour, "hour"), self._as_int(minute, "minute"))
264            # Try the current year
265            year = time.localtime()[0]
266            st_mtime = self._mktime( (year, month, day,
267                                      hour, minute, 0, 0, 0, -1) )
268            st_mtime_precision = MINUTE_PRECISION
269            # Rhs of comparison: Transform client time to server time
270            # (as on the lhs), so both can be compared with respect
271            # to the set time shift (see the definition of the time
272            # shift in `FTPHost.set_time_shift`'s docstring). The
273            # last addend allows for small deviations between the
274            # supposed (rounded) and the actual time shift.
275            #
276            # XXX The downside of this "correction" is that there is
277            # a one-minute time interval exactly one year ago that
278            # may cause that datetime to be recognized as the current
279            # datetime, but after all the datetime from the server
280            # can only be exact up to a minute.
281            if st_mtime > time.time() + time_shift + st_mtime_precision:
282                # If it's in the future, use previous year.
283                st_mtime = self._mktime( (year-1, month, day,
284                                          hour, minute, 0, 0, 0, -1) )
285        # If we had a datetime before the epoch, the resulting value
286        # 0.0 doesn't tell us anything about the precision.
287        if st_mtime == 0.0:
288            st_mtime_precision = UNKNOWN_PRECISION
289        #
290        if with_precision:
291            return st_mtime, st_mtime_precision
292        else:
293            return st_mtime
294
295    def parse_ms_time(self, date, time_, time_shift):
296        """
297        Return a floating point number, like from `time.mktime`, by
298        parsing the string arguments `date` and `time_`. The parameter
299        `time_shift` is the difference
300
301            "time on server" - "time on client"
302
303        and can be set as the `time_shift` parameter in the
304        `parse_line` interface.
305
306        Times in MS-style directory listings typically have the
307        format "10-23-01 03:25PM" (month-day_of_month-two_digit_year,
308        hour:minute, am/pm).
309
310        If this method can't make sense of the given arguments, it
311        raises an `ftputil.error.ParserError`.
312        """
313        # Derived classes might want to use `self`.
314        # pylint: disable=no-self-use
315        #
316        # Derived classes may need access to `time_shift`.
317        # pylint: disable=unused-argument
318        #
319        # For the time being, I don't add a `with_precision`
320        # parameter as in the Unix parser because the precision for
321        # the DOS format is always a minute and can be set in
322        # `MSParser.parse_line`. Should you find yourself needing
323        # support for `with_precision` for a derived class, please
324        # send a mail (see ftputil.txt/html).
325        month, day, year = [self._as_int(part, "year/month/day")
326                            for part in date.split("-")]
327        if year >= 1000:
328            # We have a four-digit year, so no need for heuristics.
329            pass
330        elif year >= 70:
331            year = 1900 + year
332        else:
333            year = 2000 + year
334        try:
335            hour, minute, am_pm = time_[0:2], time_[3:5], time_[5]
336        except IndexError:
337            raise ftputil.error.ParserError("invalid time string '{}'".
338                                            format(time_))
339        hour, minute = (
340          self._as_int(hour, "hour"), self._as_int(minute, "minute"))
341        if hour == 12 and am_pm == "A":
342            hour = 0
343        if hour != 12 and am_pm == "P":
344            hour += 12
345        st_mtime = self._mktime( (year, month, day,
346                                  hour, minute, 0, 0, 0, -1) )
347        return st_mtime
348
349
350class UnixParser(Parser):
351    """`Parser` class for Unix-specific directory format."""
352
353    @staticmethod
354    def _split_line(line):
355        """
356        Split a line in metadata, nlink, user, group, size, month,
357        day, year_or_time and name and return the result as an
358        nine-element list of these values. If the name is a link,
359        it will be encoded as a string "link_name -> link_target".
360        """
361        # This method encapsulates the recognition of an unusual
362        # Unix format variant (see ticket
363        # http://ftputil.sschwarzer.net/trac/ticket/12 ).
364        line_parts = line.split()
365        FIELD_COUNT_WITHOUT_USERID = 8
366        FIELD_COUNT_WITH_USERID = FIELD_COUNT_WITHOUT_USERID + 1
367        if len(line_parts) < FIELD_COUNT_WITHOUT_USERID:
368            # No known Unix-style format
369            raise ftputil.error.ParserError("line '{}' can't be parsed".
370                                            format(line))
371        # If we have a valid format (either with or without user id field),
372        # the field with index 5 is either the month abbreviation or a day.
373        try:
374            int(line_parts[5])
375        except ValueError:
376            # Month abbreviation, "invalid literal for int"
377            line_parts = line.split(None, FIELD_COUNT_WITH_USERID-1)
378        else:
379            # Day
380            line_parts = line.split(None, FIELD_COUNT_WITHOUT_USERID-1)
381            USER_FIELD_INDEX = 2
382            line_parts.insert(USER_FIELD_INDEX, None)
383        return line_parts
384
385    def parse_line(self, line, time_shift=0.0):
386        """
387        Return a `StatResult` instance corresponding to the given
388        text line. The `time_shift` value is needed to determine
389        to which year a datetime without an explicit year belongs.
390
391        If the line can't be parsed, raise a `ParserError`.
392        """
393        # The local variables are rather simple.
394        # pylint: disable=too-many-locals
395        try:
396            mode_string, nlink, user, group, size, month, day, \
397              year_or_time, name = self._split_line(line)
398        # We can get a `ValueError` here if the name is blank (see
399        # ticket #69). This is a strange use case, but at least we
400        # should raise the exception the docstring mentions.
401        except ValueError as exc:
402            raise ftputil.error.ParserError(str(exc))
403        # st_mode
404        st_mode = self.parse_unix_mode(mode_string)
405        # st_ino, st_dev, st_nlink, st_uid, st_gid, st_size, st_atime
406        st_ino = None
407        st_dev = None
408        st_nlink = int(nlink)
409        st_uid = user
410        st_gid = group
411        st_size = int(size)
412        st_atime = None
413        # st_mtime
414        st_mtime, st_mtime_precision = \
415          self.parse_unix_time(month, day, year_or_time, time_shift,
416                               with_precision=True)
417        # st_ctime
418        st_ctime = None
419        # st_name
420        if name.count(" -> ") > 1:
421            # If we have more than one arrow we can't tell where the link
422            # name ends and the target name starts.
423            raise ftputil.error.ParserError(
424                    '''name '{}' contains more than one "->"'''.format(name))
425        elif name.count(" -> ") == 1:
426            st_name, st_target = name.split(" -> ")
427        else:
428            st_name, st_target = name, None
429        stat_result = StatResult(
430                      (st_mode, st_ino, st_dev, st_nlink, st_uid,
431                       st_gid, st_size, st_atime, st_mtime, st_ctime) )
432        # These attributes are kind of "half-official". I'm not
433        # sure whether they should be used by ftputil client code.
434        # pylint: disable=protected-access
435        stat_result._st_mtime_precision = st_mtime_precision
436        stat_result._st_name = st_name
437        stat_result._st_target = st_target
438        return stat_result
439
440
441class MSParser(Parser):
442    """`Parser` class for MS-specific directory format."""
443
444    def parse_line(self, line, time_shift=0.0):
445        """
446        Return a `StatResult` instance corresponding to the given
447        text line from a FTP server which emits "Microsoft format"
448        (see end of file).
449
450        If the line can't be parsed, raise a `ParserError`.
451
452        The parameter `time_shift` isn't used in this method but is
453        listed for compatibility with the base class.
454        """
455        # The local variables are rather simple.
456        # pylint: disable=too-many-locals
457        try:
458            date, time_, dir_or_size, name = line.split(None, 3)
459        except ValueError:
460            # "unpack list of wrong size"
461            raise ftputil.error.ParserError("line '{}' can't be parsed".
462                                            format(line))
463        # st_mode
464        #  Default to read access only; in fact, we can't tell.
465        st_mode = 0o400
466        if dir_or_size == "<DIR>":
467            st_mode = st_mode | stat.S_IFDIR
468        else:
469            st_mode = st_mode | stat.S_IFREG
470        # st_ino, st_dev, st_nlink, st_uid, st_gid
471        st_ino = None
472        st_dev = None
473        st_nlink = None
474        st_uid = None
475        st_gid = None
476        # st_size
477        if dir_or_size != "<DIR>":
478            try:
479                st_size = int(dir_or_size)
480            except ValueError:
481                raise ftputil.error.ParserError("invalid size {}".
482                                                format(dir_or_size))
483        else:
484            st_size = None
485        # st_atime
486        st_atime = None
487        # st_mtime
488        st_mtime = self.parse_ms_time(date, time_, time_shift)
489        # st_ctime
490        st_ctime = None
491        stat_result = StatResult(
492                      (st_mode, st_ino, st_dev, st_nlink, st_uid,
493                       st_gid, st_size, st_atime, st_mtime, st_ctime) )
494        # These attributes are kind of "half-official". I'm not
495        # sure whether they should be used by ftputil client code.
496        # pylint: disable=protected-access
497        # _st_name and _st_target
498        stat_result._st_name = name
499        stat_result._st_target = None
500        # mtime precision in seconds
501        #  If we had a datetime before the epoch, the resulting value
502        #  0.0 doesn't tell us anything about the precision.
503        if st_mtime == 0.0:
504            stat_result._st_mtime_precision = UNKNOWN_PRECISION
505        else:
506            stat_result._st_mtime_precision = MINUTE_PRECISION
507        return stat_result
508
509#
510# Stat'ing operations for files on an FTP server
511#
512class _Stat:
513    """Methods for stat'ing directories, links and regular files."""
514
515    # pylint: disable=protected-access
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 {}: 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 {}: 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 '{}'".
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.