Skip to content

fix(recur): reject a naive UNTIL against a timezone-aware DTSTART - #676

Open
proscar87 wants to merge 3 commits into
allenporter:mainfrom
proscar87:fix-naive-until-timezone
Open

fix(recur): reject a naive UNTIL against a timezone-aware DTSTART#676
proscar87 wants to merge 3 commits into
allenporter:mainfrom
proscar87:fix-naive-until-timezone

Conversation

@proscar87

Copy link
Copy Markdown
Contributor

Part of #70 — the "verify daylight saving time in until and start is the same" item, which turned out to be a hole with a downstream consequence rather than a missing assertion.

The gap

_adjust_recurrence_date enforces most of rfc5545 §3.3.10 already. The timezone branch is:

if date_value.utcoffset():
    raise ValueError("DTSTART had UTC or local and UNTIL must be UTC")

utcoffset() is falsy for UTC — correctly allowed — but it is also None for a naive value. So DTSTART:20250715T140000Z with UNTIL=20250718T140000 validated cleanly, as did the same thing with a TZID reference.

What it costs

The calendar parses, and then fails when someone expands it:

ValueError: RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware

That comes out of the recurrence iterator, names neither the event nor the rule, and surfaces while a consumer is rendering a calendar rather than while loading it. For Home Assistant that's a broken calendar card rather than a config entry that fails with a reason.

The change

Caught during validation, next to the other rules from that section. Under dtstart_until_compat the value is read as UTC instead — a producer that omits the Z means UTC — which mirrors how the DATE/DATE-TIME mismatch a dozen lines above is already handled.

I reused the existing Google-scoped compat flag rather than adding one, since it covers exactly this DTSTART/UNTIL relationship in the same RFC section. Happy to split it into its own flag, or to make the coercion unconditional, if you'd rather — that's the one judgement call in here. Making it unconditional would turn every affected calendar from "fails somewhere" into "works", at the cost of assuming UTC for a producer that might have meant local time.

Checks

Six tests: both rejection cases, the compat coercion, and three that pin what must not change — a conforming calendar, a floating DTSTART with a floating UNTIL, and compat leaving a correct UNTIL alone.

Three of the six fail without the change. Full suite 1472 passed, 35 skipped. pre-commit run --all-files green across every hook.

I also re-parsed every .ics fixture in tests/ before and after: 19 fail either way, none newly. Several of those are invalid on purpose.

🤖 Generated with Claude Code

proscar87 and others added 2 commits August 11, 2026 20:59
…lenporter#70)

rfc5545 section 3.3.10 requires UNTIL in UTC when DTSTART carries a
timezone. `_adjust_recurrence_date` checked `utcoffset()`, which is falsy
for a naive value as well as for UTC, so a naive UNTIL passed validation.

It then failed on iteration instead, from inside the recurrence iterator:

    ValueError: RRULE UNTIL values must be specified in UTC when DTSTART
    is timezone-aware

That surfaces while a consumer is rendering a calendar rather than while
loading it, and names neither the event nor the rule.

It is now caught during validation, alongside the other rules from that
section. Under dtstart_until_compat the value is read as UTC instead --
producers that omit the Z mean UTC -- which mirrors how the DATE/DATE-TIME
mismatch a few lines up is already handled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`validate_until_dtstart` is shared by VEVENT, VTODO and VJOURNAL, and the
first pass only exercised events. Neither of the other two had any UNTIL
coverage at all before this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@proscar87

Copy link
Copy Markdown
Contributor Author

Second review pass. One real gap: validate_until_dtstart is shared by VEVENT, VTODO and VJOURNAL, and I had only exercised events.

I confirmed the fix behaves identically on all three — a VTODO and a VJOURNAL with a naive UNTIL against an aware DTSTART are both rejected, and compat coerces both. Added VTODO tests; neither VTODO nor VJOURNAL had any UNTIL coverage at all before this, which is probably why the gap in utcoffset() survived so long.

8 tests now, 5 of which fail without the change. Suite 1456 passed, all hooks green.

…nporter#70)

The "recurrence datetime field encoded properly" item from the same issue,
and the same failure mode as the UNTIL one already in this branch.

`_as_datetime` gives a DATE value dtstart's tzinfo when converting it, but
a DATE-TIME that disagreed about awareness was returned untouched. The
recurrence iterator then cannot compare it against dtstart, so the whole
expansion raises RecurrenceError -- the exclusion is not merely ignored,
the event stops expanding at all.

Measured, DTSTART:20250715T140000Z with RRULE COUNT=3:

    EXDATE:20250716T140000     -> RecurrenceError
    RDATE:20250720T140000      -> RecurrenceError
    EXDATE:20250716T140000Z    -> 15th and 17th, correct

Aligning it means reading a naive value as dtstart's zone, which is the
wall time the producer wrote, and is what the DATE branch four lines above
already assumes. The mirror case (aware value, naive dtstart) and a value
in a different named zone both already worked and are pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@proscar87

Copy link
Copy Markdown
Contributor Author

Added the second checkbox from #70 — "recurrence datetime field encoded properly" — since it turned out to be the same defect as the UNTIL one, in the same function's neighbourhood.

_as_datetime gives a DATE value dtstart's tzinfo when converting it, but a DATE-TIME that disagreed about awareness was returned untouched. The recurrence iterator then can't compare it against dtstart, so the whole expansion raises rather than the date being ignored — the event stops producing occurrences entirely.

Measured against DTSTART:20250715T140000Z with RRULE:FREQ=DAILY;COUNT=3:

before after
EXDATE:20250716T140000 RecurrenceError 15th, 17th
RDATE:20250720T140000 RecurrenceError 15th, 16th, 17th, 20th
EXDATE:20250716T140000Z 15th, 17th unchanged
EXDATE;TZID=America/Denver:20250716T080000 15th, 17th unchanged

Aligning a naive value to dtstart's zone is what the DATE branch four lines above already assumes, so this isn't a new policy — it's extending an existing one to the case it skipped. The mirror (aware value, naive dtstart) and the different-named-zone case both already worked and are now pinned so they stay that way.

14 tests in the file, 6 for this. Three fail without the change; the three that pin unchanged behaviour pass either way, which is what they're for.

Full suite 1462 passed, 35 skipped; pre-commit run --all-files green.

A note on my own verification, since it nearly fooled me. My first negative check cut the wrong lines out of _as_datetime" return date_value" is a substring of " return date_value.replace(...)", so the edit left the alignment applied unconditionally instead of removing it. That made a different test fail and looked, at a glance, like the tests catching the change. Redone properly, the three that should fail do.

That leaves one unticked box on #70: "recurrence timezone formattings". I haven't found a failing case for it yet and would rather not pad this PR with a change I can't demonstrate is needed.

🤖 Generated with Claude Code

@allenporter allenporter left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for making these improvements. May take me a few passes to review/process since datetime bugs always take awhile for me to internalize.

@@ -0,0 +1,228 @@
"""A naive UNTIL against a timezone-aware DTSTART (#70).

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Please add these tests to a test module name of the module under test. (e.g. test_component.py and/or wherever calendar_stream is tested, or next to the test compat cases for compat mode fixes.

"""


def _until(calendar) -> datetime.datetime | datetime.date:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Can you type the calendar argument please

proscar87 added a commit to proscar87/ical that referenced this pull request Aug 16, 2026
…pansion

Found reviewing the previous commit. Passing these parts through to
dateutil means an out-of-range value now reaches it, and dateutil raises
during iteration:

    BYHOUR=99  -> ValueError: hour must be in 0..23, not 99

That is the same shape as the UNTIL defect fixed in allenporter#676: parses cleanly,
then takes down the expansion far from the cause. Before this PR the value
was silently ignored, so nothing failed at all.

Validating on the field matches what by_month_day and by_setpos already
do for exactly this reason, and puts the error where the bad value is.

BYSECOND=60 is a separate case: rfc5545 permits it for a leap second and
dateutil rejects it, so it folds onto 59 rather than failing a calendar
over a value the spec allows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
allenporter pushed a commit that referenced this pull request Aug 16, 2026
…681)

* fix(recur): apply BYHOUR, BYMINUTE, BYSECOND, BYYEARDAY and BYWEEKNO (#678)

rfc5545 section 3.3.10 defines fourteen recurrence rule parts. Five were
parsed, stored and re-serialised without ever reaching the expansion, so
an event with BYHOUR=9,17 fired at whatever hour DTSTART happened to hold
and the file round-tripped unchanged. Silently wrong rather than loudly
absent.

`as_rrule()` passed nine keyword arguments to `dateutil.rrule.rrule()`;
that constructor accepts these five as well, so the engine was already
capable of computing them. Three places needed the new keys: the model,
the rule parser that splits comma lists, and the serialiser.

Tests assert against `dateutil` given the same arguments rather than
against hand-written expectations, since a hand-written list would only
restate whatever the implementation produced.

`test_recur_extra_fields_preservation` asserted that BYYEARDAY and
BYWEEKNO arrive as unparsed extras, which was a description of this bug.
It now checks that they are parsed, and that a genuinely unknown key is
still preserved as an extra.

Snapshots are regenerated because Recur's repr gained five fields. No
occurrence changed: every dtstart in the updated snapshots is identical
to the one it replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(recur): reject out-of-range BYxxx values instead of failing on expansion

Found reviewing the previous commit. Passing these parts through to
dateutil means an out-of-range value now reaches it, and dateutil raises
during iteration:

    BYHOUR=99  -> ValueError: hour must be in 0..23, not 99

That is the same shape as the UNTIL defect fixed in #676: parses cleanly,
then takes down the expansion far from the cause. Before this PR the value
was silently ignored, so nothing failed at all.

Validating on the field matches what by_month_day and by_setpos already
do for exactly this reason, and puts the error where the bad value is.

BYSECOND=60 is a separate case: rfc5545 permits it for a leap second and
dateutil rejects it, so it folds onto 59 rather than failing a calendar
over a value the spec allows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: proscar87 <proscar87@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants