Skip to content

Commit 1df3770

Browse files
tobixenclaude
andcommitted
fix: CalDAVSearcher.filter() uses resolved include_completed to avoid mutation
icalendar_searcher.Searcher.check_component() mutated include_completed from None to False (for todo searches) as a side-effect on the first search() call. Subsequent calls would then take the include_completed=False code path (with server clone + three-hacks approach) instead of the simpler else-branch used for include_completed=None. This caused inconsistent results when reusing a CalDAVSearcher object across multiple search() calls. The fix passes a copy with include_completed already resolved to filter_search_results(), leaving the original CalDAVSearcher unchanged. Also removes the post_filter=True workaround from testSearchForRecurringTask (it was no longer needed; the auto-setting logic handles it correctly). Fixes #650 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a2e7149 commit 1df3770

File tree

3 files changed

+101
-3
lines changed

3 files changed

+101
-3
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Changelogs prior to v2.0 is pruned, but was available in the v2.x releases
1212

1313
This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though for pre-releases PEP 440 takes precedence.
1414

15+
## [Unreleased]
16+
17+
### Fixed
18+
19+
* 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
20+
1521
## [3.1.0] - 2026-03-19
1622

1723
Highlights:

caldav/search.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,9 +807,17 @@ def filter(
807807
:param server_expand: Whether server was asked to expand recurrences
808808
:return: Filtered and/or split list of CalendarObjectResource objects
809809
"""
810+
## icalendar_searcher.Searcher.check_component() mutates include_completed from
811+
## None to the effective default (not self.todo) on first use, and also mutates
812+
## event/journal/todo flags from None to True/False. This breaks reuse of a
813+
## CalDAVSearcher instance across multiple search() calls (issue #650).
814+
## Use a copy with include_completed already resolved so the original is unchanged.
815+
searcher = self
816+
if self.include_completed is None:
817+
searcher = replace(self, include_completed=not self.todo if self.todo else True)
810818
return filter_search_results(
811819
objects=objects,
812-
searcher=self,
820+
searcher=searcher,
813821
post_filter=post_filter,
814822
split_expanded=split_expanded,
815823
server_expand=server_expand,

tests/test_caldav_unit.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,47 @@
159159
END:VTODO
160160
END:VCALENDAR"""
161161

162+
## Mock response with 2 pending and 2 completed todos, for testing include_completed behavior
163+
## https://github.com/python-caldav/caldav/issues/650
164+
mixed_todos_response = """<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
165+
<d:response>
166+
<d:href>/calendar/pending1.ics</d:href>
167+
<d:propstat>
168+
<d:prop>
169+
<cal:calendar-data>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</cal:calendar-data>
170+
</d:prop>
171+
<d:status>HTTP/1.1 200 OK</d:status>
172+
</d:propstat>
173+
</d:response>
174+
<d:response>
175+
<d:href>/calendar/pending2.ics</d:href>
176+
<d:propstat>
177+
<d:prop>
178+
<cal:calendar-data>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</cal:calendar-data>
179+
</d:prop>
180+
<d:status>HTTP/1.1 200 OK</d:status>
181+
</d:propstat>
182+
</d:response>
183+
<d:response>
184+
<d:href>/calendar/completed1.ics</d:href>
185+
<d:propstat>
186+
<d:prop>
187+
<cal:calendar-data>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</cal:calendar-data>
188+
</d:prop>
189+
<d:status>HTTP/1.1 200 OK</d:status>
190+
</d:propstat>
191+
</d:response>
192+
<d:response>
193+
<d:href>/calendar/completed2.ics</d:href>
194+
<d:propstat>
195+
<d:prop>
196+
<cal:calendar-data>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</cal:calendar-data>
197+
</d:prop>
198+
<d:status>HTTP/1.1 200 OK</d:status>
199+
</d:propstat>
200+
</d:response>
201+
</d:multistatus>"""
202+
162203
## from https://github.com/python-caldav/caldav/issues/495
163204
recurring_task_response = """<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
164205
<d:response>
@@ -342,11 +383,54 @@ def testSearchForRecurringTask(self):
342383
expand=True,
343384
start=datetime(2025, 1, 1),
344385
end=datetime(2025, 6, 5),
345-
## TODO - TEMP workaround for compatibility issues! post_filter should not be needed!
346-
post_filter=True,
347386
)
348387
assert len(mytasks) == 9
349388

389+
def testSearcherReuseConsistency_Issue650(self):
390+
"""
391+
Regression test for https://github.com/python-caldav/caldav/issues/650
392+
393+
A CalDAVSearcher with todo=True and default include_completed (None) should
394+
return consistent results across multiple search() calls. Previously, the
395+
icalendar_searcher library would mutate include_completed from None to False
396+
during the first search(), changing which code path subsequent calls took.
397+
"""
398+
client = MockedDAVClient(mixed_todos_response)
399+
calendar = Calendar(client, url="/calendar/issue650/")
400+
401+
## Test 1: searcher with include_completed=None (default) should give consistent results
402+
searcher = calendar.searcher(todo=True)
403+
assert searcher.include_completed is None, "include_completed should start as None"
404+
405+
first_result = searcher.search()
406+
assert len(first_result) == 2, f"Expected 2 pending todos, got {len(first_result)}"
407+
408+
## After calling search(), include_completed must not have been mutated
409+
assert searcher.include_completed is None, (
410+
"include_completed was mutated from None during search() - "
411+
"this breaks reuse of the searcher object (issue #650)"
412+
)
413+
414+
second_result = searcher.search()
415+
assert len(second_result) == 2, (
416+
f"Second search() call returned {len(second_result)} results, "
417+
f"expected 2 - inconsistent behavior after searcher reuse (issue #650)"
418+
)
419+
420+
## Test 2: explicit include_completed=False should also give correct results
421+
searcher_false = calendar.searcher(todo=True, include_completed=False)
422+
result_false = searcher_false.search()
423+
assert len(result_false) == 2, (
424+
f"include_completed=False returned {len(result_false)} results, expected 2"
425+
)
426+
427+
## Test 3: include_completed=True should return all todos
428+
searcher_true = calendar.searcher(todo=True, include_completed=True)
429+
result_true = searcher_true.search()
430+
assert len(result_true) == 4, (
431+
f"include_completed=True returned {len(result_true)} results, expected 4"
432+
)
433+
350434
def testLoadByMultiGet404(self):
351435
xml = """
352436
<D:multistatus xmlns:D="DAV:">

0 commit comments

Comments
 (0)