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
14 changes: 12 additions & 2 deletions docs/design/API_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1199,7 +1199,7 @@ initial set:
|---|---|
| `write` | create, update and delete items at all |
| `component.event`, `component.task`, `component.journal` | can hold that component |
| `create-collection`, `delete-collection` | backend-level |
| `create-collection`, `delete-collection` | backend-level; see below |
| `collection.set-name` | `set_name()` |
| `search.server-side`, `search.time-range`, `search.text` | how much the server filters; never affects results ([§4 Search](#4-search)) |
| `changes` | `changes()`: `FULL` with a native token, `EMULATED` by listing |
Expand All @@ -1222,6 +1222,14 @@ initial set:
| `dates` | a `DATE` value stays a date; `LOSSY` stores midnight UTC |
| `time-log` | the time log of a task or event ("Time tracking" in [§3.4](#34-task)); `LOSSY` with `details["lost"]`, or `details["total-only"]` |

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

Text is not a feature: non-ASCII, escaped and multi-line text, and every
value of a property with several, round-trip on every backend. The
conformance suite stores a corpus of real-world objects and lets `verify`
Expand Down Expand Up @@ -1309,7 +1317,9 @@ reloaded and compared with what was sent. The comparison covers every
property that was sent, after normalisation, except those the mapper had
already reported as not storable (a lossy write, above), and skips what
servers legitimately change: `DTSTAMP`, `SEQUENCE`, `LAST-MODIFIED`, property
order, and the identity a backend assigns (`uid`, `native_id`, `etag`;
and parameter order, a parameter spelt out at its RFC 5545 default
(`PARTSTAT=NEEDS-ACTION`, `RSVP=FALSE`, ...), and the identity a backend
assigns (`uid`, `native_id`, `etag`;
[§3.2 Identity](#32-identity)). A difference raises `VerificationError`
listing what the server dropped or changed. It is not a `LossyWriteError`:
that one means nothing was sent, while a `VerificationError` means the
Expand Down
1 change: 1 addition & 0 deletions news/+create-collection.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``create_collection()`` refuses to make a collection that would hold none of the requested components, raising ``UnsupportedError``. Where a server ignores the requested components, as SOGo does, the ``create-collection`` capability says so (``LOSSY``), and the collection's ``components`` say what it actually holds.
32 changes: 29 additions & 3 deletions src/calendaring/_async/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
from urllib.parse import urlsplit

from ..capabilities import Capabilities, Feature, Support
from ..errors import AmbiguousError, ConfigurationError, NotFoundError, UnsupportedError
from ..errors import AmbiguousError, CalendaringError, ConfigurationError, NotFoundError, UnsupportedError
from ..loss import LossPolicy
from ..types import Component
from .collection import AsyncCollection
from .collection import COMPONENT_FEATURE, AsyncCollection

if TYPE_CHECKING:
from .workspace import AsyncWorkspace
Expand All @@ -27,6 +27,18 @@
EXTRAS = {"caldav": "caldav"}


def _none_held(components: frozenset[Component], backend: str, *, left: str | None = None) -> UnsupportedError:
"""The refusal of a collection that would hold none of ``components``, for the first of them.

``left`` names one that was made and could not be deleted again.
"""
wanted = sorted(components, key=str)
message = f"cannot make a collection holding any of {[str(c) for c in wanted]}"
if left is not None:
message += f"; the one made, {left!r}, could not be deleted"
return UnsupportedError(COMPONENT_FEATURE[wanted[0]], message, backend=backend)


class AsyncBackend(ABC):
"""One configured store: a CalDAV account, a directory, a file, a feed, a tracker instance."""

Expand Down Expand Up @@ -119,9 +131,23 @@ async def create_collection(
) -> AsyncCollection:
if self.capabilities.level(Feature.CREATE_COLLECTION) is Support.UNSUPPORTED:
raise UnsupportedError(Feature.CREATE_COLLECTION, backend=self.id)
return await self._create_collection(name, frozenset(components))
wanted = frozenset(components)
made = await self._create_collection(name, wanted)
if wanted and not made.components & wanted: # Bedework makes events-only calendars whatever is asked
if self.capabilities.level(Feature.DELETE_COLLECTION) is Support.UNSUPPORTED:
raise _none_held(wanted, self.id, left=made.id)
try:
await made.delete_collection()
except CalendaringError as exc:
raise _none_held(wanted, self.id, left=made.id) from exc
raise _none_held(wanted, self.id)
return made

async def _create_collection(self, name: str, components: frozenset[Component]) -> AsyncCollection:
"""Make the collection; one that holds none of ``components`` is deleted again and refused.

A backend that knows it cannot hold any of them raises ``_none_held()`` itself, before any I/O.
"""
raise NotImplementedError(f"{type(self).__name__} declares {Feature.CREATE_COLLECTION} but cannot")

# --- lifecycle ----------------------------------------------------
Expand Down
56 changes: 47 additions & 9 deletions src/calendaring/_async/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ async def test_get_finds_the_exact_uid(self, backend: AsyncBackend, collection:
assert (await collection.get(longer.uid)).native_id == longer.native_id
with pytest.raises(NotFoundError):
await collection.get(f"-{suffix}")
if backend.capabilities.supports(Feature.CREATE_COLLECTION):
if backend.capabilities.supports(Feature.CREATE_COLLECTION, allow_lossy=True):
other = await self.open_collection(backend, "conformance-other")
if writable(other) and holds(other, Component.TASK):
elsewhere = await other.add_task(summary="in another collection")
Expand Down Expand Up @@ -1119,7 +1119,7 @@ async def test_a_token_from_another_collection_raises(
) -> None:
if level(collection, Feature.CHANGES) is Support.UNSUPPORTED:
return # test_a_bad_token_raises checked that changes() raises
if not backend.capabilities.supports(Feature.CREATE_COLLECTION):
if not backend.capabilities.supports(Feature.CREATE_COLLECTION, allow_lossy=True):
pytest.skip("a second collection is needed, and the backend cannot make one")
other = await self.open_collection(backend, "conformance-other")
if level(other, Feature.CHANGES) is Support.UNSUPPORTED:
Expand Down Expand Up @@ -1273,10 +1273,11 @@ async def test_time_spent_in_a_meeting(self, collection: AsyncCollection) -> Non
[meeting] = await self.seed(collection, [Event.new(summary="planning", start=at(5, 9), end=at(5, 10))])
if await self.assert_time_log_unsupported(collection, meeting) or not writable(collection):
return
before = await collection.reload(meeting) # as stored: a server may add to what was sent
await meeting.log_time(start=at(5, 9), end=at(5, 10), users=("mailto:alice@example.com",))
[entry] = await meeting.time_log()
assert entry.duration == HOUR
assert await collection.reload(meeting) == meeting, "logging time does not touch the meeting itself"
assert await collection.reload(meeting) == before, "logging time does not touch the meeting itself"

async def test_time_entries_in_a_range(self, collection: AsyncCollection) -> None:
item = await self.logged_against(collection)
Expand All @@ -1296,7 +1297,7 @@ async def test_move(self, backend: AsyncBackend, collection: AsyncCollection) ->
if level(collection, Feature.MOVE) is Support.UNSUPPORTED:
await self.assert_unsupported(collection, Feature.MOVE, lambda: collection.move(task, collection))
return
if not backend.capabilities.supports(Feature.CREATE_COLLECTION):
if not backend.capabilities.supports(Feature.CREATE_COLLECTION, allow_lossy=True):
pytest.skip("a second collection is needed, and the backend cannot make one")
target = await self.open_collection(backend, "conformance-target")
moved = await collection.move(task, target)
Expand All @@ -1310,7 +1311,7 @@ async def test_changes_follow_a_move(self, backend: AsyncBackend, collection: As
if Support.UNSUPPORTED in (level(collection, Feature.CHANGES), level(collection, Feature.MOVE)):
return # the tests of each feature checked that it raises
task = await self.add_task(collection, summary="moving")
if not backend.capabilities.supports(Feature.CREATE_COLLECTION):
if not backend.capabilities.supports(Feature.CREATE_COLLECTION, allow_lossy=True):
pytest.skip("a second collection is needed, and the backend cannot make one")
target = await self.open_collection(backend, "conformance-target")
if level(target, Feature.CHANGES) is Support.UNSUPPORTED:
Expand Down Expand Up @@ -1342,6 +1343,28 @@ async def test_set_name(self, backend: AsyncBackend, collection: AsyncCollection
if name is not None: # a borrowed or reused collection is put back as it was
await collection.set_name(name)

async def test_create_collection_holds_one_of_what_was_asked(self, backend: AsyncBackend) -> None:
"""A collection holding none of the requested components is refused, not made."""
if not backend.capabilities.supports(Feature.CREATE_COLLECTION, allow_lossy=True):
pytest.skip("the backend makes no collections")
deletable = backend.capabilities.level(Feature.DELETE_COLLECTION) is not Support.UNSUPPORTED
if not deletable and await self.left_behind(backend, "conformance-events") is not None:
return # the run that left it checked it
before = [c.id for c in await backend.collections()]
refused: UnsupportedError | None = None
try:
made = await backend.create_collection("conformance-events", components=(Component.EVENT,))
except UnsupportedError as exc:
refused = exc
if refused is not None:
assert refused.feature == Feature.COMPONENT_EVENT
if deletable:
assert [c.id for c in await backend.collections()] == before, "a refused collection was left behind"
return
if deletable:
self._made.append(made)
assert Component.EVENT in made.components

async def test_create_and_delete_collection(self, backend: AsyncBackend) -> None:
before = [c.id for c in await backend.collections()]
if backend.capabilities.level(Feature.CREATE_COLLECTION) is Support.UNKNOWN:
Expand All @@ -1359,7 +1382,7 @@ async def test_create_and_delete_collection(self, backend: AsyncBackend) -> None
for kind, (_, _, _, _, feature) in KINDS.items():
assert (kind == Component.TASK) == (level(attempted, feature) is not Support.UNSUPPORTED), feature
return
if not backend.capabilities.supports(Feature.CREATE_COLLECTION):
if not backend.capabilities.supports(Feature.CREATE_COLLECTION, allow_lossy=True):
with pytest.raises(UnsupportedError) as info:
await backend.create_collection("conformance-new")
assert info.value.feature == Feature.CREATE_COLLECTION
Expand All @@ -1368,12 +1391,27 @@ async def test_create_and_delete_collection(self, backend: AsyncBackend) -> None
deletable = backend.capabilities.supports(Feature.DELETE_COLLECTION)
# 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
if made is None:
made = await backend.create_collection("conformance-new", components=(Component.TASK,))
assert made.components == frozenset({Component.TASK})
refused: UnsupportedError | None = None
try:
made = await backend.create_collection("conformance-new", components=(Component.TASK,))
except UnsupportedError as exc:
if not lossy:
raise
refused = exc
if refused is not None:
# The requested set is not kept, and what was made held no tasks (Bedework: events only).
assert refused.feature == Feature.COMPONENT_TASK
if deletable:
assert [c.id for c in await backend.collections()] == before, "a refused collection was left behind"
return
assert made is not None
if not lossy: # where it is, create_collection() saw to it that tasks are among what it holds
assert made.components == frozenset({Component.TASK})
assert made.id in [c.id for c in await backend.collections()]
for kind, (_, _, _, _, feature) in KINDS.items():
assert (kind == Component.TASK) == (level(made, feature) is not Support.UNSUPPORTED), feature
assert (kind in made.components) == (level(made, feature) is not Support.UNSUPPORTED), feature
if not deletable:
with pytest.raises(UnsupportedError) as info:
await made.delete_collection()
Expand Down
4 changes: 3 additions & 1 deletion src/calendaring/_async/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from ..items import Item
from ..loss import LossPolicy
from ..types import BrokenObject, Component, StoredObject
from .backend import AsyncBackend
from .backend import AsyncBackend, _none_held
from .collection import AsyncCollection

__all__ = ["AsyncMemoryBackend", "AsyncMemoryCollection"]
Expand Down Expand Up @@ -154,6 +154,8 @@ async def collection(self, name_or_id: str) -> AsyncCollection:
return await super().collection(name_or_id)

async def _create_collection(self, name: str, components: frozenset[Component]) -> AsyncCollection:
if components and not components & self.profile.components:
raise _none_held(components, self.id)
id = name
while id in self.store.calendars:
id = f"{name}-{self.store.next()}"
Expand Down
10 changes: 9 additions & 1 deletion src/calendaring/_caldav_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

__all__ = ["backend_capabilities", "collection_capabilities"]

F, E, U, X = Support.FULL, Support.EMULATED, Support.UNSUPPORTED, Support.UNKNOWN
F, L, E, U, X = Support.FULL, Support.LOSSY, Support.EMULATED, Support.UNSUPPORTED, Support.UNKNOWN

LEVELS = {
"full": F,
Expand Down Expand Up @@ -114,6 +114,14 @@ def _table(features: Any) -> dict[Feature, Any]:
table.update(dict.fromkeys(STORED, X))
for feature, name in FROM_SERVER.items():
table[feature] = _level(features, name)
if (
table[Feature.CREATE_COLLECTION] is F
and _level(features, "create-calendar.with-supported-component-types") is U
):
table[Feature.CREATE_COLLECTION] = {
"level": L,
"note": "the requested components are not kept: the server decides",
}
return table


Expand Down
35 changes: 34 additions & 1 deletion src/calendaring/_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import Any

import icalendar
from icalendar.parser import Contentline

from .capabilities import Capabilities, Feature, Support
from .items import X_PLANNED_END, X_PLANNED_START
Expand Down Expand Up @@ -64,6 +65,18 @@
"SIBLING": Feature.TASK_RELATIONS_PARENT,
}

# RFC 5545 §3.2's parameter defaults: a server that spells one out has changed nothing.
PARAMETER_DEFAULTS = {
"CUTYPE": "INDIVIDUAL",
"ENCODING": "8BIT",
"FBTYPE": "BUSY",
"PARTSTAT": "NEEDS-ACTION",
"RELATED": "START",
"RELTYPE": "PARENT",
"ROLE": "REQ-PARTICIPANT",
"RSVP": "FALSE",
}

# Single date-time properties a server may store in UTC instead of with their TZID.
INSTANTS = frozenset({"DTSTART", "DTEND", "DUE", "COMPLETED", "RECURRENCE-ID", X_PLANNED_START, X_PLANNED_END})

Expand Down Expand Up @@ -229,6 +242,7 @@ def property_lines(component: icalendar.Component, name: str) -> list[bytes]:
def _comparable(component: icalendar.Component, name: str) -> list[Any]:
"""What ``verify`` compares for a property, so that a server's harmless rewrites compare equal.

A parameter at its RFC 5545 default is the same as none, and parameters are in any order.
``CATEGORIES`` is a set however it is split over lines and commas, and
an aware date-time on a single event is an instant, whether it is stored
with its TZID or in UTC. A floating time is not an instant, and in a
Expand All @@ -240,7 +254,26 @@ def _comparable(component: icalendar.Component, name: str) -> list[Any]:
value = component.decoded(name)
if isinstance(value, datetime) and value.tzinfo is not None:
return [value.astimezone(UTC)]
return list(property_lines(component, name))
return sorted(_without_defaults(line) for line in property_lines(component, name))


def _without_defaults(line: bytes) -> tuple[Any, ...]:
"""A content line with its parameters in order, those at their RFC 5545 default left out.

The value is kept as written: an escaped comma is not a list separator. A parameter's
list of values is a set, like the parameters themselves.
"""
try:
name, params, value = Contentline(line.decode()).raw_parts() # type: ignore[no-untyped-call] # icalendar leaves it unannotated
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)))
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


def _recurs(component: icalendar.Component) -> bool:
Expand Down
Loading
Loading