adapters: Connecting to servers#

The adapter base classes, and the adapters that ship with calendaring-sync.

Each shipped adapter is a module of its own that needs the extra of the same name. Importing this package imports no protocol’s client library; import the adapter’s module, such as calendaring_sync.adapters.caldav, to use it.

Base classes#

The base classes every adapter is built on.

Adapter holds the order of a sync, written once for every protocol: open a session, read and ingest what changed, then push the pending edits. An adapter subclasses it, fills in how its protocol reads and sends, and adds SyncAdapter or AsyncAdapter for the public methods.

Both variants run the same steps. A step is a generator that yields what a client call returned, the answer itself from a synchronous client or a coroutine from an asynchronous one, or an InStore for a call into the store, and gets each result sent back. SyncAdapter runs the steps as they come; AsyncAdapter awaits the coroutines and runs the store calls as its description says.

class calendaring_sync.adapters.base.Adapter(replica: Replica, scope_id: str)[source]#

Bases: ABC

Syncs one scope with a Replica, for any protocol.

A sync opens a session, reads what changed on the server and ingests it, then pushes the pending edits one at a time: claimed, sent, settled. When the server refuses the scope’s sync token, the scope is reset and listed in full in a new session. An adapter subclasses it together with SyncAdapter or AsyncAdapter, which add the public methods.

A subclass sets these class attributes, which open each session:

  • _fingerprint_scheme: the name of the adapter’s fingerprint scheme.

  • _media_type: the media type of the scope’s content.

  • _guarded_by_version: True if a push applies only while the object still has the version the edit was based on.

  • _unique_logical_key: True if no two objects in the scope can share a logical key.

  • _guard_covers_scope: True if the server checks a push against the whole scope rather than the object, so after one rejection every later push would be rejected too, and they wait for the next sync.

It writes these methods for its protocol, each a generator of Steps except _waits(), _new_object_id() and _edit_fields():

These names start with an underscore because your application calls the adapter’s public methods: a read or a send called on its own would skip the session that orders them.

Parameters:
  • replica – Where the sync state is kept.

  • scope_id – The scope’s id.

abstractmethod _edit_fields(content: bytes, *, create: bool) → EditFields[source]#

Read an edit’s fields from its content, the same way a sync reads the server’s copy.

Parameters:
  • content – The edit’s content.

  • create – Whether the edit is a create, which may need more of the content, such as a logical key.

Raises:

ValueError – If the content can’t be an object of the scope.

abstractmethod _new_object_id() → str | None[source]#

Return an object id for a create, or None if the server picks it.

abstractmethod _read(session: Session, scope: Scope, *, full_listing: bool) → Generator[Any, Any, ReadResult][source]#

Fetch what changed in the scope, ingest it, and say how the read ended.

Without a token in scope.token, or with full_listing, list every object; otherwise ask for the changes since the token. Ingest each ChangeSet with yield InStore(session.ingest, (change_set,)).

Parameters:
  • session – The open session to ingest into.

  • scope – The scope as the store holds it.

  • full_listing – Whether the caller asked for a full listing.

Returns:

ReadResult.INVALID_TOKEN if the server refused the token, before anything was ingested; ReadResult.TRUNCATED if the server cut the changes short; ReadResult.COMPLETE otherwise.

abstractmethod _send(claimed: Record) → Generator[Any, Any, Applied | Rejected | Gone | LogicalKeyTaken | Unknown][source]#

Send one claimed edit and return how it ended.

An error raised here reaches the caller and leaves the push in flight, since the request may have reached the server; the next complete ingest settles it.

Parameters:

claimed – The record claim() returned, holding the edit to send.

abstractmethod _waits(record: Record) → bool[source]#

Whether a pending edit can’t be sent yet, so it waits for a later sync.

Parameters:

record – A record with a pending edit.

edit_fields(content: bytes) → EditFields[source]#

Read an object’s fields from its content, the same way a sync reads the server’s copy.

Use it for content your application made itself, such as a Merge. Reads nothing from the store or the server.

Raises:

ValueError – If the content can’t be an object of the scope, as for a create.

property needs_full_listings: bool#

Whether the server can lose changes without refusing the scope’s token.

If it’s True, an incremental sync may miss a change or a removal, so schedule a sync with full_listing=True now and then, such as once a day. False by default; an adapter whose server can lose changes says so.

property scope_id: str#

The scope this adapter syncs.

class calendaring_sync.adapters.base.AsyncAdapter(replica: Replica, scope_id: str)[source]#

Bases: Adapter

The public methods of an adapter for an asynchronous client.

The same as SyncAdapter’s, as coroutines. Each store call runs in a worker thread, so a long ingest doesn’t block the event loop.

change(local_key: int, content: bytes) → None[source]#

See change().

create(content: bytes) → int[source]#

See create().

sync(*, full_listing: bool = False) → None[source]#

See sync().

waiting() → list[Record][source]#

See waiting().

class calendaring_sync.adapters.base.InStore(call: ~collections.abc.Callable[[...], ~typing.Any], args: tuple[~typing.Any, ...] = (), kwargs: ~collections.abc.Mapping[str, ~typing.Any] = <factory>)[source]#

Bases: object

A call into the store, which an asynchronous adapter runs in a worker thread.

call#

The function to call, such as session.ingest.

Type:

collections.abc.Callable[[…], Any]

args#

Its positional arguments.

Type:

tuple[Any, …]

kwargs#

Its keyword arguments.

Type:

collections.abc.Mapping[str, Any]

Examples

A step that ingests a change set:

yield InStore(session.ingest, (change_set,))
run() → Any[source]#

Make the call and return what it returned.

class calendaring_sync.adapters.base.ReadResult(*values)[source]#

Bases: Enum

How the read part of a sync ended, which decides what the sync does next.

COMPLETE = 'complete'#

Every change was ingested, so the pushes go out.

INVALID_TOKEN = 'invalid token'#

The server refused the scope’s sync token, so the scope is reset and listed in full.

TRUNCATED = 'truncated'#

The server cut the changes short. The pushes wait, and the next sync continues from the checkpoint.

calendaring_sync.adapters.base.Steps#

The steps of a sync, as a generator that yields client answers or InStore calls, is sent back what each one returned, and returns its result.

alias of Generator[Any, Any, _T]

class calendaring_sync.adapters.base.SyncAdapter(replica: Replica, scope_id: str)[source]#

Bases: Adapter

The public methods of an adapter for a synchronous client.

Your application calls sync() when it decides to sync, and records its edits with create() and change(). Deletes and discards need nothing from the content, so they go to delete() and discard(). The adapter’s class says what content it takes and which of its client’s errors reach you; the next sync settles a push such an error interrupted. Every method also raises the store’s errors, as Replica describes.

change(local_key: int, content: bytes) → None[source]#

Record a change of an object to this content; the next sync() sends it.

Reads the record, then writes the change, each in a transaction of its own. A change to a create that wasn’t sent yet stays a create, so the content must pass what a create’s does.

Raises:
create(content: bytes) → int[source]#

Record a create of an object with this content, and return its local key.

The next sync() sends it. Runs in a write transaction.

Raises:
sync(*, full_listing: bool = False) → None[source]#

Sync the scope: ingest what changed on the server, then push your edits.

A scope with no token is listed in full. When the server refuses the token, the scope is reset and listed in full. Your edits are pushed only once everything the server reported was ingested. Each step runs in a store transaction of its own.

Parameters:

full_listing – List every object instead of asking for changes.

Raises:
waiting() → list[Record][source]#

Return the pending edits the next sync() won’t send, because it can’t yet.

Runs in a read transaction.

Raises:

CalDAV#

The CalDAV adapter, which syncs one calendar collection through python-caldav.

It needs the caldav extra, as Install shows. Adapters explains every rule it follows, and Server compatibility how each tested server behaves.

class calendaring_sync.adapters.caldav.AsyncCalDAVAdapter(replica: Replica, calendar: Calendar, *, scope_id: str | None = None)[source]#

CalDAVAdapter for a calendar on an asynchronous client.

Its methods are coroutines, as AsyncAdapter describes; everything else is as CalDAVAdapter describes.

Raises:
  • TypeError – If the calendar is on a synchronous client, or an argument has the wrong type.

  • ValueError – If the calendar has no URL or no client.

change(local_key: int, content: bytes) → None#

See change().

create(content: bytes) → int#

See create().

edit_fields(content: bytes) → EditFields#

Read an object’s fields from its content, the same way a sync reads the server’s copy.

Use it for content your application made itself, such as a Merge. Reads nothing from the store or the server.

Raises:

ValueError – If the content can’t be an object of the scope, as for a create.

property needs_full_listings: bool#

Whether the server may leave changes out without refusing the scope’s token.

Read from the server profile configured on the client, python-caldav’s sync-token.invalid-token feature; Adapter.needs_full_listings says what to do when it’s True.

property scope_id: str#

The scope this adapter syncs.

sync(*, full_listing: bool = False) → None#

See sync().

waiting() → list[Record]#

See waiting().

class calendaring_sync.adapters.caldav.CalDAVAdapter(replica: Replica, calendar: Calendar, *, scope_id: str | None = None)[source]#

Syncs one calendar collection through a synchronous python-caldav client.

The calendar is one scope. The content create() and change() take is iCalendar data in UTF-8, and a create’s must hold an object with a UID; anything else raises ValueError. A failure python-caldav reports, such as a refused login, reaches you as its caldav.lib.error.DAVError, unchanged.

Parameters:
  • replica – Where the sync state is kept.

  • calendar – The calendar to sync.

  • scope_id – The scope’s id. Defaults to the calendar’s URL, without a login and spelled one way, so calendars on two servers never share a scope and one calendar always gets the same one.

Raises:
  • TypeError – If the calendar is on an asynchronous client, or an argument has the wrong type.

  • ValueError – If the calendar has no URL or no client.

Examples

A sync, an edit, and the sync that pushes it:

adapter = CalDAVAdapter(replica, calendar)
adapter.sync()
key = adapter.create(ics_bytes)
adapter.sync()
change(local_key: int, content: bytes) → None#

Record a change of an object to this content; the next sync() sends it.

Reads the record, then writes the change, each in a transaction of its own. A change to a create that wasn’t sent yet stays a create, so the content must pass what a create’s does.

Raises:
create(content: bytes) → int#

Record a create of an object with this content, and return its local key.

The next sync() sends it. Runs in a write transaction.

Raises:
edit_fields(content: bytes) → EditFields#

Read an object’s fields from its content, the same way a sync reads the server’s copy.

Use it for content your application made itself, such as a Merge. Reads nothing from the store or the server.

Raises:

ValueError – If the content can’t be an object of the scope, as for a create.

property needs_full_listings: bool#

Whether the server may leave changes out without refusing the scope’s token.

Read from the server profile configured on the client, python-caldav’s sync-token.invalid-token feature; Adapter.needs_full_listings says what to do when it’s True.

property scope_id: str#

The scope this adapter syncs.

sync(*, full_listing: bool = False) → None#

Sync the scope: ingest what changed on the server, then push your edits.

A scope with no token is listed in full. When the server refuses the token, the scope is reset and listed in full. Your edits are pushed only once everything the server reported was ingested. Each step runs in a store transaction of its own.

Parameters:

full_listing – List every object instead of asking for changes.

Raises:
waiting() → list[Record]#

Return the pending edits the next sync() won’t send, because it can’t yet.

Runs in a read transaction.

Raises:
calendaring_sync.adapters.caldav.FINGERPRINT_SCHEME = 'caldav-canonical-1'#

The name of the canonical fingerprint this adapter computes. A change to how it’s computed gets a new name, so every scope is listed again.

calendaring_sync.adapters.caldav.MEDIA_TYPE = 'text/calendar'#

The media type of a CalDAV scope’s content, iCalendar data.

calendaring_sync.adapters.caldav.MULTIGET_SIZE = 100#

How many objects one calendar-multiget fetches, and one page of a full listing holds.

JMAP#

The JMAP adapter, which syncs one account’s calendar events through calendaring-jmap.

It needs the jmap extra, as Install shows. Adapters explains every rule it follows, and Server compatibility how each tested server behaves.

class calendaring_sync.adapters.jmap.AsyncJMAPAdapter(replica: Replica, client: AsyncJMAPClient, *, account_id: str, scope_id: str | None = None)[source]#

JMAPAdapter for an asynchronous client.

Its methods are coroutines, as AsyncAdapter describes; everything else is as JMAPAdapter describes.

Raises:

TypeError – If an argument has the wrong type, such as a synchronous client.

change(local_key: int, content: bytes) → None#

See change().

create(content: bytes) → int#

See create().

edit_fields(content: bytes) → EditFields#

Read an object’s fields from its content, the same way a sync reads the server’s copy.

Use it for content your application made itself, such as a Merge. Reads nothing from the store or the server.

Raises:

ValueError – If the content can’t be an object of the scope, as for a create.

property needs_full_listings: bool#

Whether the server can lose changes without refusing the scope’s token.

If it’s True, an incremental sync may miss a change or a removal, so schedule a sync with full_listing=True now and then, such as once a day. False by default; an adapter whose server can lose changes says so.

property scope_id: str#

The scope this adapter syncs.

sync(*, full_listing: bool = False) → None#

See sync().

waiting() → list[Record]#

See waiting().

calendaring_sync.adapters.jmap.FINGERPRINT_SCHEME = 'jmap-canonical-1'#

The name of the canonical fingerprint this adapter computes. A change to how it’s computed gets a new name, so every scope is listed again.

class calendaring_sync.adapters.jmap.JMAPAdapter(replica: Replica, client: JMAPClient, *, account_id: str, scope_id: str | None = None)[source]#

Syncs one account’s calendar events through a synchronous calendaring-jmap client.

The account’s events are one scope. The content create() and change() take is a JSCalendar event as UTF-8 JSON. A create’s must have a uid and at least one calendar in calendarIds, and so must a change to a create not sent yet; anything else raises ValueError. The server picks a created event’s id, and waiting() always returns an empty list. A failure calendaring-jmap reports, such as a refused login, reaches you as its JMAPError, unchanged.

Parameters:
  • replica – Where the sync state is kept.

  • client – The server’s client.

  • account_id – The account whose events to sync, such as the one the client’s get_account_id() returns, or a shared account’s id.

  • scope_id – The scope’s id. Defaults to the client’s session URL, without a login and spelled one way, then #, the account id and /CalendarEvent, so accounts on two servers never share a scope.

Raises:

TypeError – If an argument has the wrong type, such as an asynchronous client.

Examples

A sync, an edit, and the sync that pushes it:

adapter = JMAPAdapter(replica, client, account_id=client.get_account_id())
adapter.sync()
key = adapter.create(event_json_bytes)
adapter.sync()
change(local_key: int, content: bytes) → None#

Record a change of an object to this content; the next sync() sends it.

Reads the record, then writes the change, each in a transaction of its own. A change to a create that wasn’t sent yet stays a create, so the content must pass what a create’s does.

Raises:
create(content: bytes) → int#

Record a create of an object with this content, and return its local key.

The next sync() sends it. Runs in a write transaction.

Raises:
edit_fields(content: bytes) → EditFields#

Read an object’s fields from its content, the same way a sync reads the server’s copy.

Use it for content your application made itself, such as a Merge. Reads nothing from the store or the server.

Raises:

ValueError – If the content can’t be an object of the scope, as for a create.

property needs_full_listings: bool#

Whether the server can lose changes without refusing the scope’s token.

If it’s True, an incremental sync may miss a change or a removal, so schedule a sync with full_listing=True now and then, such as once a day. False by default; an adapter whose server can lose changes says so.

property scope_id: str#

The scope this adapter syncs.

sync(*, full_listing: bool = False) → None#

Sync the scope: ingest what changed on the server, then push your edits.

A scope with no token is listed in full. When the server refuses the token, the scope is reset and listed in full. Your edits are pushed only once everything the server reported was ingested. Each step runs in a store transaction of its own.

Parameters:

full_listing – List every object instead of asking for changes.

Raises:
waiting() → list[Record]#

Return the pending edits the next sync() won’t send, because it can’t yet.

Runs in a read transaction.

Raises:
calendaring_sync.adapters.jmap.MEDIA_TYPE = 'application/jscalendar+json'#

The media type of a JMAP scope’s content, JSCalendar (RFC 8984).

calendaring_sync.adapters.jmap.PAGE_SIZE = 100#

How many events one /changes page reports, and one page of a full listing holds. Below every tested server’s maxObjectsInGet.

Last updated on 2026-10-27.