Skip to content

Commit e792caa

Browse files
committed
Subtract datetimes in timesince as 4.1.1 did, then add the offset change
#56 converted the current time to UTC whenever dt.utcoffset() was known. The subtraction itself asks the tzinfo, not the datetime, so the two could disagree: a datetime subclass with its own utcoffset() and a tzinfo that does not know the offset raised TypeError on CPython and was off by the local UTC offset on PyPy, where 4.1.1 returned the right age. timesince now subtracts as 4.1.1 did. Only when both datetimes share one tzinfo, the case where Python ignores the offsets, and that tzinfo knows both offsets, the change between them is added back. Daylight saving changes are counted as #56 intended, and every other input gets the result 4.1.1 gave.
1 parent 08270c6 commit e792caa

2 files changed

Lines changed: 46 additions & 8 deletions

File tree

‎_python_utils_tests/test_formatters.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,41 @@ def test_timesince_aware_datetime(timezone: datetime.timezone) -> None:
5757
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
5858

5959

60+
class _UnknownOffset(datetime.tzinfo):
61+
"""A timezone that does not know its UTC offset, as ``tzinfo`` allows."""
62+
63+
def utcoffset(self, dt: datetime.datetime | None) -> None:
64+
"""Report the offset as unknown."""
65+
return
66+
67+
def dst(self, dt: datetime.datetime | None) -> None:
68+
"""Report the daylight saving offset as unknown."""
69+
return
70+
71+
def tzname(self, dt: datetime.datetime | None) -> str:
72+
"""Name the timezone."""
73+
return 'unknown'
74+
75+
def fromutc(self, dt: datetime.datetime) -> datetime.datetime:
76+
"""Use the UTC wall clock as the local one."""
77+
return dt.replace(tzinfo=self)
78+
79+
80+
class _OwnOffsetDateTime(datetime.datetime):
81+
"""A datetime subclass that reports an offset its tzinfo does not know."""
82+
83+
def utcoffset(self) -> datetime.timedelta:
84+
"""Report a fixed offset of three hours."""
85+
return datetime.timedelta(hours=3)
86+
87+
88+
def test_timesince_datetime_subclass_with_own_offset() -> None:
89+
"""Subtract a subclass with its own offset the way a datetime does."""
90+
age: datetime.timedelta = datetime.timedelta(hours=1, minutes=30)
91+
moment: datetime.datetime = _OwnOffsetDateTime.now(_UnknownOffset()) - age
92+
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'
93+
94+
6095
@pytest.mark.parametrize(
6196
('moment', 'current'),
6297
[

‎python_utils/formatters.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,17 @@ def timesince(
155155
# A date has no `tzinfo`, and no time to subtract either. It gets
156156
# the same `TypeError` from the subtraction as it always did.
157157
now = datetime.datetime.now(getattr(dt, 'tzinfo', None))
158-
if (
159-
getattr(dt, 'tzinfo', None) is not None
160-
and dt.utcoffset() is not None
161-
):
162-
# Subtraction with the same tzinfo ignores offset changes and fold.
163-
# Convert only now: converting dt could overflow near min or max.
164-
now = now.astimezone(datetime.timezone.utc)
165-
diff = abs(now - dt)
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)
166169

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

0 commit comments

Comments
 (0)