From 84b899fab7b0f30ea008338d9c5ecbd59eb1309f Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 25 Oct 2026 00:25:04 +0200 Subject: [PATCH 01/11] test: use the test servers from caldav.testing caldav now ships its test-server registry in caldav.testing, so the conftest imports it instead of loading tests/test_servers by path from a caldav checkout, and CALDAV_SOURCE is gone. The registry also merges all its config sources, finds the checkout's yaml whatever the working directory, and probes external servers itself, so the conftest's workarounds for those go too. https://github.com/python-caldav/caldav/issues/817 https://github.com/python-caldav/caldav/pull/824 Prompt: Now that we have #817 done on the local caldav master (please confirm), please fix the calendaring library to use this, and run the integration tests towards all the docker servers plus all the external servers defined under [a caldav checkout holding the test servers' passwords] [#817: https://github.com/python-caldav/caldav/issues/817] Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- CONTRIBUTING.md | 18 +++++----- tests/conftest.py | 85 ++++++----------------------------------------- 2 files changed, 19 insertions(+), 84 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3baea4..cd6fe49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,10 +40,10 @@ 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 a caldav +from after [caldav issue 817](https://github.com/python-caldav/caldav/issues/817) +was fixed. 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 +52,10 @@ 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`. 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 diff --git a/tests/conftest.py b/tests/conftest.py index 1b03f8e..985aecb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,26 +4,17 @@ 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,64 +62,22 @@ 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)] - ) - 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]: """The servers ``--caldav-servers`` asked for, by name; the embedded ones are left to the fixtures above.""" 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} - ) - registered = registry.enabled_servers() - available = {s.name: s for s in registered if not isinstance(s, framework.EmbeddedTestServer)} + 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 + registered = get_registry().enabled_servers() + 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()] @@ -167,17 +116,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 +123,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) From ed80c6024ce7898bdc297a2e779149309ffafb20 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 25 Oct 2026 00:25:23 +0200 Subject: [PATCH 02/11] test: allow test servers without TLS verification caldav's Zimbra test server is configured with ssl_verify_cert: false, and urllib3's "Unverified HTTPS request" warning then failed every Zimbra test under filterwarnings = error. The filter matches on the message, so it needs no urllib3 import. Prompt: Now that we have #817 done on the local caldav master (please confirm), please fix the calendaring library to use this, and run the integration tests towards all the docker servers plus all the external servers defined under [a caldav checkout holding the test servers' passwords] [#817: https://github.com/python-caldav/caldav/issues/817; this fixes what the Zimbra run turned up] Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 64579ad..fad5f5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,11 @@ 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"] # The async tests need no marker, so the generated sync copy carries none. asyncio_mode = "auto" From 1b06758e7a6d7e314c4676789ae88fccf409cc94 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 25 Oct 2026 08:41:21 +0100 Subject: [PATCH 03/11] test: run caldav's test servers in parallel A run on every caldav test server took hours, most of them on the slowest server. Under pytest -n N --dist loadgroup each server's tests now stay on one worker, so the servers run in parallel and keep one connection and one session lock each; pytest-xdist joins the dev extra. --caldav-servers-config adds the servers of a test-server yaml outside the caldav checkout, its entries winning on a name clash. Prompt: The biggest problem is the purelymail runs [due to test sleeps due to purelymail having an async search cache], a single run can easily take several hoursl If we cannot do that with pytest-xdist, then it's needed to do aome similar lgoic as in [a personal script that runs caldav's tests in parallel, one tmux window per slow server] Followup-Prompt: Do the needful ensuring all tests are run towardes all servers - if ecloud failed on the last run, then it's a good reason to retry, [(sic)] Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- CONTRIBUTING.md | 16 ++++++++++++++++ pyproject.toml | 1 + tests/conftest.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd6fe49..7d1be48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,11 @@ 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 @@ -63,6 +68,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/pyproject.toml b/pyproject.toml index fad5f5b..52aa7d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ 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", diff --git a/tests/conftest.py b/tests/conftest.py index 985aecb..30b56ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ import socket from collections.abc import Iterator from dataclasses import dataclass +from pathlib import Path from typing import Any import pytest @@ -64,6 +65,12 @@ def pytest_addoption(parser: pytest.Parser) -> None: metavar="NAMES", help="also test against caldav's test servers: comma-separated names (Baikal,Nextcloud,...) or 'all'", ) + 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", + ) def caldav_test_servers(config: pytest.Config) -> dict[str, Any]: @@ -76,7 +83,21 @@ def caldav_test_servers(config: pytest.Config) -> dict[str, Any]: 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 - registered = get_registry().enabled_servers() + 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, EmbeddedTestServer)} if wanted == "all": return available @@ -107,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.""" From 4c44ab34ea00736cafacc06c1e802e91b4aa6d21 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 26 Oct 2026 00:10:02 +0100 Subject: [PATCH 04/11] fix: honour hints on lagging searches and journals A server whose time-range REPORT answers from a lagging index (search-cache delay, filtered-only: Purelymail) now gets its ranges filtered on the client, as plain listings are current there. A calendar no longer claims the component kinds the server is hinted to refuse (robur lists VJOURNAL, then answers a journal with 400), which had failed every search scenario there. Purelymail's 20 failing tests now pass, and robur's search scenarios fall from 26 failures to a transient 500. Prompt: please check the handover document. Start creating that sweep-script for ecloud, and then ... the primary mission is to figure out why tests are failing. Fork out subagents with opus when needed. [handover: notes on the test results from calendaring tests when run on all of caldav's test servers] Followup-Prompt: [start the fixes, beginning with the search-related ones] Assisted-By: Claude Sonnet 5.5, Claude Opus 5.5 Reviewed-by: Tobias Brox --- src/calendaring/_async/caldav.py | 5 +++-- src/calendaring/_caldav_features.py | 11 +++++++++- src/calendaring/_sync/caldav.py | 5 +++-- tests/test_caldav_features.py | 31 ++++++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/calendaring/_async/caldav.py b/src/calendaring/_async/caldav.py index 091c2c0..8a6ed6f 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 ( @@ -249,9 +249,10 @@ 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]: diff --git a/src/calendaring/_caldav_features.py b/src/calendaring/_caldav_features.py index 9d91464..44b0f51 100644 --- a/src/calendaring/_caldav_features.py +++ b/src/calendaring/_caldav_features.py @@ -139,7 +139,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 +152,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}) diff --git a/src/calendaring/_sync/caldav.py b/src/calendaring/_sync/caldav.py index b070816..4a4a698 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 ( @@ -249,9 +249,10 @@ 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]: diff --git a/tests/test_caldav_features.py b/tests/test_caldav_features.py index a973e2f..4c0d097 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,31 @@ 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 From 26c6fc958f81057e5351e8a8d05e87e59d632ea5 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 26 Oct 2026 00:13:30 +0100 Subject: [PATCH 05/11] fix: verify and conformance tolerate harmless rewrites RDATE and EXDATE compare as sets of values, so a server that splits RDATE:a,b into one line per date no longer fails verification (Bedework, Cyrus). A TZID compares by how the zone behaves from 1970 on, so Europe/Oslo stored as Europe/Berlin (gmx) is accepted while Europe/Sofia stored as Europe/Berlin is still a difference. In the conformance suite, time-entry users compare in any order, and a backend whose delete-collection level is unknown is tried, as open_collection already does (Cyrus). Prompt: please check the handover document. Start creating that sweep-script for ecloud, and then ... the primary mission is to figure out why tests are failing. Fork out subagents with opus when needed. [handover: notes on the test results from calendaring tests when run on all of caldav's test servers] Followup-Prompt: "gmx rewrites Oslo to Berlin". That's harmless. Does it rewrite Sofia to Berlin too? That's not harmless. Please check. Followup-Prompt: [start the fixes, beginning with the search-related ones] Assisted-By: Claude Sonnet 5.5, Claude Opus 5.5 Reviewed-by: Tobias Brox --- src/calendaring/_async/conformance.py | 7 +++++-- src/calendaring/_mapping.py | 29 ++++++++++++++++++++++++++- src/calendaring/_sync/conformance.py | 7 +++++-- tests/test_caldav_features.py | 4 +--- tests/test_verify.py | 25 +++++++++++++++++++++++ 5 files changed, 64 insertions(+), 8 deletions(-) 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/_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/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/tests/test_caldav_features.py b/tests/test_caldav_features.py index 4c0d097..f07e081 100644 --- a/tests/test_caldav_features.py +++ b/tests/test_caldav_features.py @@ -128,9 +128,7 @@ def test_time_range_is_not_sent_where_filtered_searches_lag_behind_writes() -> N 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"] - } + 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: 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" + ] From 049069f736f2cdd303b4fb21acf2e27e5aef3c63 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 29 Oct 2026 23:06:38 +0100 Subject: [PATCH 06/11] docs: adding some more comments in the scenario tests Reviewed-by: Tobias Brox --- tests/test_scenarios.py | 46 ++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 6334cc0..2b9c7da 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,18 +1,16 @@ """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. +Those tests are slightly redundant, the conformance suites should +ensure in details that everything works as intended. + +When the backend cannot support the scenario, the test asserts that +this is properly flagged (``UnsupportedError`` or ``LossyWriteError``) Add caldav's docker servers with ``--caldav-servers=Baikal,Nextcloud`` (see ``conftest.py``). + """ from __future__ import annotations @@ -77,6 +75,7 @@ def open_backend(where: str, directory: Path, request: pytest.FixtureRequest) -> return Backend.open(server.url, kind="caldav", **server.options) +## TODO: hard-coded dates does not work, quite some systems will suddenly stop working when 2026 is beyond the event horizon 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) @@ -155,13 +154,19 @@ def other_calendar( 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. @@ -169,19 +174,29 @@ def test_a_new_calendar_is_listed_and_can_be_renamed(backend: Backend, calendar: 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)) assert calendar.search() == [] return + + ## Adding an event into the collection meeting = calendar.add_event(summary="Planning meeting", start=at(2, 9), end=at(2, 10)) + ## Do a date search for events found = calendar.events(start=at(2, 0), end=at(3, 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() @@ -190,6 +205,8 @@ def test_an_event_is_added_found_changed_and_deleted(calendar: Collection) -> No def test_a_task_is_completed_and_uncompleted(calendar: Collection) -> None: """The plann use case: add a task, tick it off, and change one's mind.""" + + ## TODO: no checks that the collection support tasks? task = calendar.add_task(summary="Buy milk") assert [t.uid for t in calendar.tasks(include_completed=False)] == [task.uid] @@ -198,18 +215,23 @@ def test_a_task_is_completed_and_uncompleted(calendar: Collection) -> None: assert calendar.tasks(include_completed=False) == [] assert calendar.reload(task).component.status == "COMPLETED" + ## What went wrong? Did I spill the milk? task.uncomplete() assert [t.uid for t in calendar.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. +def test_a_due_date_is_kept(calendar: 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(calendar, Feature.TASK_DUE): with pytest.raises(LossyWriteError): calendar.add_task(summary="Pay the rent", end=date(2026, 11, 1)) assert calendar.tasks() == [] return + + ## Add the task, reload the calendar task = calendar.add_task(summary="Pay the rent", end=date(2026, 11, 1)) assert calendar.reload(task).component.end == date(2026, 11, 1) From 544748d8d3db4480c96403550e49f94527692289 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 30 Oct 2026 00:47:50 +0100 Subject: [PATCH 07/11] fix: skip components a server cannot store Infomaniak and CCS refuse any MKCALENDAR whose component set names VJOURNAL, so every collection made for the scenario stories failed in setup. Their hints already say save-load.journal is unsupported: create_collection() now leaves such components out of the request, and refuses before any I/O when nothing is left; the capability says LOSSY. Prompt: Also, I note that there are tons of failures and errors, please look into it Followup-Prompt: go ahead [with separate calendar, task list and journal fixtures in the scenario tests] Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- docs/design/API_DESIGN.md | 4 +++- src/calendaring/_async/caldav.py | 8 ++++++-- src/calendaring/_caldav_features.py | 8 ++++++++ src/calendaring/_sync/caldav.py | 8 ++++++-- tests/_async/test_caldav_backend.py | 22 ++++++++++++++++++++++ tests/_sync/test_caldav_backend.py | 22 ++++++++++++++++++++++ tests/test_caldav_features.py | 8 ++++++++ 7 files changed, 75 insertions(+), 5 deletions(-) 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/src/calendaring/_async/caldav.py b/src/calendaring/_async/caldav.py index 8a6ed6f..5bdde04 100644 --- a/src/calendaring/_async/caldav.py +++ b/src/calendaring/_async/caldav.py @@ -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 @@ -259,7 +259,11 @@ 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/_caldav_features.py b/src/calendaring/_caldav_features.py index 44b0f51..c3dfff7 100644 --- a/src/calendaring/_caldav_features.py +++ b/src/calendaring/_caldav_features.py @@ -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 diff --git a/src/calendaring/_sync/caldav.py b/src/calendaring/_sync/caldav.py index 4a4a698..3a2666f 100644 --- a/src/calendaring/_sync/caldav.py +++ b/src/calendaring/_sync/caldav.py @@ -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 @@ -259,7 +259,11 @@ 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/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/_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/test_caldav_features.py b/tests/test_caldav_features.py index f07e081..a4f9925 100644 --- a/tests/test_caldav_features.py +++ b/tests/test_caldav_features.py @@ -153,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 "") From b692eb2a338f29f0c10eeb00074201cf039aa5cd Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 30 Oct 2026 00:48:09 +0100 Subject: [PATCH 08/11] fix: no time log in a memory list without events Time entries are events in the task's own collection, so a tasks-only collection must declare time-log unsupported, as the caldav and files backends already did; the memory backend claimed it and then refused the entry. The three copies of that narrowing are now one helper, held_only(), which leaves alone a backend keeping its own time log. Prompt: quite many systems, including many caldav servers have separated task lists and calendars. Looking into the scenario tests, I see only a "calendar"-fixture, I think it's needed with a separate "tasklist"-fixture. Also, have a look at my TODO-notes there. Followup-Prompt: go ahead [with the fixtures above; the new tasks-only fixture exposed this] Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- src/calendaring/_async/backend.py | 4 ++-- src/calendaring/_async/collection.py | 9 +-------- src/calendaring/_async/files.py | 10 +++------- src/calendaring/_async/memory.py | 14 ++++---------- src/calendaring/_caldav_features.py | 8 ++++---- src/calendaring/_sync/backend.py | 4 ++-- src/calendaring/_sync/collection.py | 9 +-------- src/calendaring/_sync/files.py | 10 +++------- src/calendaring/_sync/memory.py | 14 ++++---------- src/calendaring/capabilities.py | 25 +++++++++++++++++++++++++ tests/_async/test_generic.py | 1 + tests/_sync/test_generic.py | 1 + 12 files changed, 51 insertions(+), 58 deletions(-) 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/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/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 c3dfff7..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 @@ -183,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/_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/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/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_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_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 From f4c8b020e8b5e1757a7c7229d457f57147fdaddf Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 30 Oct 2026 00:48:17 +0100 Subject: [PATCH 09/11] test: separate events, tasks and journals Many servers keep task lists apart from calendars, and CCS makes a calendar for one component only, so the stories now get a calendar, a task list and a journal each. Where a backend makes none for a kind, a story marked checks_refusal gets one for another kind and checks the refusal there; the others are skipped, as before. The time log goes into the calendar where the task list holds no events. The dates are next week's rather than November 2026, which would one day be past every server's time range. The task story checks that the list holds tasks. Also the reworded docstring and comments. Prompt: quite many systems, including many caldav servers have separated task lists and calendars. Looking into the scenario tests, I see only a "calendar"-fixture, I think it's needed with a separate "tasklist"-fixture. Also, have a look at my TODO-notes there. Followup-Prompt: go ahead [with the proposed fixtures, relative dates and the TODO notes] Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- pyproject.toml | 3 + tests/test_scenarios.py | 245 ++++++++++++++++++++++------------------ 2 files changed, 141 insertions(+), 107 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 52aa7d8..50527e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,9 @@ filterwarnings = [ "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/tests/test_scenarios.py b/tests/test_scenarios.py index 2b9c7da..3076769 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -2,11 +2,15 @@ These tests are optimized for readability: one story each. -Those tests are slightly redundant, the conformance suites should -ensure in details that everything works as intended. +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``) +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``). @@ -16,7 +20,7 @@ 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 @@ -75,10 +79,18 @@ def open_backend(where: str, directory: Path, request: pytest.FixtureRequest) -> return Backend.open(server.url, kind="caldav", **server.options) -## TODO: hard-coded dates does not work, quite some systems will suddenly stop working when 2026 is beyond the event horizon -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]: @@ -97,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: @@ -110,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() @@ -129,30 +142,35 @@ 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 other_calendar( +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_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 @@ -173,6 +191,7 @@ def test_a_new_calendar_is_listed_and_can_be_renamed(backend: Backend, calendar: 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 @@ -180,15 +199,15 @@ def test_an_event_is_added_found_changed_and_deleted(calendar: Collection) -> No 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 ## Adding an event into the collection - meeting = calendar.add_event(summary="Planning meeting", start=at(2, 9), end=at(2, 10)) + 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(2, 0), end=at(3, 0)) + found = calendar.events(start=at(0, 0), end=at(1, 0)) ## Assert event is found assert [e.uid for e in found] == [meeting.uid] @@ -203,63 +222,71 @@ def test_an_event_is_added_found_changed_and_deleted(calendar: Collection) -> No 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.""" - ## TODO: no checks that the collection support tasks? - 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" - ## What went wrong? Did I spill the milk? + ## 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(calendar: Collection) -> None: - """ Add a task with a due-date""" +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(calendar, Feature.TASK_DUE): + 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 - ## Add the task, reload the calendar - 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. @@ -270,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() @@ -329,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] From 0ba62117f3a62cdf89c0b6f6bfe39ec74d9faa7f Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 30 Oct 2026 00:50:49 +0100 Subject: [PATCH 10/11] docs: news fragment for caldav's test servers The pull request's changelog check wants one; the change is to the test setup only, hence internal. Prompt: [review and push the branch] Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- news/+caldav-test-servers.internal.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 news/+caldav-test-servers.internal.rst 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``. From dfecd8a2755bab3167bf07ec72fb3852c6167605 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 30 Oct 2026 00:57:42 +0100 Subject: [PATCH 11/11] build: test servers need caldav 3.7 The tests take caldav's test servers from caldav.testing, which caldav 3.7.0 is the first release to ship; with 3.6 the type check fails on its imports. The library itself still works with caldav 3.6. Prompt: Why is PR38 on hold? Because it needs caldav 3.7.0 maybe? 3.7.0 is out today Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- CONTRIBUTING.md | 5 ++--- pyproject.toml | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d1be48..1fa7525 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,9 +40,8 @@ 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, `caldav.testing`. That needs a caldav -from after [caldav issue 817](https://github.com/python-caldav/caldav/issues/817) -was fixed. The servers are not started for you; start them in a caldav +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: ``` diff --git a/pyproject.toml b/pyproject.toml index 50527e2..707d845 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,8 @@ 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 = [