Skip to content

fix(recur): apply BYHOUR, BYMINUTE, BYSECOND, BYYEARDAY and BYWEEKNO - #681

Merged
allenporter merged 2 commits into
allenporter:mainfrom
proscar87:fix-rrule-missing-byparts
Aug 16, 2026
Merged

fix(recur): apply BYHOUR, BYMINUTE, BYSECOND, BYYEARDAY and BYWEEKNO#681
allenporter merged 2 commits into
allenporter:mainfrom
proscar87:fix-rrule-missing-byparts

Conversation

@proscar87

Copy link
Copy Markdown
Contributor

Fixes #678.

as_rrule() passed nine keyword arguments to dateutil.rrule.rrule(). That constructor accepts byhour, byminute, bysecond, byyearday and byweekno too, so the engine could already compute them — they were simply never handed over, while being parsed, stored and re-serialised. An event kept the hour from DTSTART and the file round-tripped unchanged, which is why this looked like it worked.

Measured before and after, DTSTART:20250715T140000Z:

RRULE before after
FREQ=DAILY;COUNT=3;BYHOUR=9,17 15th 14:00, 16th 14:00, 17th 14:00 15th 17:00, 16th 09:00, 16th 17:00
FREQ=YEARLY;COUNT=2;BYYEARDAY=200 2025-07-15 2025-07-19, 2026-07-19

Three places needed the new keys, and missing any one of them fails differently: the model, the rule parser that splits comma-separated lists (without it, BYHOUR=9,17 arrives as the string '9,17' and validation fails), and _as_rrule_str.

Tests

tests/types/test_recur_byparts.py, 13 cases. Each part is asserted against dateutil.rrule given the same arguments rather than against a hand-written list — the expansion engine is the same one this library uses, so a hand-written expectation would only restate whatever the implementation produced.

Also pinned: the parts still round-trip to ics, a rule using none of them is byte-identical to before, and negative BYYEARDAY counts from the end of the year.

Two things you should look at rather than take on trust

A test asserted the bug. test_recur_extra_fields_preservation checked that BYYEARDAY and BYWEEKNO arrive as unparsed extras — a description of this defect, written when they weren't modelled. Left alone it would have failed; changed carelessly it would have stopped testing anything. It now asserts they parse, and that a genuinely unknown key (X-EXTRA) is still preserved as an extra, which is what the test was really for.

Snapshots are regenerated, because Recur's repr gained five fields. I checked that no occurrence actually changed: every dtstart in the updated snapshots is identical to the one it replaced — the diff is only the five empty lists appearing in the repr.

CI note

pre-commit run ty fails on ical/recur_adapter.py:84 with an unused-ignore warning. That is present on an untouched main — verified against a clean worktree of upstream/main — and is unrelated to this change, so I have not touched it. Every other hook passes. Suite: 1469 passed, 35 skipped.

🤖 Generated with Claude Code

…llenporter#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>
@@ -0,0 +1,144 @@
"""The BYxxx rule parts that were parsed but never applied.

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.

This belongs in test_recur.py. We add tests in the module they are testing, not arbitrarily named files that AI likes to generate.

…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>
@proscar87

Copy link
Copy Markdown
Contributor Author

Reviewing my own commit turned up a defect I had introduced, plus a valid rfc5545 value I would have broken. Both fixed in the second commit — flagging rather than force-pushing over it, because one of them is a behaviour change you may want decided differently.

Passing these parts through meant out-of-range values now reach dateutil, which raises during iteration:

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

Before this PR, BYHOUR=99 was silently ignored and nothing failed. With the first commit alone, it parsed cleanly and then took down the expansion — the same shape as the UNTIL defect in #676, which I'd just finished arguing against. I'd have shipped the bug I was fixing elsewhere.

Now validated on the field, which is what by_month_day and by_setpos already do for the same reason, and puts the error where the bad value is.

BYSECOND=60 is a valid rfc5545 value that dateutil rejects — the spec permits 60 for a leap second, dateutil insists on 0..59. My own field docstring said so while the code would have raised on it. It now folds onto 59 rather than failing a calendar over something the spec allows.

The behaviour change worth your opinion

A malformed BYHOUR=99 went from silently ignored to CalendarParseError. That is stricter than before this PR.

I chose it for consistency — BYMONTHDAY=99 already raises today, so modelling these five parts puts them in a family with an established rule. But the library is deliberately lenient elsewhere, and you may prefer dropping out-of-range values with a debug log instead, which would keep such files loading. Say the word and I'll change it; it is a few lines either way.

18 tests now. Five fail without the validators, and the rest of the PR's cases are unaffected. Suite 1474 passed, 35 skipped; all hooks green except the pre-existing ty warning on recur_adapter.py noted above.

🤖 Generated with Claude Code

@allenporter

Copy link
Copy Markdown
Owner

Thanks, proposed solution looks fine by me.

@allenporter
allenporter merged commit 2ed0811 into allenporter:main Aug 16, 2026
6 checks passed
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.

RRULE silently ignores BYHOUR, BYMINUTE, BYSECOND, BYYEARDAY and BYWEEKNO

2 participants