diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3baea4..1fa7525 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,10 +40,9 @@ caldav's test helper is told, so that port must be free. The CalDAV conformance suite and the readable stories in `tests/test_scenarios.py` (which also run on the files and memory backends) can also run against the docker and external servers of -caldav's own test framework. That framework is not in caldav's wheel, so -it needs a caldav source checkout: an editable caldav install is found by -itself, otherwise set `CALDAV_SOURCE`. The servers are not started for -you; start them in the checkout first: +caldav's own test framework, `caldav.testing`. That needs caldav 3.7.0 +or later. The servers are not started for you; start them in a caldav +checkout first: ``` (cd ~/caldav/tests/docker-test-servers/baikal && ./start.sh) @@ -52,12 +51,15 @@ pytest --caldav-servers=Baikal -k Baikal `--caldav-servers` takes a comma-separated list of names, and a named server that is not running fails its tests. `all` takes every server -caldav knows of: the docker servers, the external ones in the checkout's -`tests/caldav_test_servers.yaml` (or `~/.config/caldav/test_servers.yaml`), -and the sections of your caldav configuration marked `test_allowed`. -caldav itself reads only the first of those sources it finds, so the -conftest adds the `test_allowed` sections -([caldav issue 817](https://github.com/python-caldav/caldav/issues/817)). +caldav knows of: the docker servers, the external ones in +`~/.config/caldav/test_servers.yaml` (and in `tests/caldav_test_servers.yaml` +when caldav is installed from a checkout), and the sections of your +caldav configuration marked `test_allowed`. +`--caldav-servers-config=PATH` adds the servers of another such yaml +file, and its entries win over those caldav found itself. Give both +options with `=`: with a space, pytest takes the value for a test path, +moves its root directory there, never loads `tests/conftest.py` and +stops with "unrecognized arguments". Under `all`, a server that is not running is skipped. Without docker, caldav lists a docker server only while it is running. For these servers, the `PYTHON_CALDAV_TEST_*` switches and caldav's per-server @@ -65,6 +67,17 @@ locks apply, so a caldav test run on the same server is waited for rather than clobbered; the embedded Xandikos and Radicale always run, and take no lock. +A run on every server takes hours, most of them on the slowest +servers. Run the servers in parallel, spread over the workers, one +server per worker at a time: + +``` +pytest --caldav-servers=all -n 12 --dist loadgroup +``` + +`--dist loadgroup` is what keeps a server's tests together; plain `-n` +spreads them, and two workers then fight over one server. + ## Sync and async code The async code in the `_async/` directories is the source; the `_sync/` diff --git a/docs/design/API_DESIGN.md b/docs/design/API_DESIGN.md index 05e9fc5..970d763 100644 --- a/docs/design/API_DESIGN.md +++ b/docs/design/API_DESIGN.md @@ -1225,7 +1225,9 @@ initial set: `create_collection(components=...)` is `LOSSY` where the server does not keep the requested components, as caldav says of SOGo (a calendar for all of them), Bedework 5 (one for events only) and GMX. No loss policy applies, as nothing -is written; the collection's `components` say what it holds. A collection +is written; the collection's `components` say what it holds. A component +the server is known not to store is not asked for at all, and makes it +`LOSSY` too: Infomaniak and CCS refuse any calendar naming `VJOURNAL`. A collection that would hold none of what was asked is deleted again where it can be, and refused with `UnsupportedError` for the first requested component in sorted order. diff --git a/news/+caldav-test-servers.internal.rst b/news/+caldav-test-servers.internal.rst new file mode 100644 index 0000000..fcc89f0 --- /dev/null +++ b/news/+caldav-test-servers.internal.rst @@ -0,0 +1,3 @@ +The CalDAV tests can run against every test server of caldav's own test +framework, ``caldav.testing`` (``pytest --caldav-servers=all``), in +parallel with ``pytest -n N --dist loadgroup``. diff --git a/pyproject.toml b/pyproject.toml index 64579ad..707d845 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,13 +35,15 @@ dependencies = [ # 3.6: save(no_overwrite=True) sends If-None-Match: *, and save(raw_data=...) sends given bytes. caldav = ["caldav>=3.6"] # Embedded CalDAV servers the caldav backend's conformance tests run against. -test-servers = ["caldav>=3.6", "xandikos>=0.2", "radicale>=3.2"] +# 3.7: caldav.testing, the registry of caldav's own test servers. +test-servers = ["caldav>=3.7", "xandikos>=0.2", "radicale>=3.2"] # ruff and mypy are pinned exactly; keep ruff in step with .pre-commit-config.yaml. # So is unasync: its output is committed, and the freshness test compares bytes. dev = [ "calendaring[caldav,test-servers]", "pytest>=8.0", "pytest-asyncio>=1.0", + "pytest-xdist>=3.5", "unasync==0.6.0", "ruff==0.17.0", "mypy==2.4.0", @@ -120,8 +122,15 @@ ignore_missing_imports = true testpaths = ["tests"] python_files = ["test_*.py"] # Treat warnings as errors - tests should run clean (roadmap 1.3 demands it too) -filterwarnings = ["error"] +filterwarnings = [ + "error", + # A test server configured with ssl_verify_cert: false (caldav's Zimbra) + "ignore:Unverified HTTPS request:Warning", +] norecursedirs = ["prototypes", "docs", "build"] +markers = [ + "checks_refusal: a scenario story that, where no collection for its kind can be made, checks the refusal in one of another kind", +] # The async tests need no marker, so the generated sync copy carries none. asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/src/calendaring/_async/backend.py b/src/calendaring/_async/backend.py index da25914..a100307 100644 --- a/src/calendaring/_async/backend.py +++ b/src/calendaring/_async/backend.py @@ -8,11 +8,11 @@ from typing import TYPE_CHECKING, Any, ClassVar, Self from urllib.parse import urlsplit -from ..capabilities import Capabilities, Feature, Support +from ..capabilities import COMPONENT_FEATURE, Capabilities, Feature, Support from ..errors import AmbiguousError, CalendaringError, ConfigurationError, NotFoundError, UnsupportedError from ..loss import LossPolicy from ..types import Component -from .collection import COMPONENT_FEATURE, AsyncCollection +from .collection import AsyncCollection if TYPE_CHECKING: from .workspace import AsyncWorkspace diff --git a/src/calendaring/_async/caldav.py b/src/calendaring/_async/caldav.py index 091c2c0..5bdde04 100644 --- a/src/calendaring/_async/caldav.py +++ b/src/calendaring/_async/caldav.py @@ -21,7 +21,7 @@ import icalendar from icalendar_searcher import Searcher -from .._caldav_features import backend_capabilities, collection_capabilities +from .._caldav_features import backend_capabilities, collection_capabilities, stored_components from .._caldav_io import Calendar from ..capabilities import Feature from ..errors import ( @@ -35,7 +35,7 @@ from ..items import _items_of, _parse from ..loss import LossPolicy from ..types import BrokenObject, Component, StoredObject, SyncToken -from .backend import AsyncBackend +from .backend import AsyncBackend, _none_held from .collection import AsyncCollection from .dav import AsyncDAV @@ -249,16 +249,21 @@ async def _open(cls, url: str, **options: Any) -> AsyncCalDAVBackend: return cls(AsyncDAV(url, **options), id=backend_id, loss=loss, verify=verify) async def _wrap(self, calendar: Calendar) -> AsyncCalDAVCollection: - components = frozenset( + listed = frozenset( Component(c) for c in await self._dav.supported_components(calendar) if c in {k.value for k in Component} ) + components = stored_components(self._dav.client.features, listed) return self.collection_class(self, calendar, name=await self._dav.display_name(calendar), components=components) async def collections(self) -> list[AsyncCollection]: return [await self._wrap(c) for c in await self._dav.calendars()] async def _create_collection(self, name: str, components: frozenset[Component]) -> AsyncCollection: - return await self._wrap(await self._dav.make_calendar(name, sorted(c.value for c in components))) + # Infomaniak and CCS refuse any set naming a kind they cannot store; ask for the rest. + asked = stored_components(self._dav.client.features, components) + if components and not asked: + raise _none_held(components, self.id) + return await self._wrap(await self._dav.make_calendar(name, sorted(c.value for c in asked))) async def close(self) -> None: await self._dav.close() diff --git a/src/calendaring/_async/collection.py b/src/calendaring/_async/collection.py index 3b8b718..e13681b 100644 --- a/src/calendaring/_async/collection.py +++ b/src/calendaring/_async/collection.py @@ -26,7 +26,7 @@ from icalendar_searcher import Searcher from .._mapping import Change, differences, plan_calendar_losses, plan_losses -from ..capabilities import BACKEND_FEATURES, Capabilities, Feature, Support +from ..capabilities import BACKEND_FEATURES, COMPONENT_FEATURE, Capabilities, Feature, Support from ..errors import ( AlreadyExistsError, CalendaringError, @@ -78,13 +78,6 @@ def _by_start(entry: TimeEntry) -> tuple[bool, float]: return entry.start is None, when.timestamp() if when is not None else 0.0 -COMPONENT_FEATURE = { - Component.EVENT: Feature.COMPONENT_EVENT, - Component.TASK: Feature.COMPONENT_TASK, - Component.JOURNAL: Feature.COMPONENT_JOURNAL, -} - - def _occurrence_now(calendar: icalendar.Calendar, item: Item) -> icalendar.Component | None: """The occurrence ``item`` was, as the stored object has it now: its override, or one generated from the master.""" wanted = item.component["RECURRENCE-ID"] diff --git a/src/calendaring/_async/conformance.py b/src/calendaring/_async/conformance.py index cf7cf9e..c0cfbef 100644 --- a/src/calendaring/_async/conformance.py +++ b/src/calendaring/_async/conformance.py @@ -1178,7 +1178,10 @@ async def test_time_log_round_trips_as_declared(self, collection: AsyncCollectio "start-time": (read.start, at(3, 9)), "end": (read.end, at(3, 11)), } - expected |= {"users": (read.users, users), "comment": (read.comment, "first draft\nwith figures")} + expected |= { + "users": (tuple(sorted(read.users)), tuple(sorted(users))), + "comment": (read.comment, "first draft\nwith figures"), + } for name, (got, sent) in expected.items(): if name in lost: assert got in (None, ()), f"{name} is declared lost, yet read back as {got!r}: never invent it" @@ -1388,7 +1391,7 @@ async def test_create_and_delete_collection(self, backend: AsyncBackend) -> None assert info.value.feature == Feature.CREATE_COLLECTION assert [c.id for c in await backend.collections()] == before return - deletable = backend.capabilities.supports(Feature.DELETE_COLLECTION) + deletable = backend.capabilities.level(Feature.DELETE_COLLECTION) is not Support.UNSUPPORTED # Without delete-collection, only the first run on a backend makes it: later ones find it left there. made = None if deletable else await self.left_behind(backend, "conformance-new") lossy = backend.capabilities.level(Feature.CREATE_COLLECTION) is Support.LOSSY diff --git a/src/calendaring/_async/files.py b/src/calendaring/_async/files.py index b57d529..e267139 100644 --- a/src/calendaring/_async/files.py +++ b/src/calendaring/_async/files.py @@ -22,13 +22,13 @@ from .._concurrency import ato_thread from .._files_store import Entry, IcsFile, Vdir, collections, layout, make_collection -from ..capabilities import EDIT_ICALENDAR_PENDING, NO_EVENTS_FOR_TIME_LOG, Capabilities, Feature, Support +from ..capabilities import EDIT_ICALENDAR_PENDING, Capabilities, Feature, Support, held_only from ..errors import ConfigurationError, InvalidDataError from ..items import _parse from ..loss import LossPolicy from ..types import BrokenObject, Component, StoredObject from .backend import AsyncBackend -from .collection import COMPONENT_FEATURE, AsyncCollection +from .collection import AsyncCollection __all__ = ["AsyncFilesBackend", "AsyncFilesCollection"] @@ -59,11 +59,7 @@ def collection_capabilities(single_file: bool, components: frozenset[Component]) Feature.COLLECTION_SET_NAME: U, } ) - for kind, feature in COMPONENT_FEATURE.items(): - if kind not in components: - table[feature] = U - if Component.EVENT not in components: # time entries are VEVENTs in the item's own collection - table[Feature.TIME_LOG] = {"level": U, "note": NO_EVENTS_FOR_TIME_LOG} + held_only(table, components) for feature in (Feature.CREATE_COLLECTION, Feature.DELETE_COLLECTION): del table[feature] return Capabilities(table) diff --git a/src/calendaring/_async/memory.py b/src/calendaring/_async/memory.py index 89aa729..f3de0c7 100644 --- a/src/calendaring/_async/memory.py +++ b/src/calendaring/_async/memory.py @@ -17,7 +17,7 @@ from icalendar_searcher import Searcher from .._memory_store import FULL, MemoryCalendar, MemoryObject, MemoryStore, Profile -from ..capabilities import Capabilities, Feature, Support +from ..capabilities import Capabilities, Feature, Support, held_only from ..errors import ConflictError, InvalidDataError from ..items import Item from ..loss import LossPolicy @@ -135,15 +135,9 @@ async def _open(cls, url: str, **options: Any) -> AsyncMemoryBackend: def _collection_capabilities(self, components: frozenset[Component]) -> Capabilities: """The profile's table, narrowed to the components this collection holds.""" - table: dict[Feature, Any] = self.profile.collection_capabilities - for kind, feature in ( - (Component.EVENT, Feature.COMPONENT_EVENT), - (Component.TASK, Feature.COMPONENT_TASK), - (Component.JOURNAL, Feature.COMPONENT_JOURNAL), - ): - if kind not in components: - table[feature] = Support.UNSUPPORTED - return Capabilities(table) + # A profile without events (a tracker) keeps its own time log, if it has one. + own = Component.EVENT not in self.profile.components + return Capabilities(held_only(self.profile.collection_capabilities, components, own_time_log=own)) async def collections(self) -> list[AsyncCollection]: return [self.collection_class(self, c) for c in self.store.calendars.values()] diff --git a/src/calendaring/_caldav_features.py b/src/calendaring/_caldav_features.py index 9d91464..71d2065 100644 --- a/src/calendaring/_caldav_features.py +++ b/src/calendaring/_caldav_features.py @@ -18,11 +18,11 @@ from .capabilities import ( BACKEND_FEATURES, EDIT_ICALENDAR_PENDING, - NO_EVENTS_FOR_TIME_LOG, Capabilities, Capability, Feature, Support, + held_only, ) from .types import Component @@ -122,6 +122,14 @@ def _table(features: Any) -> dict[Feature, Any]: "level": L, "note": "the requested components are not kept: the server decides", } + elif table[Feature.CREATE_COLLECTION] is F and ( + refused := sorted(c.value for c in Component if _level(features, COMPONENT_FROM_SERVER[c][1]) is U) + ): + # Infomaniak and CCS: a calendar asked for these is made without them + table[Feature.CREATE_COLLECTION] = { + "level": L, + "note": f"made without {', '.join(refused)}, which the server does not store", + } return table @@ -139,7 +147,8 @@ def _changes(features: Any) -> dict[str, Any]: def _time_range(features: Any) -> dict[str, Any]: - """Events only, and only where the server matches recurrences and open ranges as RFC 4791 says. + """Events only, and only where the server matches recurrences and open ranges as RFC 4791 says, + and answers a range from an index that is current. Tasks are always filtered client-side: RFC 4791 ยง9.9's rules for a ``VTODO`` are where servers disagree most, and an under-match loses data. @@ -151,11 +160,19 @@ def _time_range(features: Any) -> dict[str, Any]: "search.time-range.open.end", "search.recurrences.includes-implicit.event", ) + cache = features.is_supported("search-cache", dict) + if cache.get("behaviour") == "delay" and cache.get("filtered-only"): # a plain listing is current, a range is not + return {"level": E, "components": []} if all(_level(features, name) is F for name in needed): return {"level": E, "components": ["VEVENT"]} return {"level": E, "components": []} +def stored_components(features: Any, listed: frozenset[Component]) -> frozenset[Component]: + """What a calendar lists, less the kinds its server is known to refuse (robur lists VJOURNAL, then answers 400).""" + return frozenset(c for c in listed if _level(features, COMPONENT_FROM_SERVER[c][1]) is not U) + + def backend_capabilities(features: Any) -> Capabilities: return Capabilities({f: v for f, v in _table(features).items() if f in BACKEND_FEATURES}) @@ -166,7 +183,7 @@ def collection_capabilities(features: Any, components: frozenset[Component]) -> table[Feature.CHANGES] = _changes(features) table[Feature.SEARCH_TIME_RANGE] = _time_range(features) for kind, (feature, name) in COMPONENT_FROM_SERVER.items(): - table[feature] = _level(features, name) if kind in components else U - if Component.EVENT not in components: # time entries are VEVENTs in the item's own collection - table[Feature.TIME_LOG] = {"level": U, "note": NO_EVENTS_FOR_TIME_LOG} + if kind in components: + table[feature] = _level(features, name) + held_only(table, components) return Capabilities({f: Capability.coerce(v) for f, v in table.items()}) diff --git a/src/calendaring/_mapping.py b/src/calendaring/_mapping.py index ba0186e..352913e 100644 --- a/src/calendaring/_mapping.py +++ b/src/calendaring/_mapping.py @@ -10,6 +10,9 @@ from __future__ import annotations import copy +import functools +import hashlib +import zoneinfo from collections.abc import Callable from datetime import UTC, date, datetime, time from typing import Any @@ -250,6 +253,14 @@ def _comparable(component: icalendar.Component, name: str) -> list[Any]: """ if name == "CATEGORIES": return sorted({str(c) for value in _values(component, name) for c in getattr(value, "cats", [value])}) + if name in {"RDATE", "EXDATE"}: # a list of values, however it is split over lines + return sorted( + { + (n, params, one) + for n, params, value in map(_without_defaults, property_lines(component, name)) + for one in str(value).split(",") + } + ) if name in INSTANTS and not isinstance(component.get(name), list) and not _recurs(component): value = component.decoded(name) if isinstance(value, datetime) and value.tzinfo is not None: @@ -268,7 +279,7 @@ def _without_defaults(line: bytes) -> tuple[Any, ...]: except ValueError: # icalendar's own lines parse, so this one is a server's: compared verbatim return line.decode(), (), "" kept = sorted( - (key.upper(), tuple(sorted(str(v) for v in values))) + (key.upper(), tuple(sorted(_zone(str(v)) if key.upper() == "TZID" else str(v) for v in values))) for key, raw in params.items() for values in [raw if isinstance(raw, list) else [raw]] if not (len(values) == 1 and PARAMETER_DEFAULTS.get(key.upper()) == str(values[0]).upper()) @@ -276,6 +287,22 @@ def _without_defaults(line: bytes) -> tuple[Any, ...]: return name.upper(), tuple(kept), value +@functools.cache +def _zone(tzid: str) -> str: + """A time zone by how it behaves, so that one tzdata renamed into another (Europe/Oslo, now a link to Berlin) compares equal. + + The UTC offset at the start of every month from 1970 on: tzdata merges + zones only where they agree from 1970, and Europe/Sofia, an hour ahead of + Berlin all year, is not Berlin. A zone this machine does not know is its name. + """ + try: + zone = zoneinfo.ZoneInfo(tzid) + except (zoneinfo.ZoneInfoNotFoundError, ValueError): + return tzid + offsets = (zone.utcoffset(datetime(year, month, 1)) for year in range(1970, 2041) for month in range(1, 13)) + return "zone:" + hashlib.sha1(repr(list(offsets)).encode()).hexdigest() + + def _recurs(component: icalendar.Component) -> bool: """A series' times are local times: moved to UTC, its occurrences shift at a DST change.""" return "RRULE" in component or "RDATE" in component diff --git a/src/calendaring/_sync/backend.py b/src/calendaring/_sync/backend.py index fdd19ba..91acf77 100644 --- a/src/calendaring/_sync/backend.py +++ b/src/calendaring/_sync/backend.py @@ -8,11 +8,11 @@ from typing import TYPE_CHECKING, Any, ClassVar, Self from urllib.parse import urlsplit -from ..capabilities import Capabilities, Feature, Support +from ..capabilities import COMPONENT_FEATURE, Capabilities, Feature, Support from ..errors import AmbiguousError, CalendaringError, ConfigurationError, NotFoundError, UnsupportedError from ..loss import LossPolicy from ..types import Component -from .collection import COMPONENT_FEATURE, Collection +from .collection import Collection if TYPE_CHECKING: from .workspace import Workspace diff --git a/src/calendaring/_sync/caldav.py b/src/calendaring/_sync/caldav.py index b070816..3a2666f 100644 --- a/src/calendaring/_sync/caldav.py +++ b/src/calendaring/_sync/caldav.py @@ -21,7 +21,7 @@ import icalendar from icalendar_searcher import Searcher -from .._caldav_features import backend_capabilities, collection_capabilities +from .._caldav_features import backend_capabilities, collection_capabilities, stored_components from .._caldav_io import Calendar from ..capabilities import Feature from ..errors import ( @@ -35,7 +35,7 @@ from ..items import _items_of, _parse from ..loss import LossPolicy from ..types import BrokenObject, Component, StoredObject, SyncToken -from .backend import Backend +from .backend import Backend, _none_held from .collection import Collection from .dav import DAV @@ -249,16 +249,21 @@ def _open(cls, url: str, **options: Any) -> CalDAVBackend: return cls(DAV(url, **options), id=backend_id, loss=loss, verify=verify) def _wrap(self, calendar: Calendar) -> CalDAVCollection: - components = frozenset( + listed = frozenset( Component(c) for c in self._dav.supported_components(calendar) if c in {k.value for k in Component} ) + components = stored_components(self._dav.client.features, listed) return self.collection_class(self, calendar, name=self._dav.display_name(calendar), components=components) def collections(self) -> list[Collection]: return [self._wrap(c) for c in self._dav.calendars()] def _create_collection(self, name: str, components: frozenset[Component]) -> Collection: - return self._wrap(self._dav.make_calendar(name, sorted(c.value for c in components))) + # Infomaniak and CCS refuse any set naming a kind they cannot store; ask for the rest. + asked = stored_components(self._dav.client.features, components) + if components and not asked: + raise _none_held(components, self.id) + return self._wrap(self._dav.make_calendar(name, sorted(c.value for c in asked))) def close(self) -> None: self._dav.close() diff --git a/src/calendaring/_sync/collection.py b/src/calendaring/_sync/collection.py index 6024b49..e308840 100644 --- a/src/calendaring/_sync/collection.py +++ b/src/calendaring/_sync/collection.py @@ -26,7 +26,7 @@ from icalendar_searcher import Searcher from .._mapping import Change, differences, plan_calendar_losses, plan_losses -from ..capabilities import BACKEND_FEATURES, Capabilities, Feature, Support +from ..capabilities import BACKEND_FEATURES, COMPONENT_FEATURE, Capabilities, Feature, Support from ..errors import ( AlreadyExistsError, CalendaringError, @@ -78,13 +78,6 @@ def _by_start(entry: TimeEntry) -> tuple[bool, float]: return entry.start is None, when.timestamp() if when is not None else 0.0 -COMPONENT_FEATURE = { - Component.EVENT: Feature.COMPONENT_EVENT, - Component.TASK: Feature.COMPONENT_TASK, - Component.JOURNAL: Feature.COMPONENT_JOURNAL, -} - - def _occurrence_now(calendar: icalendar.Calendar, item: Item) -> icalendar.Component | None: """The occurrence ``item`` was, as the stored object has it now: its override, or one generated from the master.""" wanted = item.component["RECURRENCE-ID"] diff --git a/src/calendaring/_sync/conformance.py b/src/calendaring/_sync/conformance.py index 44c3805..bbf4eba 100644 --- a/src/calendaring/_sync/conformance.py +++ b/src/calendaring/_sync/conformance.py @@ -1178,7 +1178,10 @@ def test_time_log_round_trips_as_declared(self, collection: Collection) -> None: "start-time": (read.start, at(3, 9)), "end": (read.end, at(3, 11)), } - expected |= {"users": (read.users, users), "comment": (read.comment, "first draft\nwith figures")} + expected |= { + "users": (tuple(sorted(read.users)), tuple(sorted(users))), + "comment": (read.comment, "first draft\nwith figures"), + } for name, (got, sent) in expected.items(): if name in lost: assert got in (None, ()), f"{name} is declared lost, yet read back as {got!r}: never invent it" @@ -1388,7 +1391,7 @@ def test_create_and_delete_collection(self, backend: Backend) -> None: assert info.value.feature == Feature.CREATE_COLLECTION assert [c.id for c in backend.collections()] == before return - deletable = backend.capabilities.supports(Feature.DELETE_COLLECTION) + deletable = backend.capabilities.level(Feature.DELETE_COLLECTION) is not Support.UNSUPPORTED # Without delete-collection, only the first run on a backend makes it: later ones find it left there. made = None if deletable else self.left_behind(backend, "conformance-new") lossy = backend.capabilities.level(Feature.CREATE_COLLECTION) is Support.LOSSY diff --git a/src/calendaring/_sync/files.py b/src/calendaring/_sync/files.py index 93545d3..899fa35 100644 --- a/src/calendaring/_sync/files.py +++ b/src/calendaring/_sync/files.py @@ -22,13 +22,13 @@ from .._concurrency import to_thread from .._files_store import Entry, IcsFile, Vdir, collections, layout, make_collection -from ..capabilities import EDIT_ICALENDAR_PENDING, NO_EVENTS_FOR_TIME_LOG, Capabilities, Feature, Support +from ..capabilities import EDIT_ICALENDAR_PENDING, Capabilities, Feature, Support, held_only from ..errors import ConfigurationError, InvalidDataError from ..items import _parse from ..loss import LossPolicy from ..types import BrokenObject, Component, StoredObject from .backend import Backend -from .collection import COMPONENT_FEATURE, Collection +from .collection import Collection __all__ = ["FilesBackend", "FilesCollection"] @@ -59,11 +59,7 @@ def collection_capabilities(single_file: bool, components: frozenset[Component]) Feature.COLLECTION_SET_NAME: U, } ) - for kind, feature in COMPONENT_FEATURE.items(): - if kind not in components: - table[feature] = U - if Component.EVENT not in components: # time entries are VEVENTs in the item's own collection - table[Feature.TIME_LOG] = {"level": U, "note": NO_EVENTS_FOR_TIME_LOG} + held_only(table, components) for feature in (Feature.CREATE_COLLECTION, Feature.DELETE_COLLECTION): del table[feature] return Capabilities(table) diff --git a/src/calendaring/_sync/memory.py b/src/calendaring/_sync/memory.py index 49ec1e6..f783703 100644 --- a/src/calendaring/_sync/memory.py +++ b/src/calendaring/_sync/memory.py @@ -17,7 +17,7 @@ from icalendar_searcher import Searcher from .._memory_store import FULL, MemoryCalendar, MemoryObject, MemoryStore, Profile -from ..capabilities import Capabilities, Feature, Support +from ..capabilities import Capabilities, Feature, Support, held_only from ..errors import ConflictError, InvalidDataError from ..items import Item from ..loss import LossPolicy @@ -135,15 +135,9 @@ def _open(cls, url: str, **options: Any) -> MemoryBackend: def _collection_capabilities(self, components: frozenset[Component]) -> Capabilities: """The profile's table, narrowed to the components this collection holds.""" - table: dict[Feature, Any] = self.profile.collection_capabilities - for kind, feature in ( - (Component.EVENT, Feature.COMPONENT_EVENT), - (Component.TASK, Feature.COMPONENT_TASK), - (Component.JOURNAL, Feature.COMPONENT_JOURNAL), - ): - if kind not in components: - table[feature] = Support.UNSUPPORTED - return Capabilities(table) + # A profile without events (a tracker) keeps its own time log, if it has one. + own = Component.EVENT not in self.profile.components + return Capabilities(held_only(self.profile.collection_capabilities, components, own_time_log=own)) def collections(self) -> list[Collection]: return [self.collection_class(self, c) for c in self.store.calendars.values()] diff --git a/src/calendaring/capabilities.py b/src/calendaring/capabilities.py index 48b1e46..b4a6dbb 100644 --- a/src/calendaring/capabilities.py +++ b/src/calendaring/capabilities.py @@ -8,14 +8,18 @@ from types import MappingProxyType from typing import Any +from .types import Component + __all__ = [ "BACKEND_FEATURES", + "COMPONENT_FEATURE", "EDIT_ICALENDAR_PENDING", "NO_EVENTS_FOR_TIME_LOG", "Capabilities", "Capability", "Feature", "Support", + "held_only", ] @@ -80,6 +84,27 @@ class Feature(StrEnum): # Why time-log is UNSUPPORTED in a collection holding no events (entries are VEVENTs): NO_EVENTS_FOR_TIME_LOG = "holds no events to log time in; set time_log_collection" +COMPONENT_FEATURE = { + Component.EVENT: Feature.COMPONENT_EVENT, + Component.TASK: Feature.COMPONENT_TASK, + Component.JOURNAL: Feature.COMPONENT_JOURNAL, +} + + +def held_only( + table: dict[Feature, Any], components: frozenset[Component], *, own_time_log: bool = False +) -> dict[Feature, Any]: + """``table``, changed in place, for a collection holding only ``components``. + + ``own_time_log``: the backend keeps time entries itself, as a tracker does, rather than as events. + """ + for kind, feature in COMPONENT_FEATURE.items(): + if kind not in components: + table[feature] = Support.UNSUPPORTED + if Component.EVENT not in components and not own_time_log: # time entries are VEVENTs in the item's collection + table[Feature.TIME_LOG] = {"level": Support.UNSUPPORTED, "note": NO_EVENTS_FOR_TIME_LOG} + return table + @dataclass(frozen=True) class Capability: diff --git a/tests/_async/test_caldav_backend.py b/tests/_async/test_caldav_backend.py index b582472..d15aa26 100644 --- a/tests/_async/test_caldav_backend.py +++ b/tests/_async/test_caldav_backend.py @@ -21,6 +21,7 @@ PartialResultError, SyncTokenError, Task, + UnsupportedError, ) pytest.importorskip("caldav") @@ -81,6 +82,27 @@ async def test_an_option_caldav_does_not_take_is_a_configuration_error() -> None await AsyncBackend.open("https://dav.example.com/", kind="caldav", no_such_option=1) +async def test_a_calendar_is_not_asked_for_what_the_server_cannot_store(monkeypatch: pytest.MonkeyPatch) -> None: + """Infomaniak and CCS refuse any MKCALENDAR naming VJOURNAL; their hints say so.""" + asked: list[list[str]] = [] + + async def make_calendar(name: str, components: list[str]) -> Any: + asked.append(components) + raise CalendaringError("not sent in this test") + + backend = await AsyncBackend.open( + "https://dav.example.com/", kind="caldav", features={"save-load.journal": {"support": "unsupported"}} + ) + async with backend: + monkeypatch.setattr(backend._dav, "make_calendar", make_calendar) # type: ignore[attr-defined] + with pytest.raises(UnsupportedError): + await backend.create_collection("journal", components=(Component.JOURNAL,)) + assert asked == [] + with pytest.raises(CalendaringError, match="not sent"): + await backend.create_collection("both", components=(Component.EVENT, Component.JOURNAL)) + assert asked == [["VEVENT"]] + + async def test_wire_bytes_are_sent_as_given(collection: AsyncCollection, monkeypatch: pytest.MonkeyPatch) -> None: # icalendar would fold this line. Xandikos refolds what it stores, so what is checked is the PUT. client = collection._backend._dav.client # type: ignore[attr-defined] diff --git a/tests/_async/test_generic.py b/tests/_async/test_generic.py index 2978824..c55d93e 100644 --- a/tests/_async/test_generic.py +++ b/tests/_async/test_generic.py @@ -468,6 +468,7 @@ async def test_a_separate_time_log_collection() -> None: assert [i.uid for i in await log.search()] == [entry.id] assert await tasks.events() == [] tasks.time_log_collection = None + assert tasks.capabilities.level(Feature.TIME_LOG) is Support.UNSUPPORTED with pytest.raises(UnsupportedError): await task.log_time(spent=HOUR) # the task list itself holds no events diff --git a/tests/_sync/test_caldav_backend.py b/tests/_sync/test_caldav_backend.py index 3c4a4f7..cdaae25 100644 --- a/tests/_sync/test_caldav_backend.py +++ b/tests/_sync/test_caldav_backend.py @@ -21,6 +21,7 @@ PartialResultError, SyncTokenError, Task, + UnsupportedError, ) pytest.importorskip("caldav") @@ -81,6 +82,27 @@ def test_an_option_caldav_does_not_take_is_a_configuration_error() -> None: Backend.open("https://dav.example.com/", kind="caldav", no_such_option=1) +def test_a_calendar_is_not_asked_for_what_the_server_cannot_store(monkeypatch: pytest.MonkeyPatch) -> None: + """Infomaniak and CCS refuse any MKCALENDAR naming VJOURNAL; their hints say so.""" + asked: list[list[str]] = [] + + def make_calendar(name: str, components: list[str]) -> Any: + asked.append(components) + raise CalendaringError("not sent in this test") + + backend = Backend.open( + "https://dav.example.com/", kind="caldav", features={"save-load.journal": {"support": "unsupported"}} + ) + with backend: + monkeypatch.setattr(backend._dav, "make_calendar", make_calendar) # type: ignore[attr-defined] + with pytest.raises(UnsupportedError): + backend.create_collection("journal", components=(Component.JOURNAL,)) + assert asked == [] + with pytest.raises(CalendaringError, match="not sent"): + backend.create_collection("both", components=(Component.EVENT, Component.JOURNAL)) + assert asked == [["VEVENT"]] + + def test_wire_bytes_are_sent_as_given(collection: Collection, monkeypatch: pytest.MonkeyPatch) -> None: # icalendar would fold this line. Xandikos refolds what it stores, so what is checked is the PUT. client = collection._backend._dav.client # type: ignore[attr-defined] diff --git a/tests/_sync/test_generic.py b/tests/_sync/test_generic.py index d153d07..a9a204b 100644 --- a/tests/_sync/test_generic.py +++ b/tests/_sync/test_generic.py @@ -468,6 +468,7 @@ def test_a_separate_time_log_collection() -> None: assert [i.uid for i in log.search()] == [entry.id] assert tasks.events() == [] tasks.time_log_collection = None + assert tasks.capabilities.level(Feature.TIME_LOG) is Support.UNSUPPORTED with pytest.raises(UnsupportedError): task.log_time(spent=HOUR) # the task list itself holds no events diff --git a/tests/conftest.py b/tests/conftest.py index 1b03f8e..30b56ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,26 +4,18 @@ when they are not installed (``pip install -e .[caldav,test-servers]``). ``--caldav-servers=Baikal,Nextcloud`` (or ``all``) adds the docker and -external servers of caldav's own test framework. That framework is not -in caldav's wheel, so it is loaded from a caldav source checkout: -``$CALDAV_SOURCE``, or the one an editable caldav install points at. -Servers are never started here: start them in the checkout +external servers of caldav's own test framework, ``caldav.testing``. +Servers are never started here: start them in a caldav checkout (``tests/docker-test-servers//start.sh``) before the run. """ from __future__ import annotations -import contextlib -import importlib.util -import os import socket -import sys from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path -from types import ModuleType from typing import Any -from urllib.parse import urlsplit import pytest @@ -71,42 +63,14 @@ def pytest_addoption(parser: pytest.Parser) -> None: "--caldav-servers", default="", metavar="NAMES", - help="also test against caldav's test servers: comma-separated names (Baikal,Nextcloud,...) " - "or 'all'; needs a caldav source checkout ($CALDAV_SOURCE)", + help="also test against caldav's test servers: comma-separated names (Baikal,Nextcloud,...) or 'all'", ) - - -def caldav_checkout() -> Path: - if "CALDAV_SOURCE" in os.environ: - return Path(os.environ["CALDAV_SOURCE"]).expanduser() - import caldav - - return Path(caldav.__file__).parent.parent - - -def caldav_test_framework() -> ModuleType: - """caldav's ``tests/test_servers`` package, imported under a name that cannot clash with ours. - - A path import, until caldav ships it: https://github.com/python-caldav/caldav/issues/817 - """ - name = "caldav_test_servers" - if name in sys.modules: - return sys.modules[name] - checkout = caldav_checkout() - package = checkout / "tests" / "test_servers" - if not (package / "__init__.py").exists(): - raise pytest.UsageError( - f"--caldav-servers needs a caldav source checkout; none at {checkout} (set CALDAV_SOURCE)" - ) - spec = importlib.util.spec_from_file_location( - name, package / "__init__.py", submodule_search_locations=[str(package)] + parser.addoption( + "--caldav-servers-config", + default=None, + metavar="PATH", + help="a caldav test-server yaml to add to the servers caldav finds itself; it wins on a name clash", ) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - return module def caldav_test_servers(config: pytest.Config) -> dict[str, Any]: @@ -114,21 +78,27 @@ def caldav_test_servers(config: pytest.Config) -> dict[str, Any]: wanted = config.getoption("--caldav-servers") if not wanted: return {} - framework = caldav_test_framework() - # caldav reads tests/caldav_test_servers.yaml relative to the working directory. - with contextlib.chdir(caldav_checkout()): - registry = framework.get_registry() - # caldav takes the first config source it finds; add the test_allowed sections of the - # caldav configuration even when a test-server file exists (caldav issue 817). - from caldav.config import get_all_test_servers - - allowed = get_all_test_servers() - known = {s.name.lower() for s in registry.all_servers()} - registry.load_from_config( - {n: {"type": "external", "enabled": True, **c} for n, c in allowed.items() if n.lower() not in known} - ) + try: + from caldav.testing.base import EmbeddedTestServer + from caldav.testing.registry import get_registry + except ImportError as e: + raise pytest.UsageError(f"--caldav-servers needs a caldav with the test-server registry: {e}") from e + registry = get_registry() + if path := config.getoption("--caldav-servers-config"): + from caldav.testing.config_loader import load_test_server_config + + # caldav falls back to its default sources when the file is missing + if not Path(path).is_file(): + raise pytest.UsageError(f"--caldav-servers-config: no file {path}") + extra = load_test_server_config(path) + # The registry keeps a server it already has and ignores the file's entry + known = {s.name.lower(): s.name for s in registry.all_servers()} + for name in extra: + if name.lower() in known: + registry.unregister(known[name.lower()]) + registry.load_from_config(extra) registered = registry.enabled_servers() - available = {s.name: s for s in registered if not isinstance(s, framework.EmbeddedTestServer)} + available = {s.name: s for s in registered if not isinstance(s, EmbeddedTestServer)} if wanted == "all": return available names = [n.strip() for n in wanted.split(",") if n.strip()] @@ -158,6 +128,26 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: metafunc.parametrize("caldav_server", caldav_server_names(metafunc.config), indirect=True, scope="session") +@pytest.hookimpl(tryfirst=True) # before xdist reads the groups +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Under ``pytest -n N --dist loadgroup``, keep each server's tests on one worker. + + A server then has one connection and one session lock, and the servers run in parallel; + the slowest server sets the pace instead of the sum of them all. + """ + if not config.pluginmanager.hasplugin("xdist"): + return + servers = set(caldav_server_names(config)) + for item in items: + params = getattr(item, "callspec", None) + for name in (params.params.get(key) for key in ("caldav_server", "where")) if params else (): + if name in servers: + item.add_marker(pytest.mark.xdist_group(name)) + # Radicale always listens on 5232, and stop() shuts down whichever one answers there + if "radicale_server" in getattr(item, "fixturenames", ()): + item.add_marker(pytest.mark.xdist_group("Radicale")) + + @dataclass class CalDAVServer: """A running server, and what ``Backend.open(url, kind="caldav", **options)`` needs to reach it.""" @@ -167,17 +157,6 @@ class CalDAVServer: options: dict[str, Any] -def reachable(url: str) -> bool: - """Whether anything answers at the URL's host and port; caldav takes an external server's word for it.""" - parts = urlsplit(url) - port = parts.port or (443 if parts.scheme == "https" else 80) - try: - socket.create_connection((parts.hostname, port), timeout=5).close() - except OSError: - return False - return True - - def running_caldav_server(request: pytest.FixtureRequest, name: str) -> CalDAVServer: """The server by name; a docker or external one is never started, only checked to be up.""" if name in EMBEDDED: @@ -185,8 +164,7 @@ def running_caldav_server(request: pytest.FixtureRequest, name: str) -> CalDAVSe else: server = request.config.stash[SERVERS][name] server.claim() # waits while a caldav test run is using the same server - # An external server without a URL gets one from its auto-connect hints, in caldav. - if not (server.is_accessible() and (not server.url or reachable(server.url))): + if not server.is_accessible(): message = f"{name} is not running at {server.url}; start it from the caldav checkout first" if request.config.getoption("--caldav-servers") == "all": pytest.skip(message) diff --git a/tests/test_caldav_features.py b/tests/test_caldav_features.py index a973e2f..a4f9925 100644 --- a/tests/test_caldav_features.py +++ b/tests/test_caldav_features.py @@ -10,7 +10,11 @@ from caldav.compatibility_hints import FeatureSet # noqa: E402 -from calendaring._caldav_features import backend_capabilities, collection_capabilities # noqa: E402 +from calendaring._caldav_features import ( # noqa: E402 + backend_capabilities, + collection_capabilities, + stored_components, +) from calendaring.capabilities import Feature, Support # noqa: E402 from calendaring.types import Component # noqa: E402 @@ -113,6 +117,29 @@ def test_time_range_narrows_events_only_where_recurrences_match_too() -> None: assert collection_capabilities(no_implicit, ALL).details(Feature.SEARCH_TIME_RANGE) == {"components": []} +def test_time_range_is_not_sent_where_filtered_searches_lag_behind_writes() -> None: + # Purelymail: a plain listing is current at once, a time-range REPORT answers from an index that lags + lagging = features( + { + "search": {"support": "full"}, + "search-cache": {"behaviour": "delay", "delay": 180, "filtered-only": True}, + } + ) + assert collection_capabilities(lagging, ALL).details(Feature.SEARCH_TIME_RANGE) == {"components": []} + # Bedework lags in plain listings too: filtering on the client would not help, so the range is still sent + everywhere = features({"search": {"support": "full"}, "search-cache": {"behaviour": "delay", "delay": 3}}) + assert collection_capabilities(everywhere, ALL).details(Feature.SEARCH_TIME_RANGE) == {"components": ["VEVENT"]} + + +def test_a_calendar_does_not_hold_what_the_server_refuses_to_store() -> None: + # robur lists VJOURNAL as supported, then answers a journal with 400 + listed = frozenset({Component.EVENT, Component.TASK, Component.JOURNAL}) + refusing = hinted(save_load="full", **{"save-load.journal": "ungraceful"}) + assert stored_components(refusing, listed) == {Component.EVENT, Component.TASK} + assert stored_components(hinted(save_load="full"), listed) == listed + assert stored_components(features(), listed) == listed # unhinted: nothing known + + def test_a_calendar_is_made_lossily_where_the_server_ignores_the_component_set() -> None: server = hinted(save_load="full", create_calendar="full") assert backend_capabilities(server).level(Feature.CREATE_COLLECTION) is Support.FULL @@ -126,3 +153,11 @@ def test_a_calendar_is_made_lossily_where_the_server_ignores_the_component_set() capability = backend_capabilities(ignored)[Feature.CREATE_COLLECTION] assert capability.level is Support.LOSSY assert "components" in (capability.note or "") + + +def test_a_calendar_is_made_lossily_where_the_server_cannot_store_a_component() -> None: + # Infomaniak and CCS: a calendar asked for journals is made without them + server = hinted(save_load="full", create_calendar="full", **{"save-load.journal": "unsupported"}) + capability = backend_capabilities(server)[Feature.CREATE_COLLECTION] + assert capability.level is Support.LOSSY + assert "VJOURNAL" in (capability.note or "") diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 6334cc0..3076769 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,24 +1,26 @@ """What a user of the library does, step by step, on every kind of backend. -These tests are meant to be read: one story each, in the sync mode, the -way a script would use the library. The conformance suites are what check -every promise in detail; this file checks that the obvious things work, -on local files, in memory and on real CalDAV servers. +These tests are optimized for readability: one story each. -Where a backend cannot do what the story needs, the story checks the other -half of the promise instead: the call is refused before anything is -written, with ``UnsupportedError`` for an operation and ``LossyWriteError`` -for a value the backend would have to drop. The memory backend's -``TRACKER`` and ``MINIMAL`` profiles are there to walk those paths. +These tests are slightly redundant, the conformance suites should +ensure in detail that everything works as intended. + +When the backend cannot support the scenario, the test asserts that +this is properly flagged (``UnsupportedError`` or ``LossyWriteError``). + +Events, tasks and journals are kept apart, as many servers and most +users keep them: a calendar, a task list and a journal. Where a backend +has only one collection, all three are that one. Add caldav's docker servers with ``--caldav-servers=Baikal,Nextcloud`` (see ``conftest.py``). + """ from __future__ import annotations from collections.abc import Iterator, Sequence -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, date, datetime, time, timedelta from pathlib import Path import pytest @@ -77,9 +79,18 @@ def open_backend(where: str, directory: Path, request: pytest.FixtureRequest) -> return Backend.open(server.url, kind="caldav", **server.options) -def at(day: int, hour: int) -> datetime: - """A time in November 2026, when all these stories take place.""" - return datetime(2026, 11, day, hour, tzinfo=UTC) +# Next week: a fixed date would one day be past every server's time range. +TODAY = datetime.now(UTC).date() +MONDAY = TODAY + timedelta(days=7 - TODAY.weekday()) + + +def day(n: int) -> date: + """A day of the week these stories take place in: 0 is its Monday.""" + return MONDAY + timedelta(days=n) + + +def at(n: int, hour: int) -> datetime: + return datetime.combine(day(n), time(hour), tzinfo=UTC) def summaries(items: Sequence[SyncItem]) -> list[str]: @@ -98,11 +109,14 @@ def backend(where: str, tmp_path: Path, request: pytest.FixtureRequest) -> Itera def fresh_calendar( - backend: Backend, - name: str, - components: tuple[Component, ...] = (Component.EVENT, Component.TASK, Component.JOURNAL), + backend: Backend, request: pytest.FixtureRequest, kind: Component, label: str = "" ) -> Iterator[Collection]: - """A new calendar where the backend can make one; else the one it has, as new as the backend.""" + """A new collection for ``kind`` where the backend can make one; else the one it has, as new as the backend. + + Where the backend can make none for ``kind``, a story marked ``checks_refusal`` gets one for another + kind, and checks the refusal there; any other story is skipped. + """ + name = f"scenario {request.node.originalname} ({kind.value}{label})" on_a_server = backend.kind == "caldav" if backend.capabilities.level(Feature.CREATE_COLLECTION) is Support.UNSUPPORTED: if on_a_server: @@ -111,18 +125,16 @@ def fresh_calendar( return if on_a_server and backend.capabilities.level(Feature.DELETE_COLLECTION) is Support.UNSUPPORTED: pytest.skip("the server cannot delete calendars, so every story would leave one behind") - try: - calendar = backend.create_collection(name, components=components) - except UnsupportedError: - # A tracker asked for an events-only calendar refuses: it would hold none of it. - pytest.skip("the backend cannot make a calendar holding any of what was asked for") - # Journals are optional; the journal story checks their refusal. - missing = set(components) - calendar.components - {Component.JOURNAL} - if on_a_server and missing: - calendar.delete_collection() - pytest.skip( - f"the server made a calendar without {sorted(c.value for c in missing)} (Bedework makes events-only ones)" - ) + # A tracker makes no calendar for events, Bedework none for tasks, and many servers none for journals. + others = [c for c in Component if c is not kind] if request.node.get_closest_marker("checks_refusal") else [] + for asked in [kind, *others]: + try: + calendar = backend.create_collection(name, components=(asked,)) + break + except UnsupportedError: + continue + else: + pytest.skip(f"the backend cannot make a collection for {kind.value}") yield calendar if backend.capabilities.level(Feature.DELETE_COLLECTION) is not Support.UNSUPPORTED: calendar.delete_collection() @@ -130,114 +142,151 @@ def fresh_calendar( @pytest.fixture def calendar(backend: Backend, request: pytest.FixtureRequest) -> Iterator[Collection]: - """A calendar for the test alone, holding nothing yet.""" - yield from fresh_calendar(backend, f"scenario {request.node.originalname}") + """A calendar for events, for the test alone, holding nothing yet.""" + yield from fresh_calendar(backend, request, Component.EVENT) @pytest.fixture -def events_calendar(backend: Backend, request: pytest.FixtureRequest) -> Iterator[Collection]: - """A calendar made for events only, as many users keep one beside their task list.""" - if backend.capabilities.level(Feature.CREATE_COLLECTION) is Support.UNSUPPORTED: - pytest.skip("the backend has one calendar, and makes no others") - yield from fresh_calendar(backend, f"scenario {request.node.originalname}", components=(Component.EVENT,)) +def tasklist(backend: Backend, request: pytest.FixtureRequest) -> Iterator[Collection]: + """A task list for the test alone, holding nothing yet.""" + yield from fresh_calendar(backend, request, Component.TASK) + + +@pytest.fixture +def journal(backend: Backend, request: pytest.FixtureRequest) -> Iterator[Collection]: + """A journal for the test alone, holding nothing yet.""" + yield from fresh_calendar(backend, request, Component.JOURNAL) @pytest.fixture -def other_calendar( +def other_tasklist( backend: Backend, where: str, tmp_path: Path, request: pytest.FixtureRequest ) -> Iterator[Collection]: - """A second calendar: on the same backend, or on another one of the same kind if it holds only one.""" + """A second task list: on the same backend, or on another one of the same kind if it holds only one.""" if backend.capabilities.level(Feature.CREATE_COLLECTION) is not Support.UNSUPPORTED: - yield from fresh_calendar(backend, f"scenario {request.node.originalname} (other)") + yield from fresh_calendar(backend, request, Component.TASK, ", other") return with open_backend(where, tmp_path / "other", request) as other: yield other.collections()[0] +@pytest.mark.checks_refusal def test_a_new_calendar_is_listed_and_can_be_renamed(backend: Backend, calendar: Collection) -> None: + ## The calendar fixture is a freshly made collection + ## backend.collections() should list up all collections, including calendar assert calendar.id in [c.id for c in backend.collections()] + ## Can the backend set a name on a collection? + ## If not, assert UnsupportedError is raised when trying to rename it if unsupported(calendar, Feature.COLLECTION_SET_NAME): with pytest.raises(UnsupportedError): calendar.set_name("Renamed") assert backend.collection(calendar.id).name != "Renamed" return + + ## Do the rename calendar.set_name("Renamed") # Ask the backend again, rather than trusting the object we renamed. assert backend.collection(calendar.id).name == "Renamed" +@pytest.mark.checks_refusal def test_an_event_is_added_found_changed_and_deleted(calendar: Collection) -> None: + + ## Assert that we get an appropriate error if trying to insert an event + ## into a collection that cannot hold events if unsupported(calendar, Feature.COMPONENT_EVENT): # A task tracker holds no events. with pytest.raises(UnsupportedError): - calendar.add_event(summary="Planning meeting", start=at(2, 9), end=at(2, 10)) + calendar.add_event(summary="Planning meeting", start=at(0, 9), end=at(0, 10)) assert calendar.search() == [] return - meeting = calendar.add_event(summary="Planning meeting", start=at(2, 9), end=at(2, 10)) - found = calendar.events(start=at(2, 0), end=at(3, 0)) + ## Adding an event into the collection + meeting = calendar.add_event(summary="Planning meeting", start=at(0, 9), end=at(0, 10)) + + ## Do a date search for events + found = calendar.events(start=at(0, 0), end=at(1, 0)) + ## Assert event is found assert [e.uid for e in found] == [meeting.uid] + ## Editing an event meeting.component.summary = "Planning meeting, moved to room 2" meeting.save() + + ## Asserting the edit can be read back from the collection assert calendar.reload(meeting).component.summary == "Planning meeting, moved to room 2" meeting.delete() assert calendar.events() == [] -def test_a_task_is_completed_and_uncompleted(calendar: Collection) -> None: +@pytest.mark.checks_refusal +def test_a_task_is_completed_and_uncompleted(tasklist: Collection) -> None: """The plann use case: add a task, tick it off, and change one's mind.""" - task = calendar.add_task(summary="Buy milk") - assert [t.uid for t in calendar.tasks(include_completed=False)] == [task.uid] + + ## A collection that cannot hold tasks says so, rather than storing something else + if unsupported(tasklist, Feature.COMPONENT_TASK): + with pytest.raises(UnsupportedError): + tasklist.add_task(summary="Buy milk") + assert tasklist.search() == [] + return + + task = tasklist.add_task(summary="Buy milk") + assert [t.uid for t in tasklist.tasks(include_completed=False)] == [task.uid] task.complete() # A completed task drops out of the to-do list, but is not gone. - assert calendar.tasks(include_completed=False) == [] - assert calendar.reload(task).component.status == "COMPLETED" + assert tasklist.tasks(include_completed=False) == [] + assert tasklist.reload(task).component.status == "COMPLETED" + ## Ticked off by mistake: the milk is still in the shop task.uncomplete() - assert [t.uid for t in calendar.tasks(include_completed=False)] == [task.uid] + assert [t.uid for t in tasklist.tasks(include_completed=False)] == [task.uid] -def test_a_due_date_is_kept_or_the_task_is_refused(calendar: Collection) -> None: - """A task the backend cannot store with its due date must not be stored without it.""" - # For a task, icalendar's ``end`` is DUE. - if unsupported(calendar, Feature.TASK_DUE): +def test_a_due_date_is_kept(tasklist: Collection) -> None: + """Add a task with a due-date""" + ## For a task, icalendar's ``end`` is DUE. + + ## Due-date unsupported? Then we should get a LossyWriteError + if unsupported(tasklist, Feature.TASK_DUE): with pytest.raises(LossyWriteError): - calendar.add_task(summary="Pay the rent", end=date(2026, 11, 1)) - assert calendar.tasks() == [] + tasklist.add_task(summary="Pay the rent", end=day(-1)) + assert tasklist.tasks() == [] return - task = calendar.add_task(summary="Pay the rent", end=date(2026, 11, 1)) - assert calendar.reload(task).component.end == date(2026, 11, 1) + + ## Add the task, reload the task list + task = tasklist.add_task(summary="Pay the rent", end=day(-1)) + assert tasklist.reload(task).component.end == day(-1) -def test_a_calendar_for_events_refuses_tasks(events_calendar: Collection) -> None: +def test_a_calendar_for_events_refuses_tasks(calendar: Collection) -> None: """The backend is never asked to store what the calendar says it does not hold.""" - if not unsupported(events_calendar, Feature.COMPONENT_TASK): - pytest.skip("the backend made a calendar for every component anyway (SOGo does)") + if not unsupported(calendar, Feature.COMPONENT_TASK): + pytest.skip("the backend made a calendar for every component anyway (SOGo does), or has only one") with pytest.raises(UnsupportedError): - events_calendar.add_task(summary="Buy milk") - assert events_calendar.search() == [] + calendar.add_task(summary="Buy milk") + assert calendar.search() == [] -def test_a_journal_entry_is_written_where_the_backend_keeps_journals(calendar: Collection) -> None: - if unsupported(calendar, Feature.COMPONENT_JOURNAL): +@pytest.mark.checks_refusal +def test_a_journal_entry_is_written_where_the_backend_keeps_journals(journal: Collection) -> None: + if unsupported(journal, Feature.COMPONENT_JOURNAL): # Several CalDAV servers (SOGo, Zimbra, CCS, OX) have no journals, and nor do trackers. with pytest.raises(UnsupportedError): - calendar.add_journal(summary="Notes from the planning meeting", start=date(2026, 11, 2)) - assert calendar.search() == [] + journal.add_journal(summary="Notes from the planning meeting", start=day(0)) + assert journal.search() == [] return - notes = calendar.add_journal(summary="Notes from the planning meeting", start=date(2026, 11, 2)) - assert [j.uid for j in calendar.journals()] == [notes.uid] + notes = journal.add_journal(summary="Notes from the planning meeting", start=day(0)) + assert [j.uid for j in journal.journals()] == [notes.uid] def test_a_recurring_event_is_expanded_into_occurrences(calendar: Collection) -> None: if unsupported(calendar, Feature.COMPONENT_EVENT): pytest.skip("the backend holds no events; test_an_event_is_added_... checks the refusal") - standup = Event.new(summary="Stand-up", start=at(2, 9), end=at(2, 10)) + standup = Event.new(summary="Stand-up", start=at(0, 9), end=at(0, 10)) standup.component.add("RRULE", {"FREQ": "DAILY", "COUNT": 5}) if unsupported(calendar, Feature.RECURRENCE): # A backend that cannot store the rule must not silently store a one-off event. @@ -248,58 +297,58 @@ def test_a_recurring_event_is_expanded_into_occurrences(calendar: Collection) -> calendar.add(standup) # Wednesday to Friday: three of the five mornings. - mornings = calendar.events(start=at(4, 0), end=at(7, 0), expand=True) - assert sorted(m.component.start for m in mornings) == [at(4, 9), at(5, 9), at(6, 9)] + mornings = calendar.events(start=at(2, 0), end=at(5, 0), expand=True) + assert sorted(m.component.start for m in mornings) == [at(2, 9), at(3, 9), at(4, 9)] assert all(m.is_occurrence and m.uid == standup.uid for m in mornings) -def test_tasks_are_found_by_category_and_by_text(calendar: Collection) -> None: - if unsupported(calendar, Feature.CATEGORIES): +def test_tasks_are_found_by_category_and_by_text(tasklist: Collection) -> None: + if unsupported(tasklist, Feature.CATEGORIES): with pytest.raises(LossyWriteError): - calendar.add_task(summary="Write the report", categories=["work"]) + tasklist.add_task(summary="Write the report", categories=["work"]) # Not stored without its category, either. - assert calendar.tasks() == [] + assert tasklist.tasks() == [] return - calendar.add_task(summary="Write the report", categories=["work"]) - calendar.add_task(summary="Book the dentist", categories=["home"]) + tasklist.add_task(summary="Write the report", categories=["work"]) + tasklist.add_task(summary="Book the dentist", categories=["home"]) by_category = Searcher(todo=True) by_category.add_property_filter("CATEGORIES", "work") - assert summaries(calendar.search(by_category)) == ["Write the report"] + assert summaries(tasklist.search(by_category)) == ["Write the report"] by_text = Searcher(todo=True) by_text.add_property_filter("SUMMARY", "dentist", operator="contains") - assert summaries(calendar.search(by_text)) == ["Book the dentist"] + assert summaries(tasklist.search(by_text)) == ["Book the dentist"] -def test_changes_tells_a_sync_client_what_happened(calendar: Collection) -> None: +def test_changes_tells_a_sync_client_what_happened(tasklist: Collection) -> None: """What a sync tool does: take a token, come back later, and get only the news.""" - calendar.add_task(summary="Kept") - edited = calendar.add_task(summary="Edited") - deleted = calendar.add_task(summary="Deleted") - if unsupported(calendar, Feature.CHANGES): + tasklist.add_task(summary="Kept") + edited = tasklist.add_task(summary="Edited") + deleted = tasklist.add_task(summary="Deleted") + if unsupported(tasklist, Feature.CHANGES): with pytest.raises(UnsupportedError): - calendar.changes() + tasklist.changes() return - token = calendar.changes().token + token = tasklist.changes().token edited.component.summary = "Edited, and then some" edited.save() deleted.delete() # The task left alone is not news. - news = calendar.changes(token) + news = tasklist.changes(token) assert [i.uid for i in news.changed] == [edited.uid] assert news.deleted == [deleted.native_id] -def test_a_stale_copy_cannot_overwrite_a_newer_one(calendar: Collection) -> None: +def test_a_stale_copy_cannot_overwrite_a_newer_one(tasklist: Collection) -> None: """Two clients edit the same task: the second one to save is told, not silently overwritten.""" - if unsupported(calendar, Feature.WRITE_CONDITIONAL): + if unsupported(tasklist, Feature.WRITE_CONDITIONAL): pytest.skip("the backend overwrites by design, so there is no refusal to check") - calendar.add_task(summary="Shared task") - [mine] = calendar.tasks() - [theirs] = calendar.tasks() + tasklist.add_task(summary="Shared task") + [mine] = tasklist.tasks() + [theirs] = tasklist.tasks() theirs.component.summary = "Their edit" theirs.save() @@ -307,36 +356,40 @@ def test_a_stale_copy_cannot_overwrite_a_newer_one(calendar: Collection) -> None mine.component.summary = "My edit" with pytest.raises(ConflictError): mine.save() - assert calendar.reload(mine).component.summary == "Their edit" + assert tasklist.reload(mine).component.summary == "Their edit" -def test_a_task_is_moved_to_another_list(calendar: Collection, other_calendar: Collection) -> None: - task = calendar.add_task(summary="Renew the passport") - if unsupported(calendar, Feature.MOVE): +def test_a_task_is_moved_to_another_list(tasklist: Collection, other_tasklist: Collection) -> None: + task = tasklist.add_task(summary="Renew the passport") + if unsupported(tasklist, Feature.MOVE): with pytest.raises(UnsupportedError): - calendar.move(task, other_calendar) - assert [t.uid for t in calendar.tasks()] == [task.uid] - assert other_calendar.tasks() == [] + tasklist.move(task, other_tasklist) + assert [t.uid for t in tasklist.tasks()] == [task.uid] + assert other_tasklist.tasks() == [] return - calendar.move(task, other_calendar) + tasklist.move(task, other_tasklist) - assert calendar.tasks() == [] + assert tasklist.tasks() == [] # By summary, not UID: a tracker gives what it moves a new identity, as moving an issue does. - assert summaries(other_calendar.tasks()) == ["Renew the passport"] + assert summaries(other_tasklist.tasks()) == ["Renew the passport"] -def test_time_is_logged_against_a_task(calendar: Collection) -> None: - """Roadmap 1.6: time entries are events in the task's own calendar.""" - task = calendar.add_task(summary="Fix the bike") - if unsupported(calendar, Feature.TIME_LOG): +@pytest.mark.checks_refusal +def test_time_is_logged_against_a_task(tasklist: Collection, calendar: Collection) -> None: + """Roadmap 1.6: time entries are events, in the task's own collection or in a calendar beside it.""" + task = tasklist.add_task(summary="Fix the bike") + if unsupported(tasklist, Feature.TIME_LOG): + # A task list holding no events logs time in the calendar. + tasklist.time_log_collection = calendar + if unsupported(tasklist.time_log_collection or tasklist, Feature.TIME_LOG): # A tracker keeps no events to log time in. with pytest.raises(UnsupportedError): - task.log_time(spent=timedelta(hours=1), day=date(2026, 11, 2)) - assert summaries(calendar.search()) == ["Fix the bike"] + task.log_time(spent=timedelta(hours=1), day=day(0)) + assert summaries(tasklist.search()) == ["Fix the bike"] return - task.log_time(start=at(2, 9), end=at(2, 10)) - task.log_time(spent=timedelta(minutes=30), day=date(2026, 11, 3)) + task.log_time(start=at(0, 9), end=at(0, 10)) + task.log_time(spent=timedelta(minutes=30), day=day(1)) assert task.time_spent() == timedelta(hours=1, minutes=30) # The task itself is still the only thing the to-do list shows. - assert [t.uid for t in calendar.tasks()] == [task.uid] + assert [t.uid for t in tasklist.tasks()] == [task.uid] diff --git a/tests/test_verify.py b/tests/test_verify.py index 9d1b2d7..0abf2ec 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -116,3 +116,28 @@ def test_a_loss_names_only_the_alarms_that_were_not_kept() -> None: assert "AUDIO" not in str(replaced.sent) assert "server default" in str(replaced.stored) assert "AUDIO" not in str(replaced.stored) + + +def test_a_list_of_dates_is_the_same_however_it_is_split() -> None: + # Bedework and Cyrus split RDATE:a,b into one line per date + sent = calendar("DTSTART;VALUE=DATE:20261105", "RDATE;VALUE=DATE:20261112,20261119") + split = calendar("DTSTART;VALUE=DATE:20261105", "RDATE;VALUE=DATE:20261119", "RDATE;VALUE=DATE:20261112") + assert names(sent, split) == [] + assert names(sent, calendar("DTSTART;VALUE=DATE:20261105", "RDATE;VALUE=DATE:20261112")) == ["RDATE"] + assert names(calendar("EXDATE:20261112T090000Z,20261119T090000Z"), calendar("EXDATE:20261119T090000Z")) == [ + "EXDATE" + ] + + +def test_a_zone_renamed_to_one_that_behaves_the_same_is_no_difference() -> None: + """gmx stores Europe/Oslo as Europe/Berlin, which tzdata has defined as one zone since 2022b.""" + sent = calendar("DTSTART;TZID=Europe/Oslo:20261020T100000", "RRULE:FREQ=WEEKLY;COUNT=30") + assert names(sent, calendar("DTSTART;TZID=Europe/Berlin:20261020T100000", "RRULE:FREQ=WEEKLY;COUNT=30")) == [] + + +def test_a_zone_renamed_to_one_that_behaves_differently_is_a_difference() -> None: + """Sofia is an hour ahead of Berlin all year: the same wall-clock time would be another instant.""" + sent = calendar("DTSTART;TZID=Europe/Sofia:20261020T100000", "RRULE:FREQ=WEEKLY;COUNT=30") + assert names(sent, calendar("DTSTART;TZID=Europe/Berlin:20261020T100000", "RRULE:FREQ=WEEKLY;COUNT=30")) == [ + "DTSTART" + ]