Skip to content

Commit

Permalink
Server side vs client side expansion
Browse files Browse the repository at this point in the history
This commit allows the client to specify if expansion of recurrent
events should happen on the server side or the client side.

I'm not happy with the interface, so there is already a deprecation
notice in the comments that this may be changed in verson 2.0.
  • Loading branch information
tobixen committed Nov 2, 2024
1 parent 58f09d1 commit e807331
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 29 deletions.
24 changes: 17 additions & 7 deletions caldav/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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.
Expand All @@ -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 = []
Expand Down
133 changes: 111 additions & 22 deletions tests/test_caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("##############################")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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())

Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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):
"""
Expand Down

0 comments on commit e807331

Please sign in to comment.