Changeset 1916:a526dce5108f


Ignore:
Timestamp:
Apr 5, 2020, 9:57:55 PM (16 months ago)
Author:
Stefan Schwarzer <sschwarzer@…>
Branch:
default
Message:
Assume server time without time shift is UTC

By default, assume the timestamps in the FTP server's listings are in
UTC. The user can still set a time shift in case the server doesn't
use UTC timestamps.

The reasons for assuming UTC are explained in ticket #134.

Dealing with "only" UTC and the time shift makes the code a bit easier
to handle because we don't need to consider mismatches between UTC and
local time (let alone different local times on server and client).
That said, we _may_ have time differences between server and client,
but such a time difference should be solely covered by the time shift
value.

Especially tricky is the decision which year to choose for the
timestamp if the directory line from the server doesn't contain a year
value. This can't be solved clearly, so use a heuristics (see the code
comments in `parse_unix_time`).

ticket: 134
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • ftputil/stat.py

    r1908 r1916  
    207207    def _datetime(year, month, day, hour, minute, second):
    208208        """
    209         Return naive `datetime.datetime` object for the given year, month, day,
     209        Return UTC `datetime.datetime` object for the given year, month, day,
    210210        hour, minute and second.
    211211
     
    214214        """
    215215        try:
    216             return datetime.datetime(year, month, day, hour, minute, second)
     216            return datetime.datetime(
     217                year, month, day, hour, minute, second, tzinfo=datetime.timezone.utc
     218            )
    217219        except ValueError:
    218220            invalid_datetime = (
     
    276278                self._as_int(minute, "minute"),
    277279            )
    278             # Year and datetime in the local server time
    279             server_year = (
    280                 datetime.datetime.now() + datetime.timedelta(seconds=time_shift)
    281             ).year
    282         server_datetime = self._datetime(server_year, month, day, hour, minute, 0)
    283         # Datetime in the local client time
    284         client_datetime = server_datetime - datetime.timedelta(seconds=time_shift)
    285         if not year_is_known:
    286             # If the client datetime is in the future, the timestamp is actually
    287             # in the past, from last year. Add the deviation (the `timedelta`
    288             # value) to account for differences because of limited precision in
    289             # the directory listing.
    290             if client_datetime > datetime.datetime.now() + datetime.timedelta(
    291                 seconds=st_mtime_precision
    292             ):
    293                 client_datetime = client_datetime.replace(year=client_datetime.year - 1)
    294         # According to
    295         # https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp
    296         # this assumes that the datetime is in local time and returns the
    297         # seconds since the epoch, just what we want.
    298         st_mtime = client_datetime.timestamp()
     280            # First assume the year of the directory/file is the current year.
     281            server_now = datetime.datetime.utcnow().replace(
     282                tzinfo=datetime.timezone.utc
     283            ) + datetime.timedelta(seconds=time_shift)
     284            server_year = server_now.year
     285            # If the server datetime derived from this year seems to be in the
     286            # future, subtract one year.
     287            #
     288            # Things to consider:
     289            #
     290            # Since the server time will be rounded down to full minutes, apply
     291            # the same truncation to the presumed current server time.
     292            #
     293            # Due to possible small errors in the time setting of the server
     294            # (not only because of the parsing), there will always be a small
     295            # time window in which we can't be sure whether the parsed time is
     296            # in the future or not. Make a mistake of one second and the time
     297            # is off one year.
     298            #
     299            # To resolve this conflict, if in doubt assume that a directory or
     300            # file has just been created, not that it is by chance to the
     301            # minute one year old.
     302            #
     303            # Hence, add a small time difference that must be exceeded in order
     304            # to assume the time is in the future. This time difference is
     305            # arbitrary, but we have to assume _some_ value.
     306            if self._datetime(
     307                server_year, month, day, hour, minute, 0
     308            ) > server_now.replace(second=0) + datetime.timedelta(seconds=120):
     309                server_year -= 1
     310        # The time shift is the time difference the server is ahead. So to get
     311        # back to client time (UTC), subtract the time shift. The calculation
     312        # is supposed to be the same for negative time shifts; in this case we
     313        # subtract a negative time shift, i. e. add the absolute value of the
     314        # time shift to the server date time.
     315        server_utc_datetime = self._datetime(
     316            server_year, month, day, hour, minute, 0
     317        ) - datetime.timedelta(seconds=time_shift)
     318        st_mtime = server_utc_datetime.timestamp()
    299319        # If we had a datetime before the epoch, the resulting value 0.0 doesn't
    300320        # tell us anything about the precision.
     
    433453        # pylint: disable=too-many-locals
    434454        try:
    435             mode_string, nlink, user, group, size, month, day, year_or_time, name = self._split_line(
    436                 line
    437             )
     455            (
     456                mode_string,
     457                nlink,
     458                user,
     459                group,
     460                size,
     461                month,
     462                day,
     463                year_or_time,
     464                name,
     465            ) = self._split_line(line)
    438466        # We can get a `ValueError` here if the name is blank (see
    439467        # ticket #69). This is a strange use case, but at least we
  • test/test_stat.py

    r1908 r1916  
    2929def stat_tuple_to_seconds(t):
    3030    """
    31     Return a float number representing the local time associated with
    32     the six-element tuple `t`.
     31    Return a float number representing the UTC timestamp from the six-element
     32    tuple `t`.
    3333    """
    3434    assert len(t) == 6, "need a six-element tuple (year, month, day, hour, min, sec)"
     
    3838        return 0.0
    3939    else:
    40         return time.mktime(t + (0, 0, -1))
     40        return datetime.datetime(*t, tzinfo=datetime.timezone.utc).timestamp()
    4141
    4242
     
    7676        # corresponds to the hard-coded value in the string lists
    7777        # below.
    78         now = time.localtime()
    79         # We need only month, day, hour and minute.
    80         current_time_parts = now[1:5]
    81         time_parts_in_listing = (12, 19, 23, 11)
    82         if current_time_parts > time_parts_in_listing:
    83             return now[0]
     78        client_datetime = datetime.datetime.utcnow().replace(
     79            tzinfo=datetime.timezone.utc
     80        )
     81        server_datetime_candidate = client_datetime.replace(
     82            month=12, day=19, hour=23, minute=11, second=0
     83        )
     84        if server_datetime_candidate > client_datetime:
     85            return server_datetime_candidate.year - 1
    8486        else:
    85             return now[0] - 1
     87            return server_datetime_candidate.year
    8688
    8789    #
     
    524526            host._stat._parser = ftputil.stat.UnixParser()
    525527            host.set_time_shift(supposed_time_shift)
    526             local_server_time = datetime.datetime.now() + datetime.timedelta(
    527                 seconds=supposed_time_shift + deviation
    528             )
     528            server_time = datetime.datetime.utcnow().replace(
     529                tzinfo=datetime.timezone.utc
     530            ) + datetime.timedelta(seconds=supposed_time_shift + deviation)
    529531            stat_result = host._stat._parser.parse_line(
    530                 self.dir_line(local_server_time), host.time_shift()
     532                self.dir_line(server_time), host.time_shift()
    531533            )
    532534            # We expect `st_mtime` in UTC.
     
    534536                stat_result.st_mtime,
    535537                (
    536                     local_server_time
    537                     # Convert back to local client time.
     538                    server_time
     539                    # Convert back to client time.
    538540                    - datetime.timedelta(seconds=supposed_time_shift)
    539                     # `timestamp()` implicitly converts from local time to UTC.
    540541                ).timestamp(),
    541542            )
     
    543544    def test_time_shifts(self):
    544545        """Test correct year depending on time shift value."""
    545         # 1. test: Client and server share the same local time
     546        # 1. test: Client and server share the same time (UTC). This is true if
     547        # the directory listing from the server is in UTC.
    546548        self._test_time_shift(0.0)
    547549        # 2. test: Server is three hours ahead of client
     
    590592            host.stat_cache.disable()
    591593            stat_result = host.stat("/foo")
    592             # TODO: Make the value for `st_mtime` robust against DST "time
    593             # zone" changes.
    594594            expected_result = (
    595595                "StatResult(st_mode=17901, st_ino=None, st_dev=None, "
    596596                "st_nlink=2, st_uid='45854', st_gid='200', st_size=512, "
    597                 "st_atime=None, st_mtime=957391200.0, st_ctime=None)"
     597                "st_atime=None, st_mtime=957398400.0, st_ctime=None)"
    598598            )
    599599            assert repr(stat_result) == expected_result
Note: See TracChangeset for help on using the changeset viewer.