Skip to content

Commit

Permalink
Fix possible error when clock is far in the future on Windows (#1297)
Browse files Browse the repository at this point in the history
Due to year 2038 problem, is seems the "tm_gmtoff" can be -2**32,
causing the "timezone()" creation to fail. Added a fallback.

I suppose we could technically use the fallback in all cases by default,
but using "localtime()" is certainly faster.

Also it's complicated to unit test, the "freeze_time()" stuff is quite a
mess.
  • Loading branch information
Delgan authored Feb 13, 2025
1 parent e310e20 commit 258326b
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
=============

- Fix a regression preventing formatting of ``record["time"]`` when using ``zoneinfo.ZoneInfo`` timezones (`#1260 <https://github.com/Delgan/loguru/pull/1260>`_, thanks `@bijlpieter <https://github.com/bijlpieter>`_).
- Fix possible ``ValueError`` raised on Windows when system clock was set far ahead in the future (`#1291 <https://github.com/Delgan/loguru/issues/1291>`_).
- Allow the ``rotation`` argument of file sinks to accept a list of rotation conditions, any of which can trigger the rotation (`#1174 <https://github.com/Delgan/loguru/issues/1174>`_, thanks `@CollinHeist <https://github.com/CollinHeist>`_).
- Update the default log format to include the timezone offset since it produces less ambiguous logs (`#856 <https://github.com/Delgan/loguru/pull/856>`_, thanks `@tim-x-y-z <https://github.com/tim-x-y-z>`_).
- Add requirement for ``$TERM`` not to be ``"dumb"`` to enable colorization (`#1287 <https://github.com/Delgan/loguru/pull/1287>`_, thanks `@snosov1 <https://github.com/snosov1>`_).
Expand Down
38 changes: 28 additions & 10 deletions loguru/_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,21 +141,39 @@ def __format__(self, fmt):
return _compile_format(fmt)(self)


def aware_now():
now = datetime_.now()
timestamp = now.timestamp()
local = localtime(timestamp)
def _fallback_tzinfo(timestamp):
utc_naive = datetime_.fromtimestamp(timestamp, tz=timezone.utc).replace(tzinfo=None)
offset = datetime_.fromtimestamp(timestamp) - utc_naive
seconds = offset.total_seconds()
zone = strftime("%Z")
return timezone(timedelta(seconds=seconds), zone)


def _get_tzinfo(timestamp):
try:
local = localtime(timestamp)
except (OSError, OverflowError):
# The "localtime()" can overflow on some platforms when the timestamp is too large.
# Not sure the fallback won't also overflow, though.
return _fallback_tzinfo(timestamp)

try:
seconds = local.tm_gmtoff
zone = local.tm_zone
except AttributeError:
# Workaround for Python 3.5.
utc_naive = datetime_.fromtimestamp(timestamp, tz=timezone.utc).replace(tzinfo=None)
offset = datetime_.fromtimestamp(timestamp) - utc_naive
seconds = offset.total_seconds()
zone = strftime("%Z")
# The attributes were not availanble on all platforms before Python 3.6.
return _fallback_tzinfo(timestamp)

try:
return timezone(timedelta(seconds=seconds), zone)
except ValueError:
# The number of seconds returned by "tm_gmtoff" might be invalid on Windows (year 2038+).
# Curiously, the fallback workaround does not exhibit the same problem.
return _fallback_tzinfo(timestamp)

tzinfo = timezone(timedelta(seconds=seconds), zone)

def aware_now():
now = datetime_.now()
timestamp = now.timestamp()
tzinfo = _get_tzinfo(timestamp)
return datetime.combine(now.date(), now.time().replace(tzinfo=tzinfo))
13 changes: 11 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,12 @@ def freeze_time(monkeypatch):
freezegun_localtime = freezegun.api.fake_localtime
builtins_open = builtins.open

fakes = {"zone": "UTC", "offset": 0, "include_tm_zone": True}
fakes = {
"zone": "UTC",
"offset": 0,
"include_tm_zone": True,
"tm_gmtoff_override": None,
}

def fake_localtime(t=None):
fix_struct = os.name == "nt" and sys.version_info < (3, 6)
Expand Down Expand Up @@ -259,6 +264,9 @@ def fake_localtime(t=None):
override = {"tm_zone": fakes["zone"], "tm_gmtoff": fakes["offset"]}
attributes = []

if fakes["tm_gmtoff_override"] is not None:
override["tm_gmtoff"] = fakes["tm_gmtoff_override"]

for attribute, _ in struct_time_attributes:
if attribute in override:
value = override[attribute]
Expand All @@ -275,7 +283,7 @@ def patched_open(filepath, *args, **kwargs):
return builtins_open(filepath, *args, **kwargs)

@contextlib.contextmanager
def freeze_time(date, timezone=("UTC", 0), *, include_tm_zone=True):
def freeze_time(date, timezone=("UTC", 0), *, include_tm_zone=True, tm_gmtoff_override=None):
# Freezegun does not behave very well with UTC and timezones, see spulec/freezegun#348.
# In particular, "now(tz=utc)" does not return the converted datetime.
# For this reason, we re-implement date parsing here to properly handle aware date using
Expand All @@ -300,6 +308,7 @@ def freeze_time(date, timezone=("UTC", 0), *, include_tm_zone=True):
context.setitem(fakes, "zone", zone)
context.setitem(fakes, "offset", offset)
context.setitem(fakes, "include_tm_zone", include_tm_zone)
context.setitem(fakes, "tm_gmtoff_override", tm_gmtoff_override)

context.setattr(loguru._file_sink, "get_ctime", ctimes.__getitem__)
context.setattr(loguru._file_sink, "set_ctime", ctimes.__setitem__)
Expand Down
45 changes: 42 additions & 3 deletions tests/test_datetime.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import datetime
import os
import re
import sys
from time import strftime
from unittest.mock import Mock

import freezegun
import pytest

import loguru
from loguru import logger

if sys.version_info < (3, 6):
Expand All @@ -14,6 +16,11 @@
UTC_NAME = "UTC"


def _expected_fallback_time_zone():
# For some reason, Python versions and interepreters return different time zones here.
return strftime("%Z")


@pytest.mark.parametrize(
("time_format", "date", "timezone", "expected"),
[
Expand Down Expand Up @@ -161,12 +168,44 @@ def test_file_formatting(freeze_time, tmp_path):


def test_missing_struct_time_fields(writer, freeze_time):
with freeze_time("2011-01-02 03:04:05.6", include_tm_zone=False):
with freeze_time("2011-01-02 03:04:05.6", ("A", 7200), include_tm_zone=False):
logger.add(writer, format="{time:YYYY MM DD HH mm ss SSSSSS ZZ zz}")
logger.debug("X")

result = writer.read()
zone = _expected_fallback_time_zone()

assert result == "2011 01 02 03 04 05 600000 +0200 %s\n" % zone


@pytest.mark.parametrize("tm_gmtoff", [-4294963696, 4294963696])
def test_value_of_gmtoff_is_invalid(writer, freeze_time, tm_gmtoff):
with freeze_time("2011-01-02 03:04:05.6", ("ABC", -3600), tm_gmtoff_override=tm_gmtoff):
logger.add(writer, format="{time:YYYY MM DD HH mm ss SSSSSS ZZ zz}")
logger.debug("X")

result = writer.read()
assert re.fullmatch(r"2011 01 02 03 04 05 600000 [+-]\d{4} .*\n", result)
zone = _expected_fallback_time_zone()

assert result == "2011 01 02 03 04 05 600000 -0100 %s\n" % zone


@pytest.mark.parametrize("exception", [OSError, OverflowError])
def test_localtime_raising_exception(writer, freeze_time, monkeypatch, exception):
with freeze_time("2011-01-02 03:04:05.6", ("A", 7200), include_tm_zone=True):
with monkeypatch.context() as context:
mock = Mock(side_effect=exception)
context.setattr(loguru._datetime, "localtime", mock, raising=True)

logger.add(writer, format="{time:YYYY MM DD HH mm ss SSSSSS ZZ zz}")
logger.debug("X")

assert mock.called

result = writer.read()
zone = _expected_fallback_time_zone()

assert result == "2011 01 02 03 04 05 600000 +0200 %s\n" % zone


@pytest.mark.skipif(sys.version_info < (3, 9), reason="No zoneinfo module available")
Expand Down

0 comments on commit 258326b

Please sign in to comment.