Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 24 additions & 88 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@
## caldav_server_checker, and things got messy from there. We should
## double-check the values where there is any doubt, and clean up.

## 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.

Expand Down Expand Up @@ -433,6 +428,7 @@ class FeatureSet:
"save-load.todo.recurrences.count": {"description": "The server will receive and store a recurring task with a count set in the RRULE", "default": {"support": "full"}},
"save-load.todo.recurrences.thisandfuture": {"description": "Completing a recurring task with rrule_mode='thisandfuture' works (modifies RRULE and saves back to server)", "default": {"support": "full"}},
"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)", "default": {"support": "full"}},
"save-load.todo.uncomplete": {"description": "A completed task can be made pending again (Todo.uncomplete()): after saving it with STATUS:NEEDS-ACTION and without COMPLETED, it loads back that way.", "default": {"support": "full"}},
"save-load.journal": {
"description": "The server will even accept journals",
"default": { "support": "full" }
Expand Down Expand Up @@ -546,7 +542,12 @@ class FeatureSet:
},
"search.time-range.todo": {"description": "basic time range searches for tasks works", "default": {"support": "full"}},
"search.time-range.todo.no-dtstart": {
"description": "A VTODO without DTSTART (but with DUE) is returned by a date-range search. RFC5545 and RFC4791 section 9.9 say such a task has a defined time span and should be found, so 'full' (the default) is the compliant behaviour; some servers (Davical, Stalwart, Synology) skip any task lacking DTSTART. Probed with a closed window; servers that skip such tasks only in closed ranges (returning them in open-ended ones) are instead tracked by the 'vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range' flag.",
"description": "A VTODO without DTSTART (but with DUE) is returned by a date-range search. RFC5545 and RFC4791 section 9.9 say such a task has a defined time span and should be found, so 'full' (the default) is the compliant behaviour; some servers (Davical, Stalwart, Synology) skip any task lacking DTSTART. Probed with a closed window.",
"default": {"support": "full"},
"links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-9.9"],
},
"search.time-range.todo.no-dtstart.no-due": {
"description": "A VTODO with neither DTSTART nor DUE (nor COMPLETED or CREATED) is returned by a date-range search. RFC4791 section 9.9 says such a task overlaps every time range. Zimbra only finds tasks with DTSTART or DUE set. Unless set, it follows search.time-range.todo.no-dtstart, as a server skipping tasks without DTSTART skips these too. Probed with a closed window.",
"default": {"support": "full"},
"links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-9.9"],
},
Expand Down Expand Up @@ -850,16 +851,13 @@ def __init__(self, feature_set_dict=None):
if isinstance(feature_set_dict, FeatureSet):
self._server_features = copy.deepcopy(feature_set_dict._server_features)
self.backward_compatibility_mode = feature_set_dict.backward_compatibility_mode
self._old_flags = copy.copy(feature_set_dict._old_flags) if hasattr(feature_set_dict, '_old_flags') else []
return

## 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 = {}
## TODO: remove this when it can be removed
self._old_flags = []
if feature_set_dict:
self._copy_feature_set(feature_set_dict, collapse=False)

Expand Down Expand Up @@ -908,10 +906,6 @@ def copy_feature_set(self, feature_set, collapse=True):
## warning with stacklevel=3 points at the user's code
def _copy_feature_set(self, feature_set, collapse=True):
for feature in feature_set:
## TODO: temp - should be removed
if feature == 'old_flags':
self._old_flags = feature_set[feature]
continue
if feature == 'write-delay':
warnings.warn(
"The 'write-delay' feature is deprecated - use 'synchronous-write', "
Expand Down Expand Up @@ -1357,60 +1351,10 @@ def compare(self, observed):
)
return mismatches

#### OLD STYLE

## THE LIST BELOW IS TO BE REMOVED COMPLETELY. DO NOT USE IT.

## It's not considered to be part of the public API (though, it should
## have been prefixed with _ to make it clear). The list is being
## removed little-by-little, without regards of SemVer.

## 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
## code itself to work around some known compatibility issues, like
## the caldav.lib.vcal.fix function.
## Here is a list of all observed (in)compatibility issues the test framework needs to know about
## TODO:
## * references to the relevant parts of the RFC would be nice.
## * Research should be done to triple-check that the issue is on the server side, and not on the client side
## * Some of the things below should be possible to probe the server for.
## * Perhaps some more readable format should be considered (yaml?).
## * Consider how to get this into the documentation
incompatibility_description = {
'event_by_url_is_broken':
"""A GET towards a valid calendar object resource URL will yield 404 (wtf?)""",

'vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range':
"""only open-ended date searches for todo-items will find tasks without a dtstart""",

'vtodo_datesearch_notime_task_is_skipped':
"""date searches for todo-items will (only) find tasks that has either """
"""a dtstart or due set""",

'vtodo_no_due_infinite_duration':
"""date search will find todo-items without due if dtstart is """
"""before the date search interval. This is in breach of rfc4791"""
"""section 9.9""",

'vtodo-cannot-be-uncompleted':
"""If a VTODO object has been set with STATUS:COMPLETE, it's not possible to delete the COMPLTEDED attribute and change back to STATUS:IN-ACTION""",

'sticky_events':
"""Events should be deleted before the calendar is deleted, """
"""and/or deleting a calendar may not have immediate effect""",

'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""",

'fastmail_buggy_noexpand_date_search':
"""The 'blissful anniversary' recurrent example event is returned when asked for a no-expand date search for some timestamps covering a completely different date""",

'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""",

}

## Server profiles. Each dict below is a feature set (see
## FeatureSet.FEATURES above) describing one server. In addition there
## are some hacks in the library code itself to work around some known
## compatibility issues, like the caldav.lib.vcal.fix function.
xandikos = {
## Probed 2026-10-11 on 0.4.8, unchanged on master (0.4.9): a token that
## isn't a hex SHA crashes the git store lookup, and a hex one is refused
Expand Down Expand Up @@ -1677,20 +1621,14 @@ def compare(self, observed):
'save-load.icalendar.related-to': {'support': 'unsupported'},
'search.time-range.open.start': {'support': 'broken'},

"old_flags": [
## setting display name in zimbra does not work (display name,
## calendar-ID and URL is the same, the display name cannot be
## changed, it can only be given if no calendar-ID is given. In
## earlier versions of Zimbra display-name could be changed, but
## then the calendar would not be available on the old URL
## anymore)
## 'event_by_url_is_broken' removed - works in zimbra/zcs-foss:latest
'vtodo_datesearch_notime_task_is_skipped',
## Probed 2026-10-20: a task with DUE but no DTSTART is found by a
## date search, one with neither is not
'search.time-range.todo.no-dtstart.no-due': {'support': 'unsupported'},

## TODO: I just discovered that when searching for a date some
## years after a recurring daily event was made, the event does
## not appear.
],

## extra properties not specified in RFC4791/RFC5545. Zimbra stores
## calendar-order, and stores calendar-color only when set as a hex value -
## it rejects/ignores a colour name like "blue". (The old 'calendar_color'
Expand Down Expand Up @@ -2079,12 +2017,6 @@ def compare(self, observed):
'principal-search': {'support': 'unsupported'},
## DAViCal skips VTODOs without DTSTART in date-range searches.
'search.time-range.todo.no-dtstart': {'support': 'unsupported'},
"old_flags": [
#'no_journal', ## it threw a 500 internal server error! ## for old versions
#'nofreebusy', ## for old versions
## 'fragile_sync_tokens' removed - covered by 'sync-token': {'support': 'fragile'}
'vtodo_datesearch_notime_task_is_skipped',
],
## extra properties not specified in RFC4791/RFC5545
"calendar-color": {"support": "full"},
"calendar-order": {"support": "full"},
Expand Down Expand Up @@ -2184,7 +2116,10 @@ def compare(self, observed):
'test-calendar': {'cleanup-regime': 'wipe-calendar'},

}
## Old notes for sogo (todo - incorporate them in the structure above)
## Old notes for sogo (todo - incorporate them in the structure above).
## The commented-out lists here and below name flags of the old-style
## system, removed in https://github.com/python-caldav/caldav/issues/585 -
## kept only as a record of what was once observed on those servers.
## 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
Expand Down Expand Up @@ -2606,10 +2541,11 @@ def compare(self, observed):
## Open-start searches used to work (no hint, so full), but were
## rejected with 403 Forbidden 2026-10-11
'search.time-range.open.start': {'support': 'fragile'},
"old_flags": [
#"text_search_is_case_insensitive",
"vtodo-cannot-be-uncompleted",
]
## Probed 2026-10-20
"save-load.todo.uncomplete": {
"support": "unsupported",
"behaviour": "the task is saved as pending, but loads back with STATUS:COMPLETED",
},
}

## https://ox.io/
Expand Down
1 change: 1 addition & 0 deletions changes/585.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Compatibility features `save-load.todo.uncomplete` (a completed task can be made pending again with `Todo.uncomplete()`; GMX keeps it completed) and `search.time-range.todo.no-dtstart.no-due` (a date search finds a task with neither DTSTART nor DUE, as RFC 4791 section 9.9 says it should; Zimbra does not). They replace the last two old-style flags in the Zimbra and GMX profiles.
1 change: 1 addition & 0 deletions changes/585.removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The old-style compatibility flags are gone: `caldav.compatibility_hints.incompatibility_description`, and the `old_flags` key in a feature set. They were only ever used by the test suite to skip or adjust tests, and every flag still in use is now a compatibility feature.
2 changes: 1 addition & 1 deletion docs/design/FEATURE_COMPLETE_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ Also related: [#697](https://github.com/python-caldav/caldav/issues/697) - smart
- **Related issues:** [#585](https://github.com/python-caldav/caldav/issues/585), [#482](https://github.com/python-caldav/caldav/issues/482), [#128](https://github.com/python-caldav/caldav/issues/128), [#619](https://github.com/python-caldav/caldav/issues/619), [#515](https://github.com/python-caldav/caldav/issues/515)

**Tasks:**
- [ ] Remove old incompatibility flags ([#585](https://github.com/python-caldav/caldav/issues/585))
- [x] Remove old incompatibility flags ([#585](https://github.com/python-caldav/caldav/issues/585))
- [ ] Obsolete `get_duration`, `get_due`, `get_dtend` ([#482](https://github.com/python-caldav/caldav/issues/482))
- [ ] v4.0: remove everything that raises a `DeprecationWarning`, and add the
warning to methods deprecated without one ([#619](https://github.com/python-caldav/caldav/issues/619))
Expand Down
30 changes: 8 additions & 22 deletions docs/design/TODO_COMPATIBILITY_HINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,13 @@ and once in the post-loop block (line 606). The second call (line 606) uses the
Needs careful review; may be an off-by-one in the loop termination logic, or the two
conditions are truly independent and the second call is simply redundant.

## 3. `_old_flags` — in-progress migration, clear removal path
## 3. `_old_flags` (fixed)

**Location**: `FeatureSet.__init__`, `FeatureSet.copy_feature_set`, and ~12 server config
dicts in `compatibility_hints.py`

`_old_flags` is explicitly marked `## TODO: remove this when it can be removed`. It is
a shim that carries forward the legacy flat-list quirk system while server configs are
being migrated to the new dotted-feature style.

`test_caldav.py:1006` still reads `self.caldav.features._old_flags` to validate that old
flags are known strings. The test at line 1015-1016 confirms all flags are present in
`incompatibility_description`.

**Fix**: For each server dict that still has `'old_flags': [...]`, translate the listed
flags into equivalent new-style features and remove the `old_flags` key. Once all server
dicts are migrated:
- Remove the `if feature == 'old_flags':` special-case in `copy_feature_set`
- Remove `self._old_flags = []` from `__init__`
- Remove the `_old_flags` copy in the copy-constructor branch
- Remove the validation in `test_caldav.py`
The last old-style flags are gone (https://github.com/python-caldav/caldav/issues/585):
the two still set by a server profile became `save-load.todo.uncomplete` and
`search.time-range.todo.no-dtstart.no-due`, each probed by caldav-server-tester, and the
rest were set by no profile. `incompatibility_description`, the `old_flags` key and the
test helpers reading them are removed.

## 4. `feature_tree` / `_dots_to_tree` — internal detail that can be simplified

Expand Down Expand Up @@ -104,8 +91,7 @@ return (

The `enable`, `value` and `observed` slots exist to correctly compare `client-feature` and
`server-observation` nodes during collapse. In practice, collapse is only ever invoked
on server-feature nodes (the only type that appears in the server config dicts, aside
from `old_flags`). The extra slots add noise without effect.
on server-feature nodes (the only type that appears in the server config dicts). The extra slots add noise without effect.

**Fix**: Simplify to `return feature_dict.get('support')` once the broader type-system
simplification is settled.
Expand Down Expand Up @@ -150,5 +136,5 @@ want a second look at the same time.

## Ordering / dependencies

Items 3 (old_flags) and 6 (rename) are independent and safe to do first. Items 1, 2,
Items 3 (old_flags) and 6 (rename) are done. Items 1, 2,
4, 5 are interrelated around the type-system simplification and are best tackled together.
19 changes: 4 additions & 15 deletions tests/test_async_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,6 @@ def skip_unless_support(self, feature):
msg = self._features.find_feature(feature).get("description", feature)
pytest.skip("Test skipped due to server incompatibility issue: " + msg)

def check_compatibility_flag(self, flag: str) -> bool:
return flag in getattr(self._features, "_old_flags", [])

@pytest.fixture(scope="class")
@classmethod
def test_server(cls) -> TestServer:
Expand Down Expand Up @@ -1740,13 +1737,10 @@ async def test_todo_datesearch(self, async_task_list: Any) -> None:
foo = 5
if not self.is_supported("search.recurrences.includes-implicit.todo"):
foo -= 1
if not self.is_supported(
"search.time-range.todo.no-dtstart"
) or self.check_compatibility_flag(
"vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range"
):
foo -= 2
elif self.check_compatibility_flag("vtodo_datesearch_notime_task_is_skipped"):
if not self.is_supported("search.time-range.todo.no-dtstart"):
foo -= 1
## follows no-dtstart unless set
if not self.is_supported("search.time-range.todo.no-dtstart.no-due"):
foo -= 1

if implicit_fragile:
Expand Down Expand Up @@ -1779,14 +1773,9 @@ async def test_create_journal_list_and_journal_entry(self, async_journal_list: A

# ==================== Group E – Properties & Meta ====================

def _skip_on_compatibility_flag(self, flag: str) -> None:
if self.check_compatibility_flag(flag):
pytest.skip(f"Test skipped due to compatibility flag: {flag}")

@pytest.mark.asyncio
async def test_support(self, async_client: Any) -> None:
"""check_dav_support / check_cdav_support / check_scheduling_support."""
self._skip_on_compatibility_flag("dav_not_supported")
assert await async_client.check_dav_support()
assert await async_client.check_cdav_support()
if self.is_supported("scheduling", return_type=str) != "unknown":
Expand Down
Loading
Loading