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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -52,19 +51,33 @@ 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
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/`
Expand Down
4 changes: 3 additions & 1 deletion docs/design/API_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions news/+caldav-test-servers.internal.rst
Original file line number Diff line number Diff line change
@@ -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``.
13 changes: 11 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/calendaring/_async/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions src/calendaring/_async/caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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

Expand Down Expand Up @@ -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()
9 changes: 1 addition & 8 deletions src/calendaring/_async/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down
7 changes: 5 additions & 2 deletions src/calendaring/_async/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
10 changes: 3 additions & 7 deletions src/calendaring/_async/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 4 additions & 10 deletions src/calendaring/_async/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()]
Expand Down
27 changes: 22 additions & 5 deletions src/calendaring/_caldav_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand All @@ -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.
Expand All @@ -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})

Expand All @@ -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()})
29 changes: 28 additions & 1 deletion src/calendaring/_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -268,14 +279,30 @@ 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())
)
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
Expand Down
4 changes: 2 additions & 2 deletions src/calendaring/_sync/backend.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading