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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
* `search()`'s generator driver now feeds exceptions raised while executing a request back into the search logic, so the server-compatibility fallbacks and per-object load error handling actually take effect (previously dead code). Applies to both the sync and async code paths.
* `compatibility_hints`: OX was pinned to `create-calendar.set-displayname: unsupported` (a value masked by a checker bug that verified the feature by display-name lookup, which a leftover/colliding calendar would shadow); OX stores the display name as a property separate from the calendar URL and honours it at creation time, so the expectation is corrected to `full`.
* `compatibility_hints`: Stalwart's `search.recurrences.expanded.exception` was inheriting the default `full`, but Stalwart's server-side `CALDAV:expand` only suppresses the exception-overridden occurrence when `SEQUENCE` is absent. With `SEQUENCE` present (as real-world clients always emit) it returns both the original occurrence and the override, so the expectation is corrected to `fragile`.
* `compatibility_hints`: the `synology` profile understated what Synology Calendar can do (it embeds a modified DAViCal, and re-probing a DSM 7 server shows it behaves like DAViCal nearly everywhere). Text search is no longer assumed case-insensitive, `is-not-defined` searches and the `calendar-color`/`calendar-order` properties are now used rather than avoided, and a time-range search without a component type is sent as one query instead of being split per component type. Delivery of iTIP scheduling requests to the attendee inbox is likewise no longer assumed absent. Calendar deletion is still unsupported there - Synology refuses a CalDAV `DELETE` on a calendar collection, so `Calendar.delete()` empties the calendar instead and its `cal_id` cannot be reused. The profile is now expressed as `synology = davical | {deviations}`, so shared DAViCal quirks are stated once.
* Config file sections with `features` but no `caldav_url` were rejected, even though the URL can be derived from the `auto-connect.url` compatibility hints. Explicitly passed parameters already worked this way; now `get_davclient(config_section=...)` and friends behave consistently.
* `jmap/convert/jscal_to_ical.py`: a `recurrenceOverrides` entry that does not include a `"start"` key (the common case — title-only change, description update, etc.) produced a child `VEVENT` with `DTSTART` copied from the master event's start time rather than from the override key. This effectively relocated every non-rescheduled override to the master's first occurrence, breaking all override display. Default is now the override key itself.
* `jmap/convert/jscal_to_ical.py`: `EXDATE` and `RECURRENCE-ID` values were always emitted as floating (timezone-less) `DATE-TIME` regardless of the event's `timeZone` or `showWithoutTime` flag. Per RFC 5545 §3.8.5.1 the value type must match `DTSTART`; a floating `EXDATE` on a `TZID`-anchored event does not match any instance, so excluded occurrences reappear. Override keys are now parsed with the event timezone applied (`TZID`-anchored events) or as `date` objects (all-day events).
Expand Down
40 changes: 25 additions & 15 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1345,21 +1345,6 @@ def compare(self, observed):
## save.duplicate-event is left at the default "full".)
}

synology = {
'principal-search': False,
'sync-token': 'fragile',
'delete-calendar': False,
'search.comp-type.optional': 'fragile',
'search.is-not-defined': {'support': 'fragile', 'behaviour': 'works for CLASS but not for CATEGORIES'},
'search.text.case-sensitive': {'support': 'unsupported'},
'search.time-range.alarm': {'support': 'unsupported'},
## Synology skips VTODOs without DTSTART in date-range searches.
'search.time-range.todo.no-dtstart': {'support': 'unsupported'},
'test-calendar': {'cleanup-regime': 'wipe-calendar'},
'scheduling.schedule-tag': False,
'scheduling.mailbox.inbox-delivery': False,
}

baikal = { ## version 0.10.1
# Baikal (sabre/dav) delivers iTIP notifications to the attendee inbox AND auto-schedules
# into their calendar.
Expand Down Expand Up @@ -1425,6 +1410,8 @@ def compare(self, observed):
# 'get_object_by_uid_is_broken'
#]

## See the synology profile above: Synology Calendar ships a modified DAViCal,
## so the two profiles should be kept in sync where the fork has not diverged.
davical = {
# Disable HTTP/2 multiplexing - davical doesn't support it well and niquests
# lazy responses cause MultiplexingError when accessing status_code
Expand Down Expand Up @@ -1454,6 +1441,29 @@ def compare(self, observed):
"calendar-order": {"support": "full"},
}

## Synology Calendar (the DSM package) embeds a modified DAViCal, hence
## deriving from the davical profile above.
##
## Re-probed with caldav-server-tester against a DSM 7 Synology Calendar
## 2026-08-23: every feature the checker could test matched davical except
## calendar deletion, so only the deviations are listed here.
synology = davical | {
## The one real behavioural divergence of the fork: DSM manages calendars
## through its own UI/API and refuses a CalDAV DELETE on the collection,
## where upstream DAViCal allows it. Consequence for callers: Calendar
## .delete() degrades to wiping the objects, so a cal_id is never freed for
## reuse and a later MKCALENDAR at the same id gets 405.
'delete-calendar': False,
## (scheduling.mailbox.inbox-delivery used to be pinned False here. It was
## added in a bulk hints commit with no per-server evidence, and it cannot be
## probed on the configured server - the checker needs a second user account,
## and every test that reads it skips on the principal count first. So it was
## an unfalsifiable guess; it now inherits davical, which delivers iTIP
## requests to the attendee inbox. Pin it again only with a real observation.)
## Test bookkeeping rather than a server feature.
'test-calendar': {'cleanup-regime': 'wipe-calendar'},
}

sogo = {
"scheduling.schedule-tag": False,
"scheduling.mailbox.inbox-delivery": False,
Expand Down
131 changes: 129 additions & 2 deletions tests/fixture_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,17 @@ async def _get_or_create_impl(
## should use a dedicated calendar. This can be specified in the features
## as for now.
test_cal_info = client.features.is_supported("test-calendar", return_type=dict)
if "name" in test_cal_info or "cal_url" in test_cal_info or "cal_id" in test_cal_info:
## Only the keys principal.calendar() actually accepts may be forwarded - the
## same dict also carries bookkeeping like "support" and "cleanup-regime".
## cal_url is a keyword of its own and is *not* interchangeable with cal_id:
## it is joined against the client URL verbatim, while cal_id is URL-quoted
## and joined against the calendar home set (a PROPFIND on async clients).
lookup = {k: v for k, v in test_cal_info.items() if k in ("name", "cal_id", "cal_url")}
if lookup:
## TODO: we should consider some better error messages if the configured calendar
## does not exist
return (principal.calendar(**test_cal_info), False)
## A lookup by name round-trips, so on an async client this is a coroutine.
return (await _maybe_await(principal.calendar(**lookup)), False)

# Check if server supports calendar creation via features
supports_create = True
Expand Down Expand Up @@ -224,6 +231,126 @@ async def aget_or_create_test_calendar(
)


def _supports(client: Any, feature: str) -> bool:
"""Feature lookup that defaults to True when the client carries no feature set.

A missing/empty feature set means "nothing is known about this server", and
the fixtures have always treated that as "assume it works".
"""
features = getattr(client, "features", None)
return features.is_supported(feature) if features else True


async def atry_principal(client: Any) -> Any:
"""Discover the principal, or ``None`` if the server won't tell us.

Some servers don't support principal discovery at all; the async fixtures
treat that as "no principal" and fall back to URL-based lookups.
"""
from caldav.aio import AsyncPrincipal
from caldav.lib.error import AuthorizationError, NotFoundError

try:
return await AsyncPrincipal.create(client)
except (NotFoundError, AuthorizationError):
return None


async def afix_calendar(
client: Any,
principal: Any,
*,
cal_id: str,
calendar_name: str | None = None,
keep_name: bool = False,
supported_calendar_component_set: list[str] | None = None,
) -> tuple[Any, bool]:
"""Async counterpart of ``test_caldav.py``'s ``_fixCalendar``.

Hands back an *empty* test calendar at ``cal_id`` plus a flag telling
whether it had to be created, taking care of the server-capability
bookkeeping that every async fixture and every async test that wants its
own calendar used to repeat inline:

* a leftover calendar from an interrupted run is deleted first - but only
on servers where deleting actually frees the URL. On servers where it
does not (``delete-calendar`` unsupported: Synology, Nextcloud), a
``delete()`` is a no-op wipe, the MKCALENDAR that follows would 405 with
"a collection already exists at that location", and the correct move is
to reuse the calendar instead.
* the display name is dropped on servers that cannot set one, or that move
the calendar to a server-chosen URL when one is set, and on
component-restricted calendars - same three-legged rule as
``_fixCalendar_``: the fixture is always looked up by ``cal_id``. Pass
``keep_name=True`` to opt out, for a test whose subject *is* the display
name; that is the async spelling of ``_fixCalendar_``'s "only mangle the
name when the caller did not supply one" (``if "name" not in kwargs``).
* whatever is left inside the calendar is wiped, so the caller can count
objects from zero whether the calendar is brand new or reused.

Pair every call with :func:`arelease_calendar`.
"""
from caldav.lib import error

## A component-restricted fixture (VTODO-only / VJOURNAL-only) is only ever
## found by cal_id, and naming it can collide on servers enforcing
## per-principal unique calendar names (SOGo) - so it stays nameless too.
restricted = bool(supported_calendar_component_set) and (
"VEVENT" not in supported_calendar_component_set
)
if (
calendar_name is not None
and not keep_name
and (
restricted
or not _supports(client, "create-calendar.set-displayname")
or not _supports(client, "create-calendar.stable-url")
)
):
calendar_name = None

if principal is not None and _supports(client, "delete-calendar.free-namespace"):
try:
await adelete_calendar_if_present(principal, cal_id)
except error.DeleteError:
## The server advertises calendar deletion but refused this one.
## Not fatal: get_or_create below reuses whatever is there.
pass

calendar, created = await aget_or_create_test_calendar(
client,
principal,
calendar_name=calendar_name,
cal_id=cal_id,
supported_calendar_component_set=supported_calendar_component_set,
)

if calendar is not None:
await cleanup_calendar_objects(calendar)

return calendar, created


async def arelease_calendar(client: Any, calendar: Any, created: bool) -> None:
"""Tear down a calendar handed out by :func:`afix_calendar`.

Deletes it when we created it and deletion frees the URL again; otherwise
just empties it, so that servers moving deleted calendars to a trashbin
(or refusing deletion outright) don't accumulate junk.
"""
if calendar is None:
return
if created and _supports(client, "delete-calendar.free-namespace"):
try:
await _maybe_await(calendar.delete())
return
except Exception:
## Best-effort teardown: a calendar that refuses to go must not
## turn a passing test red. Fall through and at least empty it.
pass
await cleanup_calendar_objects(calendar)


async def cleanup_calendar_objects(calendar: Any) -> None:
"""
Remove all objects from a calendar (for test isolation).
Expand Down
Loading
Loading