Skip to content

Compare instants, not wall clocks, in croniter_range - #261

Open
hdimer wants to merge 1 commit into
pallets-eco:mainfrom
hdimer:croniter-range-compare-instants
Open

hdimer wants to merge 1 commit into
pallets-eco:mainfrom
hdimer:croniter-range-compare-instants

Conversation

@hdimer

@hdimer hdimer commented Aug 10, 2026

Copy link
Copy Markdown

Fixes #259.

croniter_range decides both its iteration direction and its stop condition
with start < stop and v < stop. CPython specifies that comparing two aware
datetimes that share a tzinfo ignores that tzinfo and compares the base
datetimes, and fold is part of neither, so across a DST transition those
comparisons use wall-clock order rather than real elapsed time.

The one-microsecond nudge that makes the bounds inclusive has the same defect
from the other side: arithmetic on an aware datetime moves the wall clock and
resets fold, so a one-microsecond step can move a bound by a whole DST
offset. Fixing only the comparison is not enough — the nudge puts the bound
back on the wrong side.

Three symptoms, one cause

Silently returns too few results. Australia/Sydney, a four-minute
interval inside the fall-back hour, contains six fire times:

tz = zoneinfo.ZoneInfo("Australia/Sydney")
start = datetime(2019, 4, 7, 2, 22, tzinfo=tz)          # +11:00, UTC 15:22
stop  = datetime(2019, 4, 7, 2, 26, tzinfo=tz, fold=1)  # +10:00, UTC 16:26

len(list(croniter_range(start, stop, "*/13 * * * *")))  # 1, expected 6

The generator ends early. No exception, no warning — the caller gets a prefix
of the correct answer.

Returns results outside the requested interval. Europe/London, a reverse
range across spring-forward, yields a fire time at UTC 01:00 for a lower bound
of UTC 01:15.

Runs in the wrong direction. A forward interval whose bounds happen to read
backwards on the wall clock (02:39+11:00 → 02:26+10:00, which is forwards in
real time) iterates backwards and yields values below the requested start.

In all three cases, stepping the underlying croniter over the same window
gives the right answer, so croniter_range was answering a question its own
iterator already answers correctly.

The change

Two small module-level helpers, _instant and _shift, and three rewired
comparison sites in croniter_range. _instant normalises an aware datetime to
UTC for ordering and returns a naive one unchanged; _shift moves a bound by
real elapsed time instead of wall-clock time. The existing _is_successor does
the same UTC-normalised comparison for get_next/get_prev, so this brings
croniter_range in line with the rest of the module.

I did not fold cont into _is_successor: that helper calls astimezone
unguarded, which for a naive datetime would silently assume the machine's local
zone, and croniter_range's naive path is the common one.

Verification

  • Three tests in test_croniter_range.py, one per symptom. Each fails on
    main for its own reason and passes after. They assert on local-time
    isoformat() strings, matching the neighbouring DST tests, so the expected
    values show the wall clock running backwards while the sequence advances.
  • Full suite: 251 passed, 92 subtests.
  • Differential sweep, 768 ordinary ranges (6 zones including naive and UTC, 4
    expressions, both directions, both exclude_ends): zero differences
    against main. The change is inert unless a bound is ambiguous or the bounds
    carry different offsets.
  • Invariant check over ~118,000 generated DST-crossing ranges across 10 zones,
    including the 30-minute (Australia/Lord_Howe) and 45-minute
    (Pacific/Chatham) shifts, asserting every yielded instant falls inside the
    requested bounds and the sequence is monotonic in real time: main violates
    those invariants ~11,000 times, this branch zero times.
  • pytz is unaffected either way, and I verified that: pytz never shares a
    tzinfo instance across a transition, so its comparisons were already
    ordering by instant. The fold half of this is zoneinfo-specific.

Out of scope

While checking croniter_range against get_next as an oracle I hit a
disagreement confined to Australia/Lord_Howe: get_next skips a fire time
that get_prev and match both accept. That is the separate _add_tzinfo
issue reported in #258 — it reproduces with a bare croniter(...) and no
croniter_range involved, and this branch does not touch it.


Used AI assistance on this; I reviewed and tested the change myself.

@hdimer
hdimer marked this pull request as ready for review August 10, 2026 23:56
potiuk added a commit to hdimer/croniter that referenced this pull request Nov 1, 2026
Resolve conflicts with pallets-eco#284, pallets-eco#263 and pallets-eco#267 by keeping all entries.

Generated-by: Claude Opus 5

@potiuk potiuk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The approach is right: compare UTC instants, and move the inclusive bounds through UTC instead of wall-clock time. I checked it against an oracle built from get_next filtered by UTC instant. That covered Sydney/London fall-back and New York spring-forward under zoneinfo, pytz and dateutil, both directions and both exclude_ends. This logic got 0 wrong out of 14,904 cases, where main got 1,229 wrong. All three new tests fail on main for their stated reasons. One change is needed before merge:

  • datetime.date bounds regress (_instant, src/croniter/croniter.py:170): croniter_range(date(2020,1,1), date(2020,1,3), "0 0 * * *") yields three datetimes on main and now raises AttributeError: 'datetime.date' object has no attribute 'utcoffset'. A guard (isinstance(d, datetime) before touching utcoffset) plus a test would keep it working.
  • nit: _instant calls astimezone(UTC) unguarded, so an aware bound at datetime.min/datetime.max with a non-zero offset raises OverflowError where main didn't.
  • The branch conflicts with main again in CHANGELOG.rst; please merge main when you push the fix.

This review was drafted by an AI-assisted tool and
confirmed by an croniter maintainer. After you've
addressed the points above and pushed an update, an croniter
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

croniter_range decided both its iteration direction and its stop condition
with `start < stop` and `v < stop`. CPython specifies that comparing two aware
datetimes that share a tzinfo ignores that tzinfo and compares the base
datetimes, and fold is part of neither, so across a DST transition those
comparisons use wall-clock order instead of real elapsed time.

The one-microsecond nudge that makes the bounds inclusive had the same defect
from the other side: arithmetic on an aware datetime moves the wall clock and
resets fold, so a one-microsecond step could move a bound by a whole DST
offset.

Three observable symptoms, one cause:

  - Australia/Sydney, a four-minute interval inside the fall-back hour:
    croniter_range returned 1 of the 6 fire times it contains and ended
    silently, with no exception.
  - Europe/London, a reverse range across spring-forward: a fire time 15
    minutes below the requested lower bound.
  - A forward interval whose bounds read backwards on the wall clock ran the
    range backwards.

Stepping the underlying croniter over the same windows gives the right answer
in every case, so croniter_range was answering a question its own iterator
answers correctly.

Bounds and fire times are now compared as instants, and the inclusive-ends
nudge moves by real elapsed time. Naive datetimes carry no offset and take
the unchanged path.

Fixes pallets-eco#259
@potiuk
potiuk force-pushed the croniter-range-compare-instants branch from 1977d45 to 9815b10 Compare November 4, 2026 09:18
@potiuk

potiuk commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

I rebased this branch onto current main so it's up to date, and CI is green on the new head (9815b10). The content of your change is the same.

The conflicts were only between separate additions, and I kept both sides.

Because this was a force-push, please reset your local branch before pushing again:

git fetch <your-fork-remote>
git reset --hard <your-fork-remote>/croniter-range-compare-instants

My earlier review comments still stand, so I'm now waiting for your response on those. Thanks!

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

Development

Successfully merging this pull request may close these issues.

croniter_range stop test ignores the UTC offset: silently drops results and returns out-of-interval values

2 participants