From 513fc89ad2007b3aa6c958dc41586d931f63f8cd Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 03:50:12 +0200 Subject: [PATCH 1/2] fix: advance a fixed-time task completed early For an RRULE with BY* parts, _next() keeps DTSTART and returns the first occurrence after the completion time. A task completed before its DTSTART thus got that same occurrence back and did not move, in both the "safe" and the "thisandfuture" mode. The occurrence is now looked for after DTSTART in that case, as case 1A of the docstring already said. Fixes https://github.com/python-caldav/caldav/issues/782 Prompt: Fix 782, 799, 598, 795, 805, 806 [python-caldav issues; this commit is https://github.com/python-caldav/caldav/issues/782]. The small ones can share one PR, the bigger ones should probably have separated PRs. (...) Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- caldav/calendarobjectresource.py | 4 +++ changes/782.fixed.md | 1 + tests/test_caldav_unit.py | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 changes/782.fixed.md diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index 71aefa2d..1f55b485 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -2652,6 +2652,10 @@ def _next(self, ts=None, i=None, dtstart=None, rrule=None, by=None, no_count=Tru dtstart = dtstart.astimezone(timezone.utc) if not ts: ts = dtstart + elif isinstance(ts, datetime) and isinstance(dtstart, datetime) and ts < dtstart: + ## Completed before DTSTART (case 1A): the occurrence at + ## DTSTART is the one done, so look past it (issue #782) + ts = dtstart ## Counting is taken care of other places if no_count and "COUNT" in rrule: rrule = rrule.copy() diff --git a/changes/782.fixed.md b/changes/782.fixed.md new file mode 100644 index 00000000..ad2be0f2 --- /dev/null +++ b/changes/782.fixed.md @@ -0,0 +1 @@ +`complete(handle_rrule=True)` on a recurring task with fixed times (`BY*` parts in the `RRULE`, e.g. `FREQ=YEARLY;BYMONTH=4,9`) did not advance the task when it was completed before its `DTSTART`: the next occurrence was looked for after the completion time, which found the occurrence just completed. It is now looked for after `DTSTART`. diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index e5a15fd9..dd2d257b 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -5972,6 +5972,49 @@ def test_build_safe_completed_count_one_skips_next(self) -> None: ) assert todo.icalendar_component["RRULE"]["COUNT"] == [1] + ## Issue https://github.com/python-caldav/caldav/issues/782: a + ## fixed-time task (BY* in the RRULE) done before its DTSTART + EARLY = todo6.replace("RRULE:FREQ=YEARLY", "RRULE:FREQ=YEARLY;BYMONTH=4,9") + + def test_next_fixed_time_completed_early(self) -> None: + todo = self._make_todo(self.EARLY) + ## done two days before the 1992-04-15 13:30 occurrence + early = datetime(1992, 4, 13, 10, tzinfo=timezone.utc) + assert todo._next(early) == datetime(1992, 9, 15, 13, 30, tzinfo=timezone.utc) + ## done after DTSTART: unchanged + late = datetime(1992, 4, 16, 10, tzinfo=timezone.utc) + assert todo._next(late) == datetime(1992, 9, 15, 13, 30, tzinfo=timezone.utc) + + def test_next_floating_fixed_time_completed_early(self) -> None: + """A floating DTSTART is taken as local time before the comparison.""" + todo = self._make_todo( + self.EARLY.replace("DTSTART:19920415T133000Z", "DTSTART:19920415T133000").replace( + "DUE:19920516T045959Z", "DUE:19920516T045959" + ) + ) + dtstart = datetime(1992, 4, 15, 13, 30).astimezone(timezone.utc) + assert todo._next(dtstart - timedelta(days=2)) == datetime(1992, 9, 15, 13, 30).astimezone( + timezone.utc + ) + + def test_safe_completion_completed_early_advances(self) -> None: + todo = self._make_todo(self.EARLY) + completed = todo._build_recurring_safe_completed( + datetime(1992, 4, 13, 10, tzinfo=timezone.utc) + ) + assert completed is not None + assert todo.icalendar_component["DTSTART"].dt == datetime( + 1992, 9, 15, 13, 30, tzinfo=timezone.utc + ) + + def test_thisandfuture_completed_early_advances(self) -> None: + todo = self._make_todo(self.EARLY) + todo._prepare_recurring_thisandfuture(datetime(1992, 4, 13, 10, tzinfo=timezone.utc)) + thisandfuture = todo.icalendar_instance.subcomponents[-1] + nxt = datetime(1992, 9, 15, 13, 30, tzinfo=timezone.utc) + assert thisandfuture["RECURRENCE-ID"].dt == nxt + assert thisandfuture["DTSTART"].dt == nxt + def test_safe_completion_issues_two_puts(self, monkeypatch: Any) -> None: """The standalone completed copy must not be PUT twice. From 136ebea4e2dca52ca5a9f28893b09e55e310c4c1 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 05:12:28 +0200 Subject: [PATCH 2/2] docs: TODO: all-day fixed-time task completion Completing a recurring all-day task with BY* parts raises TypeError in _next(); noted for later. Prompt: [fix issues found during the review process; this one filed for later] Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- docs/design/TODO.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/design/TODO.md b/docs/design/TODO.md index 7eba3721..34bb8358 100644 --- a/docs/design/TODO.md +++ b/docs/design/TODO.md @@ -4,6 +4,10 @@ `search()` decides the `!=` / `<>`, `!contains` and `def` operators client-side and leaves them out of the server query (https://github.com/python-caldav/caldav/issues/568). A caldav-server-tester probe for `negate-condition` support, and the compatibility_hints flag it would feed, would let `search()` send `!contains` as a negated `text-match` to the servers that honour it. +## Completing an all-day fixed-time recurring task + +`Todo._next()` in `caldav/calendarobjectresource.py` raises `TypeError` (naive vs aware) for a task with `DTSTART;VALUE=DATE` and `BY*` parts in its `RRULE`: dateutil turns the date into a naive datetime and compares it with the UTC completion timestamp, so `complete(handle_rrule=True)` fails for such tasks. Fix: convert a date DTSTART to an aware datetime (or the timestamp to a date) before both the comparison and `rrulestr()`, with a unit test. Found in the review of the fix for https://github.com/python-caldav/caldav/issues/782. + ## Calendar cleanup in integration tests For servers where calendars cannot be created, the test will take the first available calendar it finds and wipe it. That is not acceptable without the user explicitly configuring that this is OK.