diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b43bf49..87df9f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## IMPORTANT - "flapping" changeset! -In 2.0.0 I dropped the dependency on the requests library as the project is stagnant and adopted niquests, a fork of the requests library. It's a small change, and three github issues could be closed just by doing this switch. In addition niquests supports HTTP/2 and is one possible way forward for implementing async support. However, the change has proven controversial, shortly after releasing 2.0 I had to revert back to requests and release 2.0.1. Right after releasing 2.0.1, I reverted again so that the master branch is using niquests. +The requests library is stagnant, so in 2.0.0 I replaced it with a fork niquests. It's a very tiny changeset, and three github issues could be closed just by doing this switch. In addition niquests supports HTTP/2 and is one possible way forward for implementing async support. However, the change has proven controversial, shortly after releasing 2.0 I had to revert back to requests and release 2.0.1. Right after releasing 2.0.1, I reverted again so that the master branch is using niquests. My plan now is to keep doing dual releases while maintaining the 2.x-series - one with niquests and one with requests. You are encouraged to make an informed decision on weather you are most comfortable with the stable but stagnant requests, or the niquests fork and choose your version accordingly. When I'm starting to work on 3.0 (which will support async requests), I will think deeply about this and either choose niquests, httpx, or (it's always possible to hope!) requests 3.0. **Your opinion is valuable for me**. Feel free to comment on https://github.com/python-caldav/caldav/issues/457, https://github.com/python-caldav/caldav/issues/530 or https://github.com/jawah/niquests/issues/267 if you have a github account, and if not you can reach out at python-http@plann.no @@ -16,6 +16,10 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 ## [Unreleased] +### Changed + +* In 1.5.0, I moved the compability matrix from the tests directory and into the project itself - now I'm doing a major overhaul of it. This change is not much visible for end users yet - but already now it's possible to configure "compatibility hints" when setting up the davclient, and the idea is that different kind of workarounds may be applied depending on the compatibility-matrix. Search without comptype is wonky on many servers, now the `search`-method will automatically deliver a union of a search of the three different comptypes if a comptype is not set in the parameters *and* it's declared that the compatibility matrix does not work. In parallell I'm developing a stand-alone tool caldav-server-tester to check the compatibility of a caldav server. + ### Fixes * A search without filtering on comp-type on a calendar containing a mix of events, journals and tasks should return a mix of such. (All the examples in the RFC includes the comp-type filter, so many servers does not support this). There were a bug in the auto-detection of comp-type, so tasks would typically be wrapped as events or vice-versa. https://github.com/python-caldav/caldav/pull/540 diff --git a/README.md b/README.md index 7e8229b5..2009943f 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,9 @@ Features: * search events by dates * etc. -See the file [examples/basic_usage_examples.py](examples/basic_usage_examples.py) to get started. +The documentation was freshed up a bit as of version 2.0, and is available at https://caldav.readthedocs.io/ -Links: - - * [Pypi](https://pypi.org/project/caldav) - * [Documentation](docs/source/index.rst) - should be automatically mirrored on https://caldav.readthedocs.io/en/latest/ +The package is published at [Pypi](https://pypi.org/project/caldav) Licences: diff --git a/caldav/collection.py b/caldav/collection.py index ccd1a366..9a9ce4e4 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -763,7 +763,7 @@ def search( xml=None, comp_class: Optional[_CC] = None, todo: Optional[bool] = None, - include_completed: bool = False, + include_completed: bool = None, sort_keys: Sequence[str] = (), sort_reverse: bool = False, expand: bool = False, @@ -860,21 +860,18 @@ def search( if todo and not include_completed: matches1 = self.search( todo=True, - comp_class=comp_class, ignore_completed1=True, include_completed=True, **kwargs, ) matches2 = self.search( todo=True, - comp_class=comp_class, ignore_completed2=True, include_completed=True, **kwargs, ) matches3 = self.search( todo=True, - comp_class=comp_class, ignore_completed3=True, include_completed=True, **kwargs, @@ -903,30 +900,48 @@ def search( raise error.ConsistencyError( "Inconsistent usage parameters: xml together with other search options" ) + + ## For some of the workarounds below, we will do a recursive search, with all + ## those arguments: + kwargs2 = { + "include_completed": include_completed, + "sort_reverse": sort_reverse, + "expand": expand, + "server_expand": server_expand, + "split_expanded": split_expanded, + "props": props, + } + + if not comp_class and not self.client.features.check_support( + "search.comp-type-optional" + ): + if kwargs2["include_completed"] is None: + kwargs2["include_completed"] = True + objects = ( + self.search(event=True, **kwargs2, **kwargs) + + self.search(todo=True, **kwargs2, **kwargs) + + self.search(journal=True, **kwargs2, **kwargs) + ) + self.sort_objects(objects, sort_keys, sort_reverse) + return objects + try: (response, objects) = self._request_report_build_resultlist( xml, comp_class, props=props ) + except error.ReportError as err: - ## Hack for some calendar servers - ## yielding 400 if the search does not include compclass. - ## Partial fix for https://github.com/python-caldav/caldav/issues/401 - ## This assumes the client actually wants events and not tasks - ## The calendar server in question did not support tasks - ## However the most correct would probably be to join - ## events, tasks and journals. - ## TODO: we need server compatibility hints! - ## https://github.com/python-caldav/caldav/issues/402 - if not comp_class and not "400" in err.reason: + ## This is only for backward compatibility. The logic is even flawed. + ## But it does partially fix https://github.com/python-caldav/caldav/issues/401 + if ( + self.client.features.backward_compatibility_mode + and not comp_class + and not "400" in err.reason + ): return self.search( - event=True, - include_completed=include_completed, sort_keys=sort_keys, sort_reverse=sort_reverse, - expand=expand, - server_expand=server_expand, - split_expanded=split_expanded, - props=props, + *kwargs2, **kwargs, ) raise @@ -976,6 +991,17 @@ def search( for o in objects_: objects.extend(o.split_expanded()) + ## partial workaround for https://github.com/python-caldav/caldav/issues/201 + for obj in objects: + try: + obj.load(only_if_unloaded=True) + except: + pass + + self.sort_objects(objects, sort_keys, sort_reverse) + return objects + + def sort_objects(self, objects, sort_keys, sort_reverse): def sort_key_func(x): ret = [] comp = x.icalendar_component @@ -1003,7 +1029,6 @@ def sort_key_func(x): > datetime.now().strftime("%F%H%M%S") ), } - ## ref https://github.com/python-caldav/caldav/issues/448 - allow strings instead of a sequence here for sort_key in sort_keys: val = comp.get(sort_key, None) if val is None: @@ -1020,19 +1045,11 @@ def sort_key_func(x): return ret if sort_keys: + ## ref https://github.com/python-caldav/caldav/issues/448 - allow strings instead of a sequence here if isinstance(sort_keys, str): sort_keys = (sort_keys,) objects.sort(key=sort_key_func, reverse=sort_reverse) - ## partial workaround for https://github.com/python-caldav/caldav/issues/201 - for obj in objects: - try: - obj.load(only_if_unloaded=True) - except: - pass - - return objects - def build_search_xml_query( self, comp_class=None, diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 069cf2e3..961bf95d 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -1,22 +1,336 @@ # fmt: off -"""This text was updated 2025-05-17. The plan is to reorganize this -file a lot over the next few months, see -https://github.com/python-caldav/caldav/issues/402 - +""" This file serves as a database of different compatibility issues we've encountered while working on the caldav library, and descriptions on how the well-known servers behave. - -As for now, this is a list of binary "flags" that could be turned on -or off. My experience is that there are often neuances, so the -compatibility matrix will be changed from being a list of flags to a -key=value store in the near future (at least, that's the plan). - -The issues may be grouped together, maybe even organized -hierarchically. I did consider organizing the compatibility issues in -some more advanced way, but I don't want to overcomplicate things - I -will try out the key-value-approach first. """ +import copy + +## NEW STYLE +## (we're gradually moving stuff from the good old +## "incompatibility_description" below over to +## "compatibility_features") + +class FeatureSet: + """Work in progress ... TODO: write a better class description. + + This class holds the description of different behaviour observed in + a class constant. + + An object of this class describes the feature set of a server. + + TODO: use enums? + type -> "client-feature", "server-peculiarity", "tests-behaviour", "server-observation", "server-feature" (last is default) + support -> "supported" (default), "unsupported", "fragile", "broken", "ungraceful" + + types: + * client-feature means the client is supposed to do special things (like, rate-limiting). While the need for rate-limiting may be set by the server, it may not be possible to reliably establish it by probling the server, and the value may differ for different clients. + * server-peculiarity - weird behaviour detected at the server side, behaviour that is too odd to be described as "missing support for a feature". Example: there is some cache working, causing a delay from some object is sent to the server and until it can be retrieved. The difference between an "unsupported server-feature" and a "server-peculiarity" may be a bit floating - like, arguably "instant updates" may be considered a feature. + """ + FEATURES = { + "get-current-user-principal": { + "description": "Support for RFC5397, current principal extension. Most CalDAV servers have this, but it is an extension to the standard"}, + "get-current-user-principal.has-calendar": { + "type": "server-observation", + "description": "Principal has one or more calendars. Some servers and providers comes with a pre-defined calendar for each user, for other servers a calendar has to be explicitly created (supported means there exists a calendar - it may be because the calendar was already provisioned together with the principal, or it may be because a calendar was created manually, the checks can't see the difference)"}, + "rate-limit": { + "type": "client-feature", + "description": "client (or test code) must not send requests too fast", + "extra_keys": { + "interval": "Rate limiting window, in seconds", + "count": "Max number of requests to send within the interval", + }}, + "search-cache": { + "type": "server-peculiarity", + "description": "The server delivers search results from a cache which is not immediately updated when an object is changed. Hence recent changes may not be reflected in search results", + "extra_keys": { + "delay": "after this number of seconds, we may be reasonably sure that the search results are updated", + } + }, + "tests-cleanup-calendar": { + "type": "tests-behaviour", + "description": "Deleting a calendar does not delete the objects, or perhaps create/delete of calendars does not work at all. For each test run, every calendar resource object should be deleted for every test run", + }, + "create-calendar": { + "description": "RFC4791 says that \"support for MKCALENDAR on the server is only RECOMMENDED and not REQUIRED because some calendar stores only support one calendar per user (or principal), and those are typically pre-created for each account\". Hence a conformant server may opt to not support creating calendars, this is often seen for cloud services (some services allows extra calendars to be made, but not through the CalDAV protocol). (RFC4791 also says that the server MAY support MKCOL in section 8.5.2. I do read it as MKCOL may be used for creating calendars - which is weird, since section 8.5.2 is titled \"external attachments\". We should consider testing this as well)", + }, + "create-calendar.auto": { + "default": { "support": "unsupported" }, + "description": "Accessing a calendar which does not exist automatically creates it", + }, + "create-calendar.set-displayname": { + "description": "It's possible to set the displayname on a calendar upon creation" + }, + "delete-calendar": { + "description": "RFC4791 says nothing about deletion of calendars, so the server implementation is free to choose weather this should be supported or not. Section 3.2.3.2 in RFC 6638 says that if a calendar is deleted, all the calendarobjectresources on the calendar should also be deleted - but it's a bit unclear if this only applies to scheduling objects or not. Some calendar servers moves the object to a trashcan rather than deleting it" + }, + "delete-calendar.free-namespace": { + "description": "The delete operations clears the namespace, so that another calendar with the same ID/name can be created" + }, + "save-load": { + "description": "it's possible to save and load objects to the calendar" + }, + "save-load.event": {"description": "it's possible to save and load events to the calendar"}, + "save-load.event.recurrences": {"description": "it's possible to save and load recurring events to the calendar - events with an RRULE property set, including recurrence sets"}, + "save-load.todo": {"description": "it's possible to save and load tasks to the calendar"}, + "save-load.todo.recurrences": {"description": "it's possible to save and load recurring tasks to the calendar"}, + "save-load.todo.mixed-calendar": {"description": "The same calendar may contain both events and tasks (Zimbra only allows tasks to be placed on special task lists)"}, + "search": { + "description": "calendar MUST support searching for objects using the REPORT method, as specified in RFC4791, section 7" + }, + "search.comp-type-optional": { + "description": "In all the search examples in the RFC, comptype is given during a search, the client specifies if it's event or tasks or journals that is wanted. However, as I read the RFC this is not required. If omitted, the server should deliver all objects. Many servers will not return anything if the COMPTYPE filter is not set. Other servers will return 404" + }, + ## TODO - there is still quite a lot of search-related + ## stuff that hasn't been moved from the old "quirk list" + "search.time-range": { + "description": "Search for time or date ranges should work. This is specified in RFC4791, section 7.4 and section 9.9"}, + "search.time-range.todo": {"description": "basic time range searches for tasks works"}, + "search.time-range.event": {"description": "basic time range searches for event works"}, + "search.time-range.journal": {"description": "basic time range searches for journal works"}, + "search.category": { + "description": "Search for category should work. This is not explicitly specified in RFC4791, but covered in section 9.7.5. No examples targets categories explicitly, but there are some text match examples in section 7.8.6 and following sections"}, + "search.category.fullstring": { + "description": "searches on the full string categories. Meaning that a search for `category='hands,feet,head'` will match if categories is set so, but it may not necessary match with `CATEGORIES:head,feet,hands`"}, + "search.category.fullstring.smart": { + "description": "For an event with `CATEGORIES:hands,feet,head` we'll also get a match when searching for \"feet,hands,head\"" + }, + "search.recurrences": { + "description": "Support for recurrences in search" + }, + "search.recurrences.includes-implicit": { + "description": "RFC 4791, section 7.4 says that the server MUST expand recurring components to determine whether any recurrence instances overlap the specified time range. Considered supported i.e. if a search for 2005 yields a yearly event happening first time in 2004.", + "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-7.4"], + }, + "search.recurrences.includes-implicit.todo": { + "description": "tasks can also be recurring" + }, + "search.recurrences.includes-implicit.event": { + "description": "support for events" + }, + "search.recurrences.includes-implicit.infinite-scope": { + "description": "Needless to say, search on any future date range, no matter how far out in the future, should yield the recurring object" + }, + "search.recurrences.expanded": { + "description": "According to RFC 4791, the server MUST expand recurrence objects if asked for it - but many server doesn't do that. Some servers don't do expand at all, others deliver broken data, typically missing RECURRENCE-ID. The python caldav client library (from 2.0) does the expand-operation client-side no matter if it's supported or not", + "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5"], + }, + "search.recurrences.expanded.todo": { + "description": "examding tasks" + }, + "search.recurrences.expanded.event": { + "description": "examding events" + }, + "search.recurrences.expanded.exception": { + "description": "Server expand should work correctly also if a recurrence set with exceptions is given" + }, + } + + def __init__(self, feature_set_dict=None): + """ + TODO: describe the feature_set better. + + Should be a dict on the same style as self.FEATURES, but different. + + Shortcuts accepted in the dict, like: + + { + "recurrences.search-includes-implicit-recurrences.infinite-scope": + "unsupported" } + + is equivalent with + + { + "recurrences": { + "features": { + "search-includes-inplicit-recurrences": { + "infinite-scope": + "support": "unsupported" }}}} + + (TODO: is this sane? Am I reinventing a configuration language?) + """ + if isinstance(feature_set_dict, FeatureSet): + self._server_features = copy.deepcopy(feature_set_dict._server_features) + + ## TODO: copy the FEATURES dict, or just the feature_set dict? + ## (anyways, that is an internal design decision that may be + ## changed ... but we need test code in place) + self.backward_compatibility_mode = feature_set_dict is None + self._server_features = {} + if feature_set_dict: + self.copyFeatureSet(feature_set_dict) + + ## TODO: Why is this camelCase while every other method is with under_score? rename ... + def copyFeatureSet(self, feature_set, collapse=True): + for feature in feature_set: + ## TODO: temp - should be removed + if feature == 'old_flags': + continue + feature_info = self.find_feature(feature) + value = feature_set[feature] + if not feature in self._server_features: + self._server_features[feature] = {} + server_node = self._server_features[feature] + if isinstance(value, bool): + server_node['support'] = "full" if value else "unsupported" + elif isinstance(value, str) and not 'support' in server_node: + server_node['support'] = value + elif isinstance(value, dict): + server_node.update(value) + else: + assert False + if collapse: + self.collapse() + + def collapse(self): + """ + If all subfeatures are the same, it should be collapsed into the parent + """ + features = list(self._server_features.keys()) + parents = set() + for feature in features: + if '.' in feature: + parents.add(feature[:feature.rfind('.')]) + parents = list(parents) + ## Parents needs to be ordered by the number of dots. We proceed those with most dots first. + parents.sort(key = lambda x: (-x.count('.'), x)) + for parent in parents: + parent_info = self.find_feature(parent) + + if len(parent_info['subfeatures']): + foo = self.check_support(parent, return_type=dict, return_defaults=False) + dont_collapse = False + for sub in parent_info['subfeatures']: + bar = self._server_features.get(f"{parent}.{sub}") + if bar is None: + dont_collapse = True + break + if foo is None: + foo = bar + elif bar != foo: + dont_collapse = True + break + if not dont_collapse: + if not parent in self._server_features: + self._server_features[parent] = {} + for sub in parent_info['subfeatures']: + self._server_features.pop(f"{parent}.{sub}") + self.copyFeatureSet({parent: foo}) + + def _default(self, feature_info): + if isinstance(feature_info, str): + feature_info = self.find_feature(feature_info) + if 'default' in feature_info: + return feature_info['default'] + feature_type = feature_info.get('type', 'server-feature') + ## TODO: move the default values up to some constant dict probably, like self.DEFAULTS = { "server-feature": {...}} + if feature_type == 'server-feature': + return { "support": "full" } + elif feature_type == 'client-feature': + return { "enable": False } + elif feature_type == 'server-peculiarity': + return { "behaviour": "normal" } + elif feature_type == 'server-observation': + return { "observed": True } + else: + breakpoint() + + def check_support(self, feature, return_type=bool, return_defaults=True): + """Work in progress + + TODO: rename. This method does not do any checking, just a + lookup. "get_support" sounds wrong, but perhaps + "lookup_support"? + + TODO: write a better docstring + + """ + feature_info = self.find_feature(feature) + feature_ = feature + while True: + if feature_ in self._server_features: + return self._convert_node(self._server_features[feature_], feature_info, return_type) + if not '.' in feature_: + if not return_defaults: + return None + return self._convert_node(self._default(feature_info), feature_info, return_type) + feature_ = feature_[:feature_.rfind('.')] + + def _convert_node(self, node, feature_info, return_type): + if return_type == str: + ## TODO: consider feature_info['type'], be smarter about it + return node.get('support', node.get('enable', node.get('behaviour'))) + elif return_type == dict: + return node + elif return_type == bool: + ## TODO: consider feature_info['type'], be smarter about this + return node.get('support', 'full') == 'full' and not node.get('enable') and not node.get('behaviour') and not node.get('observed') + else: + assert False + + @classmethod + def find_feature(cls, feature: str) -> dict: + """ + Feature should be a string like feature.subfeature.subsubfeature. + + Looks through the FEATURES list and returns the relevant section. + + Will raise an Error if feature is not found + + (this is very simple now - used to be a hierarchy dict to be traversed) + """ + assert feature in cls.FEATURES ## 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]: + cls.FEATURES[feature]['parent'] = cls.find_feature(feature[:feature.rfind('.')]) + if not 'subfeatures' in cls.FEATURES[feature]: + tree = cls.feature_tree() + for x in feature.split('.'): + tree = tree[x] + cls.FEATURES[feature]['subfeatures'] = tree + return cls.FEATURES[feature] + + @classmethod + def _dots_to_tree(cls, target, source): + for feat in source: + node = target + path = feat.split('.') + for part in path: + if not part in node: + node[part] = {} + node = node[part] + return target + + @classmethod + def feature_tree(cls) -> dict: + """A "path" may have several "subpaths" in self.FEATURES + (i.e. feat.subfeat.A, feat.subfeat.B, feat.subfeat.C) + + This method will return `{'feat': { 'subfeat': {'A': {}, ...}}}` + making it possible to traverse the feature tree + """ + ## I'm an old fart, grown up in an age where CPU-cycles was considered + ## expensive ... so I always cache things when possible ... + if hasattr(cls, '_feature_tree'): + return cls._feature_tree + cls._feature_tree = {} + cls._dots_to_tree(cls._feature_tree, cls.FEATURES) + return cls._feature_tree + + def dotted_feature_set_list(self, compact=False): + ret = {} + if compact: + self.collapse() + for x in self._server_features: + feature = self._server_features[x] + if compact and feature == self._default(x): + continue + ret[x] = feature.copy() + return ret + +#### OLD STYLE + ## The lists below are specifying what tests should be skipped or ## modified to accept non-conforming resultsets from the different ## calendar servers. In addition there are some hacks in the library @@ -30,26 +344,6 @@ ## * Perhaps some more readable format should be considered (yaml?). ## * Consider how to get this into the documentation incompatibility_description = { - 'rate_limited': - """It may be needed to pause a bit between each request when doing tests""", - - 'search_delay': - """Server populates indexes through some background job, so it takes some time from an event is added/edited until it's possible to search for it""", - - 'cleanup_calendar': - """Remove everything on the calendar for every test""", - - 'no_delete_calendar': - """Not allowed to delete calendars - or calendar ends up in a 'trashbin'""", - - 'broken_expand': - """Server-side expand seems to work, but delivers wrong data (typically missing RECURRENCE-ID)""", - - 'no_expand': - """Server-side expand does not seem to work""", - - 'broken_expand_on_exceptions': - """The testRecurringDateWithExceptionSearch test breaks as the icalendar_component is missing a RECURRENCE-ID field. TODO: should be investigated more""", 'inaccurate_datesearch': """A date search may yield results outside the search interval""", @@ -66,20 +360,9 @@ 'no_current-user-principal': """Current user principal not supported by the server (flag is ignored by the tests as for now - pass the principal URL as the testing URL and it will work, albeit with one warning""", - 'no_recurring': - """Server is having issues with recurring events and/or todos. """ - """date searches covering recurrances may yield no results, """ - """and events/todos may not be expanded with recurrances""", - 'no_alarmsearch': """Searching for alarms may yield too few or too many or even a 500 internal server error""", - 'no_recurring_todo': - """Recurring events are supported, but not recurring todos""", - - 'no_recurring_todo_expand': - """Recurring todos aren't expanded (this is ignored by the tests now, as we're doing client-side expansion)""", - 'no_scheduling': """RFC6833 is not supported""", @@ -93,10 +376,6 @@ """The given user starts without an assigned default calendar """ """(or without pre-defined calendars at all)""", - 'non_existing_calendar_found': - """Server will not yield a 404 when accessing a random calendar URL """ - """(perhaps the calendar will be automatically created on access)""", - 'no_freebusy_rfc4791': """Server does not support a freebusy-request as per RFC4791""", @@ -112,10 +391,6 @@ 'no_journal': """Server does not support journal entries""", - 'no_displayname': - """The display name of a calendar cannot be set/changed """ - """(in zimbra, display name is given from the URL)""", - 'duplicates_not_allowed': """Duplication of an event in the same calendar not allowed """ """(even with different uid)""", @@ -166,9 +441,6 @@ 'no_todo_on_standard_calendar': """Tasklists can be created, but a normal calendar does not support tasks""", - 'no_todo_datesearch': - """Date search on todo items fails""", - 'vtodo_datesearch_nodtstart_task_is_skipped': """date searches for todo-items will not find tasks without a dtstart""", @@ -223,9 +495,6 @@ 'dav_not_supported': """when asked, the server may claim it doesn't support the DAV protocol. Observed by one baikal server, should be investigated more (TODO) and robur""", - 'category_search_yields_nothing': - """When querying for a text match report over fields like the category field, server returns nothing""", - 'text_search_is_case_insensitive': """Probably not supporting the collation used by the caldav library""", @@ -271,12 +540,6 @@ 'isnotdefined_not_working': """The is-not-defined in a calendar-query not working as it should - see https://gitlab.com/davical-project/davical/-/issues/281""", - 'search_needs_comptype': - """The server may not always come up with anything useful when searching for objects and omitting to specify weather one wants to see tasks or events. https://github.com/python-caldav/caldav/issues/401""", - - 'search_always_needs_comptype': - """calendar.mail.ru: the server throws 400 when searching for objects and omitting to specify weather one wants to see tasks or events. `calendar.objects()` throws 404, even if there are events. https://github.com/python-caldav/caldav/issues/401""", - 'robur_rrule_freq_yearly_expands_monthly': """Robur expands a yearly event into a monthly event. I believe I've reported this one upstream at some point, but can't find back to it""", @@ -286,22 +549,25 @@ 'no_search_openended': """An open-ended search will not work""", - 'no_events_and_tasks_on_same_calendar': - """Zimbra has the concept of task lists ... a calendar must either be a calendar with only events, or it can be a task list, but those must never be mixed""" } -xandikos = [ +## This is for Xandikos 0.2.12. +## Lots of development going on as of summer 2025, so expect the list to become shorter soon! +xandikos = { + 'search.recurrences.includes-implicit': {'support': 'unsupported'}, + 'search.recurrences.expanded': {'support': 'unsupported'}, + 'search.time-range.todo': {'support': 'unsupported'}, + 'search.comp-type-optional': {'support': 'ungraceful'}, + "search.category.fullstring": {"support": "unsupported"}, + "old_flags": [ ## https://github.com/jelmer/xandikos/issues/8 - "no_recurring", - 'date_todo_search_ignores_duration', - 'text_search_is_exact_match_only', - "search_needs_comptype", 'vtodo_datesearch_nostart_future_tasks_delivered', ## scheduling is not supported "no_scheduling", 'no-principal-search', + 'text_search_is_exact_match_only', ## The test in the tests itself passes, but the test in the ## check_server_compatibility triggers a 500-error @@ -316,15 +582,19 @@ ## No alarm search (500 internal server error) "no_alarmsearch", -] + ] +} -## TODO - there has been quite some development in radicale recently, so this list -## should probably be gone through -radicale = [ +## This seems to work as of version 3.5.4 of Radicale. +## There is much development going on at Radicale as of summar 2025, +## so I'm expecting this list to shrink a lot soon. +radicale = { + "search.comp-type-optional": {"support": "ungraceful"}, + "search.category.fullstring": {"support": "unsupported"}, + "search.recurrences.expanded": {"support": "unsupported"}, ## This was apparently broken in commit 9d591bd5144c97ae3803512b6c22cd5ce1dfd0f9 and 371d5057de6a1f729d198ab738dd6e19c9e55099 - issue has been raised in https://github.com/Kozea/Radicale/issues/1812#issuecomment-3067913171 + 'old_flags': [ ## calendar listings and calendar creation works a bit ## "weird" on radicale - "broken_expand", - "no_default_calendar", "no_alarmsearch", ## This is fixed and will be released soon ## freebusy is not supported yet, but on the long-term road map @@ -333,23 +603,43 @@ "no-principal-search-self", ## this may be because we haven't set up any users or authentication - so the display name of the current user principal is None 'no_scheduling', - "no_todo_datesearch", 'text_search_is_case_insensitive', + 'combined_search_not_working', #'text_search_is_exact_match_sometimes', - "search_needs_comptype", ## extra features not specified in RFC5545 "calendar_order", "calendar_color" -] + ] +} + +ecloud = { + 'delete-calendar': { + 'support': 'fragile', + 'behaviour': 'Deleting a recently created table fails'}, + 'delete-calendar.free-namespace': { + 'support': 'unsupported', + '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', + }, + 'rate-limit': { + 'enable': True, + 'interval': 25, + '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'], +} ## ZIMBRA IS THE MOST SILLY, AND THERE ARE REGRESSIONS FOR EVERY RELEASE! ## AAARGH! -zimbra = [ - ## no idea why this breaks - "non_existing_calendar_found", - +zimbra = { + 'create-calendar.set-displayname': {'support': 'unsupported'}, + 'save-load.todo.mixed-calendar': {'support': 'unsupported'}, + 'search.category': {'support': 'ungraceful'}, + '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 'no_journal', @@ -359,18 +649,15 @@ ## earlier versions of Zimbra display-name could be changed, but ## then the calendar would not be available on the old URL ## anymore) - 'no_displayname', 'duplicate_in_other_calendar_with_same_uid_is_lost', 'event_by_url_is_broken', 'no_delete_event', 'no_sync_token', 'vtodo_datesearch_notime_task_is_skipped', - 'category_search_yields_nothing', 'text_search_is_exact_match_only', 'no_relships', 'isnotdefined_not_working', "no_alarmsearch", - "no_events_and_tasks_on_same_calendar", "no-principal-search", ## TODO: I just discovered that when searching for a date some @@ -380,10 +667,10 @@ ## extra features not specified in RFC5545 "calendar_order", "calendar_color" - - ## TODO: there is more, it should be organized and moved here. + ] + ## TODO: there may be more, it should be organized and moved here. ## Search for 'zimbra' in the code repository! -] +} bedework = [ ## quite a lot of things were missing in Bedework last I checked - @@ -404,7 +691,6 @@ ## (TODO: do some research on this) 'sync_breaks_on_delete', 'no_recurring_todo', - 'non_existing_calendar_found', 'combined_search_not_working', 'text_search_is_exact_match_sometimes', @@ -414,17 +700,17 @@ ] ## See comments on https://github.com/python-caldav/caldav/issues/3 -icloud = [ - 'unique_calendar_ids', - 'duplicate_in_other_calendar_with_same_uid_breaks', - 'sticky_events', - 'no_journal', ## it threw a 500 internal server error! - 'no_todo', - "no_freebusy_rfc4791", - 'no_recurring', - 'propfind_allprop_failure', - 'object_by_uid_is_broken' -] +#icloud = [ +# 'unique_calendar_ids', +# 'duplicate_in_other_calendar_with_same_uid_breaks', +# 'sticky_events', +# 'no_journal', ## it threw a 500 internal server error! +# 'no_todo', +# "no_freebusy_rfc4791", +# 'no_recurring', +# 'propfind_allprop_failure', +# 'object_by_uid_is_broken' +#] davical = [ #'no_journal', ## it threw a 500 internal server error! ## for old versions @@ -439,26 +725,26 @@ "no_alarmsearch", ] -google = [ - 'no_mkcalendar', - 'no_overwrite', - 'no_todo', -] +#google = [ +# 'no_mkcalendar', +# 'no_overwrite', +# 'no_todo', +#] ## https://www.sogo.nu/bugs/view.php?id=3065 ## left a note about time-based sync tokens on https://www.sogo.nu/bugs/view.php?id=5163 ## https://www.sogo.nu/bugs/view.php?id=5282 ## https://bugs.sogo.nu/view.php?id=5693 ## https://bugs.sogo.nu/view.php?id=5694 -sogo = [ ## and in addition ... the requests are efficiently rate limited, as it spawns lots of postgresql connections all until it hits a limit, after that it's 501 errors ... - "time_based_sync_tokens", - "search_needs_comptype", - "fastmail_buggy_noexpand_date_search", - "text_search_not_working", - "isnotdefined_not_working", - 'no_journal', - 'no_freebusy_rfc4791' -] +#sogo = [ ## and in addition ... the requests are efficiently rate limited, as it spawns lots of postgresql connections all until it hits a limit, after that it's 501 errors ... +# "time_based_sync_tokens", +# "search_needs_comptype", +# "fastmail_buggy_noexpand_date_search", +# "text_search_not_working", +# "isnotdefined_not_working", +# 'no_journal', +# 'no_freebusy_rfc4791' +#] nextcloud = [ 'date_search_ignores_duration', @@ -476,23 +762,23 @@ 'broken_expand_on_exceptions' ] -fastmail = [ - 'duplicates_not_allowed', - 'duplicate_in_other_calendar_with_same_uid_breaks', - 'no_todo', - 'sticky_events', - 'fastmail_buggy_noexpand_date_search', - 'combined_search_not_working', - 'text_search_is_exact_match_sometimes', - 'rrule_takes_no_count', - 'isnotdefined_not_working', -] - -synology = [ - "fragile_sync_tokens", - "vtodo_datesearch_notime_task_is_skipped", - "no_recurring_todo", -] +#fastmail = [ +# 'duplicates_not_allowed', +# 'duplicate_in_other_calendar_with_same_uid_breaks', +# 'no_todo', +# 'sticky_events', +# 'fastmail_buggy_noexpand_date_search', +# 'combined_search_not_working', +# 'text_search_is_exact_match_sometimes', +# 'rrule_takes_no_count', +# 'isnotdefined_not_working', +#] + +#synology = [ +# "fragile_sync_tokens", +# "vtodo_datesearch_notime_task_is_skipped", +# "no_recurring_todo", +#] robur = [ 'non_existing_raises_other', ## AuthorizationError instead of NotFoundError @@ -522,27 +808,24 @@ "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. - 'no_current-user-principal', - 'no_todo', - 'no_journal', - 'search_always_needs_comptype', - 'no_sync_token', ## don't know if sync tokens are supported or not - the sync-token-code needs some workarounds ref https://github.com/python-caldav/caldav/issues/401 - 'text_search_not_working', - 'isnotdefined_not_working', - 'no_scheduling_mailbox', - 'no_freebusy_rfc4791', - 'no_relships', ## mail.ru recreates the icalendar content, and strips everything it doesn't know anyhting about, including relationship info -] +#calendar_mail_ru = [ +# 'no_mkcalendar', ## weird. It was working in early June 2024, then it stopped working in mid-June 2024. +# 'no_current-user-principal', +# 'no_todo', +# 'no_journal', +# 'search_always_needs_comptype', +# 'no_sync_token', ## don't know if sync tokens are supported or not - the sync-token-code needs some workarounds ref https://github.com/python-caldav/caldav/issues/401 +# 'text_search_not_working', +# 'isnotdefined_not_working', +# 'no_scheduling_mailbox', +# 'no_freebusy_rfc4791', +# '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', - ## Not a breach of standard - 'non_existing_calendar_found', - ## Known, not a breach of standard 'no_supported_components_support', diff --git a/caldav/davclient.py b/caldav/davclient.py index 5375c669..a11cce3c 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -26,6 +26,7 @@ from caldav.collection import Calendar from caldav.collection import CalendarSet from caldav.collection import Principal +from caldav.compatibility_hints import FeatureSet from caldav.elements import cdav from caldav.elements import dav from caldav.lib import error @@ -458,6 +459,7 @@ def __init__( ssl_cert: Union[str, Tuple[str, str], None] = None, headers: Mapping[str, str] = None, huge_tree: bool = False, + features: Union[FeatureSet, dict] = None, ) -> None: """ Sets up a HTTPConnection object towards the server in the url. @@ -471,6 +473,7 @@ def __init__( server etc. ssl_verify_cert can be the path of a CA-bundle or False. huge_tree: boolean, enable XMLParser huge_tree to handle big events, beware of security issues, see : https://lxml.de/api/lxml.etree.XMLParser-class.html + features: The default, None, will in version 2.x enable all existing workarounds in the code for backward compability. Otherwise it will expect a FeatureSet or a dict as defined in `caldav.compatibility_hints` and use that to figure out what workarounds are needed. The niquests library will honor a .netrc-file, if such a file exists username and password may be omitted. @@ -491,6 +494,7 @@ def __init__( log.debug("url: " + str(url)) self.url = URL.objectify(url) self.huge_tree = huge_tree + self.features = FeatureSet(features) # Prepare proxy info if proxy is not None: _proxy = proxy @@ -1087,19 +1091,21 @@ def get_davclient( try: from conf import client - idx = None - if name: + idx = os.environ.get("PYTHON_CALDAV_TEST_SERVER_IDX") + try: + idx = int(idx) + except (ValueError, TypeError): + idx = None + name = name or os.environ.get("PYTHON_CALDAV_TEST_SERVER_NAME") + if name and not idx: try: idx = int(name) name = None except ValueError: pass - try: - conn = client(idx, name) - if conn: - return conn - except: - error.weirdness("traceback from client()") + conn = client(idx, name) + if conn: + return conn except ImportError: pass finally: diff --git a/pyproject.toml b/pyproject.toml index 0eae110e..793d7efe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,8 @@ test = [ "tzlocal", "xandikos", "radicale", - "pyfakefs" + "pyfakefs", + #"caldav_server_tester" ] [tool.setuptools_scm] write_to = "caldav/_version.py" diff --git a/tests/conf.py b/tests/conf.py index e5f7bc73..4f226e1a 100644 --- a/tests/conf.py +++ b/tests/conf.py @@ -11,6 +11,7 @@ import niquests from caldav import compatibility_hints +from caldav.compatibility_hints import FeatureSet from caldav.davclient import CONNKEYS from caldav.davclient import DAVClient @@ -144,7 +145,7 @@ def teardown_radicale(self): "username": "user1", "password": "", "backwards_compatibility_url": url + "user1", - "incompatibilities": compatibility_hints.radicale, + "features": compatibility_hints.radicale, "setup": setup_radicale, "teardown": teardown_radicale, } @@ -229,7 +230,7 @@ def silly_request(): "name": "LocalXandikos", "url": url, "backwards_compatibility_url": url + "sometestuser", - "incompatibilities": compatibility_hints.xandikos, + "features": compatibility_hints.xandikos, "setup": setup_xandikos, "teardown": teardown_xandikos, } @@ -257,6 +258,7 @@ def client( elif no_args: return None for bad_param in ( + "features", "incompatibilities", "backwards_compatibility_url", "principal_url", @@ -274,7 +276,7 @@ def client( conn = DAVClient(**kwargs_) conn.setup = setup conn.teardown = teardown - conn.incompatibilities = kwargs.get("incompatibilities") + conn.features = FeatureSet(kwargs.get("features")) conn.server_name = name return conn diff --git a/tests/conf_private.py.EXAMPLE b/tests/conf_private.py.EXAMPLE index 030af76a..b6e774b3 100644 --- a/tests/conf_private.py.EXAMPLE +++ b/tests/conf_private.py.EXAMPLE @@ -30,8 +30,8 @@ caldav_servers = [ ## incompatibilities is a list of flags that can be set for ## skipping (parts) of certain tests. See ## compatibility_hints.py for premade lists - #'incompatibilities': compatibility_hints.nextcloud - 'incompatibilities': [], + #'features': compatibility_hints.nextcloud + 'features': [], ## You may even add setup and teardown methods to set up ## and rig down the calendar server diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 478d4821..64ad2695 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -8,6 +8,7 @@ belong in test_caldav_unit.py """ import codecs +import copy import json import logging import os @@ -40,7 +41,10 @@ from .conf import test_xandikos from .conf import xandikos_host from .conf import xandikos_port -from caldav import compatibility_hints +from caldav.compatibility_hints import FeatureSet +from caldav.compatibility_hints import ( + incompatibility_description, +) ## TEMP - should be removed in the future from caldav.davclient import DAVClient from caldav.davclient import DAVResponse from caldav.davclient import get_davclient @@ -660,25 +664,45 @@ class RepeatedFunctionalTestsBaseClass: _default_calendar = None + ## TODO: move to davclient or compatibility_hints + def check_support(self, feature, return_type=bool): + """ + New-style. It will replace check_compatibility_flag. + + TODO: write a better docstring + """ + return self.features.check_support(feature, return_type) + def check_compatibility_flag(self, flag): ## yield an assertion error if checking for the wrong thig - assert flag in compatibility_hints.incompatibility_description - return flag in self.incompatibilities + assert flag in incompatibility_description + return flag in self.old_features def skip_on_compatibility_flag(self, flag): if self.check_compatibility_flag(flag): - msg = compatibility_hints.incompatibility_description[flag] + msg = incompatibility_description[flag] + pytest.skip("Test skipped due to server incompatibility issue: " + msg) + + def skip_unless_support(self, feature): + if not self.check_support(feature): + msg = self.features.find_feature(feature).get("description", feature) pytest.skip("Test skipped due to server incompatibility issue: " + msg) def setup_method(self): logging.debug("############## test setup") - self.incompatibilities = set() self.cleanup_regime = self.server_params.get("cleanup", "light") self.calendars_used = [] - for flag in self.server_params.get("incompatibilities", []): - assert flag in compatibility_hints.incompatibility_description - self.incompatibilities.add(flag) + features = self.server_params.get("features", {}) + + ## Temp thing + self.old_features = features.get("old_flags", []) + + self.features = FeatureSet(self.server_params.get("features", {})) + + ## verify that all old flags are valid + for flag in self.old_features: + assert flag in incompatibility_description if self.check_compatibility_flag("unique_calendar_ids"): self.testcal_id = "testcalendar-" + str(uuid.uuid4()) @@ -690,11 +714,14 @@ def setup_method(self): self.caldav = client(**self.server_params) self.caldav.__enter__() - if self.check_compatibility_flag("rate_limited"): - self.caldav.request = _delay_decorator(self.caldav.request) - if self.check_compatibility_flag("search_delay"): + foo = self.check_support("rate-limit", dict) + if foo.get("enable"): + rate_delay = foo["interval"] / foo["count"] + self.caldav.request = _delay_decorator(self.caldav.request, t=rate_delay) + foo = self.check_support("search-cache", dict) + if foo.get("behaviour") == "delay": Calendar._search = Calendar.search - Calendar.search = _delay_decorator(Calendar.search) + Calendar.search = _delay_decorator(Calendar.search, t=foo["delay"]) if False and self.check_compatibility_flag("no-current-user-principal"): self.principal = Principal( @@ -714,7 +741,10 @@ def setup_method(self): logging.debug("##############################") def teardown_method(self): - if self.check_compatibility_flag("search_delay"): + if ( + self.check_support("search-cache", dict).get("behaviour", "normal") + != "normal" + ): Calendar.search = Calendar._search logging.debug("############################") logging.debug("############## test teardown_method") @@ -758,9 +788,9 @@ 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 self.check_compatibility_flag("no_delete_calendar"): + if self.check_compatibility_flag("sticky_events") or not self.check_support( + "delete-calendar" + ): for goo in cal.objects(): try: goo.delete() @@ -800,7 +830,7 @@ def _fixCalendar(self, **kwargs): "unique_calendar_ids" ) and self.cleanup_regime in ("light", "pre"): self._teardownCalendar(cal_id=self.testcal_id) - if self.check_compatibility_flag("no_displayname"): + if not self.check_support("create-calendar.set-displayname"): kwargs["name"] = None else: kwargs["name"] = "Yep" @@ -812,13 +842,48 @@ def _fixCalendar(self, **kwargs): ## "calendar already exists" can be ignored (at least ## if no_delete_calendar flag is set) ret = self.principal.calendar(cal_id=kwargs["cal_id"]) - if self.check_compatibility_flag("search_always_needs_comptype"): - ret.objects = lambda load_objects: ret.events() if self.cleanup_regime == "post": self.calendars_used.append(ret) - return ret + def testCheckCompatibility(self): + try: + from caldav_server_tester import ServerQuirkChecker + except: + pytest.skip("caldav_server_tester is not installed") + checker = ServerQuirkChecker(self.caldav) + checker.check_all() + + ## TODO: I think the compact view now strips out some client-side behaviour. + ## I think it shouldn't - we should rather do the stripping below + observed = checker.features_checked.dotted_feature_set_list(compact=True) + expected = self.caldav.features.dotted_feature_set_list(compact=True) + + ## This is to facilitate easier debugging. In the end, + ## observed_ and expected_ should match eatch other, while + ## observed and expected may contain more information. + observed_ = copy.deepcopy(observed) + expected_ = copy.deepcopy(expected) + + ## Strip out server-observations (which are unreliable) + ## and client-features (which cannot be reliably checked) + 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"): + for target in observed_, expected_: + if x in target: + target.pop(x) + + ## Strip all free-text information from both observed and expected + for stripdict in observed_, expected_: + for x in stripdict: + for y in ("behaviour", "description"): + if y in stripdict[x]: + stripdict[x].pop(y) + + assert observed_ == expected_ + def testSupport(self): """ Test the check_*_support methods @@ -922,7 +987,7 @@ def testGetCalendarHomeSet(self): assert "{urn:ietf:params:xml:ns:caldav}calendar-home-set" in chs def testGetDefaultCalendar(self): - self.skip_on_compatibility_flag("no_default_calendar") + self.skip_unless_support("get-current-user-principal.has-calendar") assert len(self.principal.calendars()) != 0 def testSearchShouldYieldData(self): @@ -953,7 +1018,7 @@ def testGetCalendar(self): ## Not sure if those asserts make much sense, the main point here is to exercise ## the __str__ and __repr__ methods on the Calendar object. - if not self.check_compatibility_flag("no_displayname"): + if self.check_support("create-calendar.set-displayname"): name = c.get_property(dav.DisplayName(), use_cached=True) if not name: name = c.url @@ -992,7 +1057,7 @@ def testPrincipals(self): def testCreateDeleteCalendar(self): self.skip_on_compatibility_flag("no_mkcalendar") self.skip_on_compatibility_flag("read_only") - self.skip_on_compatibility_flag("no_delete_calendar") + self.skip_unless_support("delete-calendar") if not self.check_compatibility_flag( "unique_calendar_ids" ) and self.cleanup_regime in ("light", "pre"): @@ -1006,10 +1071,9 @@ def testCreateDeleteCalendar(self): assert len(events) == 0 c.delete() - # this breaks with zimbra and radicale - if not self.check_compatibility_flag("non_existing_calendar_found"): + if self.check_support("create-calendar.auto"): with pytest.raises(self._notFound()): - self.principal.calendar(name="Yep", cal_id=self.testcal_id).events() + self.principal.calendar(name="Yapp", cal_id="shouldnotexist").events() def testChangeAttendeeStatusWithEmailGiven(self): self.skip_on_compatibility_flag("read_only") @@ -1080,13 +1144,13 @@ def testCreateEvent(self): assert len(events2) == 1 assert events2[0].url == events[0].url - if not self.check_compatibility_flag( - "no_mkcalendar" - ) and not self.check_compatibility_flag("no_displayname"): + if not self.check_compatibility_flag("no_mkcalendar") and self.check_support( + "create-calendar.set-displayname" + ): # 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_compatibility_flag("no_delete_calendar"): + if not self.check_support("delete-calendar"): assert c2.url == c.url events2 = cleanse(c2.events()) assert len(events2) == 1 @@ -1174,10 +1238,10 @@ def testObjectBySyncToken(self): objcnt += len(c.events()) obj = c.save_event(ev1) objcnt += 1 - if not self.check_compatibility_flag("no_recurring"): + if self.check_support("save-load.event.recurrences"): c.save_event(evr) objcnt += 1 - if not self.check_compatibility_flag("no_todo"): + if self.check_support("save-load.todo"): c.save_todo(todo) c.save_todo(todo2) c.save_todo(todo3) @@ -1303,10 +1367,10 @@ def testSync(self): objcnt += len(c.events()) obj = c.save_event(ev1) objcnt += 1 - if not self.check_compatibility_flag("no_recurring"): + if self.check_support("save-load.event.recurrences"): c.save_event(evr) objcnt += 1 - if not self.check_compatibility_flag("no_todo"): + if not self.check_support("save-load.todo"): c.save_todo(todo) c.save_todo(todo2) c.save_todo(todo3) @@ -1517,7 +1581,7 @@ def testSearchEvent(self): ## Search without any parameters should yield everything on calendar all_events = c.search() - if self.check_compatibility_flag("search_needs_comptype"): + if not self.check_support("search.comp-type-optional"): assert len(all_events) <= 3 + num_existing else: assert len(all_events) == 3 + num_existing @@ -1579,10 +1643,10 @@ def testSearchEvent(self): ## category some_events = c.search(comp_class=Event, category="PERSONAL") - if not self.check_compatibility_flag("category_search_yields_nothing"): + if self.check_support("search.category"): assert len(some_events) == 1 some_events = c.search(comp_class=Event, category="personal") - if not self.check_compatibility_flag("category_search_yields_nothing"): + if self.check_support("search.category"): if self.check_compatibility_flag("text_search_is_case_insensitive"): assert len(some_events) == 1 else: @@ -1601,7 +1665,7 @@ def testSearchEvent(self): assert len(some_events) in (0, 1) if self.check_compatibility_flag("text_search_is_exact_match_only"): assert len(some_events) == 0 - elif not self.check_compatibility_flag("category_search_yields_nothing"): + elif self.check_support("search.category.fullstring"): assert len(some_events) == 1 ## I expect "logical and" when combining category with a date range @@ -1611,8 +1675,8 @@ def testSearchEvent(self): start=datetime(2006, 7, 13, 13, 0), end=datetime(2006, 7, 15, 13, 0), ) - if not self.check_compatibility_flag( - "category_search_yields_nothing" + if ( + self.check_support("search.category") ) and not self.check_compatibility_flag("combined_search_not_working"): assert len(no_events) == 0 some_events = c.search( @@ -1621,9 +1685,9 @@ def testSearchEvent(self): start=datetime(1997, 11, 1, 13, 0), end=datetime(1997, 11, 3, 13, 0), ) - if not self.check_compatibility_flag( - "category_search_yields_nothing" - ) and not self.check_compatibility_flag("combined_search_not_working"): + if self.check_support("search.category") and not self.check_compatibility_flag( + "combined_search_not_working" + ): assert len(some_events) == 1 some_events = c.search(comp_class=Event, summary="Bastille Day Party") @@ -1787,7 +1851,7 @@ def testSearchTodos(self): ## Search without any parameters should yield everything on calendar all_todos = c.search() - if self.check_compatibility_flag("search_needs_comptype"): + if not self.check_support("search.comp-type-optional"): assert len(all_todos) <= 6 + pre_cnt else: assert len(all_todos) == 6 + pre_cnt @@ -1820,14 +1884,14 @@ def testSearchTodos(self): ## category ## Too much copying of the examples ... some_todos = c.search(comp_class=Todo, category="FINANCE") - if not self.check_compatibility_flag( - "category_search_yields_nothing" + if ( + self.check_support("search.category") ) and not self.check_compatibility_flag("text_search_not_working"): assert len(some_todos) == 6 + pre_cnt some_todos = c.search(comp_class=Todo, category="finance") - if not self.check_compatibility_flag( - "category_search_yields_nothing" - ) and not self.check_compatibility_flag("text_search_not_working"): + if self.check_support("search.category") and not self.check_compatibility_flag( + "text_search_not_working" + ): if self.check_compatibility_flag("text_search_is_case_insensitive"): assert len(some_todos) == 6 + pre_cnt else: @@ -1846,8 +1910,8 @@ def testSearchTodos(self): assert len(some_todos) - pre_cnt in (0, 6) elif self.check_compatibility_flag("text_search_is_exact_match_only"): assert len(some_todos) - pre_cnt == 0 - elif not self.check_compatibility_flag( - "category_search_yields_nothing" + elif not self.check_support( + "search.category" ) and not self.check_compatibility_flag("text_search_not_working"): ## This is the correct thing, according to the letter of the RFC assert len(some_todos) - pre_cnt == 6 @@ -2324,7 +2388,7 @@ def testTodoDatesearch(self): self.skip_on_compatibility_flag("read_only") # bedeworks does not support VTODO self.skip_on_compatibility_flag("no_todo") - self.skip_on_compatibility_flag("no_todo_datesearch") + self.skip_unless_support("search.time-range.todo") self.skip_on_compatibility_flag("no_search") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) @@ -2397,9 +2461,7 @@ def testTodoDatesearch(self): # Hence a compliant server should chuck out all the todos except t5. # Not all servers perform according to (my interpretation of) the RFC. foo = 5 - if self.check_compatibility_flag( - "no_recurring" - ) or self.check_compatibility_flag("no_recurring_todo"): + if not self.check_support("search.recurrences.includes-implicit.todo"): foo -= 1 ## t6 will not be returned if self.check_compatibility_flag( "vtodo_datesearch_nodtstart_task_is_skipped" @@ -2413,24 +2475,16 @@ def testTodoDatesearch(self): assert len(todos2) == foo ## verify that "expand" works - if not self.check_compatibility_flag( - "no_recurring" - ) and not self.check_compatibility_flag("no_recurring_todo"): + if self.check_support("search.recurrences.includes-implicit.todo"): ## 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 todos1 if "DTSTART:20020415T1330" in x.data]) == 1 + assert len([x for x in todos2 if "DTSTART:20020415T1330" in x.data]) == 1 + if self.check_support("search.recurrences.expanded.todo"): assert ( - len([x for x in todos2 if "DTSTART:20020415T1330" in x.data]) == 1 + len([x for x in todos4 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 @@ -2453,12 +2507,9 @@ def testTodoDatesearch(self): ## * t4 should probably be returned, as it has no dtstart nor due and ## hence is also considered to span over infinite time urls_found = [x.url for x in todos1] - urls_found2 = [x.url for x in todos1] + urls_found2 = [x.url for x in todos2] assert urls_found == urls_found2 - if not ( - self.check_compatibility_flag("no_recurring") - or self.check_compatibility_flag("no_recurring_todo") - ): + if self.check_support("search.recurrences.includes-implicit.todo"): urls_found.remove(t6.url) if not self.check_compatibility_flag( "vtodo_datesearch_nodtstart_task_is_skipped" @@ -2481,10 +2532,8 @@ def testSearchWithoutCompType(self): """ Test for https://github.com/python-caldav/caldav/issues/539 """ - self.skip_on_compatibility_flag("search_needs_comptype") - self.skip_on_compatibility_flag("search_always_needs_comptype") - self.skip_on_compatibility_flag("no_todo") - self.skip_on_compatibility_flag("no_events_and_tasks_on_same_calendar") + self.skip_unless_support("search.comp-type-optional") + self.skip_unless_support("save-load.todo.mixed-calendar") cal = self._fixCalendar() cal.save_todo(todo) cal.save_event(ev1) @@ -2657,8 +2706,8 @@ def testUnicodeEvent(self): def testSetCalendarProperties(self): self.skip_on_compatibility_flag("read_only") - self.skip_on_compatibility_flag("no_displayname") - self.skip_on_compatibility_flag("no_delete_calendar") + self.skip_unless_support("create-calendar.set-displayname") + self.skip_unless_support("delete-calendar") c = self._fixCalendar() assert c.url is not None @@ -2684,8 +2733,8 @@ def testSetCalendarProperties(self): try: cc.delete() except error.DeleteError: - if not self.check_compatibility_flag( - "no_delete_calendar" + if not self.check_support( + "delete-calendar" ) or self.check_compatibility_flag("unique_calendar_ids"): raise @@ -2799,9 +2848,7 @@ def testCreateOverwriteDeleteEvent(self): # add event e1 = c.save_event(ev1) - todo_ok = not self.check_compatibility_flag( - "no_todo" - ) and not self.check_compatibility_flag("no_events_and_tasks_on_same_calendar") + todo_ok = self.check_support("save-load.todo.mixed-calendar") if todo_ok: t1 = c.save_todo(todo) assert e1.url is not None @@ -2965,7 +3012,7 @@ def testRecurringDateSearch(self): event? """ self.skip_on_compatibility_flag("read_only") - self.skip_on_compatibility_flag("no_recurring") + self.skip_unless_support("search.recurrences.includes-implicit.event") self.skip_on_compatibility_flag("no_search") c = self._fixCalendar() @@ -3001,13 +3048,7 @@ def testRecurringDateSearch(self): 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", - ) + ## r3 was client-side expansion, but this is the default now ## server side expansion r4 = c.search( event=True, @@ -3020,12 +3061,10 @@ def testRecurringDateSearch(self): assert r1[0].data.count("END:VEVENT") == 1 assert r2[0].data.count("END:VEVENT") == 1 ## due to expandation, the DTSTART should be in 2008 - 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 + assert r1[0].data.count("DTSTART;VALUE=DATE:2008") == 1 + assert r2[0].data.count("DTSTART;VALUE=DATE:2008") == 1 + if self.check_support("search.recurrences.expanded.event"): + assert r4[0].data.count("DTSTART;VALUE=DATE:2008") == 1 ## With expand=True and searching over two recurrences ... r1 = c.date_search( @@ -3080,12 +3119,7 @@ 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", - ) + ## client expand removed, since that's default from 2.0 rs = c.search( start=datetime(2024, 3, 31, 0, 0), end=datetime(2024, 5, 4, 0, 0, 0), @@ -3093,22 +3127,16 @@ def testRecurringDateWithExceptionSearch(self): expand="server", ) - 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 len(r) == 2 + if self.check_support("search.recurrences.expanded.event"): + assert len(rs) == 2 assert "RRULE" not in r[0].data assert "RRULE" not in r[1].data - 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) + asserts_on_results = [r] + if self.check_support("search.recurrences.expanded.exception"): + asserts_on_results.append(rs) for r in asserts_on_results: assert isinstance( @@ -3135,7 +3163,7 @@ def testEditSingleRecurrence(self): Only the recurrence should be edited, not the rest of the event. """ - self.skip_on_compatibility_flag("no_recurring") + self.skip_unless_support("search.recurrences.includes-implicit.event") cal = self._fixCalendar() ## Create a daily recurring event