source: ftputil/stat.py @ 1742:9993f6af7b40

Last change on this file since 1742:9993f6af7b40 was 1742:9993f6af7b40, checked in by Stefan Schwarzer <sschwarzer@…>, 20 months ago
Disable message `no-self-use` Keep the `self` argument because derived classes may need it.
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    def __init__(self, host):
517        self._host = host
518        self._path = host.path
519        # Use the Unix directory parser by default.
520        self._parser = UnixParser()
521        # Allow one chance to switch to another parser if the default
522        # doesn't work.
523        self._allow_parser_switching = True
524        # Cache only lstat results. `stat` works locally on `lstat` results.
525        self._lstat_cache = ftputil.stat_cache.StatCache()
526
527    def _host_dir(self, path):
528        """
529        Return a list of lines, as fetched by FTP's `LIST` command,
530        when applied to `path`.
531        """
532        return self._host._dir(path)
533
534    def _stat_results_from_dir(self, path):
535        """
536        Yield stat results extracted from the directory listing `path`.
537        Omit the special entries for the directory itself and its parent
538        directory.
539        """
540        lines = self._host_dir(path)
541        # `cache` is the "high-level" `StatCache` object whereas
542        # `cache._cache` is the "low-level" `LRUCache` object.
543        cache = self._lstat_cache
544        # Auto-grow cache if the cache up to now can't hold as many
545        # entries as there are in the directory `path`.
546        if cache._enabled and len(lines) >= cache._cache.size:
547            new_size = int(math.ceil(1.1 * len(lines)))
548            cache.resize(new_size)
549        # Yield stat results from lines.
550        for line in lines:
551            if self._parser.ignores_line(line):
552                continue
553            # For `listdir`, we are interested in just the names,
554            # but we use the `time_shift` parameter to have the
555            # correct timestamp values in the cache.
556            stat_result = self._parser.parse_line(line,
557                                                  self._host.time_shift())
558            if stat_result._st_name in [self._host.curdir, self._host.pardir]:
559                continue
560            loop_path = self._path.join(path, stat_result._st_name)
561            self._lstat_cache[loop_path] = stat_result
562            yield stat_result
563
564    def _real_listdir(self, path):
565        """
566        Return a list of directories, files etc. in the directory
567        named `path`.
568
569        Like `os.listdir` the returned list elements have the type
570        of the path argument.
571
572        If the directory listing from the server can't be parsed,
573        raise a `ParserError`.
574        """
575        # We _can't_ put this check into `FTPHost._dir`; see its docstring.
576        path = self._path.abspath(path)
577        # `listdir` should only be allowed for directories and links to them.
578        if not self._path.isdir(path):
579            raise ftputil.error.PermanentError(
580                  "550 {}: no such directory or wrong directory parser used".
581                  format(path))
582        # Set up for `for` loop.
583        names = []
584        for stat_result in self._stat_results_from_dir(path):
585            st_name = stat_result._st_name
586            names.append(st_name)
587        return names
588
589    def _real_lstat(self, path, _exception_for_missing_path=True):
590        """
591        Return an object similar to that returned by `os.lstat`.
592
593        If the directory listing from the server can't be parsed,
594        raise a `ParserError`. If the directory can be parsed and the
595        `path` is not found, raise a `PermanentError`. That means that
596        if the directory containing `path` can't be parsed we get a
597        `ParserError`, independent on the presence of `path` on the
598        server.
599
600        (`_exception_for_missing_path` is an implementation aid and
601        _not_ intended for use by ftputil clients.)
602        """
603        path = self._path.abspath(path)
604        # If the path is in the cache, return the lstat result.
605        if path in self._lstat_cache:
606            return self._lstat_cache[path]
607        # Note: (l)stat works by going one directory up and parsing
608        # the output of an FTP `LIST` command. Unfortunately, it is
609        # not possible to do this for the root directory `/`.
610        if path == "/":
611            raise ftputil.error.RootDirError(
612                  "can't stat remote root directory")
613        dirname, basename = self._path.split(path)
614        # If even the directory doesn't exist and we don't want the
615        # exception, treat it the same as if the path wasn't found in the
616        # directory's contents (compare below). The use of `isdir` here
617        # causes a recursion but that should be ok because that will at
618        # the latest stop when we've gotten to the root directory.
619        if not self._path.isdir(dirname) and not _exception_for_missing_path:
620            return None
621        # Loop through all lines of the directory listing. We
622        # probably won't need all lines for the particular path but
623        # we want to collect as many stat results in the cache as
624        # possible.
625        lstat_result_for_path = None
626        for stat_result in self._stat_results_from_dir(dirname):
627            # Needed to work without cache or with disabled cache.
628            if stat_result._st_name == basename:
629                lstat_result_for_path = stat_result
630        if lstat_result_for_path is not None:
631            return lstat_result_for_path
632        # Path was not found during the loop.
633        if _exception_for_missing_path:
634            #TODO Use FTP `LIST` command on the file to implicitly use
635            # the usual status code of the server for missing files
636            # (450 vs. 550).
637            raise ftputil.error.PermanentError(
638                  "550 {}: no such file or directory".format(path))
639        else:
640            # Be explicit. Returning `None` is a signal for
641            # `_Path.exists/isfile/isdir/islink` that the path was
642            # not found. If we would raise an exception, there would
643            # be no distinction between a missing path or a more
644            # severe error in the code above.
645            return None
646
647    def _real_stat(self, path, _exception_for_missing_path=True):
648        """
649        Return info from a "stat" call on `path`.
650
651        If the directory containing `path` can't be parsed, raise
652        a `ParserError`. If the listing can be parsed but the
653        `path` can't be found, raise a `PermanentError`. Also raise
654        a `PermanentError` if there's an endless (cyclic) chain of
655        symbolic links "behind" the `path`.
656
657        (`_exception_for_missing_path` is an implementation aid and
658        _not_ intended for use by ftputil clients.)
659        """
660        # Save for error message.
661        original_path = path
662        # Most code in this method is used to detect recursive
663        # link structures.
664        visited_paths = set()
665        while True:
666            # Stat the link if it is one, else the file/directory.
667            lstat_result = self._real_lstat(path, _exception_for_missing_path)
668            if lstat_result is None:
669                return None
670            # If the file is not a link, the `stat` result is the
671            # same as the `lstat` result.
672            if not stat.S_ISLNK(lstat_result.st_mode):
673                return lstat_result
674            # If we stat'ed a link, calculate a normalized path for
675            # the file the link points to.
676            dirname, _ = self._path.split(path)
677            path = self._path.join(dirname, lstat_result._st_target)
678            path = self._path.abspath(self._path.normpath(path))
679            # Check for cyclic structure.
680            if path in visited_paths:
681                # We had seen this path already.
682                raise ftputil.error.RecursiveLinksError(
683                  "recursive link structure detected for remote path '{}'".
684                  format(original_path))
685            # Remember the path we have encountered.
686            visited_paths.add(path)
687
688    def __call_with_parser_retry(self, method, *args, **kwargs):
689        """
690        Call `method` with the `args` and `kwargs` once. If that
691        results in a `ParserError` and only one parser has been
692        used yet, try the other parser. If that still fails,
693        propagate the `ParserError`.
694        """
695        # Do _not_ set `_allow_parser_switching` in a `finally` clause!
696        # This would cause a `PermanentError` due to a not-found
697        # file in an empty directory to finally establish the
698        # parser - which is wrong.
699        try:
700            result = method(*args, **kwargs)
701            # If a `listdir` call didn't find anything, we can't
702            # say anything about the usefulness of the parser.
703            if (method is not self._real_listdir) and result:
704                self._allow_parser_switching = False
705            return result
706        except ftputil.error.ParserError:
707            if self._allow_parser_switching:
708                self._allow_parser_switching = False
709                self._parser = MSParser()
710                return method(*args, **kwargs)
711            else:
712                raise
713
714    # Don't use these methods, but instead the corresponding methods
715    # in the `FTPHost` class.
716    def _listdir(self, path):
717        """
718        Return a list of items in `path`.
719
720        Raise a `PermanentError` if the path doesn't exist, but
721        maybe raise other exceptions depending on the state of
722        the server (e. g. timeout).
723        """
724        return self.__call_with_parser_retry(self._real_listdir, path)
725
726    def _lstat(self, path, _exception_for_missing_path=True):
727        """
728        Return a `StatResult` without following links.
729
730        Raise a `PermanentError` if the path doesn't exist, but
731        maybe raise other exceptions depending on the state of
732        the server (e. g. timeout).
733        """
734        return self.__call_with_parser_retry(self._real_lstat, path,
735                                             _exception_for_missing_path)
736
737    def _stat(self, path, _exception_for_missing_path=True):
738        """
739        Return a `StatResult` with following links.
740
741        Raise a `PermanentError` if the path doesn't exist, but
742        maybe raise other exceptions depending on the state of
743        the server (e. g. timeout).
744        """
745        return self.__call_with_parser_retry(self._real_stat, path,
746                                             _exception_for_missing_path)
Note: See TracBrowser for help on using the repository browser.