Skip to content

fix(api): correct DST spring-forward handling in parse_time_range#39231

Open
lntutor wants to merge 1 commit into
langgenius:mainfrom
lntutor:fix/parse-time-range-dst-gap
Open

fix(api): correct DST spring-forward handling in parse_time_range#39231
lntutor wants to merge 1 commit into
langgenius:mainfrom
lntutor:fix/parse-time-range-dst-gap

Conversation

@lntutor

@lntutor lntutor commented Jul 19, 2026

Copy link
Copy Markdown

Closes #39230

Summary

libs/datetime_utils.py::parse_time_range resolved a non-existent (DST spring-forward) wall-clock time by adding a hardcoded one hour, assuming every gap is exactly one hour. Real tzdata has other gap sizes, producing two bugs:

  • Gaps > 1 h (e.g. Antarctica/Troll +2 h, Pacific/Apia 24 h): +1h lands inside the gap, so the retry tz.localize(..., is_dst=None) raises NonExistentTimeError again — uncaught, leaking a raw pytz exception out of a function documented to raise only ValueError.
  • Gaps < 1 h (e.g. Australia/Lord_Howe +30 min): the result is shifted by an hour instead of the actual gap, so the returned UTC instant is wrong (16:15 UTC instead of 15:45 UTC for 02:15), silently shifting analytics time-range bounds.

Change

 except pytz.NonExistentTimeError:
-    dt += datetime.timedelta(hours=1)
-    return tz.localize(dt, is_dst=None).astimezone(utc)
+    return tz.normalize(tz.localize(dt, is_dst=False)).astimezone(utc)

tz.normalize(tz.localize(dt, is_dst=False)) shifts a non-existent time forward by exactly the gap size for any offset change (30 min, 1 h, 2 h, ...), matching zoneinfo fold=0. It never re-raises. Standard one-hour gaps are unchanged (America/New_York 02:30 → 07:30 UTC). The AmbiguousTimeError (fall-back) branch is untouched.

Verified the fix matches stdlib zoneinfo fold=0 for Antarctica/Troll, Australia/Lord_Howe, Pacific/Apia, and America/New_York.

Tests

Replaces the previous mock-based test_parse_time_range_dst_nonexistent_time (which patched pytz.timezone and asserted the old localize call sequence rather than a real result — it used Jan 1, not an actual DST transition) with real-timezone tests:

  • standard 1 h gap — America/New_York 02:30 → 07:30 UTC (unchanged behavior),
  • gap > 1 h — Antarctica/Troll 01:30 must not raise and resolves to 01:30 UTC,
  • gap < 1 h — Australia/Lord_Howe 02:15 → 2024-10-05 15:45 UTC.

All 25 test_datetime_utils tests pass; the full tests/unit_tests/libs suite stays green; ruff check and ruff format --check clean.

The NonExistentTimeError branch adjusted a non-existent (spring-forward)
wall-clock time by a hardcoded +1 hour, which assumes every DST gap is
exactly one hour. Real tzdata has gaps of other sizes:

- Gaps longer than one hour (e.g. Antarctica/Troll, +2h): adding one hour
  lands inside the gap, so the retry tz.localize(..., is_dst=None) raises
  NonExistentTimeError again -- uncaught, leaking a raw pytz exception out
  of a function documented to raise only ValueError.
- Gaps shorter than one hour (e.g. Australia/Lord_Howe, +30min): the
  result is shifted an hour instead of 30 minutes, so the returned UTC
  instant is wrong and silently drops data from analytics time ranges.

Resolve the non-existent time with tz.normalize(tz.localize(dt,
is_dst=False)) instead, which shifts forward by exactly the gap size for
any offset change and matches zoneinfo fold=0. Standard one-hour gaps are
unchanged (America/New_York 02:30 -> 07:30 UTC).

Replaces the mock-based nonexistent-time test (which asserted the old
call sequence) with real-timezone tests covering the standard, >1h, and
<1h gaps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6RtoHuxrDqTUo9Mw9h4Cv
@dosubot dosubot Bot added the size:XS This PR changes 0-9 lines, ignoring generated files. label Jul 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Pyrefly Type Coverage

Metric Base PR Delta
Type coverage 54.01% 54.01% -0.00%
Strict coverage 53.50% 53.50% -0.00%
Typed symbols 34,407 34,407 0
Untyped symbols 29,577 29,579 +2
Modules 3029 3029 0

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

Labels

size:XS This PR changes 0-9 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parse_time_range mishandles DST spring-forward gaps that aren't exactly one hour (crashes on >1h gaps, wrong instant on <1h gaps)

1 participant