diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc2372b..dd282426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ Changelogs prior to v2.0 is pruned, but was available in the v2.x releases This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence. +## [Unreleased] + +### Fixed + +* Reusing a `CalDAVSearcher` across multiple `search()` calls could yield inconsistent results: the first call would return only pending tasks (correct), but subsequent calls would change behaviour because `icalendar_searcher.Searcher.check_component()` mutated the `include_completed` field from `None` to `False` as a side-effect. Fixed by passing a copy with `include_completed` already resolved to `filter_search_results()`, leaving the original searcher object unchanged. Fixes https://github.com/python-caldav/caldav/issues/650 + ## [3.1.0] - 2026-03-19 Highlights: diff --git a/caldav/search.py b/caldav/search.py index b1a639e7..856df01e 100644 --- a/caldav/search.py +++ b/caldav/search.py @@ -807,9 +807,17 @@ def filter( :param server_expand: Whether server was asked to expand recurrences :return: Filtered and/or split list of CalendarObjectResource objects """ + ## icalendar_searcher.Searcher.check_component() mutates include_completed from + ## None to the effective default (not self.todo) on first use, and also mutates + ## event/journal/todo flags from None to True/False. This breaks reuse of a + ## CalDAVSearcher instance across multiple search() calls (issue #650). + ## Use a copy with include_completed already resolved so the original is unchanged. + searcher = self + if self.include_completed is None: + searcher = replace(self, include_completed=not self.todo if self.todo else True) return filter_search_results( objects=objects, - searcher=self, + searcher=searcher, post_filter=post_filter, split_expanded=split_expanded, server_expand=server_expand, diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 5777a73a..94aa4d38 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -159,6 +159,47 @@ END:VTODO END:VCALENDAR""" +## Mock response with 2 pending and 2 completed todos, for testing include_completed behavior +## https://github.com/python-caldav/caldav/issues/650 +mixed_todos_response = """ + + /calendar/pending1.ics + + + BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:pending1\r\nSUMMARY:Pending 1\r\nSTATUS:NEEDS-ACTION\r\nDTSTAMP:20250101T000000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n + + HTTP/1.1 200 OK + + + + /calendar/pending2.ics + + + BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:pending2\r\nSUMMARY:Pending 2\r\nDTSTAMP:20250101T000000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n + + HTTP/1.1 200 OK + + + + /calendar/completed1.ics + + + BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:completed1\r\nSUMMARY:Completed 1\r\nSTATUS:COMPLETED\r\nDTSTAMP:20250101T000000Z\r\nCOMPLETED:20250101T120000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n + + HTTP/1.1 200 OK + + + + /calendar/completed2.ics + + + BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:completed2\r\nSUMMARY:Completed 2\r\nSTATUS:COMPLETED\r\nDTSTAMP:20250101T000000Z\r\nCOMPLETED:20250101T120000Z\r\nEND:VTODO\r\nEND:VCALENDAR\r\n + + HTTP/1.1 200 OK + + +""" + ## from https://github.com/python-caldav/caldav/issues/495 recurring_task_response = """ @@ -342,11 +383,54 @@ def testSearchForRecurringTask(self): expand=True, start=datetime(2025, 1, 1), end=datetime(2025, 6, 5), - ## TODO - TEMP workaround for compatibility issues! post_filter should not be needed! - post_filter=True, ) assert len(mytasks) == 9 + def testSearcherReuseConsistency_Issue650(self): + """ + Regression test for https://github.com/python-caldav/caldav/issues/650 + + A CalDAVSearcher with todo=True and default include_completed (None) should + return consistent results across multiple search() calls. Previously, the + icalendar_searcher library would mutate include_completed from None to False + during the first search(), changing which code path subsequent calls took. + """ + client = MockedDAVClient(mixed_todos_response) + calendar = Calendar(client, url="/calendar/issue650/") + + ## Test 1: searcher with include_completed=None (default) should give consistent results + searcher = calendar.searcher(todo=True) + assert searcher.include_completed is None, "include_completed should start as None" + + first_result = searcher.search() + assert len(first_result) == 2, f"Expected 2 pending todos, got {len(first_result)}" + + ## After calling search(), include_completed must not have been mutated + assert searcher.include_completed is None, ( + "include_completed was mutated from None during search() - " + "this breaks reuse of the searcher object (issue #650)" + ) + + second_result = searcher.search() + assert len(second_result) == 2, ( + f"Second search() call returned {len(second_result)} results, " + f"expected 2 - inconsistent behavior after searcher reuse (issue #650)" + ) + + ## Test 2: explicit include_completed=False should also give correct results + searcher_false = calendar.searcher(todo=True, include_completed=False) + result_false = searcher_false.search() + assert len(result_false) == 2, ( + f"include_completed=False returned {len(result_false)} results, expected 2" + ) + + ## Test 3: include_completed=True should return all todos + searcher_true = calendar.searcher(todo=True, include_completed=True) + result_true = searcher_true.search() + assert len(result_true) == 4, ( + f"include_completed=True returned {len(result_true)} results, expected 4" + ) + def testLoadByMultiGet404(self): xml = """