Skip to content

Commit fd57fa8

Browse files
tobixenclaude
andcommitted
Unify sync and async search logic via CalDAVSearcher
This commit consolidates the search compatibility logic so that both sync Calendar.search() and AsyncCalendar.search() use the same CalDAVSearcher class for building queries and handling server quirks. Changes: - Added async_search() method to CalDAVSearcher (search.py) that mirrors the sync search() method but performs HTTP requests asynchronously - Simplified AsyncCalendar.search() to delegate to CalDAVSearcher.async_search() instead of duplicating the compatibility logic - Updated sync Calendar.search() to unconditionally delegate to async since both now use the same underlying CalDAVSearcher logic - Added comprehensive async integration tests (test_async_integration.py) that test search, events, todos, and principal operations This eliminates the duplication where pending todo searches had two separate code paths (one in sync, one in async). Now all search logic flows through CalDAVSearcher, ensuring consistent behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 86ff439 commit fd57fa8

4 files changed

Lines changed: 678 additions & 129 deletions

File tree

‎caldav/async_collection.py‎

Lines changed: 11 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -651,8 +651,8 @@ async def search(
651651
"""
652652
Search for calendar objects.
653653
654-
This async method delegates to CalDAVSearcher for query building and
655-
filtering, but handles the HTTP request asynchronously.
654+
This async method uses CalDAVSearcher.async_search() which shares all
655+
the compatibility logic with the sync version.
656656
657657
Args:
658658
xml: Raw XML query to send (overrides other filters)
@@ -715,102 +715,17 @@ async def search(
715715
if not xml and filters:
716716
xml = filters
717717

718-
# Set post_filter=True when searching for pending todos (same as sync version)
719-
# This ensures completed todos are filtered out on the client side
720-
if post_filter is None and my_searcher.todo and not my_searcher.include_completed:
721-
post_filter = True
722-
723-
# Build the XML query using CalDAVSearcher
724-
if not xml or (not isinstance(xml, str) and not xml.tag.endswith("calendar-query")):
725-
(xml, my_searcher.comp_class) = my_searcher.build_search_xml_query(
726-
server_expand, props=props, filters=xml, _hacks=_hacks
727-
)
728-
729-
# CalDAVSearcher uses sync classes, convert to async equivalents for result objects.
730-
# Important: Don't modify my_searcher.comp_class directly, as build_search_xml_query()
731-
# may be called again below and it only understands sync classes.
732-
from caldav.calendarobjectresource import (
733-
Event as SyncEvent,
734-
Journal as SyncJournal,
735-
Todo as SyncTodo,
718+
# Use CalDAVSearcher.async_search() which has all the compatibility logic
719+
return await my_searcher.async_search(
720+
self,
721+
server_expand=server_expand,
722+
split_expanded=split_expanded,
723+
props=props,
724+
xml=xml,
725+
post_filter=post_filter,
726+
_hacks=_hacks,
736727
)
737728

738-
sync_to_async = {
739-
SyncEvent: AsyncEvent,
740-
SyncTodo: AsyncTodo,
741-
SyncJournal: AsyncJournal,
742-
}
743-
async_comp_class = sync_to_async.get(my_searcher.comp_class, my_searcher.comp_class)
744-
745-
# Handle servers that require component type in search
746-
if not my_searcher.comp_class and not self.client.features.is_supported(
747-
"search.comp-type-optional"
748-
):
749-
if my_searcher.include_completed is None:
750-
my_searcher.include_completed = True
751-
# Search for each component type
752-
objects: list[AsyncCalendarObjectResource] = []
753-
for comp_class in (AsyncEvent, AsyncTodo, AsyncJournal):
754-
try:
755-
comp_xml = my_searcher.build_search_xml_query(
756-
server_expand, props=props, _hacks=_hacks
757-
)[0]
758-
_, comp_objects = await self._request_report_build_resultlist(
759-
comp_xml, comp_class, props
760-
)
761-
objects.extend(comp_objects)
762-
except error.ReportError:
763-
pass
764-
return my_searcher.sort(objects)
765-
766-
# Send the REPORT request
767-
try:
768-
response, objects = await self._request_report_build_resultlist(
769-
xml, async_comp_class, props
770-
)
771-
except error.ReportError:
772-
if self.client.features.backward_compatibility_mode and not my_searcher.comp_class:
773-
# Try searching with each component type
774-
objects = []
775-
for comp_class in (AsyncEvent, AsyncTodo, AsyncJournal):
776-
try:
777-
_, comp_objects = await self._request_report_build_resultlist(
778-
xml, comp_class, props
779-
)
780-
objects.extend(comp_objects)
781-
except error.ReportError:
782-
pass
783-
else:
784-
raise
785-
786-
# Load objects that need it
787-
loaded_objects = []
788-
for o in objects:
789-
try:
790-
await o.load(only_if_unloaded=True)
791-
loaded_objects.append(o)
792-
except Exception:
793-
log.error(
794-
"Server does not want to reveal details about the calendar object",
795-
exc_info=True,
796-
)
797-
objects = loaded_objects
798-
799-
# Filter out empty objects (Google quirk)
800-
objects = [o for o in objects if o.has_component()]
801-
802-
# Apply client-side filtering
803-
objects = my_searcher.filter(objects, post_filter, split_expanded, server_expand)
804-
805-
# Ensure objects are loaded
806-
for obj in objects:
807-
try:
808-
await obj.load(only_if_unloaded=True)
809-
except Exception:
810-
pass
811-
812-
return my_searcher.sort(objects)
813-
814729
async def events(self) -> list[AsyncEvent]:
815730
"""
816731
Get all events in the calendar.

‎caldav/collection.py‎

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,40 +1150,29 @@ def search(
11501150
* ``filters`` - other kind of filters (in lxml tree format)
11511151
11521152
"""
1153-
# Try async delegation for simple cases (non-todo searches or when
1154-
# include_completed=True). For pending todo searches, use the sync
1155-
# implementation which has complex logic for handling recurrences
1156-
# and server compatibility quirks.
1157-
# Note: When include_completed is not specified and todo=True, the
1158-
# CalDAVSearcher defaults include_completed to False (pending todos only).
1159-
is_todo_search = searchargs.get("todo", False)
1160-
include_completed = searchargs.get("include_completed")
1161-
# Default include_completed to False for todo searches, True otherwise
1162-
if include_completed is None:
1163-
include_completed = not is_todo_search
1164-
is_pending_todo_search = is_todo_search and not include_completed
1165-
1166-
if not is_pending_todo_search:
1167-
try:
1168-
1169-
async def _async_search(async_cal):
1170-
return await async_cal.search(
1171-
xml=xml,
1172-
server_expand=server_expand,
1173-
split_expanded=split_expanded,
1174-
sort_reverse=sort_reverse,
1175-
props=props,
1176-
filters=filters,
1177-
post_filter=post_filter,
1178-
_hacks=_hacks,
1179-
**searchargs,
1180-
)
1153+
# Try async delegation first - both sync and async now use the same
1154+
# CalDAVSearcher compatibility logic (sync uses search(), async uses
1155+
# async_search()), so delegation is safe for all search types.
1156+
try:
1157+
1158+
async def _async_search(async_cal):
1159+
return await async_cal.search(
1160+
xml=xml,
1161+
server_expand=server_expand,
1162+
split_expanded=split_expanded,
1163+
sort_reverse=sort_reverse,
1164+
props=props,
1165+
filters=filters,
1166+
post_filter=post_filter,
1167+
_hacks=_hacks,
1168+
**searchargs,
1169+
)
11811170

1182-
async_results = self._run_async_calendar(_async_search)
1183-
return [self._async_object_to_sync(obj) for obj in async_results]
1184-
except NotImplementedError:
1185-
# Fall back to sync implementation for mocked clients
1186-
pass
1171+
async_results = self._run_async_calendar(_async_search)
1172+
return [self._async_object_to_sync(obj) for obj in async_results]
1173+
except NotImplementedError:
1174+
# Fall back to sync implementation for mocked clients
1175+
pass
11871176

11881177
## Late import to avoid cyclic imports
11891178
from .search import CalDAVSearcher

0 commit comments

Comments
 (0)