diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index 63b09664..e74a1c05 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -1009,8 +1009,10 @@ def is_loaded(self): potentially breaking couplings """ return ( - self._data or self._vobject_instance or self._icalendar_instance - ) and self.data.count("BEGIN:") > 1 + (self._data and self._data.count("BEGIN:") > 1) + or self._vobject_instance + or self._icalendar_instance + ) def has_component(self): """ @@ -1145,6 +1147,18 @@ def _set_deprecated_vobject_instance(self, inst: "vobject.base.Component"): ) def _set_icalendar_instance(self, inst): + if not isinstance(inst, icalendar.Calendar): + ## assume inst is an Event, Journal or Todo. + ## TODO: perhaps a bit better sanity checking here? + try: ## DEPRECATION TODO: remove this try/except the future + ## icalendar 7.x behaviour (not released yet as of 2025-09 + cal = icalendar.Calendar.new() + except: + cal = icalendar.Calendar() + cal.add("prodid", "-//python-caldav//caldav//en_DK") + cal.add("version", "2.0") + cal.add_component(inst) + inst = cal self._icalendar_instance = inst self._data = None self._vobject_instance = None diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 3f108b24..5eec8c6e 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -124,6 +124,17 @@ class FeatureSet: "search.recurrences.expanded.exception": { "description": "Server expand should work correctly also if a recurrence set with exceptions is given" }, + ## TODO: as for now, the tests will run towards the first calendar it will find, and most of the tests will assume the calendar is empty. This is bad. + "test-calendar": { + "type": "tests-behaviour", + "description": "if the server does not allow creating new calendars, then use the calendar with the given name for running tests (NOT SUPPORTED YET!), wipe the calendar between each test run (alternative for calendars not supporting the creation of new calendars is a very expensive delete objects one-by-one by uid)", + "extra_keys": { "name": "calendar name", "cleanup-regime": "thorough|pre|post|light|wipe-calendar" } + }, + "test-calendar.compatibility-tests": { + "type": "tests-behaviour", + "description": "if the server does not allow creating new calendars, then use the calendar with the given name for running the compatibility tests", + "extra_keys": { "name": "calendar name", "cleanup": "Set to True to clean up the calendar after compatibility run" } ## if needed, pad up with cal_id, url, etc + } ## if needed we may pad up with test-calendar.compatibility-tests.events, etc, etc } def __init__(self, feature_set_dict=None): @@ -235,6 +246,8 @@ def _default(self, feature_info): return { "behaviour": "normal" } elif feature_type == 'server-observation': return { "observed": True } + elif feature_type == 'tests-behaviour': + return { } else: breakpoint() @@ -247,6 +260,9 @@ def check_support(self, feature, return_type=bool, return_defaults=True): TODO: write a better docstring + The dotted features is essentially a tree. If feature foo + is unsupported it basically means that feature foo.bar is also + unsupported. Hence the extra logic visiting "nodes". """ feature_info = self.find_feature(feature) feature_ = feature @@ -260,6 +276,14 @@ def check_support(self, feature, return_type=bool, return_defaults=True): feature_ = feature_[:feature_.rfind('.')] def _convert_node(self, node, feature_info, return_type): + """ + Return the information in a "node" given the wished return_type + + (The dotted feature format was an afterthought, the first + iteration of this code the feature tree was actually a + hierarchical dict, hence the naming of the method. I + considered it too complicated though) + """ if return_type == str: ## TODO: consider feature_info['type'], be smarter about it return node.get('support', node.get('enable', node.get('behaviour'))) @@ -282,7 +306,7 @@ def find_feature(cls, feature: str) -> dict: (this is very simple now - used to be a hierarchy dict to be traversed) """ - assert feature in cls.FEATURES ## TODO ... raise a better exception? + assert feature in cls.FEATURES ## A feature in the configured feature-list does not exist. TODO ... raise a better exception? if not 'name' in cls.FEATURES[feature]: cls.FEATURES[feature]['name'] = feature if '.' in feature and not 'parent' in cls.FEATURES[feature]: @@ -489,9 +513,6 @@ def dotted_feature_set_list(self, compact=False): 'object_by_uid_is_broken': """calendar.object_by_uid(uid) does not work""", - 'no_mkcalendar': - """mkcalendar is not supported""", - 'no_overwrite': """events cannot be edited""", @@ -618,18 +639,23 @@ def dotted_feature_set_list(self, compact=False): } ecloud = { + 'search.category.fullstring.smart': {'support': 'unsupported'}, ## TODO: verify + 'search.comp-type-optional': {'support': 'ungraceful'}, + 'search.recurrences.expanded.todo': {'support': 'unsupported'}, + 'search.recurrences.expanded.exception': {'support': 'unsupported'}, ## TODO: verify + 'delete-calendar': { 'support': 'fragile', - 'behaviour': 'Deleting a recently created table fails'}, - 'delete-calendar.free-namespace': { - 'support': 'unsupported', + 'behaviour': 'Deleting a recently created calendar fails'}, + 'delete-calendar.free-namespace': { ## TODO: not caught by server-tester 'behaviour': "deleting a calendar moves it to a trashbin, thrashbin has to be manually 'emptied' from the web-ui before the namespace is freed up"}, 'search.comp-type-optional': { 'support': 'ungraceful', }, + ## TODO: this applies only to test runs, not to ordinary usage 'rate-limit': { 'enable': True, - 'interval': 25, + 'interval': 10, 'count': 1, 'description': "It's needed to manually empty trashbin frequently when running tests. Since this oepration takes some time and/or there are some caches, it's needed to run tests slowly, even when hammering the 'empty thrashbin' frequently"}, 'old_flags': ['no-principal-search-all', 'no-principal-search-self', 'unique_calendar_ids'], @@ -638,9 +664,11 @@ def dotted_feature_set_list(self, compact=False): ## ZIMBRA IS THE MOST SILLY, AND THERE ARE REGRESSIONS FOR EVERY RELEASE! ## AAARGH! zimbra = { + 'search.recurrences.expanded.exception': {'support': 'unsupported'}, ## TODO: verify 'create-calendar.set-displayname': {'support': 'unsupported'}, 'save-load.todo.mixed-calendar': {'support': 'unsupported'}, 'search.category': {'support': 'ungraceful'}, + 'search.recurrences.expanded.todo': { "support": "unsupported" }, 'search.comp-type-optional': {'support': 'fragile'}, ## TODO: more research on this, looks like a bug in the checker "old_flags": [ ## apparently, zimbra has no journal support @@ -715,18 +743,23 @@ def dotted_feature_set_list(self, compact=False): # 'object_by_uid_is_broken' #] -davical = [ - #'no_journal', ## it threw a 500 internal server error! ## for old versions - #'nofreebusy', ## for old versions - 'fragile_sync_tokens', ## no issue raised yet - 'vtodo_datesearch_nodtstart_task_is_skipped', ## no issue raised yet - 'broken_expand_on_exceptions', ## no issue raised yet - 'date_todo_search_ignores_duration', - 'calendar_color', - 'calendar_order', - 'vtodo_datesearch_notime_task_is_skipped', - "no_alarmsearch", -] +davical = { + "search.category.fullstring.smart": { "support": "unsupported" }, + "search.comp-type-optional": { "support": "fragile" }, + "search.recurrences.expanded.todo": { "support": "unsupported" }, + "search.recurrences.expanded.exception": { "support": "unsupported" }, + "old_flags": [ + #'no_journal', ## it threw a 500 internal server error! ## for old versions + #'nofreebusy', ## for old versions + 'fragile_sync_tokens', ## no issue raised yet + 'vtodo_datesearch_nodtstart_task_is_skipped', ## no issue raised yet + 'date_todo_search_ignores_duration', + 'calendar_color', + 'calendar_order', + 'vtodo_datesearch_notime_task_is_skipped', + "no_alarmsearch", + ] +} #google = [ # 'no_mkcalendar', @@ -783,33 +816,44 @@ def dotted_feature_set_list(self, compact=False): # "no_recurring_todo", #] -robur = [ - 'non_existing_raises_other', ## AuthorizationError instead of NotFoundError - 'no_scheduling', - 'no_sync_token', - 'no_supported_components_support', - 'no_journal', - 'no_freebusy_rfc4791', - 'no_todo_datesearch', ## returns nothing - 'text_search_not_working', - "no-principal-search", - 'no_relships', - 'isnotdefined_not_working', - 'no_alarmsearch', - 'broken_expand', -] +robur = { + "delete-calendar": { "support": "fragile" }, + "search.time-range.todo": { "support": "unsupported" }, + "search.category": { "support": "unsupported" }, + "search.comp-type-optional": { "support": "ungraceful" }, + "search.recurrences.expanded.todo": { "support": "unsupported" }, + #"search.recurrences.expanded.event": { "support": "fragile" }, + "search.recurrences.expanded.exception": { "support": "unsupported" }, + 'old_flags': [ + 'non_existing_raises_other', ## AuthorizationError instead of NotFoundError + 'no_scheduling', + 'no_sync_token', + 'no_supported_components_support', + 'no_journal', + 'no_freebusy_rfc4791', + 'text_search_not_working', + "no-principal-search", + 'no_relships', + 'isnotdefined_not_working', + 'no_alarmsearch', + 'unique_calendar_ids', + ] +} -posteo = [ - 'no_scheduling', - 'no_mkcalendar', - 'no_journal', - 'no_recurring_todo', - 'no_sync_token', - 'combined_search_not_working', - 'no_alarmsearch', - 'broken_expand', - "no-principal-search-self", -] +posteo = { + "create-calendar": { "support": "unsupported" }, + "search.recurrences.expanded.exception": { "support": "unsupported" }, + "search.recurrences.includes-implicit.todo": { "support": "unsupported" }, + 'old_flags': [ + 'no_scheduling', + 'no_journal', + #'no_recurring_todo', ## todo + 'no_sync_token', + 'combined_search_not_working', + 'no_alarmsearch', + "no-principal-search-self" + ] +} #calendar_mail_ru = [ # 'no_mkcalendar', ## weird. It was working in early June 2024, then it stopped working in mid-June 2024. @@ -825,37 +869,46 @@ def dotted_feature_set_list(self, compact=False): # 'no_relships', ## mail.ru recreates the icalendar content, and strips everything it doesn't know anyhting about, including relationship info #] -purelymail = [ - ## Known, work in progress - 'no_scheduling', - - ## Known, not a breach of standard - 'no_supported_components_support', - +purelymail = { ## Purelymail claims that the search indexes are "lazily" populated, ## so search works some minutes after the event was created/edited. - 'search_delay', + 'search-cache': {'behaviour': 'delay', 'delay': 120}, + 'old_flags': [ + ## Known, work in progress + 'no_scheduling', - "no-principal-search", ## more research may be needed. "cant-operate-on-root", indicating that the URL may need adjusting? + ## Known, not a breach of standard + 'no_supported_components_support', - ## I haven't raised this one with them yet - 'no_alarmsearch', -] + "no-principal-search", ## more research may be needed. "cant-operate-on-root", indicating that the URL may need adjusting? -gmx = [ - "no_scheduling_mailbox", - "no_mkcalendar", - "search_needs_comptype", - #"text_search_is_case_insensitive", - "no-principal-search-all", - "no_freebusy_rfc4791", - "no_expand", - "no_search_openended", - "no_sync_token", - "no_scheduling_calendar_user_address_set", - "no-principal-search-self", - "vtodo-cannot-be-uncompleted", - #"no-principal-search-all", -] + ## I haven't raised this one with them yet + 'no_alarmsearch', + ] +} + +gmx = { + ## This WILL create some arbitrary objects from year 2000 on your calendar when running + ## tests. + ## It's fine for me as my gmx calendar is only for testing, but it may not be + ## fine for you. + "test-calendar.compatibility-tests": { "name": "Mein Kalender", "cleanup": True }, + 'create-calendar': {'support': 'unsupported'}, + 'search.category.fullstring.smart': {'support': 'unsupported'}, + 'search.comp-type-optional': {'support': 'fragile', 'description': 'unexpected results from date-search without comp-type - but only sometimes - TODO: research more'}, + 'search.recurrences.expanded': {'support': 'unsupported'}, + "old_flags": [ + "no_scheduling_mailbox", + #"text_search_is_case_insensitive", + "no-principal-search-all", + "no_freebusy_rfc4791", + "no_search_openended", + "no_sync_token", + "no_scheduling_calendar_user_address_set", + "no-principal-search-self", + "vtodo-cannot-be-uncompleted", + #"no-principal-search-all", + ] +} # fmt: on diff --git a/caldav/lib/vcal.py b/caldav/lib/vcal.py index b5258a38..dc223b26 100644 --- a/caldav/lib/vcal.py +++ b/caldav/lib/vcal.py @@ -165,7 +165,10 @@ def create_ical(ical_fragment=None, objtype=None, language="en_DK", **props): my_instance = icalendar.Calendar() if objtype is None: objtype = "VEVENT" - component = icalendar.cal.component_factory[objtype]() + try: + component = icalendar.cal.component_factory[objtype]() + except: + component = icalendar.cal.component_factory.ComponentFactory()[objtype]() my_instance.add_component(component) ## STATUS should default to NEEDS-ACTION for tasks, if it's not set ## (otherwise we cannot easily add a task to a davical calendar and diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 64ad2695..419d103c 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -145,6 +145,17 @@ "test6", "c26921f4-0653-11ef-b756-58ce2a14e2e5", "e2a2e13e-34f2-11f0-ae12-1c1bb5134174", + "csc_event_with_categories", + "csc_monthly_recurring_event", + "csc_monthly_recurring_with_exception", + "csc_simple_event1", + "csc_simple_event2", + "csc_simple_event3", + "csc_simple_event4", + "csc_simple_task2", + "csc_simple_task3", + "csc_monthly_recurring_task", + "csc_simple_task1", ) ## TODO: todo7 is an item without uid. Should be taken care of somehow. @@ -690,7 +701,6 @@ def skip_unless_support(self, feature): def setup_method(self): logging.debug("############## test setup") - self.cleanup_regime = self.server_params.get("cleanup", "light") self.calendars_used = [] features = self.server_params.get("features", {}) @@ -700,6 +710,14 @@ def setup_method(self): self.features = FeatureSet(self.server_params.get("features", {})) + calendar_info = self.check_support("test-calendar", dict) + self.cleanup_regime = calendar_info.get("cleanup-regime", "light") + + if not "cleanup" in self.server_params and not self.check_support( + "create-calendar" + ): + self.cleanup_regime = "thorough" + ## verify that all old flags are valid for flag in self.old_features: assert flag in incompatibility_description @@ -759,7 +777,15 @@ def _cleanup(self, mode=None): if self.check_compatibility_flag("read_only"): return ## no cleanup needed if ( - self.check_compatibility_flag("no_mkcalendar") + not self.check_support("create-calendar") + and self.cleanup_regime == "wipe-calendar" + ): + cal = self._fixCalendar() + ## do we need a try-except-pass? + for x in cal.search(): + x.delete() + elif ( + not self.check_support("create-calendar") or self.cleanup_regime == "thorough" ): for uid in uids_used: @@ -788,9 +814,7 @@ def _cleanup(self, mode=None): def _teardownCalendar(self, name=None, cal_id=None): try: cal = self.principal.calendar(name=name, cal_id=cal_id) - if self.check_compatibility_flag("sticky_events") or not self.check_support( - "delete-calendar" - ): + if self.check_compatibility_flag("sticky_events"): for goo in cal.objects(): try: goo.delete() @@ -799,6 +823,21 @@ def _teardownCalendar(self, name=None, cal_id=None): cal.delete() except: pass + try: + cal.events() + if check_support("delete-calendar", str) == "fragile": + ## sometimes it's needed to sleep a bit before deleting a calendar. TODO: improve the compatibility-description. + time.sleep(10) + try: + cal.delete() + except: + pass + remaining = cal.search() + for x in remaining: + x.delete() + cal.delete() + except: + pass def _fixCalendar(self, **kwargs): """ @@ -806,9 +845,9 @@ def _fixCalendar(self, **kwargs): should see if there exists a test calendar, if that's not possible, give up and return the primary calendar. """ - if self.check_compatibility_flag( - "no_mkcalendar" - ) or self.check_compatibility_flag("read_only"): + if not self.check_support("create-calendar") or self.check_compatibility_flag( + "read_only" + ): if not self._default_calendar: calendars = self.principal.calendars() for c in calendars: @@ -870,7 +909,7 @@ def testCheckCompatibility(self): for x in set(observed.keys()).union(set(expected.keys())): find_feature = checker.features_checked.find_feature type_ = find_feature(x).get("type", "server-feature") - if type_ in ("client-feature", "server-observation"): + if type_ in ("client-feature", "server-observation", "tests-behaviour"): for target in observed_, expected_: if x in target: target.pop(x) @@ -882,6 +921,7 @@ def testCheckCompatibility(self): if y in stripdict[x]: stripdict[x].pop(y) + checker.cleanup(force=False) assert observed_ == expected_ def testSupport(self): @@ -1055,7 +1095,7 @@ def testPrincipals(self): assert all((isinstance(x, Principal) for x in all_principals)) def testCreateDeleteCalendar(self): - self.skip_on_compatibility_flag("no_mkcalendar") + self.skip_unless_support("create-calendar") self.skip_on_compatibility_flag("read_only") self.skip_unless_support("delete-calendar") if not self.check_compatibility_flag( @@ -1127,7 +1167,7 @@ def testCreateEvent(self): existing_urls = {x.url for x in existing_events} cleanse = lambda events: [x for x in events if x.url not in existing_urls] - if not self.check_compatibility_flag("no_mkcalendar"): + if self.check_support("create-calendar"): ## we're supposed to be working towards a brand new calendar assert len(existing_events) == 0 @@ -1144,13 +1184,13 @@ def testCreateEvent(self): assert len(events2) == 1 assert events2[0].url == events[0].url - if not self.check_compatibility_flag("no_mkcalendar") and self.check_support( + if self.check_support("create-calendar") and self.check_support( "create-calendar.set-displayname" ): - # We should be able to access the calender through the name + ## We should be able to access the calender through the name c2 = self.principal.calendar(name="Yep") - ## may break if we have multiple calendars with the same name - if not self.check_support("delete-calendar"): + ## (but may break if we have multiple calendars with the same name) + if self.check_support("delete-calendar"): assert c2.url == c.url events2 = cleanse(c2.events()) assert len(events2) == 1 @@ -1167,6 +1207,29 @@ def testCreateEvent(self): assert len(events) == len(existing_events) + 2 ev2.delete() + @pytest.mark.parametrize("klass", ["Calendar", "Event"]) + def testCreateEventFromiCal(self, klass): + c = self._fixCalendar() + try: ## TODO: remove this try-except + icalcal = icalendar.Calendar.new() + except: + ## Calendar.new() is supported from icalendar 7, which is yet to be released as of 2025-09 + pytest.skip("Newer icalendar version required") + + icalevent = icalendar.Event.new( + uid="ctuid1", + start=datetime(2015, 10, 10, 8, 7, 6), + end=datetime(2015, 10, 10, 9, 7, 6), + summary="This is a test event", + ) + icalcal.add_component(icalevent) + + ## Parametrized test - we should test both with the Calendar object and the Event object + obj = {"Calendar": icalcal, "Event": icalevent}[klass] + event = c.save_event(obj) + events = c.events() + assert len([x for x in events if x.icalendar_component["uid"] == "ctuid1"]) == 1 + def testAlarm(self): ## Ref https://github.com/python-caldav/caldav/issues/132 c = self._fixCalendar() @@ -1453,7 +1516,7 @@ def testSync(self): def testLoadEvent(self): self.skip_on_compatibility_flag("read_only") - self.skip_on_compatibility_flag("no_mkcalendar") + self.skip_unless_support("create-calendar") if not self.check_compatibility_flag( "unique_calendar_ids" ) and self.cleanup_regime in ("light", "pre"): @@ -1476,7 +1539,7 @@ def testLoadEvent(self): def testCopyEvent(self): self.skip_on_compatibility_flag("read_only") - self.skip_on_compatibility_flag("no_mkcalendar") + self.skip_unless_support("create-calendar") if not self.check_compatibility_flag( "unique_calendar_ids" ) and self.cleanup_regime in ("light", "pre"): @@ -1574,6 +1637,7 @@ def testSearchEvent(self): c = self._fixCalendar() num_existing = len(c.events()) + num_existing_t = len(c.todos()) c.save_event(ev1) c.save_event(ev3) @@ -1581,10 +1645,7 @@ def testSearchEvent(self): ## Search without any parameters should yield everything on calendar all_events = c.search() - if not self.check_support("search.comp-type-optional"): - assert len(all_events) <= 3 + num_existing - else: - assert len(all_events) == 3 + num_existing + assert len(all_events) <= 3 + num_existing + num_existing_t ## Search with comp_class set to Event should yield all events on calendar all_events = c.search(comp_class=Event) @@ -1595,7 +1656,7 @@ def testSearchEvent(self): no_events = c.search(todo=True) except: no_events = [] - assert len(no_events) == 0 + assert len(no_events) == num_existing_t ## Date search should be possible some_events = c.search( @@ -2508,7 +2569,7 @@ def testTodoDatesearch(self): ## hence is also considered to span over infinite time urls_found = [x.url for x in todos1] urls_found2 = [x.url for x in todos2] - assert urls_found == urls_found2 + assert set(urls_found) == set(urls_found2) if self.check_support("search.recurrences.includes-implicit.todo"): urls_found.remove(t6.url) if not self.check_compatibility_flag( @@ -2652,7 +2713,7 @@ def testUtf8Event(self): # TODO: what's the difference between this and testUnicodeEvent? # TODO: split up in creating a calendar with non-ascii name # and an event with non-ascii description - self.skip_on_compatibility_flag("no_mkcalendar") + self.skip_unless_support("create-calendar") if not self.check_compatibility_flag( "unique_calendar_ids" ) and self.cleanup_regime in ("light", "pre"): @@ -2685,7 +2746,7 @@ def testUtf8Event(self): def testUnicodeEvent(self): self.skip_on_compatibility_flag("read_only") - self.skip_on_compatibility_flag("no_mkcalendar") + self.skip_unless_support("create-calendar") if not self.check_compatibility_flag( "unique_calendar_ids" ) and self.cleanup_regime in ("light", "pre"): @@ -2714,7 +2775,7 @@ def testSetCalendarProperties(self): ## TODO: there are more things in this test that ## should be run even if mkcalendar is not available. - self.skip_on_compatibility_flag("no_mkcalendar") + self.skip_unless_support("create-calendar") props = c.get_properties( [ @@ -3101,7 +3162,7 @@ def testRecurringDateSearch(self): # The recurring events should not be expanded when using the # events() method r = c.events() - if not self.check_compatibility_flag("no_mkcalendar"): + if not not self.check_support("create-calendar"): assert len(r) == 1 assert r[0].data.count("END:VEVENT") == 1