diff --git a/caldav/objects.py b/caldav/objects.py index 9e14022..b1a218d 100644 --- a/caldav/objects.py +++ b/caldav/objects.py @@ -1133,6 +1133,7 @@ def search( include_completed: bool = False, sort_keys: Sequence[str] = (), sort_reverse: bool = False, + expand: Union[bool, Literal["server"], Literal["client"]] = False, split_expanded: bool = True, props: Optional[List[CalendarData]] = None, **kwargs, @@ -1147,6 +1148,12 @@ def search( and client side filtering to make sure other search results are consistent on different server implementations. + LEGACY WARNING: the expand attribute currently takes four + possible values - True, False, server and client. The two + latter value were hastily added just prior to launching + version 1.4, the API may be reconsidered and changed without + notice when launching version 2.0 + Parameters supported: * xml - use this search query, and ignore other filter parameters @@ -1160,7 +1167,7 @@ def search( description, location, status * no-category, no-summary, etc ... search for objects that does not have those attributes. TODO: WRITE TEST CODE! - * expand - do server side expanding of recurring events/tasks + * expand - expand recurring objects * start, end: do a time range search * filters - other kind of filters (in lxml tree format) * sort_keys - list of attributes to use when sorting @@ -1169,6 +1176,7 @@ def search( not supported yet: * negated text match * attribute not set + """ ## special compatibility-case when searching for pending todos if todo and not include_completed: @@ -1209,6 +1217,8 @@ def search( objects.append(item) else: if not xml: + if expand and expand != 'client': + kwargs['expand'] = True (xml, comp_class) = self.build_search_xml_query( comp_class=comp_class, todo=todo, props=props, **kwargs ) @@ -1248,7 +1258,7 @@ def search( ## Google sometimes returns empty objects objects = [o for o in objects if o.has_component()] - if kwargs.get("expand", False): + if expand and expand != 'server': ## expand can only be used together with start and end (and not ## with xml). Error checking has already been done in ## build_search_xml_query above. @@ -1268,11 +1278,11 @@ def search( ## icalendar data containing multiple objects. The caller may ## expect multiple Event()s. This code splits events into ## separate objects: - if split_expanded: - objects_ = objects - objects = [] - for o in objects_: - objects.extend(o.split_expanded()) + if expand and split_expanded: + objects_ = objects + objects = [] + for o in objects_: + objects.extend(o.split_expanded()) def sort_key_func(x): ret = [] diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 0ab6ae0..32b474e 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -634,6 +634,10 @@ def foo(*a, **kwa): else: self.principal = self.caldav.principal() + #if self.check_compatibility_flag('delete_calendar_on_startup'): + #for x in self._fixCalendar().search(): + #x.delete() + self._cleanup("pre") logging.debug("##############################") @@ -740,6 +744,7 @@ def _fixCalendar(self, **kwargs): ret.objects = lambda load_objects: ret.events() if self.cleanup_regime == "post": self.calendars_used.append(ret) + return ret def testSupport(self): @@ -807,6 +812,7 @@ def testSearchShouldYieldData(self): ref https://github.com/python-caldav/caldav/issues/201 """ c = self._fixCalendar() + if not self.check_compatibility_flag("read_only"): ## populate the calendar with an event or two or three c.save_event(ev1) @@ -908,6 +914,7 @@ def testCreateDeleteCalendar(self): ) and self.cleanup_regime in ("light", "pre"): self._teardownCalendar(cal_id=self.testcal_id) c = self.principal.make_calendar(name="Yep", cal_id=self.testcal_id) + assert c.url is not None events = c.events() assert len(events) == 0 @@ -923,6 +930,7 @@ def testCreateDeleteCalendar(self): def testChangeAttendeeStatusWithEmailGiven(self): self.skip_on_compatibility_flag("read_only") c = self._fixCalendar() + event = c.save_event( uid="test1", dtstart=datetime(2015, 10, 10, 8, 7, 6), @@ -933,6 +941,7 @@ def testChangeAttendeeStatusWithEmailGiven(self): attendee="testuser@example.com", PARTSTAT="ACCEPTED" ) event.save() + self.skip_on_compatibility_flag("object_by_uid_is_broken") event = c.event_by_uid("test1") ## TODO: work in progress ... see https://github.com/python-caldav/caldav/issues/399 @@ -988,6 +997,7 @@ def testCalendarByFullURL(self): is broken in 0.8.0 """ mycal = self._fixCalendar() + samecal = self.caldav.principal().calendar(cal_id=str(mycal.url)) assert mycal.url.canonical() == samecal.url.canonical() ## passing cal_id as a URL object should also work. @@ -1134,6 +1144,7 @@ def testSync(self): ## Boiler plate ... make a calendar and add some content c = self._fixCalendar() + objcnt = 0 ## in case we need to reuse an existing calendar ... if not self.check_compatibility_flag("no_todo"): @@ -1237,6 +1248,7 @@ def testLoadEvent(self): self._teardownCalendar(cal_id=self.testcal_id2) c1 = self._fixCalendar(name="Yep", cal_id=self.testcal_id) c2 = self._fixCalendar(name="Yapp", cal_id=self.testcal_id2) + e1_ = c1.save_event(ev1) if not self.check_compatibility_flag("event_by_url_is_broken"): e1_.load() @@ -1263,6 +1275,7 @@ def testCopyEvent(self): ## Let's create two calendars, and populate one event on the first calendar c1 = self._fixCalendar(name="Yep", cal_id=self.testcal_id) c2 = self._fixCalendar(name="Yapp", cal_id=self.testcal_id2) + assert not len(c1.events()) assert not len(c2.events()) e1_ = c1.save_event(ev1) @@ -1315,6 +1328,7 @@ def testCopyEvent(self): def testCreateCalendarAndEventFromVobject(self): self.skip_on_compatibility_flag("read_only") c = self._fixCalendar() + ## in case the calendar is reused cnt = len(c.events()) @@ -1335,6 +1349,7 @@ def testCreateCalendarAndEventFromVobject(self): def testGetSupportedComponents(self): self.skip_on_compatibility_flag("no_supported_components_support") c = self._fixCalendar() + components = c.get_supported_components() assert components assert "VEVENT" in components @@ -1343,6 +1358,7 @@ def testSearchEvent(self): self.skip_on_compatibility_flag("read_only") self.skip_on_compatibility_flag("no_search") c = self._fixCalendar() + c.save_event(ev1) c.save_event(ev3) c.save_event(evr) @@ -1898,14 +1914,22 @@ def testCreateTaskListAndTodo(self): # adding a todo without a UID, it should also work (library will add the missing UID) t7 = c.save_todo(todo7) - assert len(c.todos()) == 3 + logging.info("Fetching the todos (should be three)") + todos = c.todos() logging.info("Fetching the events (should be none)") # c.events() should NOT return todo-items events = c.events() - assert len(events) == 0 + t7.delete() + ## Delayed asserts ... this test is fragile, since todo7 is without + ## an uid it may not be covered by the automatic cleanup procedures + ## in the test framework. + assert len(todos) == 3 + assert len(events) == 0 + assert len(c.todos())==2 + def testTodos(self): """ This test will exercise the cal.todos() method, @@ -2013,6 +2037,22 @@ def testTodoDatesearch(self): split_expanded=False, include_completed=True, ) + todos3 = c.search( + start=datetime(1997, 4, 14), + end=datetime(2015, 5, 14), + todo=True, + expand="client", + split_expanded=False, + include_completed=True, + ) + todos4 = c.search( + start=datetime(1997, 4, 14), + end=datetime(2015, 5, 14), + todo=True, + expand="client", + split_expanded=False, + include_completed=True, + ) # The RFCs are pretty clear on this. rfc5545 states: # A "VTODO" calendar component without the "DTSTART" and "DUE" (or @@ -2043,11 +2083,18 @@ def testTodoDatesearch(self): assert len(todos2) == foo ## verify that "expand" works - if not self.check_compatibility_flag( - "broken_expand" - ) and not self.check_compatibility_flag("no_recurring"): - assert len([x for x in todos1 if "DTSTART:20020415T1330" in x.data]) == 1 - assert len([x for x in todos2 if "DTSTART:20020415T1330" in x.data]) == 1 + if not self.check_compatibility_flag("no_recurring"): + ## todo1 and todo2 should be the same (todo1 using legacy method) + ## todo1 and todo2 tries doing server side expand, with fallback + ## to client side expand + if not self.check_compatibility_flag("broken_expand"): + assert len([x for x in todos1 if "DTSTART:20020415T1330" in x.data]) == 1 + assert len([x for x in todos2 if "DTSTART:20020415T1330" in x.data]) == 1 + if not self.check_compatibility_flag("no_expand"): + assert len([x for x in todos4 if "DTSTART:20020415T1330" in x.data]) == 1 + ## todo3 is client side expand, should always work + assert len([x for x in todos3 if "DTSTART:20020415T1330" in x.data]) == 1 + ## todo4 is server side expand, may work dependent on server ## exercise the default for expand (maybe -> False for open-ended search) todos1 = c.date_search(start=datetime(2025, 4, 14), compfilter="VTODO") @@ -2585,17 +2632,33 @@ def testRecurringDateSearch(self): assert len(r2) == 1 ## With expand=True, we should find one occurrence + ## legacy method name r1 = c.date_search( datetime(2008, 11, 1, 17, 00, 00), datetime(2008, 11, 3, 17, 00, 00), expand=True, ) + ## server expansion, with client side fallback r2 = c.search( event=True, start=datetime(2008, 11, 1, 17, 00, 00), end=datetime(2008, 11, 3, 17, 00, 00), expand=True, ) + ## client side expansion + r3 = c.search( + event=True, + start=datetime(2008, 11, 1, 17, 00, 00), + end=datetime(2008, 11, 3, 17, 00, 00), + expand="client", + ) + ## server side expansion + r4 = c.search( + event=True, + start=datetime(2008, 11, 1, 17, 00, 00), + end=datetime(2008, 11, 3, 17, 00, 00), + expand="server", + ) assert len(r1) == 1 assert len(r2) == 1 assert r1[0].data.count("END:VEVENT") == 1 @@ -2604,6 +2667,9 @@ def testRecurringDateSearch(self): if not self.check_compatibility_flag("broken_expand"): assert r1[0].data.count("DTSTART;VALUE=DATE:2008") == 1 assert r2[0].data.count("DTSTART;VALUE=DATE:2008") == 1 + if not self.check_compatibility_flag("no_expand"): + assert r4[0].data.count("DTSTART;VALUE=DATE:2008") == 1 + assert r3[0].data.count("DTSTART;VALUE=DATE:2008") == 1 ## With expand=True and searching over two recurrences ... r1 = c.date_search( @@ -2645,6 +2711,7 @@ def testRecurringDateSearch(self): assert r[0].data.count("END:VEVENT") == 1 def testRecurringDateWithExceptionSearch(self): + self.skip_on_compatibility_flag("no_search") c = self._fixCalendar() # evr2 is a bi-weekly event starting 2024-04-11 @@ -2656,29 +2723,51 @@ def testRecurringDateWithExceptionSearch(self): event=True, expand=True, ) + rc = c.search( + start=datetime(2024, 3, 31, 0, 0), + end=datetime(2024, 5, 4, 0, 0, 0), + event=True, + expand="client", + ) + rs = c.search( + start=datetime(2024, 3, 31, 0, 0), + end=datetime(2024, 5, 4, 0, 0, 0), + event=True, + expand="server", + ) - assert len(r) == 2 + assert len(rc) == 2 + if not self.check_compatibility_flag("broken_expand"): + assert len(r) == 2 + if not self.check_compatibility_flag("no_expand"): + assert len(rs) == 2 assert "RRULE" not in r[0].data assert "RRULE" not in r[1].data - self.skip_on_compatibility_flag("broken_expand") - assert isinstance( - r[0].icalendar_component["RECURRENCE-ID"], icalendar.vDDDTypes - ) + asserts_on_results = [ rc ] + if not self.check_compatibility_flag("broken_expand_on_exceptions") and not self.check_compatibility_flag("broken_expand"): + asserts_on_results.append(r) + if not self.check_compatibility_flag("no_expand"): + asserts_on_results.append(rs) - ## TODO: xandikos returns a datetime without a tzinfo, radicale returns a datetime with tzinfo=UTC, but perhaps other calendar servers returns the timestamp converted to localtime? + for r in asserts_on_results: + assert isinstance( + r[0].icalendar_component["RECURRENCE-ID"], icalendar.vDDDTypes + ) - assert r[0].icalendar_component["RECURRENCE-ID"].dt.replace( - tzinfo=None - ) == datetime(2024, 4, 11, 12, 30, 00) + ## TODO: xandikos returns a datetime without a tzinfo, radicale returns a datetime with tzinfo=UTC, but perhaps other calendar servers returns the timestamp converted to localtime? - assert isinstance( - r[1].icalendar_component["RECURRENCE-ID"], icalendar.vDDDTypes - ) - assert r[1].icalendar_component["RECURRENCE-ID"].dt.replace( - tzinfo=None - ) == datetime(2024, 4, 25, 12, 30, 00) + assert r[0].icalendar_component["RECURRENCE-ID"].dt.replace( + tzinfo=None + ) == datetime(2024, 4, 11, 12, 30, 00) + + assert isinstance( + r[1].icalendar_component["RECURRENCE-ID"], icalendar.vDDDTypes + ) + assert r[1].icalendar_component["RECURRENCE-ID"].dt.replace( + tzinfo=None + ) == datetime(2024, 4, 25, 12, 30, 00) def testOffsetURL(self): """