diff --git a/_python_utils_tests/test_formatters.py b/_python_utils_tests/test_formatters.py index e683c88..ad38c36 100644 --- a/_python_utils_tests/test_formatters.py +++ b/_python_utils_tests/test_formatters.py @@ -1,7 +1,9 @@ """Tests for the formatting helpers in ``python_utils.formatters``.""" import datetime +import types import typing +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import pytest @@ -55,6 +57,107 @@ def test_timesince_aware_datetime(timezone: datetime.timezone) -> None: assert formatters.timesince(moment) == '1 hour and 30 minutes ago' +@pytest.mark.parametrize( + ('moment', 'current'), + [ + ( + datetime.datetime(2026, 11, 1, 1, 30, fold=0), + datetime.datetime(2026, 11, 1, 1, 30, fold=1), + ), + ( + datetime.datetime(2026, 11, 1, 1, 30, fold=1), + datetime.datetime(2026, 11, 1, 1, 30, fold=0), + ), + ( + datetime.datetime(2026, 3, 8, 1, 30), + datetime.datetime(2026, 3, 8, 3, 30), + ), + ( + datetime.datetime(2026, 3, 8, 3, 30), + datetime.datetime(2026, 3, 8, 1, 30), + ), + ], + ids=['fall-back-past', 'fall-back-future', 'spring-past', 'spring-future'], +) +def test_timesince_counts_elapsed_time_across_dst( + moment: datetime.datetime, + current: datetime.datetime, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Count elapsed time when both datetimes share a changing UTC offset.""" + try: + timezone = ZoneInfo('America/New_York') + except ZoneInfoNotFoundError: + pytest.skip('IANA timezone data is not available') + current = current.replace(tzinfo=timezone) + moment = moment.replace(tzinfo=timezone) + + class FrozenDateTime(datetime.datetime): + """Return the chosen instant as the current time.""" + + @classmethod + def now(cls, tz: datetime.tzinfo | None = None) -> 'FrozenDateTime': + """Keep the same instant while returning the requested timezone.""" + return cls.fromtimestamp(current.timestamp(), tz) + + monkeypatch.setattr( + formatters, + 'datetime', + types.SimpleNamespace( + datetime=FrozenDateTime, + timedelta=datetime.timedelta, + timezone=datetime.timezone, + ), + ) + assert formatters.timesince(moment) == '1 hour ago' + + +@pytest.mark.parametrize( + ('moment', 'expected'), + [ + ( + datetime.datetime.min.replace( + tzinfo=datetime.timezone(datetime.timedelta(hours=1)) + ), + '2000 years and 3 months ago', + ), + ( + datetime.datetime.max.replace( + tzinfo=datetime.timezone(datetime.timedelta(hours=-1)) + ), + '8005 years and 3 months ago', + ), + ], + ids=['min-positive-offset', 'max-negative-offset'], +) +def test_timesince_aware_datetime_boundaries( + moment: datetime.datetime, + expected: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Accept aware inputs whose corresponding UTC date is out of range.""" + current = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc) + + class FrozenDateTime(datetime.datetime): + """Return the chosen instant as the current time.""" + + @classmethod + def now(cls, tz: datetime.tzinfo | None = None) -> 'FrozenDateTime': + """Keep the same instant while returning the requested timezone.""" + return cls.fromtimestamp(current.timestamp(), tz) + + monkeypatch.setattr( + formatters, + 'datetime', + types.SimpleNamespace( + datetime=FrozenDateTime, + timedelta=datetime.timedelta, + timezone=datetime.timezone, + ), + ) + assert formatters.timesince(moment) == expected + + @pytest.mark.parametrize( ('name', 'expected'), [ diff --git a/docs/guide/conversions-and-formatting.md b/docs/guide/conversions-and-formatting.md index 11b1d6a..14d3c41 100644 --- a/docs/guide/conversions-and-formatting.md +++ b/docs/guide/conversions-and-formatting.md @@ -96,4 +96,7 @@ time.format_time(datetime.datetime(2000, 1, 2, 3, 4, 5)) # '2000-01-02 03:04 time.format_time(None) # '--:--:--' ``` +For timezone-aware datetimes, `timesince` counts elapsed time across daylight +saving transitions. Naive datetimes are compared with the current local time. + See the {doc}`API reference <../python_utils>` for every parameter. diff --git a/python_utils/formatters.py b/python_utils/formatters.py index 71082c4..18601ef 100644 --- a/python_utils/formatters.py +++ b/python_utils/formatters.py @@ -150,6 +150,13 @@ def timesince( # A date has no `tzinfo`, and no time to subtract either. It gets # the same `TypeError` from the subtraction as it always did. now = datetime.datetime.now(getattr(dt, 'tzinfo', None)) + if ( + getattr(dt, 'tzinfo', None) is not None + and dt.utcoffset() is not None + ): + # Subtraction with the same tzinfo ignores offset changes and fold. + # Convert only now: converting dt could overflow near min or max. + now = now.astimezone(datetime.timezone.utc) diff = abs(now - dt) # Every unit takes its share from what the larger units left over, so a