Skip to content

Count a daylight saving change in timesince for every aware datetime - #58

Open
wolph wants to merge 1 commit into
developfrom
fix/formatters
Open

wolph wants to merge 1 commit into
developfrom
fix/formatters

Conversation

@wolph

@wolph wolph commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Found by an adversarial pass over python_utils.formatters on develop after #56 and #57, against the released 4.1.1. Every fix started as a failing test. Only errors whose fix cannot break working code are in here. The rest is listed at the bottom for a decision.

timesince

#56 converted the current time to UTC when dt.utcoffset() was known. The subtraction that follows asks the tzinfo for the offset, not dt, so a datetime subclass with its own utcoffset() made the two disagree:

Input 4.1.1 develop this branch
datetime subclass with its own utcoffset(), tzinfo without one, 30 s ago 30 seconds ago TypeError on CPython, 2 hours and 29 minutes ago on PyPy 30 seconds ago
aware datetime across a DST change (ZoneInfo, dateutil, arrow.Arrow) wall-clock difference true elapsed time true elapsed time
pendulum value in the future across a DST change -1 hours ago -1 hours ago 1 hour ago
  • timesince asks now for the offset instead of dt. now is the plain datetime that datetime.now built from the same tzinfo, so it reports the offset the subtraction will use. Against develop the change is that one expression.
  • Datetime subclasses (pandas Timestamp, pendulum DateTime) and look-alikes such as arrow.Arrow are left to their own subtraction, as in 4.1.1.

Compatibility

Three hunt rounds ran on an earlier, longer shape of this fix (a ZoneInfo-only path, since taken back). The sweeps from all three rounds were re-run on this one:

  • 20000 seeded cases in 7 zones under a frozen clock on 3.12: 1214 differ from 4.1.1, every one across an offset change, and the branch equals the true elapsed time in all 20000.
  • 22500 cases on 3.12 with pytz, dateutil, pendulum, pandas, arrow and freezegun under a frozen clock: 4.1.1 equals the true elapsed time in 19643, the branch in 21055. What the branch still gets wrong: 1373 raw pytz zones used without localize (identical on 4.1.1), and 72 freezegun rows in the repeated hour (finding 7 below). 4.1.1 also missed 201 ZoneInfo, 201 arrow, 478 dateutil and 603 freezegun rows.
  • 3000 arrow values in ZoneInfo zones on 3.10, 3.14 and PyPy 3.11: 0 differ from the elapsed time, no TypeError, no segfault.
  • Odd tzinfo and datetime subclass shapes (4870 rows, 3.12): every row the branch does not get right is a tzinfo that breaks the tzinfo contract. Those raise Python's own error where 4.1.1 printed a wall-clock number (see below).
  • The released 4.1.1 tests pass unchanged: 326 passed on 3.10 and 3.14. The suite on this branch: 366 passed on 3.10 and 3.14. ruff, mypy, basedpyright and codespell pass.

What working code can notice, against 4.1.1:

  • An aware datetime across a DST change gives the true elapsed time instead of the wall-clock difference, whatever its tzinfo (Count elapsed time across DST in timesince #56, now for every tzinfo).
  • A tzinfo that breaks the tzinfo contract (utcoffset raising NotImplementedError, returning an int, or an offset of a day or more) raises that error from timesince, where 4.1.1 printed a wall-clock difference because it never asked for the offset. The earlier shape of this branch pinned the 4.1.1 result for such a tzinfo with a test. That test is gone.

For a decision

  • 1: camel_to_underscore glues an acronym to a following As, Is or Us since 4.1.1 (APIAsString gives apias_string, URLIsValid gives urlis_valid). All 612 names in a 20412-name corpus that 4.1.0 converted correctly and 4.1.1 does not have this shape. The plural rule of 1bb0458 reads the s of the two-letter word as a plural. IDAs is both ID + As and the plural of IDA, so any change moves released output one way or the other.
  • 3: timesince says "ago" for a future datetime. Unchanged since before 4.1.1.
  • 4: apply_recursive skips lists, tuples and non-dict mappings, returns an OrderedDict as a dict and never passes **kwargs to the function. Unchanged since before 4.1.1.
  • 7: under freezegun, an input in the second pass of a repeated hour is an hour off (72 of 22500 sweep rows), because freezegun's FakeDatetime drops fold. On develop since Count elapsed time across DST in timesince #56.
  • 10: a stand-in for formatters.datetime without a timezone attribute raises AttributeError for an aware input. On develop since Count elapsed time across DST in timesince #56.
  • 11: a stand-in whose now() returns a naive datetime gives a value where 4.1.1 raised TypeError. On develop since Count elapsed time across DST in timesince #56.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T18:00:23.124133Z 119e349 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 119e349756

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread python_utils/formatters.py Outdated
Comment on lines +120 to +121
and type(dt) is _DATETIME
and type(getattr(dt, 'tzinfo', None)) is zoneinfo.ZoneInfo

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the ZoneInfo-only elapsed-time behavior

When dt uses a non-ZoneInfo timezone that retains the same tzinfo object across a DST transition, such as dateutil.tz.gettz, this exact-type gate skips the UTC conversion and subtraction returns the wall-clock difference rather than elapsed time. That contradicts docs/guide/conversions-and-formatting.md:99-100, which promises elapsed-time handling for all timezone-aware datetimes; either narrow that documented guarantee to plain ZoneInfo datetimes or preserve the behavior for the other advertised inputs.

Useful? React with 👍 / 👎.

#56 converted the current time to UTC when `dt.utcoffset()` was known, so
two datetimes that share a tzinfo no longer subtract by wall clock. The
subtraction that follows asks the tzinfo for the offset, not `dt`, and a
datetime subclass that reports its own offset made the two disagree: a
TypeError on CPython and a wrong result on PyPy.

Ask `now` for the offset instead. `now` is the plain datetime that
`datetime.now` built from the same tzinfo, so it reports the offset the
subtraction will use. Datetime subclasses and look-alikes such as
`arrow.Arrow` are left to their own subtraction.

What working code can notice, against 4.1.1: an aware datetime across a
daylight saving change gives the elapsed time instead of the wall-clock
difference, whatever its tzinfo. A tzinfo that breaks the tzinfo contract
(`utcoffset` raising, returning something other than a timedelta, or an
offset of a day or more) now raises Python's own error where 4.1.1 printed
a wall-clock difference.
@wolph wolph changed the title Keep timesince as 4.1.1 computed it outside plain ZoneInfo datetimes Count a daylight saving change in timesince for every aware datetime Oct 11, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant