Roadmap item: 1.1 Unified API design and peer review
Status: draft by Claude Opus 5.5, 2026-10-08. Reviewed and edited by the author on the following days, and his comments applied. The A1 rework was accepted on 2026-10-10. Peer-reviewed from 2026-10-10 in PR 5 and in video calls; the review was considered complete on 2026-10-13 (§11 Peer review).
Inputs: 0.1 task model survey, 0.2 sync/async decision, 0.3 decisions D1–D7
Consumers: roadmap 1.2 writes the conformance suite against this document, roadmap 1.3 generates the sync copy of what it calls async, roadmap 1.6 adds the time log to the task model in §3.4 Task.
The test the roadmap sets: does it fit CalDAV and a Gitea issue tracker without lying about either? Each section ends with how it does that.
Import names below say calendaring. The name may still change (see the note under roadmap 0.3); nothing in the design depends on it, and the one place where a name is written into user data, the X- prefix (§3.6 Properties with no standard home), is deliberately not the package name.
One Python API over many places calendar data lives: CalDAV servers,
.ics files and directories, read-only feeds, JMAP, and issue trackers
such as Gitea. A program written against it should behave the same
whichever of these it is pointed at, and should be told, rather than
find out later, when a backend cannot do what it asked. The decisions
below are labelled A1–A9 so the rest of the document can refer to them.
The objects. A Workspace holds Backends (one server, directory,
file or feed each), a backend holds Collections (a calendar, a task list, a
repository), and a collection holds items: Event, Task, Journal.
The I/O classes come in a sync and an async version. The items are plain
data, so helpers are written once for both modes; what a collection hands
out is that data bound to it (AsyncTask, SyncTask), which adds
save(), complete() and the like (A1, §1,
§2). An item wraps an icalendar.Calendar, and its
iCalendar properties are read and written through icalendar's own typed
properties (item.component.summary). The item adds identity and the few
fields iCalendar has no property for, and redefines none of the others
(A2, §3).
Same answer everywhere. Backends differ in how well they can search,
so the server's answer is never trusted alone: a backend may filter
server-side, but only more loosely, and the client always re-filters with
icalendar_searcher (A3, §4). Likewise, every item has an
etag and every collection can list changes since a token, natively or by
emulation, so a sync tool does not need to know which backend it talks to
(A7, §7).
No silent data loss. Each collection carries a table of what it
supports, and how well (A4, §5). An operation it
cannot do raises before any I/O. A write that would lose data, such as a
fourth task status on a tracker with three, also raises by default; the
caller can lower that to a warning or allow it. Emulation is used only when
the result is the same or the weaker guarantee is documented (an emulated
conditional write can race), and verify=True reads a write
back to check it (A5, §5.3).
Fields iCalendar has no property for go to RFC 9253 or the tasks draft
first, and X-PYCAL-* only as a last resort (A9,
§3.6).
When the abstraction is not enough. Errors form one hierarchy under
CalendaringError, with the backend's own exception kept as __cause__
(A6, §6), every object exposes the backend's own
object as .native, and tasks keep the backend's own status and priority
as native_status / native_priority (A8, §8).
Terms used before the section that defines them:
| Term | Meaning |
|---|---|
| backend | one configured place data lives: a CalDAV account, a JMAP account, a directory, a file, a feed, a Gitea instance (§1) |
| collection | what holds items: a calendar, a task list, a directory, a repository |
| item | one event, task or journal: one VCALENDAR with the components of one UID (§3.1) |
| occurrence | one instance of a recurring item; an item can be a single occurrence (is_occurrence) |
| data item / bound item | Task is plain data and does no I/O; AsyncTask / SyncTask is the same data plus the collection it came from, and has save() (§1.1) |
| mode | sync or async. The async code is written by hand, the sync code generated from it by unasync |
native_id |
the backend's own key for a stored item: a CalDAV href, a file name, an issue number (§3.2) |
etag |
a value that changes whenever the stored item changes; the server's ETag, or one made up by the library (§7) |
wire |
the stored object exactly as the backend returned it, bytes and all; kept beside the parsed item (§3.1) |
| token, change set | changes() returns what changed (a ChangeSet) and a token to pass next time, to get what changed since (§7) |
| capability, feature, support level | per collection, a table of what it can do (Feature) and how well (FULL, LOSSY, EMULATED, UNSUPPORTED, UNKNOWN) (§5) |
| lossy write, loss policy | a write the backend can store only by changing or dropping a value; the loss policy says whether it raises, warns or goes ahead (§5.3) |
escape hatch, .native |
the backend's own object behind ours, for what this API does not cover (§8) |
Workspace ──< Backend ──────< Collection ──────────< item: Event | Task | Journal
(config, (one store: (calendar, task list, (data over
fan-out) server, dir, repo, directory, icalendar.Calendar,
file, feed) feed) bound per mode)
- Workspace — a set of backends, usually all the ones a configuration file (roadmap 1.4) lists, and operations that fan out across them. Optional: a caller with one server never needs it.
- Backend — one configured store with its credentials, if any: a
CalDAV principal, a Gitea instance and token, a directory root, a feed
URL. Owns the HTTP session where there is one. Its class says which kind
(
CalDAVBackend,FilesBackend,GiteaBackend);kindsays the same as a string. - Collection — the unit that holds items: a CalDAV calendar, a directory
of
.icsfiles or a single.icsfile, a feed, a JMAP calendar, a Gitea repository. Owns the capability table. - Item —
Event,TaskorJournal, sharing the base classItem. One item is oneVCALENDARholding components of oneUID: a non-recurring item, a full recurrence set, or a single occurrence. It is never a calendar of several items (§3.1, What an item contains). These are mode-free data; what a collection hands out is the same data bound to it (AsyncTask,SyncTask, …, §1.1).
"Collection" rather than "Calendar" because a Gitea repository is not a calendar. "Collection" is also used in the CalDAV RFC, as a calendar is a WebDAV collection, so a CalDAV user loses nothing by the word. "Task" rather than caldav's "Todo" because the trackers say task or issue, and only iCalendar says to-do.
"Item" rather than caldav's CalendarObjectResource or a shorter
CalendarObject: RFC 4791 calls an item a "calendar object resource", but
"calendar object" reads as a calendar, a collection of items, and a PR 5
reviewer read it that way. "Item" is also what this document calls them
throughout.
"Backend" rather than "Account" or a per-backend "Client" (as in caldav's
DAVClient), because a directory or a single .ics file has neither an
account nor a server to be a client of. The cost is that "backend" now
names both the kind (the CalDAV backend) and an instance of it (this
CalDAVBackend pointed at that server). In practice the class name carries
the kind and the variable carries the instance, and the roadmap 1.2 ABC that a
backend author subclasses is the same Backend, so there is one word for
it instead of two. The aggregator is a Workspace: "client" is an HTTP
word that could mean anything, and a workspace is everything you have
configured (chosen by the author, 2026-10-09; see
§10 Open questions, Q2).
It's been decided (0.2 decision) that the async classes are the source and the sync classes are to be
generated. The public names follow httpx's pattern: Workspace / AsyncWorkspace,
Backend / AsyncBackend, Collection / AsyncCollection, all importable
from calendaring. That costs one replacement-map entry per class in roadmap 1.3's
generator (unasync would otherwise produce SyncWorkspace).
Items: mode-free data classes, plus a bound class per mode (A1).
Item,Event,TaskandJournalare the mode-free data classes of §3 Items. They do no I/O, and helpers annotate against them, so a helper is written once for both modes.AsyncEvent,AsyncTaskandAsyncJournalsubclass them (viaAsyncItem), hold theAsyncCollectionthey belong to (a tuple, with one entry for now; §10, Q9), and addsave(),delete(),reload()andrelatives(), pluscomplete()anduncomplete()on tasks. Each calls the collection method of the same name and then updates the item in place (§2.3 Collection). unasync generatesSyncEvent,SyncTaskandSyncJournal.- The collection methods are the primitive:
task.save()istask.collection.save(task)plus the in-place update.
How items are made. Library users do not call the bound classes' constructors:
- A data item comes from a factory, not a constructor:
Task.new(summary=..., end=...),Task.from_ical(data)orTask.from_icalendar(calendar)(§3.1). - A bound item comes from a collection: from a read (
search,get, …), from a write (add,save, …), fromcollection.add_task(**properties)(shorthand foradd(Task.new(**properties)), as caldav'sadd_todo), or fromcollection.bind(item), which elevates a data item, or an item bound elsewhere, to a copy bound to that collection, without I/O. On a bound item,bind()replaces its collections rather than adding to them. item.as_data()goes the other way: a mode-free copy, with no collection inside, to hand to the other mode, to pickle, or to keep.
Why this shape, in short. Data-only items (the first draft) would make
callers keep track of which collection every item came from: Workspace.search
returns items from many collections, so saving one would mean looking its
collection up first. They would also break caldav's task.save() and
todo.complete(), which plann calls 13 and 4 times. Bound-only items (no
data class) would make every helper choose between AsyncTask and
SyncTask, and would carry a live session into every copy and pickle. The
back reference itself is harmless under unasync, because the "annotations
that lie" came from caldav's dual-mode methods, not from the item knowing
its client. The cost of having both is two more classes per item type, and
two ways to save; the bound method is the thin one, so the two cannot
disagree. Decided by the author, 2026-10-10.
Placement under unasync. The data classes are hand-written outside
_async/ and shared by both modes. The bound classes live in _async/, and
unasync's default AsyncX → SyncX rename produces the sync ones with no
replacement-map entry.
A naming asymmetry, accepted. The I/O classes follow httpx: the
sync one is unprefixed (Collection / AsyncCollection). For items, the
unprefixed name goes to the data class, and the sync bound class is
SyncTask. A reader who generalises from Collection will take Task to
be the sync bound class, and AsyncTask also reads like asyncio.Task.
The rejected alternative was TaskData for the data class and Task /
AsyncTask for the bound ones, at one replacement-map entry per class.
Decided by the author after peer review, 2026-10-13: Item / SyncItem
/ AsyncItem, and likewise for each item type
(§10 Open questions, Q10).
Mirroring one item to several backends is §10 Open questions, Q9.
Lifecycle: Workspace and Backend, which own sessions, are context
managers (with / async with) and have close(), awaited in async mode.
A Collection is a cheap handle onto its backend's session and has none. The name is close in both modes, not
aclose, because unasync does not rewrite aclose (sync/async comparison §9).
Iteration: search returns a list and has an iter_search twin that
returns an Iterator / AsyncIterator, for result sets too large to hold
at once. The other listing methods (collections, tasks, events,
journals) return lists; changes() returns its set in one piece. async for → for is in
unasync's table; roadmap 0.2 did not probe it, so roadmap 1.3 adds it to the freshness test.
Signatures are given in their async form. The sync form is the same
without async/await, with AsyncX renamed to SyncX for items and to
the unprefixed name for the I/O classes. Types not defined here are in §3 Items–§7 Change detection.
class AsyncWorkspace:
@classmethod
async def from_config(cls, path: str | Path | None = None, *, section: str | None = None) -> Self: ...
def __init__(self, backends: Iterable[AsyncBackend] = ()) -> None: ...
backends: Sequence[AsyncBackend]
async def collections(self) -> MultiResult[AsyncCollection]: ...
async def collection(self, name_or_id: str) -> AsyncCollection: ... # NotFoundError, AmbiguousError
async def search(self, searcher: Searcher | None = None, **filters: Any) -> MultiResult[AsyncItem]: ...
# MultiResult[T]: dataclass with items: list[T], errors: Mapping[str, CalendaringError]
# (keyed by backend id for collections(), by collection id for search()), raise_for_errors()
async def close(self) -> None: ...from_config reads the shared configuration file through
calendaring-config,
whose docs/format.md specifies it (roadmap 1.4).
It makes one backend per section the name stands for, with the section's
name as the backend's id. It is a coroutine in async mode, since
opening a backend may need the network; any problem with the file is a
ConfigurationError.
Fan-out returns partial results. Workspace.collections asks every
backend, and Workspace.search every collection, and both return a
MultiResult (a generic dataclass): .items, .errors (keyed by backend
or collection id), and .raise_for_errors(), which raises an
ExceptionGroup (new in Python 3.11, which is the supported minimum: D4) if any collection failed. One unreachable server
must not blank a calendar application's agenda, which is what a plain raise
would do; but the failure must not be silent either, and the caller decides
which matters. Async mode runs the collections concurrently with a
TaskGroup, in the hand-written per-mode layer (sync/async comparison §9); sync mode runs
them in turn.
class AsyncBackend:
@classmethod
async def open(cls, url: str, *, username: str | None = None,
password: str | None = None, token: str | None = None,
kind: str | None = None, session: Any = None, **options: Any) -> AsyncBackend: ...
id: str # stable, from config or derived from the URL
kind: str # "caldav", "files", "feed", "jmap", "gitea", ...
capabilities: Capabilities # backend-level: create-collection, ...
native: object # see "The escape hatch"
async def collections(self) -> list[AsyncCollection]: ...
async def collection(self, name_or_id: str) -> AsyncCollection: ...
async def create_collection(self, name: str, components: Iterable[Component] = (Component.EVENT,)) -> AsyncCollection: ...
async def close(self) -> None: ...openreturns the subclass for the kind it picks from the URL whenkindis not given:file:or a path → files;webcal:→ feed (fetched ashttps:, prior art Part 2);http(s):→ probe, which is roadmap 2.3's "feed or CalDAV" detection (until then,http(s):means CalDAV, so anhttps:feed needskind="feed"); RFC 6764 discovery for a bare domain comes fromcaldav. Trackers cannot be detected and needkind=.- The files backend picks its layout from the path (roadmap 2.2): a path
ending in
.icsis a single file, one collection, created on the first write; a directory with subdirectories is a root whose subdirectories are the collections, as vdirsyncer writes them; a directory holding.icsfiles or adisplaynameis one vdir collection (what khal'spath =names); an empty directory is a root. Only a root can create and delete collections. A path that does not exist (other than a new.icsfile in an existing directory), or a file that is not.ics, is aConfigurationError. The vdir spec has no place for the components a collection holds (CalDAV'ssupported-calendar-component-set), so a collection made for fewer than all of them records them in a hidden.calendaring-componentsfile, which other tools ignore. - Renamed 2026-10-19 from
connect: for CalDAV, files and feeds the factory does no network I/O (caldav'sDAVClient()only builds an object, and opening a file connects to nothing), so "connect" over-promised. It stays a coroutine in async mode, since JMAP fetches its session resource and a tracker may check its token. sessionis the caller-owned HTTP session that Home Assistant'sinject-websessionrule asks for (prior art §1.4, Home Assistant). A backend that cannot use it raisesConfigurationErrorinstead of silently making its own.- Credentials are never required in the URL or in plain-text configuration
(roadmap 1.4's keyring work);
open's keyword arguments are for programmatic use. - Decided 2026-10-19, with the CalDAV backend
(roadmap 2.1): which configuration
keys a backend ignores. Keys starting with
test_are reserved for test tooling, andWorkspace.from_config()drops them beforeopen(). Every other key reaches the backend, and one it does not take is aConfigurationErrornaming the section, so a typo is not silently ignored. The only such keys found are caldav's test-server selection,testing_allowedandpriority; caldav is to readtest_allowedandtest_prioritytoo (on caldav #786). calendaring keeps no compatibility for the old names.featuresis not a test key: the CalDAV backend passes it to caldav.
class AsyncCollection:
# Items taken as arguments may be any Item (data or bound, either mode);
# items returned are bound to this collection. add, save, reload and move are
# overloaded per item type exactly as bind() is: Task -> AsyncTask, and so on.
id: str
backend_id: str
name: str | None
color: str | None
components: frozenset[Component] # what it can hold; class Component(Enum): EVENT, TASK, JOURNAL
capabilities: Capabilities
native: object
# reading
async def search(self, searcher: Searcher | None = None, **filters: Any) -> list[AsyncItem]: ...
def iter_search(self, searcher: Searcher | None = None, **filters: Any) -> AsyncIterator[AsyncItem]: ...
async def tasks(self, **filters: Any) -> list[AsyncTask]: ... # search(todo=True, ...)
async def events(self, **filters: Any) -> list[AsyncEvent]: ...
async def journals(self, **filters: Any) -> list[AsyncJournal]: ...
async def get(self, uid: str) -> AsyncItem: ... # NotFoundError
async def get_by_native_id(self, native_id: str) -> AsyncItem: ...
async def reload(self, item: Item) -> AsyncItem: ... # fresh copy, new etag
async def relatives(self, item: Item, reltype: str | None = None) -> list[AsyncItem]: ...
# writing
async def add(self, item: Item, *, native_id: str | None = None,
wire: bytes | None = None,
loss: LossPolicy | None = None,
verify: bool | None = None) -> AsyncItem: ...
async def add_task(self, *, loss: LossPolicy | None = None, verify: bool | None = None, **properties: Any) -> AsyncTask: ... # add(Task.new(**properties))
async def add_event(self, *, loss: LossPolicy | None = None, verify: bool | None = None, **properties: Any) -> AsyncEvent: ...
async def add_journal(self, *, loss: LossPolicy | None = None, verify: bool | None = None, **properties: Any) -> AsyncJournal: ...
async def save(self, item: Item, *, overwrite: bool = False, scope: Scope = Scope.THIS,
wire: bytes | None = None,
loss: LossPolicy | None = None, verify: bool | None = None) -> AsyncItem: ...
# scope: occurrences only, see "Recurrence"; verify: see "What happens when ... unsupported"
async def delete(self, item: Item | str, *, overwrite: bool = False) -> None: ... # NotFoundError if gone
async def complete(self, task: Task, at: datetime | None = None,
mode: Literal["safe", "this_and_future"] = "safe",
*, loss: LossPolicy | None = None, verify: bool | None = None) -> AsyncTask: ...
async def uncomplete(self, task: Task, *, loss: LossPolicy | None = None, verify: bool | None = None) -> AsyncTask: ...
async def move(self, item: Item, target: AsyncCollection,
*, loss: LossPolicy | None = None, verify: bool | None = None) -> AsyncItem: ... # bound to target
# every write takes loss= and verify=; None means the workspace's or backend's default
# binding, no I/O
def wrap(self, native_item: object) -> AsyncItem: ... # see "The escape hatch"
@overload
def bind(self, item: Task) -> AsyncTask: ...
@overload
def bind(self, item: Event) -> AsyncEvent: ...
@overload
def bind(self, item: Journal) -> AsyncJournal: ...
@overload
def bind(self, item: Item) -> AsyncItem: ...
# change detection
async def changes(self, token: SyncToken | None = None, *,
known: Mapping[str, str] | None = None) -> ChangeSet[AsyncItem]: ...
async def set_name(self, name: str) -> None: ... # collection.set-name; updates self.name
async def delete_collection(self) -> None: ...Attributes do no I/O; methods do. name and color are what the
backend reported when it listed the collection. Assigning to them would
have to write to the server as a side effect, so renaming is an explicit
set_name(), which raises UnsupportedError where the backend cannot
rename (a feed, a single .ics file). A server that names a calendar's URL
after its display name (Zimbra) moves the calendar on a rename, and
set_name() then updates id too. The same goes for items: there is no
collection.save() without an argument, and no autosave (but see
§12). This follows caldav, which settled on
methods for anything that talks to the server. Proposed by the author in
the PR 5 review;
open to the reviewer.
add creates, save updates. add fails with AlreadyExistsError if
the UID or the name is taken (CalDAV CALDAV:no-uid-conflict or a 412 on
If-None-Match: *; .taken below says which). save sends the item's etag
as a precondition and fails with ConflictError if the stored object has
changed since it was read; overwrite=True drops the precondition. caldav has
a save() that can be used both for adding and saving, causing a risk of
lost updates.
add(native_id=...) names the stored object (a CalDAV href such as
<uuid>.ics, a vdir file name), so a caller whose response was lost can
find its own create by that name. Where the backend assigns ids (Gitea,
JMAP) it raises UnsupportedError(Feature.IDENTITY_CLIENT_NATIVE_ID)
before any I/O. AlreadyExistsError.taken says which clash it was:
"native_id" (CalDAV 412 on If-None-Match: *) or "uid" (the
no-uid-conflict precondition: another object holds the UID). A sync tool
handles the two differently. Asked for by calendaring-sync
(PR 19).
wire= sends given bytes instead of a serialisation. add(item, wire=data) and save(item, wire=data) send data exactly, where a
plain write would send item.to_ical(); what the server then
stores may still differ (see "The content after a write" below). Identity
and precondition come from where they always do: on add, from
native_id= and If-None-Match; on save, from the item's native_id
and etag. The library parses data, and before any I/O raises
ValueError if its UID or component type differs from the item's, or if
the item is a single occurrence (the bytes would be a whole stored object, or a merge
input; §3.7). The capability and loss checks run on what
it parsed, so wire= is no way around them; and since nothing is mapped,
a value the backend could only store LOSSY raises
UnsupportedError(Feature.WRITE_WIRE): there is no loss to decide on,
only bytes the backend cannot take as they are. The returned item holds what data parses to.
It is for a caller that keeps bytes of its own, a sync tool pushing what
an application recorded: parsing and re-serialising would change the
bytes it compares later. Where a backend stores no iCalendar text per
item (a single .ics file, a feed, JMAP, Gitea) the write raises
UnsupportedError(Feature.WRITE_WIRE) before any I/O. wire= is on the
collection's add and save only: a bound item's save() writes the
item as it is.
Asked for by calendaring-sync
(PR 19);
proposal, open to review.
Collection writes return the written item; bound methods update in
place. collection.save(item) and the other collection writes return a new
bound copy with the new etag, and, on a backend that cannot store a
caller-chosen UID (Gitea, §3.2 Identity), a different uid
and a native_id. They do not change their argument, which may be a data
item or belong to another collection. A bound item's own methods
(task.save(), task.complete(), task.reload()) update the item itself
with those values, as caldav's do, so task.save(); ...; task.save() works
without a stale etag.
The content after a write is what was sent, not necessarily what is
stored. Some servers rewrite what they store and still return a strong
ETag for it (Radicale and Xandikos, found by calendaring-sync; RFC 4791
§5.3.4 says they must not). The returned etag is kept, because it is
what the next If-Match needs, but it may belong to content the item does
not hold, and changes() will then never report the difference. A caller
that needs the stored form, a sync tool in particular, calls reload().
verify=True only tells it that the server changed something
(§5.3).
complete is an I/O method because completing a recurring task may
write two objects: caldav's "safe" mode completes a copy of the occurrence
and moves the master's DTSTART. The modes are caldav's, with one
exception: caldav guesses that an RRULE without BY* parts means "an
interval after the actual completion" (org-mode's .+1w). That reads into
RRULE something the RFC does not say, and is dropped. The next start
follows the RRULE, and the interval-from-completion behaviour applies only
when an X- property asks for it
(proposed in
recurring-ical-events issue 292).
The icalendar manipulation goes to
edit-icalendar, and the I/O
stays here (§10 Open questions, Q3). Until then,
every backend declares task.complete-recurring UNSUPPORTED.
relatives fetches the objects an item's RELATED-TO points at (and,
for PARENT, the children pointing back), as caldav's get_relatives()
does. plann calls it 16 times, so it has to be here. Relatives outside
this collection are looked up across the backend.
move within one backend uses its own move where it has one; between
backends, and on CalDAV until caldav has a move() using WebDAV MOVE
(caldav #841), it is
add to the target, then delete from the source, declared EMULATED
because it is not atomic: a failure between the two leaves the item in both
places, never in neither. Where a server keeps a UID in one calendar only
(caldav's save.duplicate-uid.cross-calendar), the capability's order
says what to do instead: delete-first where the copy would be refused
(CCS, Cyrus, Ox), putting the item back if the add then fails (and saying so
on the error where that fails too), and add-may-move where saving the copy
moves the original (Zimbra), checking that the target holds the copy before
the original is deleted. delete-first needs the deleted UID to be reusable
(save-load.reuse-deleted-uid); where it is not, move is UNSUPPORTED.
An occurrence cannot be moved by deleting first: deleting it only excludes
it from its series, whose UID stays.
How it fits Gitea: a repository is a collection with components = {TASK}; events() returns an empty list rather than raising (there are
none), add_event(...) raises UnsupportedError(Feature.COMPONENT_EVENT).
class Item:
icalendar: icalendar.Calendar # the whole VCALENDAR, VTIMEZONEs included
component: icalendar.Component # the one that describes the item; see "What an item contains"
# (an icalendar Todo/Event/Journal)
uid: str # read-only: component's UID; identity, see "Identity"
native_id: str | None # backend's own id; None until stored
etag: str | None # real or synthetic (see "Change detection"); None until stored
wire: bytes | None # read-only: the stored object as read; see "The stored form"
collection_id: str | None
native: object | None # see "The escape hatch"
is_occurrence: bool # True when the item is one occurrence, not the whole stored object
def copy(self) -> Self: ...
def to_ical(self) -> bytes: ... # the item as it is now; see "Raw data and other libraries"
def replace_ical(self, data: str | bytes) -> None: ... # new content, same identityThe item does not repeat iCalendar's properties. Per
D6 the
icalendar object is the storage, and its properties are read and written
through icalendar's own typed properties on component:
task.component.summary = "Write the report"
task.component.DUE = datetime(2026, 11, 1, 12, tzinfo=oslo)
task.component.end # DUE, or DTSTART + DURATION
task.component.duration # DUE - DTSTART
task.component.status # icalendar.enums.STATUS
event.component.attendeesicalendar 7.3 has typed properties for nearly all of what the first
draft listed here: summary, description, categories, uid,
organizer, attendees, created, last_modified, status, priority,
start, end, duration, sequence, location, url, related_to,
rrules, exdates, and RECURRENCE_ID (upper case, like DUE). A
property it lacks (PERCENT-COMPLETE, COMPLETED, ESTIMATED-DURATION) is read as plain
iCalendar (component.get("PERCENT-COMPLETE")) and proposed to icalendar
upstream, not added here. Duplicating each property per item type in this
library would be a maintenance burden for no gain, and two typed APIs for
one value would drift apart. Decided by the author, 2026-10-10 (in the
PR 5 review).
What the item does add is what iCalendar has no property for: identity and
backend state (above), is_occurrence, and on a task the native values of
§3.5 and the X-PYCAL-* fields of
§3.6. There is one source of truth,
so properties the library does not know about survive a round trip
untouched (a conformance test proposed in prior art Part 2).
wire holds the stored object exactly as the backend returned it when the
item was read: the body of a CalDAV GET, the calendar-data of a
calendar-query or calendar-multiget response, the content of a vdir
file. On CalDAV it is caldav's raw_data (caldav 3.6,
caldav issue 763),
taken before vcal.fix() normalises the data: a GET body byte for
byte, and from a REPORT the text of the calendar-data element, which
the XML parser has already decoded (entities, and CRLF turned into LF).
It belongs to etag, and like etag it is a snapshot: an edit to
component does not change it, and a write sets it to None, because
what the server stores may differ from what was sent (see "The content
after a write" in §2.3); reload() reads it again.
copy() and as_data() keep it, as they keep etag; equality ignores it.
It is None where no bytes are this item's alone: on a data item made
from scratch; on a single occurrence, which is one component of a stored
object (and on anything the server expanded, which is generated data under
the stored object's ETag); and on backends that store no object of their
own iCalendar text per item (a single .ics file, a feed, JMAP, Gitea).
This keeps D6:
icalendar is still the storage, and nothing reads properties from
wire. It is there for a caller that has to compare or keep what the
server sent, a sync tool fingerprinting stored objects in particular, which
a re-serialisation would defeat: to_ical() normalises line folding,
property order and parameter quoting. Writing given bytes is wire= on
add and save. Asked for by calendaring-sync
(PR 19);
proposal, open to review.
Raw iCalendar text goes in and out through explicit methods, named as in
icalendar:
Task.from_ical(data)makes a new data item from a string or bytes (factories, below).item.to_ical()returns the item as it is now, serialised byicalendar. That is notwire, which is what the server sent.item.replace_ical(data)replaces the item's content and keeps its identity and backend state (native_id,etag, its collections). The new data must describe the same item: the sameUID, the same component type, the same kind of item (a row of the table in What an item contains), and on an occurrence the sameRECURRENCE-ID, or it raisesValueError. It parses the data anew, soitem.componentbecomes a new object: a reference kept from before is stale, and writes through it are lost.save()writes it, as it writes any item, and like any edit it leaveswireandnativeas they were read. This is caldav'sobj.data = text; obj.save().
No other in-memory representation. There is no vobject and no
ical object on an
item. A caller who prefers either converts through text,
vobject.readOne(item.to_ical().decode()), and comes back through
item.replace_ical(v.serialize()). Conversion helpers, and borrowing
another library's object (caldav's edit_icalendar_component() pattern,
a context manager that lends the object out and parses it back on exit;
not the change tracking that Q13 also calls borrowing), are
beyond the funded scope with no
date. They are written down so that they are not added ad hoc.
The reason is caldav's history. caldav started with vobject only.
vobject fell out of use and many users asked for icalendar, but
replacing it would have broken every caller, so caldav carries data,
vobject_instance and icalendar_instance side by side and converts
lazily between them, trying to convert as rarely as possible. That
bookkeeping is a large and painful part of its object code, and in the
author's view it does not succeed in avoiding the conversions. Here
icalendar is the only storage
(D6).
A later move to ical is not planned, but not ruled out. The same
thing could happen to icalendar. What would survive such a move
unchanged is the raw data methods above, identity and backend state, and
named factory arguments that take standard types
(Task.new(summary=...)). What would not is item.icalendar,
item.component and the typed-property path through it, which is the
whole point of D6, Task.from_icalendar(), Task.new(**properties)
(whose names and values follow icalendar.Todo.new), and client-side
search through icalendar-searcher, which works on icalendar objects.
The move would be a major version.
Two rules keep it from becoming caldav's situation:
icalendartypes appear in the public API only asitem.icalendar,item.componentand the argument ofTask.from_icalendar(). Other signatures take and return standard types (datetime,str,bytes), the library's own types, or raw data.- An attribute is never redefined to hold another library's object. A
second library would get an attribute of its own beside
icalendar, and the old one would be deprecated rather than changed in place.
The icalendar attribute is always a whole VCALENDAR, because that is how
CalDAV stores and transfers an item, but it is never a calendar in the sense
of a collection. An item holds one component type (plus VTIMEZONEs) and one
UID, as RFC 4791 §4.1 requires of a stored object, and it is exactly one of
these three:
| The item is | icalendar holds |
component |
is_occurrence |
RECURRENCE-ID on component |
caldav #398 |
|---|---|---|---|---|---|
| a non-recurring item | one component | that component | False |
absent | 100 |
| a full recurrence set | the master (RRULE or RDATE) and all its overridden occurrences, if any |
the master | False |
absent | 110, 011 |
| a single occurrence | one component: a generated occurrence, or an override | that occurrence | True |
set | 101 |
On a full recurrence set, component is the master only, and writing
through it is a trap. item.component.summary = x does what it says on a
non-recurring item, on a single occurrence, and on a series without
overrides. On a full recurrence set with overrides, each override is a
whole component that RFC 5545 uses instead of the generated occurrence,
so the write renames the series except for the overridden occurrences,
which keep the old summary. Moving the master's DTSTART is worse: the
overrides' RECURRENCE-IDs no longer match a generated occurrence.
Nothing raises, and the result looks right until someone opens one of the
overridden occurrences. Until a series edit exists, a caller changing a
series with overrides has to edit the overrides too, through
item.icalendar. The fix is left to edit-icalendar
(§10, Q14).
A single occurrence is part of a stored object, not all of it. The stored object may also hold the master and other overrides that the item does not carry. Occurrences come from three places:
search(expand=True), client-side;- a server that expanded the series itself. CalDAV's
expandreturns only the occurrences in the time range, so tomorrow's agenda does not download decades of overrides from a long series. Whether to ask the server is the backend's choice (§9,server_expand); - a stored object that holds overrides without their master, which RFC 4791
§4.1 allows, e.g. for an attendee invited to some instances only. The
backend hands out one occurrence item per override.
get(uid)returns only the one with the earliestRECURRENCE-ID(decided 2026-10-19, roadmap 2.1), since there is no master to be the whole item. This may be a problem: a caller expectingget()to return everything under a UID silently misses the other occurrences.search()returns them all.
Several occurrences of one series are several items. They share a uid,
and on a stored series they share a native_id and an etag too.
save(occurrence) merges the occurrence back into the stored object
(§3.7 Recurrence), and how delete() and conflict
detection treat siblings is §10, Q12.
What an item never holds:
- several
UIDs. A.icsfile or a feed with many items is a collection, and the backend splits it into items. - several occurrences without their master (001 in caldav
#398). A CalDAV
server answering an expanded time-range query returns, per stored object,
every occurrence in the window. caldav's
search()splits that into one object per occurrence by default (split_expanded=True) and keeps them together only on request. Here the split is the only behaviour. - no data. An item comes from a factory or a read, never as an empty shell to be loaded later.
caldav #597 asks for
helpers that tell these cases apart, and the table is the list such helpers
would answer. is_occurrence is the one that matters most, because it says
that the item is not the whole stored object. is_recurring and the like
were left out in
roadmap 1.2:
"RRULE" in item.component says it, and a helper can be added later
without breaking anything.
These are the mode-free data classes. The bound subclasses add only their collections and the delegating I/O methods (§1.1 Sync and async):
class AsyncItem(Item): # in _async/; unasync makes SyncItem
collections: tuple[AsyncCollection, ...] # exactly one for now (Q9); checked by bind()/wrap()
collection: AsyncCollection # property: collections[0]
async def save(self, *, overwrite: bool = False, scope: Scope = Scope.THIS,
loss: LossPolicy | None = None, verify: bool | None = None) -> None: ... # updates self: etag, uid, native_id
async def delete(self) -> None: ...
async def reload(self) -> None: ... # replaces self's data in place
async def relatives(self, reltype: str | None = None) -> list[AsyncItem]: ...
def as_data(self) -> Item: ... # mode-free copy, no collection
class AsyncTask(Task, AsyncItem):
async def complete(self, at: datetime | None = None,
mode: Literal["safe", "this_and_future"] = "safe",
*, loss: LossPolicy | None = None, verify: bool | None = None) -> None: ...
async def uncomplete(self, *, loss: LossPolicy | None = None, verify: bool | None = None) -> None: ...
class AsyncEvent(Event, AsyncItem): ... # exists; no methods beyond the shared ones
class AsyncJournal(Journal, AsyncItem): ... # likewise
# unasync generates SyncItem, SyncEvent, SyncTask and SyncJournal from these.Rules for bound items:
copy()returns a copy bound to the same collections;as_data()returns an unbound one.- Equality compares the data (the iCalendar content), never the collections.
- Pickling a bound item raises
TypeError, because it would carry a live session; pickleas_data()instead. relatives()returns each relative bound to the collection it was found in.collection.move()returns the item bound to the target.
Factories, which library users call instead of constructors:
Task.new(summary=..., end=..., **properties), Task.from_ical(data) (a
string or bytes) and Task.from_icalendar(calendar) (an
icalendar.Calendar already parsed; the item stores a copy, so
later changes to the caller's object do not reach it). The uid defaults to a fresh UUID,
as icalendar.Todo.new does. Bound items come only from a collection
(§1.1 Sync and async).
Three concepts, following survey 3.2:
| Meaning | CalDAV / files | Gitea | |
|---|---|---|---|
uid |
the iCalendar UID |
caller-chosen, round-trips | synthesised: "{issue number}@{host}/{owner}/{repo}", stable but not caller-chosen |
native_id |
the backend's own key | the resource href | the issue number |
| foreign-id slot | can the backend persist the caller's UID? | — (UID is native) | no; Kanboard's reference would be one |
Two capabilities say which applies: identity.client-uid (the backend
stores the caller's UID as given) and identity.foreign-id (it can store it
in a side slot and find the item by it). A third, identity.client-native-id,
says whether the caller may also choose the native_id
(add(native_id=...), §2.3). Gitea declares all three UNSUPPORTED:
a task added with UID abc comes back with the synthesised one, and
get("abc") raises NotFoundError. A caller that needs to recognise its
own items there has to keep the mapping itself. This is the place where a
sync tool built on this library learns that it cannot assume UIDs
round-trip, and the conformance suite asserts it.
class Event(Item): ... # component is an icalendar.Event
class Journal(Item): ... # component is an icalendar.JournalNo fields of their own: component.start, component.end,
component.duration, component.location and component.rrules are
icalendar's. The classes exist so that a collection can say what it
returns and a helper can say what it takes.
Incorporates survey Part 4.
class TaskStatus(StrEnum): # values are the iCalendar strings
PENDING = "PENDING" # tasks draft
NEEDS_ACTION = "NEEDS-ACTION"
IN_PROCESS = "IN-PROCESS"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
FAILED = "FAILED" # tasks draft
class Task(Item): # component is an icalendar.Todo
native_status: str | None # see "Native passthrough"; not in the iCalendar data
native_priority: str | None # likewise
planned_start: datetime | None # X-PYCAL-PLANNED-START (see "Properties with no standard home")
planned_end: datetime | None # X-PYCAL-PLANNED-END
# the time log: see "Time tracking" below, on every bound item
def set_duration(self, duration: timedelta, keep: Literal["start", "due"] = "due") -> None: ...Everything else is a property of the VTODO, read and written through
component. TaskStatus is the vocabulary backends map STATUS to and
from. It is not a field: icalendar.enums.STATUS has no PENDING or
FAILED, which the tasks draft adds, so those two are a candidate for
icalendar too. set_duration() is a method rather than a property
because it changes two of them.
Decisions in it, each from the survey:
DTSTARTmeans earliest sensible start. That is the tasks draft's reading and the first in the survey's table; the other senses the survey found (planned start, expected completion) get their own properties instead of overloading it. Actual start comes from the time log (roadmap 1.6), not from a field.durationis derived, not stored. On a task,DURATIONis eitherDUE − DTSTARTor a misused estimate.icalendar'sTodo.durationreadsDUE − DTSTART(orDURATIONwhen only that is present), andtask.set_duration(d, keep="due" | "start")moves the other end, as caldav'sset_duration(movable_attr=…)does; plann uses both. The default keepsDUE, as caldav's does, because a deadline is more often fixed than a start. The estimate isESTIMATED-DURATION, neverDURATION.remainingis left out (survey §4.4). Two of the nine systems carry it, neither is a funded backend, and every field the library defines is a mapping obligation on every backend. It is reachable through the escape hatch on the backends that have it, and can be added later without breaking anything.priorityis iCalendar's 0–9. The survey found five incompatible scales; the mapping to each is the backend's, documented as lossy. plann's semantics for 1–9 sit on top of this and are plann's.- Assignees are the task's
ATTENDEEs (component.attendees), as on any other component. The tasks draft (section 6) says it in so many words: "Tasks are assigned to actors using one or more RFC5545 'ATTENDEE' properties and/or one or more RFC9073 'PARTICIPANT' calendar components." So the standard has no gap here, and noX-property is needed. What looks like a gap is that a tracker knows a login (alice), whileATTENDEEtakes a calendar address. A calendar address is any URI, not onlymailto:(RFC 5545 §3.3.3), so the Gitea mapper writes the user's profile URL:ATTENDEE;CN=Alice:https://gitea.example.com/alice. That is valid, unique, resolvable, and round-trips, with no fake e-mail address. Mapping logins to and from those URIs is the backend's job, and there is no separate assignees field, to avoid two names for one thing. The name, role and participation status are theATTENDEE'sCN,ROLEandPARTSTATparameters, onicalendar'svCalAddress.
From roadmap 1.6, on the survey's model (§4.2, option D of §4.3). Time spent is an append-only log of entries, and the total is derived from it. Time is logged against a task, or against an event the user took part in (a meeting).
@dataclass(frozen=True)
class TimeEntry: # calendaring.timetracking, mode-free
duration: timedelta
start: datetime | None = None
end: datetime | None = None # None, with start set: the running timer
users: tuple[str, ...] = () # calendar addresses (mailto: or a profile URL, as for assignees)
comment: str | None = None
logged: datetime | None = None # when it was recorded (CREATED)
id: str | None = None # the entry's UID, or the backend's id for it
# on a bound item (task or event); reading the log is a query, so these are methods
await item.time_log() -> list[TimeEntry] # by start; a running timer last
await item.time_spent() -> timedelta # the sum, a running timer up to now
await item.log_time(*, spent=None, start=None, end=None, day=None,
users=(), comment=None) -> TimeEntry
await item.amend_time(entry, **changes) -> TimeEntry
await item.delete_time(entry)
await task.start_timer(at=None) # tasks only
await task.stop_timer(at=None, *, users=(), comment=None) -> TimeEntry
await collection.time_entries(start, end) -> list[tuple[str, TimeEntry]] # (item UID, entry)log_timetakes two ofspent,startandend, and raisesValueErrorif three disagree, so a CLI's--spent/--start/--endis a thin layer.spentalone ends the entry now.spentwithday("3 h on this, 2 h on that, on Tuesday") lines the entry up after the last entry the same people logged that day, or atcollection.workday_start(08:00 local) when there is none. The day's sum is then right, though the times are made up; a day that fills up runs on past midnight rather than lose time. RFC 5545 has no other way: aDURATIONafter aDATEDTSTARTmust be whole days.amend_timechanges what it is given and keeps the rest:spentalone keeps the start,spentwithendkeeps the end. It edits the stored entry in place, so what other clients added to it stays. An entry that has no times (from a backend that drops them) keeps none: times are never invented.- Times are never floating by accident. A time in a zone without an
IANA name (what
astimezone()gives) is stored in UTC, and a floating time mixed with a zoned one raisesValueError. - The running timer is not an entry.
start_timer()writesX-PYCAL-TIMER-STARTon theVTODO, andstop_timer()removes it and logs the entry. AVEVENTwithoutDTENDis an instant (RFC 5545), so an open entry cannot be one. The timer syncs to other devices with the task, and works where entries do not.
In iCalendar, an entry is a VEVENT:
BEGIN:VEVENT
UID:…
DTSTAMP:…
CREATED:20261020T163000Z when it was logged
DTSTART:20261020T130000Z
DTEND:20261020T150000Z
SUMMARY:<the task's summary when logged>
DESCRIPTION:<the comment>
RELATED-TO;RELTYPE=PARENT:<the task's or event's UID>
X-PYCAL-TIME-ENTRY:TRUE
BEGIN:PARTICIPANT one per user (RFC 9073)
UID:…
PARTICIPANT-TYPE:ACTIVE
CALENDAR-ADDRESS:mailto:alice@example.com
END:PARTICIPANT
END:VEVENT
- Who did the work is a
PARTICIPANT, never anATTENDEE. RFC 9073 §7.1.1 makes a participant schedulable only when itsCALENDAR-ADDRESSmatches anATTENDEE. With none, no server sends invitations, which anATTENDEEwould make it do (RFC 6638). The tasks draft (§6) assigns with the same component. An entry may name several people; one duration then applies to each. Two people who spent different times log two entries. X-PYCAL-TIME-ENTRYmarks it, so the entry is recognised in any collection and after a copy.RELATED-TOalone also matches ordinary sub-events, andCATEGORIEScollides with users' own.- Entries go to the item's collection by default, or to
collection.time_log_collection(and are searched there). Many CalDAV task lists hold onlyVTODO, and some servers cannot hold entries at all, so a separate calendar for the log is normal. - A
VJOURNALis not used: it has noDTENDorDURATION, and iCloud and Google do not store it. A journal note can point at an entry withRELATED-TO.
The capability is time-log.
FULLwhere entries round-trip.LOSSYwhere the backend keeps entries but drops fields, withdetails["lost"]naming them (start-time,end,users,comment). Request Tracker records only when time was logged, and OpenProject only a date. Those fields read back asNone, never invented, so the backend's declaration says what an entry from it lacks, and no event carries an "untimed" marker.details["total-only"]for a backend that keeps only a sum: reading gives one entry with the total and no start, andamend_timeanddelete_timeraiseUnsupportedError.UNSUPPORTEDraises before any I/O.- A tracker backend maps entries onto its own time log (roadmap 2.5) by
overriding the collection's
_time_entries,_time_log,_add_time,_amend_timeand_delete_time. The rule: the capability is the log collection's (the item's own, unlesstime_log_collectionis set); the hooks and settings are the item's collection's, and the default hooks store into the log collection. - A time entry is not a subtask:
relatives()leaves entries out.
For sum_hours, calendaring.timetracking.summarize(pairs, group_by=…, window=(start, end)) groups (item, entries) pairs into
TimeSummary(estimate, spent) per key. estimate is the
ESTIMATED-DURATION of the open tasks, and spent the logged time. The
two are kept apart, since committed and spent time answer different
questions. The window clips entries, prorating one that straddles an
edge; an entry with no start counts by when it was logged.
Not settled: percent-complete per entry (no system in the survey has
it), an activity type or billable flag (would be CATEGORIES), and where
an entry belongs after a recurring task is completed "safely" (it stays
with the UID it was logged against: survey §4.4).
native_status and native_priority are a plain str | None, set by the
backend that read the item, and not serialised into the iCalendar
data — a Kanboard column name means nothing to a CalDAV server. The rule
that makes them round-trip: the backend remembers the STATUS (or
PRIORITY) it wrote into the item along with the native value. On save,
if the property still has that value, the backend uses the native value;
if the caller changed it, the backend maps the new value. So a card read
from the "Review" column (normalised IN-PROCESS) and saved unchanged
stays in "Review", and one that the caller sets to COMPLETED goes
wherever the backend maps COMPLETED. Comparing on save, rather than
clearing the native value in a setter, also catches a change made through
item.icalendar directly.
This answers survey §4.4's question "plain string or typed object": a plain
string. Legal transitions (RT, OpenProject) are reachable through native
and are not part of the model.
D6 sets the order: an RFC 5545 property; then RFC 9253 or the tasks draft;
then an X- property under one documented prefix. The prefix is
X-PYCAL-, after the project's name, pycal.org
(the GitHub organisation is pycalendar only because pycal was taken),
not X-CALENDARING-:
plannand the other pycal tools will write the same properties, and a project prefix is not wrong for them;- the package name may change (see the top of this document), and once written into users' calendars a prefix cannot.
In this document: X-PYCAL-PLANNED-START, X-PYCAL-PLANNED-END, and from
roadmap 1.6 X-PYCAL-TIME-ENTRY and X-PYCAL-TIMER-START ("Time tracking" above). Each gets a line in a
registry table in the user documentation (roadmap 4.1), with the standard property
that would replace it if one appears.
Words. A recurring object is one with an RRULE or RDATE. An
occurrence is one instance of it: the Wednesday 10:00 meeting on one
particular Wednesday, identified by its RECURRENCE-ID. RFC 5545 calls an
occurrence a "recurrence instance", and caldav calls it a "recurrence"
(save(only_this_recurrence=…)). This document says "occurrence" because
"recurrence" also means the repetition itself (the rule, "the recurrence
set"), and the two senses get mixed up.
search(..., expand=True) returns occurrences, one item per occurrence:
items with is_occurrence set and a RECURRENCE-ID on component, expanded by
recurring_ical_events via icalendar-searcher. Several occurrences of
one series in the search window are several items with the same uid
(What an item contains).
Editing one occurrence works as it does in caldav. save(occurrence)
fetches the stored object, inserts or replaces the override component for
that RECURRENCE-ID, bumps SEQUENCE and saves the whole object. When the
stored object has no master (overrides only), the override is replaced in
place. That is what
caldav's save(only_this_recurrence=True), its default, does, and what the
recurring-ical-events user guide shows ("Edit one event of an existing
series"). save(occurrence, scope=Scope.ALL) (class Scope(Enum) with
THIS, ALL and THIS_AND_FUTURE; default THIS) applies the change to the
master instead, which is caldav's all_recurrences=True; with no master
stored there is nothing to apply it to, and it raises. The merge is
icalendar manipulation with no I/O, so it works the same on every backend
that stores RRULE (recurrence capability). Backends that do not
(Gitea) never produce occurrences.
Not supported: "this and future" for events, which splits a series in
two. caldav does not offer it either, ical's store.py is the reference
for it (prior art §1.3, ical);
by D1 it does not belong in caldav, and it is assumed to go to edit-icalendar (a draft) (§10 Open questions, Q3). edit-icalendar is AGPL-3.0 and will be a hard dependency anyway, which is why an AGPL licence for this whole library is being considered (D3). save(occurrence, scope=Scope.THIS_AND_FUTURE) raises
UnsupportedError(Feature.RECURRENCE_EDIT_THIS_AND_FUTURE) until that package can split a series.
Completing one occurrence of a recurring task is task.complete(mode=...)
(§2.3 Collection), with caldav's two modes, safe and
this_and_future. (caldav
declares "this_and_future" but only accepts "thisandfuture": it looks
up _complete_recurring_<mode>, and that method is spelled
_complete_recurring_thisandfuture. The CalDAV backend passes the working
spelling, and the mismatch is reported as
caldav issue 735.)
Completing the occurrence is icalendar manipulation, which goes to
edit-icalendar; fetching and
storing stay here (§10 Open questions, Q3). Until
edit-icalendar has it, every backend declares task.complete-recurring
UNSUPPORTED, the CalDAV backend included.
§9 Migrating from caldav compares the whole of caldav's API with this one.
await cal.search(todo=True, start=..., end=..., expand=True)
s = Searcher(todo=True, include_completed=False)
s.add_property_filter("CATEGORIES", "work")
s.add_sort_key("DUE")
await cal.search(s)The keyword form builds a Searcher from its constructor fields (todo,
event, journal, start, end, alarm_start, alarm_end,
include_completed, expand), as caldav's search() does. Anything more
goes through a Searcher object. There is no wrapper: the type is
icalendar_searcher.Searcher, imported from there, so caldav's
CalDAVSearcher subclass works as is.
The rule (A3): a backend translates as much of the searcher as it can
into a server-side query, and the generic layer then runs
searcher.filter() over everything the server returned. A server-side
query may therefore over-match but never under-match, and the result is the
same whatever the server did. The conformance suite tests exactly this: the
same data and searcher against each backend, the same answer. caldav's
CalDAVSearcher already works this way for CalDAV (server query, client
post-filter); roadmap 2.1 keeps it.
The cost is bandwidth when a server filters badly, and it is the right trade: a filter that behaves differently per backend is the problem this library exists to remove. A caller who knows better uses the escape hatch.
Consequences:
- Sorting is done client-side by the searcher, so a sorted
searchreads every page before returning.iter_searchwith sort keys does the same before yielding the first item; without sort keys it streams. - Tracker-native filters (Gitea milestone, assignee by login) are not in the searcher's vocabulary. They are reachable through the escape hatch until the searcher grows a way to express them.
- No
limit/offset.Searcherhas none, and a limit applied before the client-side filter would be wrong.iter_searchis the way to stop early. - Naive datetimes are local time, as
icalendar-searcherassumes.
How it fits Gitea: its issue search API takes state, labels, a
milestone and a since-timestamp. The backend translates todo=True, include_completed=False into state=open and CATEGORIES into labels,
fetches, and lets the searcher do the rest. Date ranges are client-side
only, and declared so (search.time-range: EMULATED).
class Support(Enum):
FULL = "full" # works, no information lost
LOSSY = "lossy" # works, but a value is mapped or truncated
EMULATED = "emulated" # done client-side; same result, more I/O or not atomic
UNSUPPORTED = "unsupported" # raises UnsupportedError
UNKNOWN = "unknown" # not probed; the operation is attempted
@dataclass(frozen=True)
class Capability:
level: Support
details: Mapping[str, Any] = field(default_factory=dict) # how it is supported; keys per feature
note: str | None = None # free text for the capability matrix
class Capabilities(Mapping[Feature, Capability]):
def supports(self, feature: Feature, *, allow_lossy: bool = False) -> bool: ... # the boolean view
def level(self, feature: Feature) -> Support: ... # the string view
def details(self, feature: Feature) -> Mapping[str, Any]: ... # the full viewSupport is an enum, not a set of behaviour objects, because it mostly
describes: it feeds the capability matrix and supports(), and only
UNSUPPORTED changes what an operation does. LossPolicy, whose values
are behaviours, is an object (§5.3).
A yes/no answer is not enough, and neither is a level alone. That is
caldav's experience with its compatibility matrix, where a value is a
boolean, a string or a dict, and helper methods reduce a dict to a boolean
or a string for code that only cares about that. This is the same idea with
a fixed shape. Every entry has a level, which callers branch on, and an
optional details mapping for how a feature is supported. For example:
| Feature | Level | details |
|---|---|---|
task.priority on Taskwarrior |
LOSSY |
{"values": [1, 5, 9]} (H/M/L) |
task.status on Gitea |
LOSSY |
{"values": ["NEEDS-ACTION", "COMPLETED"]} |
changes on CalDAV without sync-token |
EMULATED |
{"method": "etag-listing"} |
search.time-range on a CalDAV server that ignores it for tasks |
EMULATED |
{"components": ["VEVENT"]} |
In configuration files a capability can be given in the same three shapes
caldav accepts, true/false, a level string, or a dict with level and
the details, and is normalised to a Capability on load. The details keys
are documented per feature, so the capability matrix (roadmap 4.3) can
print them, and the conformance suite can use them. For example, it
asserts that a LOSSY priority comes back as one of the declared values.
values and an optional map (sent value → stored value; a value missing
from both is dropped) are enough for the generic layer to report the losses
itself, so a backend whose mapping is that simple writes no mapper. A
LOSSY entry without values is allowed, for a backend with mapping rules
of its own; the conformance suite can then check only that each value is
either stored or refused before any I/O.
supports() counts UNKNOWN as not supported: it is the view for a caller
deciding whether to rely on a feature, and nothing promises an unprobed one
works. The operation itself is still attempted.
Feature is a StrEnum with dotted values, so that it can be written
in configuration files and in the generated capability matrix (roadmap 4.3). The
initial set:
| Feature | Meaning |
|---|---|
write |
create, update and delete items at all |
component.event, component.task, component.journal |
can hold that component |
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) |
changes |
changes(): FULL with a native token, EMULATED by listing |
changes.known |
changes(known=...) fetches only what changed: FULL where etags can be listed without content (CalDAV, vdir), EMULATED where everything is read and compared (JMAP, single .ics file, feed, Gitea) |
write.wire |
add(wire=...), save(wire=...): given bytes are sent as they are; FULL on vdir, and on CalDAV through caldav's save(raw_data=...) (caldav 3.6, caldav #763); UNSUPPORTED elsewhere |
write.conditional |
FULL with an atomic precondition (ETag, content_version); EMULATED read-compare-write |
identity.client-uid, identity.foreign-id |
§3.2 Identity |
identity.client-native-id |
add(native_id=...): the caller may name the stored object (CalDAV href, vdir file name) |
properties.passthrough |
unknown properties and components survive a round trip |
recurrence, recurrence.edit-this-and-future |
stores a recurrence set: RRULE, RDATE, EXDATE and the overrides (RECURRENCE-ID); UNSUPPORTED drops all four, overrides included; §3.7 Recurrence |
recurrence.edit-occurrence |
save(occurrence) merges it into the stored object (§3.7); UNSUPPORTED where the merge is not available yet (Q3) |
task.complete-recurring |
complete() on a recurring task or one occurrence of it (§3.7) |
move |
§2.3 Collection |
task.status, task.priority, task.percent-complete |
LOSSY when values are mapped |
task.start, task.due, task.completed, task.planned, task.estimate |
|
task.relations.parent, task.relations.depends-on |
|
categories, attendees |
for every component; on a task, attendees are its assignees |
alarms |
VALARM components survive; UNSUPPORTED drops them |
timezones |
a time with a TZID keeps it (with its VTIMEZONE), a floating time stays floating; LOSSY stores both in UTC, a floating time read as local time (§4) |
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); 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 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.
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
compare what comes back; only what the declaration says may differ.
Feature names are identifiers, not attribute paths: task.due means "stores a
task's DUE", which is read as task.component.DUE, and task.estimate
means ESTIMATED-DURATION.
roadmap 1.6 added time-log ("Time tracking" in §3.4). Below 1.0 the list grows as backends need
it (§10 Open questions, Q7). The conformance suite
iterates over Feature, and a backend's declaration must cover every member
(missing = test failure, not a silent default).
It is a table rather than an IntFlag like Home Assistant's, because a
flag is yes/no and the survey's most common answer is "yes, lossily". It has
fewer levels than caldav's FeatureSet, which describes servers (including
fragile, broken and ungraceful ones) for the client's workarounds. The CalDAV
backend derives this table from caldav's: full/quirk → FULL; anything
caldav works around client-side → EMULATED; unsupported, broken,
ungraceful → UNSUPPORTED; fragile, unknown → UNKNOWN.
UNKNOWN exists because most CalDAV servers have never been probed, and a
library that refused anything unprobed would be unusable against them.
Non-CalDAV backends are code, not servers, and the conformance suite
rejects UNKNOWN from them.
On each collection, and on each backend for the backend-level features. A
collection can narrow its backend's (a feed is read-only; one CalDAV
calendar takes only VTODO), never widen it.
Three cases, matching the roadmap's "raise, degrade, or emulate":
-
Unsupported operation → raise, before I/O.
UnsupportedErrorcarries theFeature. Checked at the boundary, as Home Assistant does with its service-call validation, so nothing is half done. -
Lossy write → depends on the
LossPolicy, defaultRAISE. The backend's mapper runs before the write is sent and returns a list of what it could not store (a priority of 3 on a backend with three levels, a fourth status, aDEPENDS-ONto Gitea's API version without dependencies). The policy is then called with that list. It is an object, not an enum, so that each behaviour lives in one place and a caller can supply its own (the strategy pattern, suggested in the PR 5 review):@dataclass(frozen=True) class Loss: property: str # e.g. "PRIORITY", "STATUS", "RELATED-TO" sent: object # the value in the item stored: object | None # what the backend will store instead; None if dropped class LossPolicy(Protocol): def __call__(self, losses: Sequence[Loss], item: Item) -> None: ... # return: the write goes ahead; raise: nothing is sent RAISE: LossPolicy # raises LossyWriteError(losses) WARN: LossPolicy # emits LossyWriteWarning, then returns ALLOW: LossPolicy # returns
The three stock policies live in
calendaring.loss. A caller's own policy can log the losses, collect them for a report, or allow some properties and raise on others. The policy is set per workspace or backend and can be overridden per call (loss=). A configuration file names one of the stock three (raise,warn,allow). DefaultRAISE, because the alternative is the Home Assistant failure (prior art §1.4, Home Assistant):IN-PROCESSsilently becomingneeds_action. A warning is also what roadmap 1.3's-W errortest runs turn back into a failure. -
Emulation → automatic, only when indistinguishable. Client-side filtering, change listing and cross-backend move are emulated without asking, because the caller gets the same answer (§4 Search) or a documented weaker guarantee (
write.conditionalemulated has a race window). No emulation that changes a result is ever automatic.
The RAISE default for lossy writes is confirmed by the author (2026-10-10).
What the library cannot catch: a server that declares UNKNOWN and then
silently drops a property. caldav's hints call that unsupported; only a
probe (caldav-server-tester) or a read-back finds it. So every write takes
verify=True, from the first release: after the write the item is
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
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). 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
write happened and the stored result differs. It costs one extra
round trip, so it is off by default, and a caller can turn it on per call
or per workspace or backend, like loss=.
CalendaringError
├── ConfigurationError # roadmap 1.4; also a session the backend cannot use
├── AuthenticationError # 401, bad token
│ └── AuthorizationError # 403
├── NotFoundError # also a LookupError
├── AmbiguousError # a name matched several collections
├── ConflictError # a precondition failed; itself: changed since read
│ ├── AlreadyExistsError # add(): .taken is "uid" or "native_id"
│ └── SyncTokenError # changes(token): the backend can no longer honour the token
├── UnsupportedError # carries .feature
│ └── LossyWriteError # carries .losses; raised before anything is sent
├── InvalidDataError # the backend rejected the data, or it does not parse; also a ValueError
│ └── PartialResultError # a read left out stored objects it could not use; .results, .broken
├── RateLimitError # carries .retry_after: float | None
├── VerificationError # verify=True: written, but stored differently; carries .differences
└── BackendError # anything else from the backend or transport
└── TransportError # network, TLS, timeout
CalendaringWarning
└── LossyWriteWarning
- Every error carries
.backendand, where known,.collection_id. - The native exception is always
__cause__(raise … from exc), so a caller can reachcaldav.lib.error.DAVErroror an HTTP response without the hierarchy pretending it does not exist. NotFoundErroris also aLookupErrorandInvalidDataErroraValueError, so generic code that catches the built-ins still works.RateLimitErroris raised, not retried. Retrying belongs to the hand-written per-mode layer (roadmap 0.2:asyncio.sleepmust not be in_async/), and whether it retries is a workspace option (roadmap 1.4).- A
TransportErrorduring a write means the outcome is unknown: the write may have been stored. It is never retried, whatever the retry option says. The caller finds out withreload()after asave, withget_by_native_id()afteradd(native_id=...), and otherwise withget(uid)where UIDs round-trip (§3.2). SyncTokenErroris raised whatever status the server used for an expired or never-issued token: 403 withDAV:valid-sync-token, 412 (Xandikos), or a 500 from a server known to answer a bad token that way (Xandikos and CCS, flagged by caldav'ssync-token.invalid-tokenfeature). Onlychanges(token)raises it. Kept apart from a failedIf-Match, because the remedy differs: start again withchanges(), not reload the item. It is still aConflictError, asAlreadyExistsErroris, because all three are a failed precondition; a handler that reloads onConflictErrorshould catch the subclasses first.- One bad stored object does not hide the rest. A read that lists
(
search(),iter_search()once exhausted, the listing shorthands,changes(),relatives(), andget(uid)where the UID is not found) leaves out an object that does not parse, or that the searcher cannot match (two masters for one UID), and then raisesPartialResultError..resultsis what the call would have returned from the rest (forchanges()theChangeSet, whose token is good; foriter_search()nothing, since it yielded them already), and.brokenlists the objects left out (BrokenObject: native id, etag, error, and the bytes as read where the backend has them, to repair the object from). A broken object is never reported as deleted.get_by_native_id()of one raisesInvalidDataError. Raising rather than warning means no caller misses it; a caller that can live with the gap takes.results. The listing primitives return aBrokenObjectin place of aStoredObjectrather than raising, so the backend decides per object. - Fan-out (§2.1 Workspace) does not raise;
MultiResult.raise_for_errors()raises anExceptionGroupof these.Workspace.search()keeps aPartialResultError's.resultsamong the items, and the error among the errors.
caldav's errors map one to one where they overlap:
NotFoundError → NotFoundError, ETagMismatchError and
ScheduleTagMismatchError → ConflictError; in changes(token), a
DAVError carrying the DAV:valid-sync-token precondition, any 412, and a
500 from a server flagged as above → SyncTokenError;
AuthorizationError → AuthenticationError or AuthorizationError by
status code, RateLimitError → RateLimitError, other DAVError →
BackendError.
Adopts vdirsyncer's contract (prior art §1.2, vdirsyncer), extended to collections.
Every stored item has an etag, real or synthetic:
| Backend | etag |
write.conditional |
|---|---|---|
| CalDAV | the server's ETag | FULL (If-Match); for a weak ETag, which If-Match cannot match (GMX), a read-compare-write that can race |
| files, vdir | f"{st_mtime_ns};{st_ino}" |
EMULATED: compare, then atomic rename |
files, single .ics |
hash of the item's serialisation | EMULATED |
| feed | hash of the item's serialisation | n/a (read-only) |
| JMAP | hash of the item's JSCalendar object | EMULATED |
| Gitea | content_version, plus updated_at |
FULL for the body, EMULATED for the rest (§10 Open questions, Q6) |
JMAP has no per-object version. Its state covers every object of one
data type in the account, so used as an etag it would mark every item
changed after any write, and ifInState on /set is account-wide in the
same way (RFC 8620 §5.1, §5.3; pointed out in the
PR 5 review).
Hence a hash, and a read-compare-write that can race.
Every collection answers changes(token):
@dataclass(frozen=True)
class ChangeSet(Generic[T]): # outside _async/; T is the bound item class of the mode
changed: list[T] # new or modified since token (or known), bound to the collection
deleted: list[str] # native ids (CalDAV: hrefs) removed since token (or from known); a removal report names no UID
token: SyncToken # SyncToken = NewType("SyncToken", str); opaque, persist it and pass it back
cs = await cal.changes() # everything, plus a token
cs = await cal.changes(cs.token) # what changed since- CalDAV: RFC 6578 sync-token where the server supports it (
FULL), otherwise emulated by caldav from an ETag listing.changes()without a token lists the members withPROPFINDand takes the token from the collection'sDAV:sync-tokenproperty (RFC 6578 §4), read before the member listing or in the sameDepth: 1request, so a change made in between is reported again by the nextchanges(token)instead of lost; not from async-collectionreport with an empty token: Stalwart truncates that report with a 507 and hands back its newest token, so following it loses the rest of the calendar. ThePROPFINDway worked on all five servers calendaring-sync tests with (PR 5 review). - JMAP:
CalendarEvent/changesandTask/changes(RFC 8620 §5.2). Their state covers the whole account and data type, not one calendar. A collection keeps its own list of ids, carried in the token as for files. It reports as changed the updated ids whosecalendarIdsinclude it, and as deleted the ids on its list that were destroyed or no longer include it: an event moved to another calendar comes back as updated, not destroyed. - Files: the token is a compact encoding of
{native_id: etag}; changes are a re-scan diffed against it (EMULATED). - Feed: HTTP
ETag/Last-Modifiedshort-circuits "nothing changed"; otherwise a diff of item hashes, as for files. - Gitea:
since=on the issues API finds changed items; finding deleted ones needs a listing of all ids, soEMULATED.
Removals are reported by native_id, not uid: an RFC 6578 report names
only the href of a removed member, and a server can hold several objects
with one UID (calendaring-sync's design reached the same conclusion; see
§10 Open questions, Q11).
A token is valid only for the collection that issued it. A token the
backend can no longer honour (CalDAV valid-sync-token precondition, a
files token from a different directory) raises SyncTokenError, a
ConflictError (§6 Errors), and the caller starts again with
changes().
A full listing can skip what the caller already has. changes(known= {native_id: etag}) is a full listing compared with what the caller holds:
changed has the items that are new or whose etag differs, and only those
are fetched; deleted has the ids in known that are gone; every other id
in known is unchanged. It returns a token like any other call. Without
known, changes() reports every item as changed, as above. On CalDAV the
listing is the PROPFIND above, with DAV:getetag, and only the changed
members are fetched, in one calendar-multiget (RFC 4791 §7.9), so a token
reset downloads what changed rather than the whole calendar. known with
a token raises ValueError: an incremental report has its own baseline.
Etags the caller got back from its own writes may belong to content the
server rewrote (see "The content after a write" in §2.3), so
known= built from them will not report the rewrite.
Asked for by calendaring-sync, whose full listing works this way
(PR 19);
proposal, open to review.
A change set is always complete. A truncated page (CalDAV 507, RFC 6578
§3.6; JMAP hasMoreChanges) is followed inside changes(), which returns
only when it has everything. So ChangeSet needs no "complete or
truncated" flag, and no "full listing or incremental" flag either:
changes() without a token is always a full listing, changes(token)
always incremental, and an expired token raises rather than falling back to
a listing. A listing with known is complete too, but as a difference: an
adapter that treats a full listing as "anything not mentioned is gone" must
read known minus deleted as still there. The cost is memory on a very large calendar. calendaring-sync
keeps both flags in its own change set; an adapter built on this one sets
them from the call it made.
For Gitea, updated_at is the weakest change signal in the survey. The
synthetic etag adds content_version, so "changed twice within the clock
resolution" is seen at least for the issue body; whether it covers other
fields is checked in roadmap 2.5 (§10 Open questions, Q6).
Every Backend, Collection and item has .native:
| Backend | Backend.native |
Collection.native |
item .native |
|---|---|---|---|
| CalDAV | caldav.DAVClient / AsyncDAVClient |
caldav.Calendar |
caldav.CalendarObjectResource |
| files | the root Path |
the directory or file Path |
the file Path |
| JMAP | calendaring_jmap.JMAPClient / AsyncJMAPClient |
the calendar id | the JMAP object dict |
| Gitea | the base URL and an HTTP session | the repository dict from the API | the issue dict from the API |
The base classes type it object. Each backend's subclass narrows it
(CalDAVCollection.native: caldav.Calendar), so a caller who has checked
isinstance(cal, CalDAVCollection) gets a typed native object, and
--verifytypes (D5) stays at 100 % without an Any in the public API.
Rules: the native object is the backend's own, and reading from it is
always safe. Writing through it bypasses this library's guarantees: no
capability check, no loss check, and the item's etag is stale afterwards,
so reload() it. The item's native is a snapshot from when it was read,
not a live handle.
The other direction also works: collection.wrap(native_item) turns a
backend object (a caldav.Todo, say) into this library's item, bound to
that collection, without I/O; for a caldav object wire is its
raw_data, None if it was not read from the server
(see "The stored form" in §3.1). Together with .native that lets code use both libraries side by side,
which is what a migration needs
(§9 Migrating from caldav).
What a caldav user keeps, what changes, and what is only reachable through the escape hatch (§8 The escape hatch). Taken from caldav's public API as of 2026-10-08 (caldav 3.4.0).
| caldav | calendaring | |
|---|---|---|
get_davclient(), get_calendar(s)(), config file |
Backend.open(), Workspace.from_config() |
changed; same config file (config proposal) |
principal(), calendars(), make_calendar(supported_calendar_component_set=…) |
backend.collections(), create_collection(components=…) |
same |
get_supported_components() |
collection.components |
same |
search(…), CalDAVSearcher |
collection.search(…) with a Searcher |
same; caldav already post-filters client-side |
search(…, server_expand=True) |
not a caller choice; the backend decides, the result is the same | escape hatch |
get_object_by_uid(), event_by_uid(), todo_by_uid() |
get(uid) |
same |
event_by_url() |
get_by_native_id(href) |
same |
add_todo(summary=…), save_todo(…) |
add_task(summary=…) |
same for properties; ical= → add(Task.from_ical(…)) |
obj.save(), no_overwrite, no_create |
obj.save() on a bound item, collection.add(obj) for a new one |
same for updates; create and update split |
| ETag / Schedule-Tag preconditions on save | etag precondition, ConflictError |
same; Schedule-Tag only through the escape hatch |
obj.load(), obj.delete() |
obj.reload(), obj.delete() |
same (in place on a bound item) |
multiget(), load_by_multiget() |
used inside the backend | not a public call |
icalendar_instance, edit_icalendar_component() (borrowing) |
item.icalendar, item.component |
simpler: the item is the iCalendar data, nothing to borrow |
vobject_instance |
— | convert through item.to_ical() and item.replace_ical(), or, read-only, the escape hatch on CalDAV (item.native.vobject_instance); Raw data and other libraries |
data = text |
item.replace_ical(text) |
same, but the UID must not change |
data, wire_data |
item.wire (as read), item.to_ical() (as it is now) |
changed: wire is the server's bytes as read, not caldav's normalised copy, and set to None by a write |
search(expand=True) |
search(expand=True) |
same |
search(expand=True, split_expanded=False) |
— | dropped: an item is never several occurrences without their master (§3.1) |
save(only_this_recurrence=True) (default) |
save(occurrence) |
same (§3.7 Recurrence) |
save(all_recurrences=True) |
save(occurrence, scope=Scope.ALL) |
same |
save(only_this_recurrence=None / False) |
— | escape hatch |
expand_rrule(start, end) on an object |
search(expand=True), or recurring_ical_events directly |
changed |
| "this and future" for events | — | missing in both |
complete(handle_rrule=True, rrule_mode=…) |
task.complete(mode=…) |
same modes; caldav's "interval from completion" guess is dropped |
complete() on a recurring task, default handle_rrule=False |
task.complete() handles the RRULE (mode="safe") |
behaviour change: caldav completes the whole series by default |
uncomplete() |
task.uncomplete() |
same |
is_pending() |
task.component.status |
changed: no helper |
get_due(), get_duration(), set_duration(movable_attr=…), get_dtend(), set_end() |
task.component.end, task.component.duration, task.set_duration(keep=…), event.component.end |
same, through icalendar's properties |
set_due(due, move_dtstart=…, check_dependent=…) |
task.component.DUE = … |
not yet: move_dtstart and check_dependent |
set_relation(), get_relatives() |
item.component.related_to, item.relatives() |
same |
check_reverse_relations(), fix_reverse_relations() |
— | not yet |
objects_by_sync_token() |
collection.changes(token) |
same |
save_with_invites(), accept_invite(), decline_invite(), change_attendee_status(), schedule_inbox(), freebusy_request() |
ATTENDEE, ORGANIZER as data only |
escape hatch; scheduling (iTIP) is outside the funded scope |
add_attendee(), add_organizer() |
item.component.attendees, item.component.organizer |
same, as data |
propfind(), proppatch(), report(), mkcol(), request() |
— | escape hatch (backend.native) |
compatibility hints, features: profile |
derived capabilities (§5.1 The table); the profile stays in the config | same source |
The two not yet rows are pure logic plus a relatives lookup, and fit
in Phase 1 if plann needs them before roadmap 3.3. The behaviour change in
complete() is deliberate: completing a whole series because the caller
forgot a flag is the wrong default. caldav's own docstring says it may
make the flag mandatory.
plann is the first program that has to move (roadmap 3.3). In its
plann/*.py (git grep -F at plann 95fdab5, 2026-10-09) it calls
get_relatives( 16 times, .save( 13, get_duration( 9, get_due( 7,
.complete( 4 and set_duration( 4. A big-bang switch is not needed, because the two libraries
share their data model (icalendar objects) and the escape hatch goes
both ways:
- Connect through calendaring, keep calling caldav. plann gets its
collections from
Workspace.from_config()(roadmap 1.4, which plann's credential work already waits for) and usescollection.native, acaldav.Calendar, everywhere else. Nothing else changes. - Move the reads: search,
get,relatives. Where plann still holds caldav objects,collection.wrap()converts them. - Move the writes:
add,save,complete, with thecomplete()default checked at every call site. - What is left is the gap list. Every remaining
.nativecall is a row in the table above that plann needs, and either gets added here or stays as a deliberate CalDAV-only feature.
Progress is measurable: the number of caldav names plann uses directly.
For the duration, plann depends on both libraries, which it does anyway,
since calendaring depends on caldav.
Settled after peer review on 2026-10-13: each question is decided, deferred, or marked as not blocking; none blocks roadmap 1.2 from starting.
-
Decided, 2026-10-10: Items and I/O (A1). Mode-free data classes plus bound subclasses per mode, which keeps caldav's
task.save()(§1.1 Sync and async). -
Decided, 2026-10-09:
Workspace. The nameClient. The author disliked it ("could mean anything") and suggestedCalendaringConfig,CalendaringCollectionandCalendaring. What the object is: a set of backends, usually loaded from configuration, with fan-out operations over them. The criteria: not an HTTP word (client, session, connection), says "several sources", and survives a package rename.Name Verdict Clientan HTTP word, and says nothing about "several" CalendaringConfigit does I/O, so it is more than a config CalendaringCollectionclashes with Collection, which means a calendar hereCalendaringreads well ( Calendaring.from_config()), but breaks if the package is renamedSessionrequests/SQLAlchemy usage, but clashes with the HTTP session a backend owns Workspace"everything you have configured"; no clash; survives a rename Proposal:
Workspace, accepted by the author and applied throughout. -
Decided, 2026-10-10: Where recurring-task completion lives. caldav's
complete(handle_rrule=…)logic, and the occurrence merge behindsave(only_this_recurrence=…), are not CalDAV protocol logic, and by D1 do not belong incaldav. The files backend needs them too. The author does not want another package for a few hundred lines, and suggestedrecurring_ical_events, which already documents editing one occurrence; its maintainer asked for an issue with a proposed API. Where the code lives is decided (below); the API itself is still open, in recurring-ical-events issue 292. Until it is settled, roadmap 2.1 calls caldav's code, and roadmap 2.2 waits for the outcome rather than copying it. That means the CalDAV backend keeps caldav's "interval after completion" guess for anRRULEwithoutBY*parts until then: a known deviation from §2.3 Collection, listed in the capability matrix. Agreed with recurring-ical-events' maintainer in the PR 5 review, 2026-10-10, with this split: the merge itself (inserting or replacing an override in a recurrence set, completing one occurrence) goes torecurring_ical_eventsasicalendarmanipulation with no I/O. Working out thatsave(occurrence)needs a merge, and fetching the stored object to merge into, stays here, because it is I/O. Splitting a series ("this and future" for events) is not part of that agreement; it is assumed to go to edit-icalendar, a draft as of 2026-10-12 (§3.7). Its README also covers editing and deleting single occurrences, so the merge above may move there too, despite "no extra package" earlier in this question. Decided by the author, 2026-10-23: all of it, the occurrence merge and recurring-task completion included, goes to edit-icalendar, which supersedes recurring-ical-events issue 292. Until edit-icalendar has it, every backend declaresrecurrence.edit-occurrence,recurrence.edit-this-and-futureandtask.complete-recurringUNSUPPORTED, the CalDAV backend included: it neither copies nor calls caldav's code for them, so the "interval after completion" guess above is not inherited either. -
Decided, 2026-10-10:
verify=Trueon writes is in from the first release (§5.3 What happens when the caller asks for something unsupported). -
Resolved, 2026-10-09: assignees are
ATTENDEEs with the tracker's profile URL as the calendar address; noX-property (§3.4 Task). -
Deferred to roadmap 2.5, 2026-10-12: answered once a Gitea test server is up. Gitea's
content_versioncovers the issue body; whether it also changes on label, state or due-date edits is checked in roadmap 2.5, against a Gitea run as a CI service container (the official image with SQLite needs a few hundred MB of RAM and no persistent host). This is a task for 2.5, not a question for the author. A permanent instance is only needed for dogfooding, and is optional. Author, 2026-10-10: the integration tests run against a CI service container, as caldav's do. -
Decided, 2026-10-10: Feature granularity. Below 1.0 the list in §5.1 The table is flexible: it starts with what the funded backends need and grows when needed. Whether it is closed per release after 1.0 is a question for then.
-
Deferred to roadmap 1.4, 2026-10-13: The configuration file is shared by caldav, calendaring-jmap and this library. Where should its parser live? A proposal for the team is in CONFIGURATION_PROPOSAL.md.
-
Decided, 2026-10-12: One item, several backends (the author's suggestion): an item that references a list of backends, so that
save()orcomplete()pushes the change to all of them. It is a good feature, but it is a sync engine, not a reference:- The same task has a different
uid,native_idandetagon each backend (Gitea synthesises theuid, §3.2 Identity), so the item needs one identity record per backend. - Capabilities differ, so one save can be lossless on CalDAV and lossy on Gitea, which needs a loss decision per target.
- A save can succeed on one backend and fail on another. That is a
MultiResult, not a return value. - When the backends have diverged, which copy wins?
Decided by the author, 2026-10-12: no mirroring in 1.x. The conflict half belongs in calendaring-sync, which is being designed now (Q11). Decided by the author: bound items hold their collections as a tuple (
item.collections) from the start, with only one supported for now:bind()andwrap()produce exactly one, and building a bound item with more raisesUnsupportedError. The attribute stays when mirroring comes, butetag,native_id,collection_idand (on Gitea)uidare singular today and per backend then (first bullet above), so mirroring will change those parts of the API. - The same task has a different
-
Decided, 2026-10-13: Item class names (§1.1 Sync and async):
Task(data) /SyncTask/AsyncTaskas proposed, orTaskData/Task/AsyncTaskto match the I/O classes' httpx pattern? The proposal keeps the short name for the class most code touches. The base class follows suit:Item/SyncItem/AsyncItem, orItemData/Item/AsyncItem. Decided by the author after peer review:Item/SyncItem/AsyncItem, as proposed; the short name stays with the data class. -
Not blocking, 2026-10-13: Cooperation with calendaring-sync. Assumed to be easy: the interfaces below are kept compatible, and nothing in 1.x waits for it. Still open: which of the two depends on the other, a calendaring adapter in calendaring-sync or calendaring depending on calendaring-sync (for a
Mirror, which Q9 leaves out of 1.x), and with it the licence question at the end.calendaring-sync (Sashank, same 2026-11-01 deadline) keeps sync state and detects conflicts; protocol adapters feed it. Its design (PR 19) overlaps with §7 Change detection and with roadmap 2.1. Neither project can wait for the other, so the proposal is to share findings and keep the interfaces compatible:
- caldav fixes both need. The sync adapter worked around caldav
dropping a truncated
sync-collectionpage (RFC 6578 §3.6), losing a 403's body (sovalid-sync-tokencannot be told from a permission error),save()bumpingSEQUENCEunasked, anddelete()sending no precondition. Our CalDAV backend hits the same four. Filed as caldav issues 737, 738, 739 and 740, fixed on caldav master for 3.5.0; calendaring-sync has dropped its workarounds. Still open in caldav, and needed here too: the precondition is read only from 401/403 errors, so a 412 for an expired token arrives as a plainReportError, and a removed member in a sync result is an object with no etag rather than marked as removed. Server side: xandikos#743 (a 500 for a malformed token) and xandikos#730 (the 412). - One "calendaring" adapter later. Our
Collection.changes(), etags and conditional writes cover CalDAV, JMAP, files, feeds and Gitea. An adapter in calendaring-sync built on them would give it every calendaring backend at once; its adapter base class is public, and its docs now say adapters may live outside the package. JMAP would probably stay a native adapter there: it keeps JSCalendar rather than iCalendar, and its scope is one account and data type, not one calendar. The JSCalendar original stays reachable here as the JMAP item's.native(§8). - Adopted from their findings: removal by native id,
PROPFINDplusDAV:sync-tokenfor a full CalDAV listing, a complete change set with no flags, the untrusted etag after a write,SyncTokenError,AlreadyExistsError.taken,add(native_id=...)and no retry of a write (§2.3, §6, §7). - Proposed for a cheap calendaring adapter, open to review: the
stored bytes of an item that was read (
Item.wire) and writes of given bytes (wire=), since their core stores what the server sent and pushes what the application recorded (§3.1); andchanges(known=...), a full listing that fetches only what changed, as theirs does after a token reset (§7). - Licence. calendaring-sync is AGPL. An adapter living there may
depend on calendaring; calendaring depending on calendaring-sync
(for a
Mirror) would have to be an optional extra, like JMAP (D3). Sashank has offered to move calendaring-sync to this project's licence and unasync layout; undecided.
Discussion: the author's first comment, Sashank's answers, what is in the way of a calendaring adapter; server evidence on calendaring-sync's
evidencebranch. - caldav fixes both need. The sync adapter worked around caldav
dropping a truncated
-
Decided, 2026-10-19 (roadmap 2.1): Occurrence items:
delete()and the shared etag (What an item contains). Occurrences of one stored series share itsnative_idandetag, so the rules for a whole object do not carry over.delete(occurrence)writes anEXDATEon the master and removes that occurrence's override, if there is one. When there is no master, it removes the override, and deletes the stored object only when it was the last one. TheEXDATEtakes the master'sDTSTARTform (a date, a date-time in the sameTZID, UTC or floating). TheEXDATEbugs found in clients (Exchange 2010, Lightning, khal) all came from comparing the two in different forms.- Why not
STATUS:CANCELLED, which the author leaned towards, and which caldav planned in #35 and #598: "delete" and "cancel" are different operations, as #35 itself says. A consumer that does not filter cancelled events shows a cancelled override as live (Home Assistant until core #180947), and Home Assistant's own occurrence deletion (core #176565) usesEXDATE. Cancelling an occurrence is a separatecancel(), not built yet: it writes an override, so it waits for thesave(occurrence)merge (Q3). - Conflicts between siblings. Saving or deleting one occurrence
changes the stored object's etag under its siblings. So
delete()fetches the stored object, and raisesConflictErroronly if that occurrence changed since it was read: its override, or the occurrence generated from the master (a changedRRULE,DTSTARTorSUMMARYchanges it). It then writes with the fresh etag.overwrite=Trueskips the check. An occurrence already gone isNotFoundError.save(occurrence)will follow the same rule when the merge (Q3) lands.
-
Decided, 2026-10-15 Change tracking (the reviewer's "borrowing", PR 5 review).
Decision: explicit and unconditional saves are enough for the funded roadmap.
save()andreload()are explicit, andsave()always writes, whether or not the item was edited.save()is the primitive any later layer is built on (PR 5 video meeting). There is no trackedcomponentsubclass, and no dependency on icalendar issue 1914.What needs a change check. Saving only an edited item, direct access to properties, and implicit saves (§12) all need a way to check whether the
icalendardata has changed. They are beyond the funded scope. Some enhancements may be needed inicalendarso we know whether a calendar was changed or not, see icalendar issue 1922. -
Deferred to edit-icalendar, 2026-10-13: Writing through
componenton a recurrence set with overrides. The trap is documented in What an item contains, and the fix is left to edit-icalendar. Q3 sends splitting a series and editing single occurrences there; this adds whole-series edits on a set with overrides. For reference when that is designed:icalavoids the trap by not editing in place:store.edit(uid, patch, recurrence_id, recurrence_range)takes a partial item holding only the changed fields. Without arecurrence_id, the patch goes to every component with thatUID, the master and all overrides. With one, it forks an override (Range.NONE) or ends the series withUNTILand starts a new one (THIS_AND_FUTURE). Another option for then, or a stopgap before it, is forsave()to warn when the master changed and the stored object has overrides. A patch-style merge needs to know which properties changed.
2026-10-10 to 2026-10-13, considered complete on 2026-10-13: an
icalendar and recurring-ical-events maintainer (@niccokunzmann)
reviewed in PR 5 and in
video calls. What it changed:
the item base class is Item and carries no iCalendar properties of
its own (§3.1); what an item may
hold is spelled out (What an item contains); the
occurrence merge goes to recurring_ical_events (Q3; moved to edit-icalendar
on 2026-10-23); and change tracking is
Q13, which decided on 2026-10-12 how writes are tracked (a tracked subclass
of the icalendar components, pending icalendar issue 1914; replaced on
2026-10-15 by unconditional saves), and §12
takes his question about transactions along with implicit synchronization. The video meeting of
2026-10-12 kept save() and reload() explicit and moved implicit
synchronization beyond the funded scope. His proposal of per-mode adapters (item.sync.save()) was
prototyped and not adopted
(sync/async §12).
The table lists the reviewers considered when the review was planned. The review was done by @niccokunzmann and Sashank (below); the others were not approached for roadmap 1.1.
| Reviewer (role) | Why | What to ask |
|---|---|---|
a maintainer of icalendar |
§3 Items builds on its typed properties; the roadmap 4.5 documentation contributor comes from there | §3 Items, §3.6 Properties with no standard home |
the author of ical and Home Assistant's calendar integrations (@allenporter) |
the nearest existing multi-backend model, and the recurrence reference | §1.1 Sync and async, §5 Capabilities, §3.7 Recurrence |
a caldav user with a large codebase on it |
A1: two item classes per mode and two ways to save, against caldav's one | §1.1 Sync and async, §2.3 Collection |
plann (the author, as its maintainer) |
the committed downstream consumer; roadmap 1.6 depends on §3.4 Task | §3.4 Task, §4 Search |
| a vdirsyncer/pimsync maintainer | §7 Change detection adopts their contract | §7 Change detection |
The author has reviewed it. Q11's licence offer and dependency direction are still open, but do not block 1.x.
Sashank (calendaring-sync) reviewed §7 Change detection
from a sync tool's side on 2026-10-11, in PR 5 and in
calendaring-sync PR 19.
What it changed: the JMAP etag, the full CalDAV listing, complete change
sets, SyncTokenError, the create clashes, no retried writes and the etag
after a write (§10, Q11).
Wanted, but not a priority, and
beyond the funded scope
(PR 5 video meeting).
The funded API keeps save() and reload() explicit (§10 Q13), and
anything here is built on save().
Write strategies. An edit marks the item dirty, and a strategy writes at once, gathers and flushes on request, or syncs in the background. The p5 prototype shows gather-and-flush working in both modes without duplicated I/O.
Knowing that an item changed. Implicit writes need this, as do
saving only an edited item and direct access to properties (Q13). The
simplest check needs nothing from icalendar: compare to_ical() with
the serialisation taken when the item was read, at the cost of one
extra serialisation per read. (Not with wire: to_ical() often
differs from the server's bytes, as it normalises folding, order and
quoting, and wire is None on occurrences and on backends with no
per-item text.) A tracked component subclass would mark the item
dirty on every setter instead, with an in-place change as its known gap.
icalendar issue 1922
offers another way: compare the component with a deep-copied snapshot
(was_modified_since()), which also catches in-place changes. A yes/no
answer is enough here; no diff is needed.
Transactions. The reviewer asked for edits spanning several calls,
or several items, to behave
like a transaction.
Gather-and-flush would hold writes until the flush, but no backend here
has a multi-object transaction (CalDAV writes one resource per request,
and JMAP /set applies and reports each object on its own, RFC 8620
§5.3). A flush that fails halfway would have to report which writes were
stored, as fan-out's MultiResult does (§2.1), rather
than promise atomicity.
Drafted with AI assistance (Claude Opus 5.5 via Claude Code) from the roadmap 0.1–roadmap 0.3
documents and the source of caldav, icalendar and icalendar-searcher as
checked out on 2026-10-08 and 09. Reviewed by the author (see Status).