Skip to content

Commit 119e349

Browse files
committed
Keep timesince as 4.1.1 subtracted it outside plain ZoneInfo datetimes
Take back the offset correction of the previous commit. It asked the tzinfo for both offsets after the subtraction, which counted a DST change twice for pandas and pendulum values (their own __rsub__ already counts it), passed arrow.Arrow objects to tzinfo.utcoffset (TypeError, wrong results, and a segfault in the C zoneinfo on 3.12), and asked tzinfo shapes for an offset that 4.1.1 never requested. timesince now subtracts every input as 4.1.1 did, except a plain datetime whose tzinfo is a zoneinfo.ZoneInfo. For that input it converts the current time to UTC, as #56 does, so a daylight saving change is counted. Datetime subclasses, look-alikes such as Arrow, and inputs in other tzinfo types (dateutil, pytz, custom ones) get the 4.1.1 result, which for dateutil means the wall-clock difference across a DST change.
1 parent e792caa commit 119e349

2 files changed

Lines changed: 117 additions & 11 deletions

File tree

‎_python_utils_tests/test_formatters.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,56 @@ def test_timesince_datetime_subclass_with_own_offset() -> None:
9292
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
9393

9494

95+
class _WallClockOnly(datetime.tzinfo):
96+
"""A timezone that only converts from UTC, which ``tzinfo`` allows."""
97+
98+
def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta:
99+
"""Refuse the offset the way the ``tzinfo`` base class does."""
100+
raise NotImplementedError
101+
102+
def dst(self, dt: datetime.datetime | None) -> datetime.timedelta:
103+
"""Refuse the daylight saving offset like the base class."""
104+
raise NotImplementedError
105+
106+
def fromutc(self, dt: datetime.datetime) -> datetime.datetime:
107+
"""Put the local wall clock one hour ahead of UTC."""
108+
return (dt + datetime.timedelta(hours=1)).replace(tzinfo=self)
109+
110+
def tzname(self, dt: datetime.datetime | None) -> str:
111+
"""Name the timezone."""
112+
return 'wall clock'
113+
114+
115+
def test_timesince_tzinfo_without_utcoffset() -> None:
116+
"""Subtract two datetimes in a tzinfo without offsets as 4.1.1 did."""
117+
age: datetime.timedelta = datetime.timedelta(hours=1, minutes=30)
118+
moment: datetime.datetime = datetime.datetime.now(_WallClockOnly()) - age
119+
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
120+
121+
122+
class _LookAlike:
123+
"""Not a datetime, but subtracts from one, as ``arrow.Arrow`` does."""
124+
125+
def __init__(self, moment: datetime.datetime) -> None:
126+
"""Wrap an aware datetime."""
127+
self.moment: datetime.datetime = moment
128+
self.tzinfo: datetime.tzinfo | None = moment.tzinfo
129+
130+
def __rsub__(self, other: datetime.datetime) -> datetime.timedelta:
131+
"""Subtract the wrapped datetime from ``other``."""
132+
return other - self.moment
133+
134+
135+
def test_timesince_datetime_look_alike() -> None:
136+
"""Let an object that is not a datetime do its own subtraction."""
137+
age: datetime.timedelta = datetime.timedelta(hours=1, minutes=30)
138+
# Not one of the documented types, so typed as Any like the date test.
139+
moment: typing.Any = _LookAlike(
140+
datetime.datetime.now(datetime.timezone.utc) - age
141+
)
142+
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
143+
144+
95145
@pytest.mark.parametrize(
96146
('moment', 'current'),
97147
[
@@ -148,6 +198,43 @@ def now(cls, tz: datetime.tzinfo | None = None) -> 'FrozenDateTime':
148198
assert formatters.timesince(moment) == '1 hour ago'
149199

150200

201+
class _ElapsedRsub(datetime.datetime):
202+
"""A subclass whose own subtraction counts elapsed time, as in pandas."""
203+
204+
def __rsub__(self, other: datetime.datetime) -> datetime.timedelta:
205+
"""Subtract by UTC timestamp, so a DST change is counted."""
206+
seconds: float = other.timestamp() - datetime.datetime.timestamp(self)
207+
return datetime.timedelta(seconds=seconds)
208+
209+
210+
@pytest.mark.skipif(
211+
'America/New_York' not in available_timezones(),
212+
reason='IANA timezone data is not available',
213+
)
214+
def test_timesince_subclass_subtraction_counts_dst_once(
215+
monkeypatch: pytest.MonkeyPatch,
216+
) -> None:
217+
"""Leave a subclass that counts elapsed time itself to count it once."""
218+
timezone = ZoneInfo('America/New_York')
219+
current = datetime.datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=timezone)
220+
moment = _ElapsedRsub(2026, 11, 1, 1, 30, fold=0, tzinfo=timezone)
221+
222+
def frozen_now(tz: datetime.tzinfo | None = None) -> datetime.datetime:
223+
"""Return the chosen instant as a plain datetime, like the clock."""
224+
return datetime.datetime.fromtimestamp(current.timestamp(), tz)
225+
226+
monkeypatch.setattr(
227+
formatters,
228+
'datetime',
229+
types.SimpleNamespace(
230+
datetime=types.SimpleNamespace(now=frozen_now),
231+
timedelta=datetime.timedelta,
232+
timezone=datetime.timezone,
233+
),
234+
)
235+
assert formatters.timesince(moment) == '1 hour ago'
236+
237+
151238
@pytest.mark.parametrize(
152239
('moment', 'expected'),
153240
[

‎python_utils/formatters.py‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# pyright: reportUnnecessaryIsInstance=false
1212
import collections.abc
1313
import datetime
14+
import sys
1415
import typing
1516

1617
from python_utils import _aliases
@@ -99,6 +100,28 @@ def apply_recursive(
99100
return data
100101

101102

103+
# The class itself, for `_in_zoneinfo_zone`. Code that swaps the module's
104+
# `datetime` for a stand-in, as tests do, still gets the real class here.
105+
_DATETIME: typing.Final[type[datetime.datetime]] = datetime.datetime
106+
107+
108+
def _in_zoneinfo_zone(dt: object) -> bool:
109+
"""Tell whether ``dt`` is a plain datetime in a ``zoneinfo`` zone.
110+
111+
For that input `timesince` counts a daylight saving change. Every other
112+
input is subtracted as in 4.1.1: a datetime subclass, a look-alike such
113+
as ``arrow.Arrow`` or another tzinfo can do its own subtraction or report
114+
its offset in its own way. ``zoneinfo`` is looked up, not imported, since
115+
a ``ZoneInfo`` can only exist once that module is loaded.
116+
"""
117+
zoneinfo: typing.Any = sys.modules.get('zoneinfo')
118+
return (
119+
zoneinfo is not None
120+
and type(dt) is _DATETIME
121+
and type(getattr(dt, 'tzinfo', None)) is zoneinfo.ZoneInfo
122+
)
123+
124+
102125
def timesince(
103126
dt: datetime.datetime | datetime.timedelta,
104127
default: str = 'just now',
@@ -155,17 +178,13 @@ def timesince(
155178
# A date has no `tzinfo`, and no time to subtract either. It gets
156179
# the same `TypeError` from the subtraction as it always did.
157180
now = datetime.datetime.now(getattr(dt, 'tzinfo', None))
158-
diff = now - dt
159-
tzinfo: datetime.tzinfo | None = now.tzinfo
160-
if tzinfo is not None and tzinfo is dt.tzinfo:
161-
# Subtracting two datetimes that share a tzinfo ignores a change
162-
# of UTC offset between them, such as a daylight saving change.
163-
# Add that change back when the tzinfo knows both offsets.
164-
now_offset: datetime.timedelta | None = tzinfo.utcoffset(now)
165-
dt_offset: datetime.timedelta | None = tzinfo.utcoffset(dt)
166-
if now_offset is not None and dt_offset is not None:
167-
diff -= now_offset - dt_offset
168-
diff = abs(diff)
181+
if _in_zoneinfo_zone(dt):
182+
# Two datetimes that share a tzinfo subtract by wall clock, which
183+
# misses a daylight saving change between them. The current time
184+
# in UTC counts it. Only `now` is converted, because converting
185+
# `dt` could overflow near `datetime.min` or `datetime.max`.
186+
now = now.astimezone(datetime.timezone.utc)
187+
diff = abs(now - dt)
169188

170189
# Every unit takes its share from what the larger units left over, so a
171190
# day is never counted twice.

0 commit comments

Comments
 (0)