From 5027643e4bb3eaa2b37461536eed3159ba2497bc Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 1 Feb 2026 18:19:39 +0100 Subject: [PATCH 1/2] Refactor search.py: extract _clone_without_filters helper method Consolidates duplicated code pattern for cloning searcher with removed property filters. This pattern was repeated 3 times each in search() and async_search() methods for: - Category filter workaround - Substring search workaround - Combined search workaround The new _clone_without_filters() method handles: - Removing specific filters by key - Clearing all filters - Updating _explicit_operators set Addresses #580 Co-Authored-By: Claude Opus 4.5 --- caldav/search.py | 84 ++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/caldav/search.py b/caldav/search.py index 45ad71e7..9fb8fb07 100644 --- a/caldav/search.py +++ b/caldav/search.py @@ -27,6 +27,15 @@ _collation_to_caldav = collation_to_caldav +# Property filter attribute names used for cloning searchers with modified filters +_PROPERTY_FILTER_ATTRS = ( + "_property_filters", + "_property_operator", + "_property_locale", + "_property_collation", +) + + @dataclass class CalDAVSearcher(Searcher): """The baseclass (which is generic, and not CalDAV-specific) @@ -150,6 +159,36 @@ def add_property_filter( locale=locale, ) + def _clone_without_filters( + self, + filters_to_remove: list[str] | None = None, + clear_all_filters: bool = False, + ) -> "CalDAVSearcher": + """Create a clone of this searcher with specified property filters removed. + + This is used for compatibility workarounds where we need to remove certain + filters from the server query and apply them client-side instead. + + :param filters_to_remove: List of property filter keys to remove (e.g., ["categories", "category"]) + :param clear_all_filters: If True, remove all property filters + :return: Cloned searcher with filters removed + """ + replacements = {} + for attr in _PROPERTY_FILTER_ATTRS: + if clear_all_filters: + replacements[attr] = {} + elif filters_to_remove: + replacements[attr] = getattr(self, attr).copy() + for key in filters_to_remove: + replacements[attr].pop(key, None) + else: + continue + clone = replace(self, **replacements) + # Update _explicit_operators if we removed specific filters + if filters_to_remove and not clear_all_filters: + clone._explicit_operators = self._explicit_operators - set(filters_to_remove) + return clone + def _search_with_comptypes( self, calendar: Calendar, @@ -265,19 +304,12 @@ def search( ## special compatbility-case for servers that does not ## support category search properly - things = ("filters", "operator", "locale", "collation") - things = [f"_property_{thing}" for thing in things] if ( not calendar.client.features.is_supported("search.text.category") and ("categories" in self._property_filters or "category" in self._property_filters) and post_filter is not False ): - replacements = {} - for thing in things: - replacements[thing] = getattr(self, thing).copy() - replacements[thing].pop("categories", None) - replacements[thing].pop("category", None) - clone = replace(self, **replacements) + clone = self._clone_without_filters(["categories", "category"]) objects = clone.search(calendar, server_expand, split_expanded, props, xml) return self.filter(objects, post_filter, split_expanded, server_expand) @@ -296,14 +328,7 @@ def search( if explicit_contains: # Remove explicit substring filters from server query, # will be applied client-side instead - replacements = {} - for thing in things: - replacements[thing] = getattr(self, thing).copy() - for prop in explicit_contains: - replacements[thing].pop(prop, None) - # Also need to preserve the _explicit_operators set but remove these properties - clone = replace(self, **replacements) - clone._explicit_operators = self._explicit_operators - set(explicit_contains) + clone = self._clone_without_filters(explicit_contains) objects = clone.search(calendar, server_expand, split_expanded, props, xml) return self.filter( objects, @@ -317,10 +342,7 @@ def search( if not calendar.client.features.is_supported("search.combined-is-logical-and"): if self.start or self.end: if self._property_filters: - replacements = {} - for thing in things: - replacements[thing] = {} - clone = replace(self, **replacements) + clone = self._clone_without_filters(clear_all_filters=True) objects = clone.search(calendar, server_expand, split_expanded, props, xml) return self.filter(objects, post_filter, split_expanded, server_expand) @@ -607,19 +629,12 @@ async def async_search( ## special compatibility-case for servers that does not ## support category search properly - things = ("filters", "operator", "locale", "collation") - things = [f"_property_{thing}" for thing in things] if ( not calendar.client.features.is_supported("search.text.category") and ("categories" in self._property_filters or "category" in self._property_filters) and post_filter is not False ): - replacements = {} - for thing in things: - replacements[thing] = getattr(self, thing).copy() - replacements[thing].pop("categories", None) - replacements[thing].pop("category", None) - clone = replace(self, **replacements) + clone = self._clone_without_filters(["categories", "category"]) objects = await clone.async_search(calendar, server_expand, split_expanded, props, xml) return self.filter(objects, post_filter, split_expanded, server_expand) @@ -634,13 +649,7 @@ async def async_search( if prop in self._explicit_operators and self._property_operator[prop] == "contains" ] if explicit_contains: - replacements = {} - for thing in things: - replacements[thing] = getattr(self, thing).copy() - for prop in explicit_contains: - replacements[thing].pop(prop, None) - clone = replace(self, **replacements) - clone._explicit_operators = self._explicit_operators - set(explicit_contains) + clone = self._clone_without_filters(explicit_contains) objects = await clone.async_search( calendar, server_expand, split_expanded, props, xml ) @@ -655,10 +664,7 @@ async def async_search( if not calendar.client.features.is_supported("search.combined-is-logical-and"): if self.start or self.end: if self._property_filters: - replacements = {} - for thing in things: - replacements[thing] = {} - clone = replace(self, **replacements) + clone = self._clone_without_filters(clear_all_filters=True) objects = await clone.async_search( calendar, server_expand, split_expanded, props, xml ) From 1392dca030937af15b2b4d27a1076ff72ddcaf2f Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 1 Feb 2026 18:42:31 +0100 Subject: [PATCH 2/2] Unify sync and async search into single code path Refactor search.py to eliminate duplication between search() and async_search() by using a generator-based implementation pattern: - Add SearchAction enum for action types (RECURSIVE_SEARCH, REQUEST_REPORT, etc.) - Create _search_impl() generator containing all search logic - Generator yields (action, data) tuples for I/O operations - search() and async_search() are now thin wrappers that execute actions - Reduces ~600 lines of duplicated logic to ~200 lines of shared implementation The generator pattern allows: - Single source of truth for search logic and compatibility workarounds - sync wrapper executes actions directly - async wrapper awaits async operations - Easy to maintain and extend Addresses #580 Co-Authored-By: Claude Opus 4.5 --- caldav/search.py | 710 ++++++++++++++++++----------------------------- 1 file changed, 272 insertions(+), 438 deletions(-) diff --git a/caldav/search.py b/caldav/search.py index 9fb8fb07..daae2882 100644 --- a/caldav/search.py +++ b/caldav/search.py @@ -1,5 +1,7 @@ +import inspect import logging from dataclasses import dataclass, field, replace +from enum import Enum, auto from typing import TYPE_CHECKING, Any, Optional from icalendar.prop import TypesFactory @@ -36,6 +38,16 @@ ) +class SearchAction(Enum): + """Actions yielded by the search generator to be executed by sync/async wrappers.""" + + RECURSIVE_SEARCH = auto() # (clone, calendar, args) -> do recursive search + SEARCH_WITH_COMPTYPES = auto() # (args) -> search with all comp types + REQUEST_REPORT = auto() # (xml, comp_class, props) -> make CalDAV request + LOAD_OBJECT = auto() # (obj) -> load object data + RETURN = auto() # (result) -> return this value + + @dataclass class CalDAVSearcher(Searcher): """The baseclass (which is generic, and not CalDAV-specific) @@ -189,80 +201,23 @@ def _clone_without_filters( clone._explicit_operators = self._explicit_operators - set(filters_to_remove) return clone - def _search_with_comptypes( + def _search_impl( self, calendar: Calendar, - server_expand: bool = False, - split_expanded: bool = True, - props: list[cdav.CalendarData] | None = None, - xml: str = None, - _hacks: str = None, - post_filter: bool = None, - ) -> list[CalendarObjectResource]: - """ - Internal method - does three searches, one for each comp class (event, journal, todo). - """ - if xml and (isinstance(xml, str) or "calendar-query" in xml.tag): - raise NotImplementedError( - "full xml given, and it has to be patched to include comp_type" - ) - objects = [] - - assert self.event is None and self.todo is None and self.journal is None - - for comp_class in (Event, Todo, Journal): - clone = replace(self) - clone.comp_class = comp_class - objects += clone.search( - calendar, server_expand, split_expanded, props, xml, post_filter, _hacks - ) - return self.sort(objects) - - ## TODO: refactor, split more logic out in smaller methods - def search( - self, - calendar: Calendar = None, - server_expand: bool = False, - split_expanded: bool = True, - props: list[cdav.CalendarData] | None = None, - xml: str = None, - post_filter=None, - _hacks: str = None, - ) -> list[CalendarObjectResource]: - """Do the search on a CalDAV calendar. - - Only CalDAV-specific parameters goes to this method. Those - parameters are pretty obscure - mostly for power users and - internal usage. Unless you have some very special needs, the - recommendation is to not pass anything. - - :param calendar: Calendar to be searched (optional if searcher was created - from a calendar via ``calendar.searcher()``) - :param server_expand: Ask the CalDAV server to expand recurrences - :param split_expanded: Don't collect a recurrence set in one ical calendar - :param props: CalDAV properties to send in the query - :param xml: XML query to be sent to the server (string or elements) - :param post_filter: Do client-side filtering after querying the server - :param _hacks: Please don't ask! - - Make sure not to confuse he CalDAV properties with iCalendar properties. - - If ``xml`` is given, any other filtering will not be sent to the server. - They may still be applied through client-side filtering. (TODO: work in progress) - - ``post_filter`` takes three values, ``True`` will always - filter the results, ``False`` will never filter the results, - and the default ``None`` will cause automagics to happen (not - implemented yet). Or perhaps I'll just set it to True as - default. TODO - make a decision here - - In the CalDAV protocol, a VCALENDAR object returned from the - server may contain only one event/task/journal - but if the - object is recurrent, it may contain several recurrences. - ``split_expanded`` will split the recurrences into several - objects. If you don't know what you're doing, then leave this - flag on. - + server_expand: bool, + split_expanded: bool, + props: list[cdav.CalendarData] | None, + xml: str | None, + post_filter: bool | None, + _hacks: str | None, + ): + """Core search implementation as a generator yielding actions. + + This generator contains all the search logic and yields (action, data) tuples + that the caller (sync or async) executes. Results are sent back via .send(). + + Yields: + Tuples of (SearchAction, data) where data depends on action type """ if calendar is None: calendar = self._calendar @@ -271,8 +226,8 @@ def search( "No calendar provided. Either pass a calendar to search() or " "create the searcher via calendar.searcher()" ) + ## Handle servers with broken component-type filtering (e.g., Bedework) - ## Such servers may misclassify component types in responses comp_type_support = calendar.client.features.is_supported("search.comp-type", str) if ( (self.comp_class or self.todo or self.event or self.journal) @@ -310,32 +265,42 @@ def search( and post_filter is not False ): clone = self._clone_without_filters(["categories", "category"]) - objects = clone.search(calendar, server_expand, split_expanded, props, xml) - return self.filter(objects, post_filter, split_expanded, server_expand) + objects = yield ( + SearchAction.RECURSIVE_SEARCH, + (clone, calendar, server_expand, split_expanded, props, xml, None, None), + ) + yield ( + SearchAction.RETURN, + self.filter(objects, post_filter, split_expanded, server_expand), + ) + return ## special compatibility-case for servers that do not support substring search - ## Only applies when user explicitly requested substring search with operator="contains" if ( not calendar.client.features.is_supported("search.text.substring") and post_filter is not False ): - # Check if any property has explicitly specified operator="contains" explicit_contains = [ prop for prop in self._property_operator if prop in self._explicit_operators and self._property_operator[prop] == "contains" ] if explicit_contains: - # Remove explicit substring filters from server query, - # will be applied client-side instead clone = self._clone_without_filters(explicit_contains) - objects = clone.search(calendar, server_expand, split_expanded, props, xml) - return self.filter( - objects, - post_filter=True, - split_expanded=split_expanded, - server_expand=server_expand, + objects = yield ( + SearchAction.RECURSIVE_SEARCH, + (clone, calendar, server_expand, split_expanded, props, xml, None, None), + ) + yield ( + SearchAction.RETURN, + self.filter( + objects, + post_filter=True, + split_expanded=split_expanded, + server_expand=server_expand, + ), ) + return ## special compatibility-case for servers that does not ## support combined searches very well @@ -343,45 +308,50 @@ def search( if self.start or self.end: if self._property_filters: clone = self._clone_without_filters(clear_all_filters=True) - objects = clone.search(calendar, server_expand, split_expanded, props, xml) - return self.filter(objects, post_filter, split_expanded, server_expand) - - ## special compatibility-case when searching for pending todos + objects = yield ( + SearchAction.RECURSIVE_SEARCH, + (clone, calendar, server_expand, split_expanded, props, xml, None, None), + ) + yield ( + SearchAction.RETURN, + self.filter(objects, post_filter, split_expanded, server_expand), + ) + return + + ## There are two ways to get the pending tasks - we can + ## ask the server to filter them out, or we can do it + ## client side. + + ## If the server does not support combined searches, then it's + ## safest to do it client-side. + + ## There is a special case (observed with radicale as of + ## 2025-11) where future recurrences of a task does not + ## match when doing a server-side filtering, so for this + ## case we also do client-side filtering (but the + ## "feature" + ## search.recurrences.includes-implicit.todo.pending will + ## not be supported if the feature + ## "search.recurrences.includes-implicit.todo" is not + ## supported ... hence the weird or below) + + ## To be completely sure to get all pending tasks, for all + ## server implementations and for all valid icalendar + ## objects, we send three different searches to the + ## server. This is probably bloated, and may in many + ## cases be more expensive than to ask for all tasks. At + ## the other hand, for a well-used and well-handled old + ## todo-list, there may be a small set of pending tasks + ## and heaps of done tasks. + + ## TODO: consider if not ignore_completed3 is sufficient, + ## then the recursive part of the query here is moot, and + ## we wouldn't waste so much time on repeated queries if self.todo and self.include_completed is False: - ## There are two ways to get the pending tasks - we can - ## ask the server to filter them out, or we can do it - ## client side. - - ## If the server does not support combined searches, then it's - ## safest to do it client-side. - - ## There is a special case (observed with radicale as of - ## 2025-11) where future recurrences of a task does not - ## match when doing a server-side filtering, so for this - ## case we also do client-side filtering (but the - ## "feature" - ## search.recurrences.includes-implicit.todo.pending will - ## not be supported if the feature - ## "search.recurrences.includes-implicit.todo" is not - ## supported ... hence the weird or below) - - ## To be completely sure to get all pending tasks, for all - ## server implementations and for all valid icalendar - ## objects, we send three different searches to the - ## server. This is probably bloated, and may in many - ## cases be more expensive than to ask for all tasks. At - ## the other hand, for a well-used and well-handled old - ## todo-list, there may be a small set of pending tasks - ## and heaps of done tasks. - - ## TODO: consider if not ignore_completed3 is sufficient, - ## then the recursive part of the query here is moot, and - ## we wouldn't waste so much time on repeated queries clone = replace(self, include_completed=True) clone.include_completed = True - ## No point with expanding in the subqueries - the expand logic will be handled - ## further down. We leave server_expand as it is, though. clone.expand = False + if ( calendar.client.features.is_supported("search.text") and calendar.client.features.is_supported("search.combined-is-logical-and") @@ -395,32 +365,19 @@ def search( ) ): matches = [] - for hacks in ( - "ignore_completed1", - "ignore_completed2", - "ignore_completed3", - ): - ## The algorithm below does not handle recurrence split gently - matches.extend( - clone.search( - calendar, - server_expand, - split_expanded=False, - props=props, - xml=xml, - _hacks=hacks, - ) + for hacks in ("ignore_completed1", "ignore_completed2", "ignore_completed3"): + result = yield ( + SearchAction.RECURSIVE_SEARCH, + (clone, calendar, server_expand, False, props, xml, None, hacks), ) + matches.extend(result) else: - ## The algorithm below does not handle recurrence split gently - matches = clone.search( - calendar, - server_expand, - split_expanded=False, - props=props, - xml=xml, - _hacks=_hacks, + matches = yield ( + SearchAction.RECURSIVE_SEARCH, + (clone, calendar, server_expand, False, props, xml, None, _hacks), ) + + # Deduplicate by URL objects = [] match_set = set() for item in matches: @@ -430,8 +387,6 @@ def search( else: orig_xml = xml - ## Now the xml variable may be either a full query or a filter - ## and it may be either a string or an object. if not xml or (not isinstance(xml, str) and not xml.tag.endswith("calendar-query")): (xml, self.comp_class) = self.build_search_xml_query( server_expand, props=props, filters=xml, _hacks=_hacks @@ -443,121 +398,177 @@ def search( if self.include_completed is None: self.include_completed = True - return self._search_with_comptypes( - calendar, - server_expand, - split_expanded, - props, - orig_xml, - post_filter, - _hacks, + result = yield ( + SearchAction.SEARCH_WITH_COMPTYPES, + (calendar, server_expand, split_expanded, props, orig_xml, _hacks, post_filter), ) + yield (SearchAction.RETURN, result) + return try: - (response, objects) = calendar._request_report_build_resultlist( - xml, self.comp_class, props=props + response, objects = yield ( + SearchAction.REQUEST_REPORT, + (calendar, xml, self.comp_class, props), ) - except error.ReportError as err: - ## This is only for backward compatibility. - ## Partial fix https://github.com/python-caldav/caldav/issues/401 if ( calendar.client.features.backward_compatibility_mode and not self.comp_class and "400" not in err.reason ): - return self._search_with_comptypes( - calendar, - server_expand, - split_expanded, - props, - orig_xml, - post_filter, - _hacks, + result = yield ( + SearchAction.SEARCH_WITH_COMPTYPES, + ( + calendar, + server_expand, + split_expanded, + props, + orig_xml, + _hacks, + post_filter, + ), ) + yield (SearchAction.RETURN, result) + return raise - ## Some things, like `calendar.get_object_by_uid`, should always work, no matter if `davclient.compatibility_hints` is correctly configured or not if not objects and not self.comp_class and _hacks == "insist": - return self._search_with_comptypes( - calendar, - server_expand, - split_expanded, - props, - orig_xml, - post_filter, - _hacks, + result = yield ( + SearchAction.SEARCH_WITH_COMPTYPES, + (calendar, server_expand, split_expanded, props, orig_xml, _hacks, post_filter), ) + yield (SearchAction.RETURN, result) + return + # Post-process: load objects obj2 = [] - for o in objects: - ## This would not be needed if the servers would follow the standard ... - ## TODO: use calendar.calendar_multiget - see https://github.com/python-caldav/caldav/issues/487 try: - o.load(only_if_unloaded=True) + yield (SearchAction.LOAD_OBJECT, o) obj2.append(o) - except: + except Exception: logging.error( "Server does not want to reveal details about the calendar object", exc_info=True, ) - pass objects = obj2 - ## Google sometimes returns empty objects + # Google sometimes returns empty objects objects = [o for o in objects if o.has_component()] objects = self.filter(objects, post_filter, split_expanded, server_expand) - ## partial workaround for https://github.com/python-caldav/caldav/issues/201 + # Partial workaround for https://github.com/python-caldav/caldav/issues/201 for obj in objects: try: - obj.load(only_if_unloaded=True) - except: + yield (SearchAction.LOAD_OBJECT, obj) + except Exception: pass - return self.sort(objects) + yield (SearchAction.RETURN, self.sort(objects)) - async def _async_search_with_comptypes( + def search( self, - calendar: "AsyncCalendar", + calendar: Calendar = None, server_expand: bool = False, split_expanded: bool = True, props: list[cdav.CalendarData] | None = None, xml: str = None, + post_filter=None, _hacks: str = None, - post_filter: bool = None, - ) -> list["AsyncCalendarObjectResource"]: - """ - Internal async method - does three searches, one for each comp class. + ) -> list[CalendarObjectResource]: + """Do the search on a CalDAV calendar. + + Only CalDAV-specific parameters goes to this method. Those + parameters are pretty obscure - mostly for power users and + internal usage. Unless you have some very special needs, the + recommendation is to not pass anything. + + :param calendar: Calendar to be searched (optional if searcher was created + from a calendar via ``calendar.searcher()``) + :param server_expand: Ask the CalDAV server to expand recurrences + :param split_expanded: Don't collect a recurrence set in one ical calendar + :param props: CalDAV properties to send in the query + :param xml: XML query to be sent to the server (string or elements) + :param post_filter: Do client-side filtering after querying the server + :param _hacks: Please don't ask! + + Make sure not to confuse he CalDAV properties with iCalendar properties. + + If ``xml`` is given, any other filtering will not be sent to the server. + They may still be applied through client-side filtering. (TODO: work in progress) + + ``post_filter`` takes three values, ``True`` will always + filter the results, ``False`` will never filter the results, + and the default ``None`` will cause automagics to happen (not + implemented yet). Or perhaps I'll just set it to True as + default. TODO - make a decision here + + In the CalDAV protocol, a VCALENDAR object returned from the + server may contain only one event/task/journal - but if the + object is recurrent, it may contain several recurrences. + ``split_expanded`` will split the recurrences into several + objects. If you don't know what you're doing, then leave this + flag on. + """ - # Import unified types at runtime to avoid circular imports - # These work with both sync and async clients - from .calendarobjectresource import ( - Event as AsyncEvent, - ) - from .calendarobjectresource import ( - Journal as AsyncJournal, - ) - from .calendarobjectresource import ( - Todo as AsyncTodo, + gen = self._search_impl( + calendar, server_expand, split_expanded, props, xml, post_filter, _hacks ) + result = None + try: + action, data = gen.send(result) + except StopIteration: + return [] + + while True: + try: + if action == SearchAction.RECURSIVE_SEARCH: + clone, cal, srv_exp, spl_exp, prp, xm, pf, hk = data + result = clone.search(cal, srv_exp, spl_exp, prp, xm, pf, hk) + elif action == SearchAction.SEARCH_WITH_COMPTYPES: + cal, srv_exp, spl_exp, prp, xm, hk, pf = data + result = self._search_with_comptypes(cal, srv_exp, spl_exp, prp, xm, hk, pf) + elif action == SearchAction.REQUEST_REPORT: + cal, xm, comp_cls, prp = data + result = cal._request_report_build_resultlist(xm, comp_cls, props=prp) + elif action == SearchAction.LOAD_OBJECT: + data.load(only_if_unloaded=True) + result = None + elif action == SearchAction.RETURN: + return data + + action, data = gen.send(result) + except StopIteration: + return [] + + def _search_with_comptypes( + self, + calendar: Calendar, + server_expand: bool = False, + split_expanded: bool = True, + props: list[cdav.CalendarData] | None = None, + xml: str = None, + _hacks: str = None, + post_filter: bool = None, + ) -> list[CalendarObjectResource]: + """ + Internal method - does three searches, one for each comp class (event, journal, todo). + """ if xml and (isinstance(xml, str) or "calendar-query" in xml.tag): raise NotImplementedError( "full xml given, and it has to be patched to include comp_type" ) - objects: list[AsyncCalendarObjectResource] = [] + objects = [] assert self.event is None and self.todo is None and self.journal is None - for comp_class in (AsyncEvent, AsyncTodo, AsyncJournal): + for comp_class in (Event, Todo, Journal): clone = replace(self) clone.comp_class = comp_class - results = await clone.async_search( + objects += clone.search( calendar, server_expand, split_expanded, props, xml, post_filter, _hacks ) - objects.extend(results) return self.sort(objects) async def async_search( @@ -577,246 +588,69 @@ async def async_search( See the sync search() method for full documentation. """ - if calendar is None: - calendar = self._calendar - if calendar is None: - raise ValueError( - "No calendar provided. Either pass a calendar to async_search() or " - "create the searcher via calendar.searcher()" - ) - - # Import unified types at runtime to avoid circular imports - # These work with both sync and async clients - from .calendarobjectresource import ( - Event as AsyncEvent, - ) - from .calendarobjectresource import ( - Journal as AsyncJournal, - ) - from .calendarobjectresource import ( - Todo as AsyncTodo, + gen = self._search_impl( + calendar, server_expand, split_expanded, props, xml, post_filter, _hacks ) + result = None - ## Handle servers with broken component-type filtering (e.g., Bedework) - comp_type_support = calendar.client.features.is_supported("search.comp-type", str) - if ( - (self.comp_class or self.todo or self.event or self.journal) - and comp_type_support == "broken" - and not _hacks - and post_filter is not False - ): - _hacks = "no_comp_filter" - post_filter = True - - ## Setting default value for post_filter - if post_filter is None and ( - (self.todo and not self.include_completed) - or self.expand - or "categories" in self._property_filters - or "category" in self._property_filters - or not calendar.client.features.is_supported("search.text.case-sensitive") - or not calendar.client.features.is_supported("search.time-range.accurate") - ): - post_filter = True - - ## split_expanded should only take effect on expanded data - if not self.expand and not server_expand: - split_expanded = False - - if self.expand or server_expand: - if not self.start or not self.end: - raise error.ReportError("can't expand without a date range") - - ## special compatibility-case for servers that does not - ## support category search properly - if ( - not calendar.client.features.is_supported("search.text.category") - and ("categories" in self._property_filters or "category" in self._property_filters) - and post_filter is not False - ): - clone = self._clone_without_filters(["categories", "category"]) - objects = await clone.async_search(calendar, server_expand, split_expanded, props, xml) - return self.filter(objects, post_filter, split_expanded, server_expand) - - ## special compatibility-case for servers that do not support substring search - if ( - not calendar.client.features.is_supported("search.text.substring") - and post_filter is not False - ): - explicit_contains = [ - prop - for prop in self._property_operator - if prop in self._explicit_operators and self._property_operator[prop] == "contains" - ] - if explicit_contains: - clone = self._clone_without_filters(explicit_contains) - objects = await clone.async_search( - calendar, server_expand, split_expanded, props, xml - ) - return self.filter( - objects, - post_filter=True, - split_expanded=split_expanded, - server_expand=server_expand, - ) - - ## special compatibility-case for servers that do not support combined searches - if not calendar.client.features.is_supported("search.combined-is-logical-and"): - if self.start or self.end: - if self._property_filters: - clone = self._clone_without_filters(clear_all_filters=True) - objects = await clone.async_search( - calendar, server_expand, split_expanded, props, xml - ) - return self.filter(objects, post_filter, split_expanded, server_expand) - - ## special compatibility-case when searching for pending todos - if self.todo and self.include_completed is False: - clone = replace(self, include_completed=True) - clone.include_completed = True - clone.expand = False - if ( - calendar.client.features.is_supported("search.text") - and calendar.client.features.is_supported("search.combined-is-logical-and") - and ( - not calendar.client.features.is_supported( - "search.recurrences.includes-implicit.todo" - ) - or calendar.client.features.is_supported( - "search.recurrences.includes-implicit.todo.pending" - ) - ) - ): - matches: list[AsyncCalendarObjectResource] = [] - for hacks in ( - "ignore_completed1", - "ignore_completed2", - "ignore_completed3", - ): - results = await clone.async_search( - calendar, - server_expand, - split_expanded=False, - props=props, - xml=xml, - _hacks=hacks, - ) - matches.extend(results) - else: - matches = await clone.async_search( - calendar, - server_expand, - split_expanded=False, - props=props, - xml=xml, - _hacks=_hacks, - ) - objects: list[AsyncCalendarObjectResource] = [] - match_set = set() - for item in matches: - if item.url not in match_set: - match_set.add(item.url) - objects.append(item) - else: - orig_xml = xml - - if not xml or (not isinstance(xml, str) and not xml.tag.endswith("calendar-query")): - (xml, self.comp_class) = self.build_search_xml_query( - server_expand, props=props, filters=xml, _hacks=_hacks - ) - - # Convert sync comp_class to async equivalent - sync_to_async = { - Event: AsyncEvent, - Todo: AsyncTodo, - Journal: AsyncJournal, - } - async_comp_class = sync_to_async.get(self.comp_class, self.comp_class) - - if not self.comp_class and not calendar.client.features.is_supported( - "search.comp-type-optional" - ): - if self.include_completed is None: - self.include_completed = True - - return await self._async_search_with_comptypes( - calendar, - server_expand, - split_expanded, - props, - orig_xml, - _hacks, - post_filter, - ) + try: + action, data = gen.send(result) + except StopIteration: + return [] + while True: try: - (response, objects) = await calendar._request_report_build_resultlist( - xml, async_comp_class, props=props - ) - - except error.ReportError as err: - if ( - calendar.client.features.backward_compatibility_mode - and not self.comp_class - and "400" not in err.reason - ): - return await self._async_search_with_comptypes( - calendar, - server_expand, - split_expanded, - props, - orig_xml, - _hacks, - post_filter, + if action == SearchAction.RECURSIVE_SEARCH: + clone, cal, srv_exp, spl_exp, prp, xm, pf, hk = data + result = await clone.async_search(cal, srv_exp, spl_exp, prp, xm, pf, hk) + elif action == SearchAction.SEARCH_WITH_COMPTYPES: + cal, srv_exp, spl_exp, prp, xm, hk, pf = data + result = await self._async_search_with_comptypes( + cal, srv_exp, spl_exp, prp, xm, hk, pf ) - raise - - if not objects and not self.comp_class and _hacks == "insist": - return await self._async_search_with_comptypes( - calendar, - server_expand, - split_expanded, - props, - orig_xml, - _hacks, - post_filter, - ) - - obj2: list[AsyncCalendarObjectResource] = [] - for o in objects: - try: - # load() may return self (sync) or coroutine (async) depending on state - result = o.load(only_if_unloaded=True) - import inspect - - if inspect.isawaitable(result): - await result - obj2.append(o) - except Exception: - import logging - - logging.error( - "Server does not want to reveal details about the calendar object", - exc_info=True, - ) - objects = obj2 - - ## Google sometimes returns empty objects - objects = [o for o in objects if o.has_component()] - objects = self.filter(objects, post_filter, split_expanded, server_expand) + elif action == SearchAction.REQUEST_REPORT: + cal, xm, comp_cls, prp = data + result = await cal._request_report_build_resultlist(xm, comp_cls, props=prp) + elif action == SearchAction.LOAD_OBJECT: + load_result = data.load(only_if_unloaded=True) + if inspect.isawaitable(load_result): + await load_result + result = None + elif action == SearchAction.RETURN: + return data + + action, data = gen.send(result) + except StopIteration: + return [] - ## partial workaround for https://github.com/python-caldav/caldav/issues/201 - for obj in objects: - try: - # load() may return self (sync) or coroutine (async) depending on state - result = obj.load(only_if_unloaded=True) - import inspect + async def _async_search_with_comptypes( + self, + calendar: "AsyncCalendar", + server_expand: bool = False, + split_expanded: bool = True, + props: list[cdav.CalendarData] | None = None, + xml: str = None, + _hacks: str = None, + post_filter: bool = None, + ) -> list["AsyncCalendarObjectResource"]: + """ + Internal async method - does three searches, one for each comp class. + """ + if xml and (isinstance(xml, str) or "calendar-query" in xml.tag): + raise NotImplementedError( + "full xml given, and it has to be patched to include comp_type" + ) + objects: list[AsyncCalendarObjectResource] = [] - if inspect.isawaitable(result): - await result - except Exception: - pass + assert self.event is None and self.todo is None and self.journal is None + for comp_class in (Event, Todo, Journal): + clone = replace(self) + clone.comp_class = comp_class + results = await clone.async_search( + calendar, server_expand, split_expanded, props, xml, post_filter, _hacks + ) + objects.extend(results) return self.sort(objects) def filter(