ditto

Asyncio-native Python SDK for Ditto's v5 API.

  1"""Asyncio-native Python SDK for Ditto's v5 API."""
  2
  3from ._version import __version__
  4from .attachment import (
  5    Attachment,
  6    AttachmentFetchCompleted,
  7    AttachmentFetchDeleted,
  8    AttachmentFetcher,
  9    AttachmentFetchEvent,
 10    AttachmentFetchProgress,
 11    AttachmentToken,
 12)
 13from .auth import (
 14    AuthenticationProvider,
 15    AuthenticationStatus,
 16    AuthenticationStatusObserver,
 17    Authenticator,
 18)
 19from .config import (
 20    DittoConfig,
 21    DittoConfigConnect,
 22    DittoConfigConnection,
 23    DittoConfigConnectServer,
 24    DittoConfigConnectSmallPeersOnly,
 25    DittoSystemParameters,
 26    DittoSystemParameterValue,
 27)
 28from .differ import Diff, Differ
 29from .disk_usage import (
 30    DiskUsage,
 31    DiskUsageComponent,
 32    DiskUsageItem,
 33    DiskUsageObserver,
 34    FileType,
 35)
 36from .ditto import Ditto
 37
 38# Deliberately not star-imported: the FFI plumbing errors (DittoFfiError,
 39# DittoFfiLibraryNotFoundError, DittoFfiSymbolMissingError) stay out of the
 40# top-level surface and remain importable from ditto.errors.
 41from .errors import (
 42    DittoAlreadyExistsIOError,
 43    DittoAttachmentError,
 44    DittoAttachmentFileNotFoundError,
 45    DittoAttachmentFilePermissionDeniedError,
 46    DittoAttachmentNotFoundError,
 47    DittoAttachmentTokenInvalidError,
 48    DittoAuthenticationError,
 49    DittoClosedError,
 50    DittoCrdtError,
 51    DittoDepthLimitExceededValidationError,
 52    DittoEncryptionError,
 53    DittoEncryptionKeyInvalidError,
 54    DittoEncryptionKeyRequiredError,
 55    DittoEncryptionKeyUnexpectedError,
 56    DittoEncryptionMetadataCorruptError,
 57    DittoEncryptionUnsupportedSchemeError,
 58    DittoError,
 59    DittoExpirationHandlerMissingError,
 60    DittoFailedToCreateAttachmentError,
 61    DittoFailedToFetchAttachmentError,
 62    DittoInvalidCborError,
 63    DittoInvalidConfigError,
 64    DittoInvalidJsonError,
 65    DittoInvalidTransportConfigError,
 66    DittoIOError,
 67    DittoNotADictionaryValidationError,
 68    DittoNotFoundIOError,
 69    DittoNotJsonCompatibleError,
 70    DittoOperationFailedIOError,
 71    DittoPermissionDeniedIOError,
 72    DittoPersistenceDirectoryLockedError,
 73    DittoQueryArgumentsInvalidError,
 74    DittoQueryError,
 75    DittoQueryInvalidError,
 76    DittoQueryNotSupportedError,
 77    DittoSizeLimitExceededValidationError,
 78    DittoStoreBackendError,
 79    DittoStoreDocumentNotFoundError,
 80    DittoStoreError,
 81    DittoTransactionReadOnlyError,
 82    DittoTransportsInitializationError,
 83    DittoUnsupportedError,
 84    DittoValidationError,
 85)
 86from .logger import DittoLogger, LogLevel
 87from .presence import (
 88    Connection,
 89    ConnectionRequest,
 90    ConnectionRequestAuthorization,
 91    ConnectionType,
 92    Peer,
 93    PeerOperatingSystem,
 94    Presence,
 95    PresenceGraph,
 96    PresenceObserver,
 97)
 98from .query_result import DocumentId, QueryResult, QueryResultItem
 99from .small_peer_info import SmallPeerInfo
100from .store import Store, StoreObserver
101from .sync import Sync, SyncSubscription
102from .transaction import Transaction, TransactionCompletionAction, TransactionInfo
103from .transport_config import (
104    AwdlConfig,
105    BluetoothLEConfig,
106    DittoConnect,
107    DittoGlobal,
108    DittoHttpListenConfig,
109    DittoLanConfig,
110    DittoListen,
111    DittoPeerToPeer,
112    DittoTcpListenConfig,
113    DittoTransportConfig,
114    MulticastBetaConfig,
115    TransportConfig,
116    WifiAwareConfig,
117)
118
119__all__ = [
120    "Attachment",
121    "AttachmentFetchCompleted",
122    "AttachmentFetchDeleted",
123    "AttachmentFetchEvent",
124    "AttachmentFetchProgress",
125    "AttachmentFetcher",
126    "AttachmentToken",
127    "AuthenticationProvider",
128    "AuthenticationStatus",
129    "AuthenticationStatusObserver",
130    "Authenticator",
131    "AwdlConfig",
132    "BluetoothLEConfig",
133    "Connection",
134    "ConnectionRequest",
135    "ConnectionRequestAuthorization",
136    "ConnectionType",
137    "Diff",
138    "Differ",
139    "DiskUsage",
140    "DiskUsageComponent",
141    "DiskUsageItem",
142    "DiskUsageObserver",
143    "Ditto",
144    "DittoAlreadyExistsIOError",
145    "DittoAttachmentError",
146    "DittoAttachmentFileNotFoundError",
147    "DittoAttachmentFilePermissionDeniedError",
148    "DittoAttachmentNotFoundError",
149    "DittoAttachmentTokenInvalidError",
150    "DittoAuthenticationError",
151    "DittoClosedError",
152    "DittoConfig",
153    "DittoConfigConnect",
154    "DittoConfigConnectServer",
155    "DittoConfigConnectSmallPeersOnly",
156    "DittoConfigConnection",
157    "DittoConnect",
158    "DittoCrdtError",
159    "DittoDepthLimitExceededValidationError",
160    "DittoEncryptionError",
161    "DittoEncryptionKeyInvalidError",
162    "DittoEncryptionKeyRequiredError",
163    "DittoEncryptionKeyUnexpectedError",
164    "DittoEncryptionMetadataCorruptError",
165    "DittoEncryptionUnsupportedSchemeError",
166    "DittoError",
167    "DittoExpirationHandlerMissingError",
168    "DittoFailedToCreateAttachmentError",
169    "DittoFailedToFetchAttachmentError",
170    "DittoGlobal",
171    "DittoHttpListenConfig",
172    "DittoIOError",
173    "DittoInvalidCborError",
174    "DittoInvalidConfigError",
175    "DittoInvalidJsonError",
176    "DittoInvalidTransportConfigError",
177    "DittoLanConfig",
178    "DittoListen",
179    "DittoLogger",
180    "DittoNotADictionaryValidationError",
181    "DittoNotFoundIOError",
182    "DittoNotJsonCompatibleError",
183    "DittoOperationFailedIOError",
184    "DittoPeerToPeer",
185    "DittoPermissionDeniedIOError",
186    "DittoPersistenceDirectoryLockedError",
187    "DittoQueryArgumentsInvalidError",
188    "DittoQueryError",
189    "DittoQueryInvalidError",
190    "DittoQueryNotSupportedError",
191    "DittoSizeLimitExceededValidationError",
192    "DittoStoreBackendError",
193    "DittoStoreDocumentNotFoundError",
194    "DittoStoreError",
195    "DittoSystemParameterValue",
196    "DittoSystemParameters",
197    "DittoTcpListenConfig",
198    "DittoTransactionReadOnlyError",
199    "DittoTransportConfig",
200    "DittoTransportsInitializationError",
201    "DittoUnsupportedError",
202    "DittoValidationError",
203    "DocumentId",
204    "FileType",
205    "LogLevel",
206    "MulticastBetaConfig",
207    "Peer",
208    "PeerOperatingSystem",
209    "Presence",
210    "PresenceGraph",
211    "PresenceObserver",
212    "QueryResult",
213    "QueryResultItem",
214    "SmallPeerInfo",
215    "Store",
216    "StoreObserver",
217    "Sync",
218    "SyncSubscription",
219    "Transaction",
220    "TransactionCompletionAction",
221    "TransactionInfo",
222    "TransportConfig",
223    "WifiAwareConfig",
224    "__version__",
225]
class Attachment:
109class Attachment:
110    """A reference to a binary attachment backed by native storage.
111
112    Call :meth:`close` when done with it, or use it as a context manager.
113    The garbage-collector finalizer frees the underlying handle as a
114    backstop only — do not rely on it for prompt cleanup.
115    """
116
117    def __init__(
118        self,
119        ditto: Ditto,
120        handle: Any,
121        id_bytes: bytes,
122        length: int,
123        metadata: Mapping[str, str] | None,
124    ) -> None:
125        self._ditto = weakref.ref(ditto)
126        self._handle = handle
127        self._closed = False
128        self._path: str | None = None
129        self._lock = ProcessLocalRLock()
130        self._finalizer = weakref.finalize(self, _free_attachment, handle)
131        self._token = AttachmentToken(bytes(id_bytes), int(length), dict(metadata or {}))
132
133    @property
134    def id(self) -> str:
135        """The attachment's id, as a URL-safe base64 string."""
136
137        return self._token.id
138
139    @property
140    def length(self) -> int:
141        """The attachment's size in bytes."""
142
143        return self._token.length
144
145    @property
146    def metadata(self) -> dict[str, str]:
147        """A copy of the attachment's string-keyed metadata."""
148
149        return dict(self._token.metadata)
150
151    def _native(self) -> tuple[Ditto, Any]:
152        owner = self._ditto()
153        if self._closed or not self._handle or owner is None or owner.closed:
154            raise DittoClosedError("Attachment is closed")
155        return owner, self._handle
156
157    def _get_path(self) -> str:
158        with self._lock:
159            if self._path is None:
160                owner, handle = self._native()
161                self._path = consume_string(
162                    get_ffi().ditto_get_complete_attachment_path(owner._native_handle, handle)
163                )
164                if self._path is None:
165                    raise DittoAttachmentError("Attachment data is not locally available")
166            return self._path
167
168    async def data(self) -> bytes:
169        """Read the complete attachment into memory, off the event loop thread.
170
171        For large attachments, prefer :meth:`open_stream` to avoid buffering
172        the whole file.
173
174        Raises :class:`~ditto.errors.DittoClosedError` if the attachment or its Ditto instance has
175        been closed.
176        """
177
178        return await run_blocking(Path(self._get_path()).read_bytes)
179
180    async def copy_to_path(self, destination_path: str | Path) -> None:
181        """Copy the attachment's locally available data to a new file.
182
183        Uses exclusive creation, so ``destination_path`` must not already
184        exist — this raises ``FileExistsError`` if it does.
185        ``destination_path`` must not contain ``..`` path segments; a value
186        that does raises DittoValidationError instead of being copied.
187
188        Raises :class:`~ditto.errors.DittoClosedError` if the attachment or its Ditto instance has
189        been closed.
190        """
191
192        source = self._get_path()
193        destination = Path(destination_path)
194        # Destinations often come from document data; refuse traversal
195        # segments so a hostile value cannot climb out of the caller's
196        # intended directory.
197        if ".." in destination.parts:
198            raise DittoValidationError(
199                "copy_to_path destination must not contain '..' path segments"
200            )
201
202        def copy() -> None:
203            with Path(source).open("rb") as source_file, destination.open("xb") as destination_file:
204                shutil.copyfileobj(source_file, destination_file)
205
206        await run_blocking(copy)
207
208    def open_stream(self) -> IO[bytes]:
209        """Open the locally available attachment data for reading.
210
211        The caller owns the returned file object and must close it.
212        """
213
214        return open(self._get_path(), "rb")
215
216    def _cbor_representation(self) -> dict[str, Any]:
217        return {
218            "_ditto_internal_type_jkb12973t4b": 2,
219            "_id": self._token.id_bytes,
220            "_len": self._token.length,
221            "_meta": self._token.metadata,
222        }
223
224    def close(self) -> None:
225        """Release the native attachment handle. Safe to call more than once."""
226
227        with self._lock:
228            if self._closed:
229                return
230            self._closed = True
231            self._finalizer.detach()
232            handle = self._handle
233            self._handle = None
234        if handle:
235            get_ffi().ditto_free_attachment_handle(handle)
236
237    def __enter__(self) -> Attachment:
238        """Context manager: closes the attachment on exit."""
239
240        return self
241
242    def __exit__(self, *_: object) -> None:
243        self.close()

A reference to a binary attachment backed by native storage.

Call close() when done with it, or use it as a context manager. The garbage-collector finalizer frees the underlying handle as a backstop only — do not rely on it for prompt cleanup.

Attachment( ditto: Ditto, handle: Any, id_bytes: bytes, length: int, metadata: Mapping[str, str] | None)
117    def __init__(
118        self,
119        ditto: Ditto,
120        handle: Any,
121        id_bytes: bytes,
122        length: int,
123        metadata: Mapping[str, str] | None,
124    ) -> None:
125        self._ditto = weakref.ref(ditto)
126        self._handle = handle
127        self._closed = False
128        self._path: str | None = None
129        self._lock = ProcessLocalRLock()
130        self._finalizer = weakref.finalize(self, _free_attachment, handle)
131        self._token = AttachmentToken(bytes(id_bytes), int(length), dict(metadata or {}))
id: str
133    @property
134    def id(self) -> str:
135        """The attachment's id, as a URL-safe base64 string."""
136
137        return self._token.id

The attachment's id, as a URL-safe base64 string.

length: int
139    @property
140    def length(self) -> int:
141        """The attachment's size in bytes."""
142
143        return self._token.length

The attachment's size in bytes.

metadata: dict[str, str]
145    @property
146    def metadata(self) -> dict[str, str]:
147        """A copy of the attachment's string-keyed metadata."""
148
149        return dict(self._token.metadata)

A copy of the attachment's string-keyed metadata.

async def data(self) -> bytes:
168    async def data(self) -> bytes:
169        """Read the complete attachment into memory, off the event loop thread.
170
171        For large attachments, prefer :meth:`open_stream` to avoid buffering
172        the whole file.
173
174        Raises :class:`~ditto.errors.DittoClosedError` if the attachment or its Ditto instance has
175        been closed.
176        """
177
178        return await run_blocking(Path(self._get_path()).read_bytes)

Read the complete attachment into memory, off the event loop thread.

For large attachments, prefer open_stream() to avoid buffering the whole file.

Raises ~ditto.errors.DittoClosedError if the attachment or its Ditto instance has been closed.

async def copy_to_path(self, destination_path: str | pathlib.Path) -> None:
180    async def copy_to_path(self, destination_path: str | Path) -> None:
181        """Copy the attachment's locally available data to a new file.
182
183        Uses exclusive creation, so ``destination_path`` must not already
184        exist — this raises ``FileExistsError`` if it does.
185        ``destination_path`` must not contain ``..`` path segments; a value
186        that does raises DittoValidationError instead of being copied.
187
188        Raises :class:`~ditto.errors.DittoClosedError` if the attachment or its Ditto instance has
189        been closed.
190        """
191
192        source = self._get_path()
193        destination = Path(destination_path)
194        # Destinations often come from document data; refuse traversal
195        # segments so a hostile value cannot climb out of the caller's
196        # intended directory.
197        if ".." in destination.parts:
198            raise DittoValidationError(
199                "copy_to_path destination must not contain '..' path segments"
200            )
201
202        def copy() -> None:
203            with Path(source).open("rb") as source_file, destination.open("xb") as destination_file:
204                shutil.copyfileobj(source_file, destination_file)
205
206        await run_blocking(copy)

Copy the attachment's locally available data to a new file.

Uses exclusive creation, so destination_path must not already exist — this raises FileExistsError if it does. destination_path must not contain .. path segments; a value that does raises DittoValidationError instead of being copied.

Raises ~ditto.errors.DittoClosedError if the attachment or its Ditto instance has been closed.

def open_stream(self) -> IO[bytes]:
208    def open_stream(self) -> IO[bytes]:
209        """Open the locally available attachment data for reading.
210
211        The caller owns the returned file object and must close it.
212        """
213
214        return open(self._get_path(), "rb")

Open the locally available attachment data for reading.

The caller owns the returned file object and must close it.

def close(self) -> None:
224    def close(self) -> None:
225        """Release the native attachment handle. Safe to call more than once."""
226
227        with self._lock:
228            if self._closed:
229                return
230            self._closed = True
231            self._finalizer.detach()
232            handle = self._handle
233            self._handle = None
234        if handle:
235            get_ffi().ditto_free_attachment_handle(handle)

Release the native attachment handle. Safe to call more than once.

@dataclass(frozen=True, slots=True)
class AttachmentFetchCompleted(ditto.AttachmentFetchEvent):
254@dataclass(frozen=True, slots=True)
255class AttachmentFetchCompleted(AttachmentFetchEvent):
256    """The fetch finished; ``attachment`` is now available locally.
257
258    At most one ``Completed`` or ``Deleted`` event is delivered per fetch,
259    after any number of :class:`AttachmentFetchProgress` events.
260    """
261
262    attachment: Attachment

The fetch finished; attachment is now available locally.

At most one Completed or Deleted event is delivered per fetch, after any number of AttachmentFetchProgress events.

AttachmentFetchCompleted(attachment: Attachment)
attachment: Attachment
@dataclass(frozen=True, slots=True)
class AttachmentFetchDeleted(ditto.AttachmentFetchEvent):
273@dataclass(frozen=True, slots=True)
274class AttachmentFetchDeleted(AttachmentFetchEvent):
275    """The source attachment was deleted upstream before the fetch completed.
276
277    At most one ``Completed`` or ``Deleted`` event is delivered per fetch.
278    """

The source attachment was deleted upstream before the fetch completed.

At most one Completed or Deleted event is delivered per fetch.

class AttachmentFetchEvent:
246class AttachmentFetchEvent:
247    """Base class for events delivered to an :class:`AttachmentFetcher` handler."""
248
249    Completed: type[AttachmentFetchCompleted]
250    Progress: type[AttachmentFetchProgress]
251    Deleted: type[AttachmentFetchDeleted]

Base class for events delivered to an AttachmentFetcher handler.

Completed: type[AttachmentFetchCompleted] = <class 'AttachmentFetchCompleted'>
Progress: type[AttachmentFetchProgress] = <class 'AttachmentFetchProgress'>
Deleted: type[AttachmentFetchDeleted] = <class 'AttachmentFetchDeleted'>
@dataclass(frozen=True, slots=True)
class AttachmentFetchProgress(ditto.AttachmentFetchEvent):
265@dataclass(frozen=True, slots=True)
266class AttachmentFetchProgress(AttachmentFetchEvent):
267    """Reports fetch progress. May be delivered any number of times."""
268
269    downloaded_bytes: int
270    total_bytes: int

Reports fetch progress. May be delivered any number of times.

AttachmentFetchProgress(downloaded_bytes: int, total_bytes: int)
downloaded_bytes: int
total_bytes: int
class AttachmentFetcher:
439class AttachmentFetcher:
440    """Tracks an in-progress attachment fetch and delivers its events.
441
442    The owning :class:`~ditto.store.Store` retains this fetcher internally
443    until a :class:`AttachmentFetchCompleted` or :class:`AttachmentFetchDeleted`
444    event fires, or until :meth:`stop` is called — the caller does not need to
445    hold a reference to keep the fetch alive.
446    """
447
448    def __init__(
449        self,
450        ditto: Ditto,
451        token: Mapping[str, Any],
452        on_event: Callable[[AttachmentFetchEvent], Any],
453    ) -> None:
454        if not callable(on_event):
455            raise TypeError("on_event must be callable")
456        self._ditto = weakref.ref(ditto)
457        self._token = AttachmentToken.from_mapping(token)
458        self._closed = False
459        self._cancel_token = 0
460        self._finish_lock = ProcessLocalRLock()
461        self._loop = asyncio.get_running_loop()
462        store = ditto.store
463        payload = _FetchPayload(self._loop, on_event, self._ditto, self._token)
464        self._registry_key = registry.register(payload)
465        try:
466            payload.fetcher = weakref.ref(self)
467            store._attachment_fetchers.add(self)
468            borrowed_id = borrowed_bytes(self._token.id_bytes)
469            result = get_ffi().ditto_resolve_attachment(
470                ditto._native_handle,
471                borrowed_id.slice,
472                self._registry_key,
473                _RETAIN,
474                _RELEASE,
475                _COMPLETED,
476                _PROGRESS,
477                _DELETED,
478            )
479        except BaseException:
480            self._closed = True
481            store._attachment_fetchers.discard(self)
482            registry.release(self._registry_key)
483            raise
484        if result.status_code != 0:
485            self._closed = True
486            store._attachment_fetchers.discard(self)
487            registry.release(self._registry_key)
488            message = legacy_error_message()
489            if result.status_code == 2:
490                raise DittoAttachmentTokenInvalidError(message)
491            if result.status_code == 3:
492                raise DittoAttachmentNotFoundError(message)
493            raise DittoAttachmentError(message)
494        self._cancel_token = int(result.cancel_token)
495
496    @property
497    def ditto(self) -> Ditto | None:
498        """The owning :class:`~ditto.ditto.Ditto` instance, or ``None`` if garbage collected."""
499
500        return self._ditto()
501
502    @property
503    def closed(self) -> bool:
504        """Whether the fetch has finished, been cancelled, or had its resources released."""
505
506        return self._closed
507
508    def stop(self) -> None:
509        """Cancel an in-flight fetch.
510
511        Safe to call again after the fetch has already completed or the
512        attachment was deleted upstream. ``cancel`` and ``close`` are
513        aliases for this method.
514        """
515
516        self._finish(cancel_native=True)
517
518    def _finish(self, *, cancel_native: bool) -> None:
519        with self._finish_lock:
520            if self._closed:
521                return
522            self._closed = True
523            cancel_token, self._cancel_token = self._cancel_token, 0
524
525        owner = self._ditto()
526        if owner is not None:
527            owner.store._attachment_fetchers.discard(self)
528        try:
529            if owner is not None and cancel_native and not owner.closed and cancel_token:
530                borrowed_id = borrowed_bytes(self._token.id_bytes)
531                status = get_ffi().ditto_cancel_resolve_attachment(
532                    owner._native_handle, borrowed_id.slice, cancel_token
533                )
534                if status not in (0, 3):
535                    _log.warning("Failed to cancel attachment fetch: %s", legacy_error_message())
536        except Exception:
537            # Cancellation is best-effort during concurrent Ditto shutdown. The
538            # Python callback ownership still has to be released exactly once.
539            _log.warning("Failed to cancel attachment fetch during cleanup", exc_info=True)
540        finally:
541            payload = registry.get(self._registry_key)
542            if isinstance(payload, _FetchPayload):
543                with payload.lock:
544                    payload.references -= 1
545                    remove = payload.references <= 0
546                if remove:
547                    registry.release(self._registry_key)
548
549    cancel = stop
550    close = stop
551
552    def __enter__(self) -> AttachmentFetcher:
553        return self
554
555    def __exit__(self, *_: object) -> None:
556        self.stop()

Tracks an in-progress attachment fetch and delivers its events.

The owning ~ditto.store.Store retains this fetcher internally until a AttachmentFetchCompleted or AttachmentFetchDeleted event fires, or until stop() is called — the caller does not need to hold a reference to keep the fetch alive.

AttachmentFetcher( ditto: Ditto, token: Mapping[str, typing.Any], on_event: Callable[[AttachmentFetchEvent], typing.Any])
448    def __init__(
449        self,
450        ditto: Ditto,
451        token: Mapping[str, Any],
452        on_event: Callable[[AttachmentFetchEvent], Any],
453    ) -> None:
454        if not callable(on_event):
455            raise TypeError("on_event must be callable")
456        self._ditto = weakref.ref(ditto)
457        self._token = AttachmentToken.from_mapping(token)
458        self._closed = False
459        self._cancel_token = 0
460        self._finish_lock = ProcessLocalRLock()
461        self._loop = asyncio.get_running_loop()
462        store = ditto.store
463        payload = _FetchPayload(self._loop, on_event, self._ditto, self._token)
464        self._registry_key = registry.register(payload)
465        try:
466            payload.fetcher = weakref.ref(self)
467            store._attachment_fetchers.add(self)
468            borrowed_id = borrowed_bytes(self._token.id_bytes)
469            result = get_ffi().ditto_resolve_attachment(
470                ditto._native_handle,
471                borrowed_id.slice,
472                self._registry_key,
473                _RETAIN,
474                _RELEASE,
475                _COMPLETED,
476                _PROGRESS,
477                _DELETED,
478            )
479        except BaseException:
480            self._closed = True
481            store._attachment_fetchers.discard(self)
482            registry.release(self._registry_key)
483            raise
484        if result.status_code != 0:
485            self._closed = True
486            store._attachment_fetchers.discard(self)
487            registry.release(self._registry_key)
488            message = legacy_error_message()
489            if result.status_code == 2:
490                raise DittoAttachmentTokenInvalidError(message)
491            if result.status_code == 3:
492                raise DittoAttachmentNotFoundError(message)
493            raise DittoAttachmentError(message)
494        self._cancel_token = int(result.cancel_token)
ditto: Ditto | None
496    @property
497    def ditto(self) -> Ditto | None:
498        """The owning :class:`~ditto.ditto.Ditto` instance, or ``None`` if garbage collected."""
499
500        return self._ditto()

The owning ~ditto.ditto.Ditto instance, or None if garbage collected.

closed: bool
502    @property
503    def closed(self) -> bool:
504        """Whether the fetch has finished, been cancelled, or had its resources released."""
505
506        return self._closed

Whether the fetch has finished, been cancelled, or had its resources released.

def stop(self) -> None:
508    def stop(self) -> None:
509        """Cancel an in-flight fetch.
510
511        Safe to call again after the fetch has already completed or the
512        attachment was deleted upstream. ``cancel`` and ``close`` are
513        aliases for this method.
514        """
515
516        self._finish(cancel_native=True)

Cancel an in-flight fetch.

Safe to call again after the fetch has already completed or the attachment was deleted upstream. cancel and close are aliases for this method.

def cancel(self) -> None:
508    def stop(self) -> None:
509        """Cancel an in-flight fetch.
510
511        Safe to call again after the fetch has already completed or the
512        attachment was deleted upstream. ``cancel`` and ``close`` are
513        aliases for this method.
514        """
515
516        self._finish(cancel_native=True)

Cancel an in-flight fetch.

Safe to call again after the fetch has already completed or the attachment was deleted upstream. cancel and close are aliases for this method.

def close(self) -> None:
508    def stop(self) -> None:
509        """Cancel an in-flight fetch.
510
511        Safe to call again after the fetch has already completed or the
512        attachment was deleted upstream. ``cancel`` and ``close`` are
513        aliases for this method.
514        """
515
516        self._finish(cancel_native=True)

Cancel an in-flight fetch.

Safe to call again after the fetch has already completed or the attachment was deleted upstream. cancel and close are aliases for this method.

@dataclass(frozen=True, slots=True, eq=False)
class AttachmentToken:
 49@dataclass(frozen=True, slots=True, eq=False)
 50class AttachmentToken:
 51    """The wire representation of an attachment reference.
 52
 53    Mirrors the dict shape produced by native code and returned in a query
 54    result item's document data: ``id`` (URL-safe, unpadded base64), ``len``,
 55    and ``metadata``. :meth:`from_mapping` raises DittoValidationError for
 56    any malformed field.
 57    """
 58
 59    id_bytes: bytes
 60    length: int
 61    metadata: dict[str, str]
 62
 63    @property
 64    def id(self) -> str:
 65        """The token's id, as a URL-safe, unpadded base64 string of ``id_bytes``."""
 66
 67        return base64.urlsafe_b64encode(self.id_bytes).decode("ascii").rstrip("=")
 68
 69    def __eq__(self, other: object) -> bool:
 70        return isinstance(other, AttachmentToken) and self.id_bytes == other.id_bytes
 71
 72    def __hash__(self) -> int:
 73        return hash(self.id_bytes)
 74
 75    @classmethod
 76    def from_mapping(cls, value: Mapping[str, Any]) -> AttachmentToken:
 77        """Build a token from a document's ``id``/``len``/``metadata`` mapping.
 78
 79        Raises :class:`~ditto.errors.DittoValidationError` for any malformed field.
 80        """
 81
 82        try:
 83            encoded_id = value["id"]
 84        except KeyError as error:
 85            raise DittoValidationError("Expected an `id` key as string") from error
 86        if not isinstance(encoded_id, str):
 87            raise DittoValidationError("Expected an `id` key as string")
 88        try:
 89            padding = "=" * (-len(encoded_id) % 4)
 90            id_bytes = base64.b64decode(encoded_id + padding, altchars=b"-_", validate=True)
 91        except (ValueError, TypeError) as error:
 92            raise DittoValidationError("Failed to decode base64 string id") from error
 93        try:
 94            length = int(value["len"])
 95        except (KeyError, TypeError, ValueError, OverflowError) as error:
 96            raise DittoValidationError("Expected a `len` key as a non-negative integer") from error
 97        if length < 0:
 98            raise DittoValidationError("Expected a `len` key as a non-negative integer")
 99        metadata = value.get("metadata")
100        if not isinstance(metadata, Mapping):
101            raise DittoValidationError("Expected a `metadata` key as a dictionary")
102        if not all(
103            isinstance(key, str) and isinstance(item, str) for key, item in metadata.items()
104        ):
105            raise DittoValidationError("Attachment metadata keys and values must be strings")
106        return cls(id_bytes, length, dict(metadata))

The wire representation of an attachment reference.

Mirrors the dict shape produced by native code and returned in a query result item's document data: id (URL-safe, unpadded base64), len, and metadata. from_mapping() raises DittoValidationError for any malformed field.

AttachmentToken(id_bytes: bytes, length: int, metadata: dict[str, str])
id_bytes: bytes
length: int
metadata: dict[str, str]
id: str
63    @property
64    def id(self) -> str:
65        """The token's id, as a URL-safe, unpadded base64 string of ``id_bytes``."""
66
67        return base64.urlsafe_b64encode(self.id_bytes).decode("ascii").rstrip("=")

The token's id, as a URL-safe, unpadded base64 string of id_bytes.

@classmethod
def from_mapping(cls, value: Mapping[str, typing.Any]) -> AttachmentToken:
 75    @classmethod
 76    def from_mapping(cls, value: Mapping[str, Any]) -> AttachmentToken:
 77        """Build a token from a document's ``id``/``len``/``metadata`` mapping.
 78
 79        Raises :class:`~ditto.errors.DittoValidationError` for any malformed field.
 80        """
 81
 82        try:
 83            encoded_id = value["id"]
 84        except KeyError as error:
 85            raise DittoValidationError("Expected an `id` key as string") from error
 86        if not isinstance(encoded_id, str):
 87            raise DittoValidationError("Expected an `id` key as string")
 88        try:
 89            padding = "=" * (-len(encoded_id) % 4)
 90            id_bytes = base64.b64decode(encoded_id + padding, altchars=b"-_", validate=True)
 91        except (ValueError, TypeError) as error:
 92            raise DittoValidationError("Failed to decode base64 string id") from error
 93        try:
 94            length = int(value["len"])
 95        except (KeyError, TypeError, ValueError, OverflowError) as error:
 96            raise DittoValidationError("Expected a `len` key as a non-negative integer") from error
 97        if length < 0:
 98            raise DittoValidationError("Expected a `len` key as a non-negative integer")
 99        metadata = value.get("metadata")
100        if not isinstance(metadata, Mapping):
101            raise DittoValidationError("Expected a `metadata` key as a dictionary")
102        if not all(
103            isinstance(key, str) and isinstance(item, str) for key, item in metadata.items()
104        ):
105            raise DittoValidationError("Attachment metadata keys and values must be strings")
106        return cls(id_bytes, length, dict(metadata))

Build a token from a document's id/len/metadata mapping.

Raises ~ditto.errors.DittoValidationError for any malformed field.

@dataclass(frozen=True, slots=True)
class AuthenticationProvider:
29@dataclass(frozen=True, slots=True)
30class AuthenticationProvider:
31    """Identifies which login provider a token should be validated against.
32
33    Passed to :meth:`Authenticator.login`.
34    """
35
36    raw_value: str
37    """The provider identifier.
38
39    Must be non-empty, non-whitespace, and free of control characters — it
40    is passed to native login as a C string.
41    """
42
43    DEVELOPMENT: ClassVar[AuthenticationProvider]
44
45    def __post_init__(self) -> None:
46        if not isinstance(self.raw_value, str) or not self.raw_value.strip():
47            raise ValueError("Authentication provider cannot be empty or whitespace")
48        # The provider flows into native login as a C string; refuse control
49        # characters (including CR/LF) that could smuggle into logs or
50        # protocol frames downstream.
51        if any(ord(char) < 0x20 or ord(char) == 0x7F for char in self.raw_value):
52            raise ValueError("Authentication provider cannot contain control characters")
53
54    @classmethod
55    def custom(cls, raw_value: str) -> AuthenticationProvider:
56        """Create a provider identified by ``raw_value``."""
57
58        return cls(raw_value)
59
60    def __str__(self) -> str:
61        return self.raw_value

Identifies which login provider a token should be validated against.

Passed to Authenticator.login().

AuthenticationProvider(raw_value: str)
raw_value: str

The provider identifier.

Must be non-empty, non-whitespace, and free of control characters — it is passed to native login as a C string.

DEVELOPMENT: ClassVar[AuthenticationProvider] = AuthenticationProvider(raw_value='__playgroundProvider')
@classmethod
def custom(cls, raw_value: str) -> AuthenticationProvider:
54    @classmethod
55    def custom(cls, raw_value: str) -> AuthenticationProvider:
56        """Create a provider identified by ``raw_value``."""
57
58        return cls(raw_value)

Create a provider identified by raw_value.

@dataclass(frozen=True, slots=True)
class AuthenticationStatus:
67@dataclass(frozen=True, slots=True)
68class AuthenticationStatus:
69    """A point-in-time snapshot of this Ditto instance's authentication state."""
70
71    is_authenticated: bool
72    user_id: str | None
73    """Identifier from the auth webhook when authenticated, else ``None``."""

A point-in-time snapshot of this Ditto instance's authentication state.

AuthenticationStatus(is_authenticated: bool, user_id: str | None)
is_authenticated: bool
user_id: str | None

Identifier from the auth webhook when authenticated, else None.

class AuthenticationStatusObserver:
126class AuthenticationStatusObserver:
127    """A cancellable subscription created by :meth:`Authenticator.observe_status`."""
128
129    def __init__(
130        self, authenticator: Authenticator, handler: Callable[[AuthenticationStatus], Any]
131    ) -> None:
132        self._authenticator = weakref.ref(authenticator)
133        self._handler = handler
134        self._closed = False
135
136    def close(self) -> None:
137        """Cancel the subscription. Safe to call more than once."""
138
139        if self._closed:
140            return
141        self._closed = True
142        authenticator = self._authenticator()
143        if authenticator is not None:
144            authenticator._status_observers.discard(self)
145
146    cancel = close
147
148    def __enter__(self) -> AuthenticationStatusObserver:
149        return self
150
151    def __exit__(self, *_: object) -> None:
152        self.close()

A cancellable subscription created by Authenticator.observe_status().

AuthenticationStatusObserver( authenticator: Authenticator, handler: Callable[[AuthenticationStatus], typing.Any])
129    def __init__(
130        self, authenticator: Authenticator, handler: Callable[[AuthenticationStatus], Any]
131    ) -> None:
132        self._authenticator = weakref.ref(authenticator)
133        self._handler = handler
134        self._closed = False
def close(self) -> None:
136    def close(self) -> None:
137        """Cancel the subscription. Safe to call more than once."""
138
139        if self._closed:
140            return
141        self._closed = True
142        authenticator = self._authenticator()
143        if authenticator is not None:
144            authenticator._status_observers.discard(self)

Cancel the subscription. Safe to call more than once.

def cancel(self) -> None:
136    def close(self) -> None:
137        """Cancel the subscription. Safe to call more than once."""
138
139        if self._closed:
140            return
141        self._closed = True
142        authenticator = self._authenticator()
143        if authenticator is not None:
144            authenticator._status_observers.discard(self)

Cancel the subscription. Safe to call more than once.

class Authenticator:
155class Authenticator:
156    """Manages login, logout, and authentication-status/expiration notifications."""
157
158    def __init__(self, ditto: Ditto, loop: asyncio.AbstractEventLoop) -> None:
159        self._ditto = weakref.ref(ditto)
160        self._loop = loop
161        self._lock = ProcessLocalRLock()
162        self._expiration_handler: Callable[[Ditto, timedelta], Any] | None = None
163        self._expiration_observer: Any | None = None
164        self._expiration_registry_key: int | None = None
165        self._status_observers: set[AuthenticationStatusObserver] = set()
166        self._status = self._read_status()
167        self._status_registry_key: int | None = None
168        self._install_status_handler()
169
170    def _owner(self) -> Ditto:
171        owner = self._ditto()
172        if owner is None or owner.closed:
173            raise DittoClosedError("Authenticator's Ditto instance is closed")
174        return owner
175
176    def _read_status(self) -> AuthenticationStatus:
177        owner = self._ditto()
178        if owner is None:
179            return AuthenticationStatus(False, None)
180        ffi = get_ffi()
181        return AuthenticationStatus(
182            bool(ffi.ditto_auth_client_is_web_valid(owner._native_handle)),
183            consume_string(ffi.ditto_auth_client_user_id(owner._native_handle), ffi),
184        )
185
186    def _install_status_handler(self) -> None:
187        from ._ffi._bindings import (
188            BoxDynFnMut1_void_dittoffi_authentication_status_ptr_t,
189        )
190
191        key = registry.register(_NativeCallbackPayload(weakref.ref(self), "status"))
192        call, free = _callbacks(BoxDynFnMut1_void_dittoffi_authentication_status_ptr_t)
193        closure = BoxDynFnMut1_void_dittoffi_authentication_status_ptr_t(key, call, free)
194        self._status_registry_key = key
195        get_ffi().dittoffi_ditto_set_authentication_status_handler(
196            self._owner()._native_handle, closure
197        )
198
199    @property
200    def status(self) -> AuthenticationStatus:
201        """The most recently observed :class:`AuthenticationStatus`."""
202
203        with self._lock:
204            return self._status
205
206    @property
207    def expiration_handler(self) -> Callable[[Ditto, timedelta], Any] | None:
208        """A callback invoked shortly before auth expires, or if not yet authenticated.
209
210        Called with the owning :class:`Ditto` instance and the time
211        remaining until expiration.
212
213        Required in server connect mode: :meth:`Sync.start` raises
214        ``DittoExpirationHandlerMissingError`` if this is unset when sync
215        starts. The handler may be a synchronous or async callable and is
216        dispatched on this :class:`Authenticator`'s event loop (the loop
217        running when its owning :class:`Ditto` instance was opened).
218        """
219
220        with self._lock:
221            return self._expiration_handler
222
223    @expiration_handler.setter
224    def expiration_handler(self, handler: Callable[[Ditto, timedelta], Any] | None) -> None:
225        if handler is not None and not callable(handler):
226            raise TypeError("expiration_handler must be callable or None")
227        with self._lock:
228            self._cancel_expiration_observer()
229            self._expiration_handler = handler
230            if handler is not None:
231                self._register_expiration_observer()
232
233    def _register_expiration_observer(self) -> None:
234        from ._ffi._bindings import BoxDynFnMut1_void_uint32_t
235
236        key = registry.register(_NativeCallbackPayload(weakref.ref(self), "expiration"))
237        call, free = _callbacks(BoxDynFnMut1_void_uint32_t)
238        closure = BoxDynFnMut1_void_uint32_t(key, call, free)
239        try:
240            result = get_ffi().dittoffi_auth_login_provider_register_observer_throws(
241                self._owner()._native_handle, closure
242            )
243            self._expiration_observer = check_result(result)
244            self._expiration_registry_key = key
245        except BaseException:
246            registry.release(key)
247            raise
248
249    def _cancel_expiration_observer(self, ditto_handle: Any | None = None) -> None:
250        observer = self._expiration_observer
251        key = self._expiration_registry_key
252        handle = ditto_handle
253        if observer and handle is None:
254            owner = self._ditto()
255            if owner is not None and not owner.closed:
256                try:
257                    handle = owner._native_handle
258                except DittoClosedError:
259                    handle = None
260            if handle is None:
261                # Ditto.close() marks the peer closing before it acquires this
262                # lock. Leave ownership intact so its coordinator can retry
263                # with the raw handle it already detached from the peer.
264                return
265        self._expiration_observer = None
266        self._expiration_registry_key = None
267        try:
268            if observer:
269                ffi = get_ffi()
270                try:
271                    ffi.dittoffi_auth_login_provider_observer_cancel(handle, observer)
272                finally:
273                    ffi.dittoffi_auth_login_provider_observer_free(observer)
274        finally:
275            if key is not None:
276                registry.release(key)
277
278    def _expiration_requested(self, seconds: int) -> None:
279        handler = self.expiration_handler
280        owner = self._ditto()
281        if handler is not None and owner is not None:
282            dispatch_handler(self._loop, handler, owner, timedelta(seconds=seconds))
283
284    def _authentication_status_updated(self, status: AuthenticationStatus) -> None:
285        with self._lock:
286            if status == self._status:
287                return
288            self._status = status
289            observers = tuple(self._status_observers)
290        for observer in observers:
291            if not observer._closed:
292                dispatch_handler(self._loop, observer._handler, status)
293
294    async def login(self, token: str, provider: AuthenticationProvider | str) -> str | None:
295        """Log in with a token issued by the given authentication provider.
296
297        Returns:
298            The webhook's client-info JSON string, if any was provided.
299
300        Raises:
301            DittoAuthenticationError: login failed. Its ``client_info_json``
302                attribute carries the webhook's ``clientInfo`` payload, if
303                the webhook provided one.
304        """
305
306        owner = self._owner()
307        raw_provider = (
308            provider.raw_value if isinstance(provider, AuthenticationProvider) else provider
309        )
310
311        def invoke() -> str | None:
312            result = get_ffi().ditto_auth_client_login_with_token_and_feedback(
313                owner._native_handle, utf8(token), utf8(raw_provider)
314            )
315            client_info = consume_string(result.c_string)
316            if result.status_code != 0:
317                raise DittoAuthenticationError(
318                    "Failed to authenticate with Login.", client_info_json=client_info
319                )
320            return client_info
321
322        return await run_blocking(invoke)
323
324    def observe_status(
325        self, handler: Callable[[AuthenticationStatus], Any]
326    ) -> AuthenticationStatusObserver:
327        """Call ``handler`` with the new :class:`AuthenticationStatus` on every change.
328
329        Returns an :class:`AuthenticationStatusObserver`; close it to unsubscribe.
330        """
331
332        if not callable(handler):
333            raise TypeError("observe_status requires a callable handler")
334        observer = AuthenticationStatusObserver(self, handler)
335        self._status_observers.add(observer)
336        return observer
337
338    def logout(self, cleanup: Callable[[Ditto], Any] | None = None) -> None:
339        """Log out: purge cached credentials and stop sync.
340
341        Does not remove data already synced into the local store. If given,
342        ``cleanup`` is invoked with the owning :class:`Ditto` instance after
343        credentials are purged and sync is stopped.
344        """
345
346        owner = self._owner()
347        get_ffi().ditto_auth_client_logout(owner._native_handle)
348        owner.sync.stop()
349        if cleanup is not None:
350            cleanup(owner)
351
352    def close(self) -> None:
353        """Release native observers and the expiration handler. Safe to call more than once."""
354
355        self._close_with_native_handle(None)
356
357    def _close_with_native_handle(self, ditto_handle: Any | None) -> None:
358        with self._lock:
359            self._cancel_expiration_observer(ditto_handle)
360            self._expiration_handler = None
361            for observer in tuple(self._status_observers):
362                observer.close()

Manages login, logout, and authentication-status/expiration notifications.

Authenticator(ditto: Ditto, loop: asyncio.events.AbstractEventLoop)
158    def __init__(self, ditto: Ditto, loop: asyncio.AbstractEventLoop) -> None:
159        self._ditto = weakref.ref(ditto)
160        self._loop = loop
161        self._lock = ProcessLocalRLock()
162        self._expiration_handler: Callable[[Ditto, timedelta], Any] | None = None
163        self._expiration_observer: Any | None = None
164        self._expiration_registry_key: int | None = None
165        self._status_observers: set[AuthenticationStatusObserver] = set()
166        self._status = self._read_status()
167        self._status_registry_key: int | None = None
168        self._install_status_handler()
status: AuthenticationStatus
199    @property
200    def status(self) -> AuthenticationStatus:
201        """The most recently observed :class:`AuthenticationStatus`."""
202
203        with self._lock:
204            return self._status

The most recently observed AuthenticationStatus.

expiration_handler: Callable[[Ditto, datetime.timedelta], Any] | None
206    @property
207    def expiration_handler(self) -> Callable[[Ditto, timedelta], Any] | None:
208        """A callback invoked shortly before auth expires, or if not yet authenticated.
209
210        Called with the owning :class:`Ditto` instance and the time
211        remaining until expiration.
212
213        Required in server connect mode: :meth:`Sync.start` raises
214        ``DittoExpirationHandlerMissingError`` if this is unset when sync
215        starts. The handler may be a synchronous or async callable and is
216        dispatched on this :class:`Authenticator`'s event loop (the loop
217        running when its owning :class:`Ditto` instance was opened).
218        """
219
220        with self._lock:
221            return self._expiration_handler

A callback invoked shortly before auth expires, or if not yet authenticated.

Called with the owning Ditto instance and the time remaining until expiration.

Required in server connect mode: Sync.start() raises DittoExpirationHandlerMissingError if this is unset when sync starts. The handler may be a synchronous or async callable and is dispatched on this Authenticator's event loop (the loop running when its owning Ditto instance was opened).

async def login( self, token: str, provider: AuthenticationProvider | str) -> str | None:
294    async def login(self, token: str, provider: AuthenticationProvider | str) -> str | None:
295        """Log in with a token issued by the given authentication provider.
296
297        Returns:
298            The webhook's client-info JSON string, if any was provided.
299
300        Raises:
301            DittoAuthenticationError: login failed. Its ``client_info_json``
302                attribute carries the webhook's ``clientInfo`` payload, if
303                the webhook provided one.
304        """
305
306        owner = self._owner()
307        raw_provider = (
308            provider.raw_value if isinstance(provider, AuthenticationProvider) else provider
309        )
310
311        def invoke() -> str | None:
312            result = get_ffi().ditto_auth_client_login_with_token_and_feedback(
313                owner._native_handle, utf8(token), utf8(raw_provider)
314            )
315            client_info = consume_string(result.c_string)
316            if result.status_code != 0:
317                raise DittoAuthenticationError(
318                    "Failed to authenticate with Login.", client_info_json=client_info
319                )
320            return client_info
321
322        return await run_blocking(invoke)

Log in with a token issued by the given authentication provider.

Returns:

The webhook's client-info JSON string, if any was provided.

Raises:
  • DittoAuthenticationError: login failed. Its client_info_json attribute carries the webhook's clientInfo payload, if the webhook provided one.
def observe_status( self, handler: Callable[[AuthenticationStatus], typing.Any]) -> AuthenticationStatusObserver:
324    def observe_status(
325        self, handler: Callable[[AuthenticationStatus], Any]
326    ) -> AuthenticationStatusObserver:
327        """Call ``handler`` with the new :class:`AuthenticationStatus` on every change.
328
329        Returns an :class:`AuthenticationStatusObserver`; close it to unsubscribe.
330        """
331
332        if not callable(handler):
333            raise TypeError("observe_status requires a callable handler")
334        observer = AuthenticationStatusObserver(self, handler)
335        self._status_observers.add(observer)
336        return observer

Call handler with the new AuthenticationStatus on every change.

Returns an AuthenticationStatusObserver; close it to unsubscribe.

def logout(self, cleanup: Callable[[Ditto], Any] | None = None) -> None:
338    def logout(self, cleanup: Callable[[Ditto], Any] | None = None) -> None:
339        """Log out: purge cached credentials and stop sync.
340
341        Does not remove data already synced into the local store. If given,
342        ``cleanup`` is invoked with the owning :class:`Ditto` instance after
343        credentials are purged and sync is stopped.
344        """
345
346        owner = self._owner()
347        get_ffi().ditto_auth_client_logout(owner._native_handle)
348        owner.sync.stop()
349        if cleanup is not None:
350            cleanup(owner)

Log out: purge cached credentials and stop sync.

Does not remove data already synced into the local store. If given, cleanup is invoked with the owning Ditto instance after credentials are purged and sync is stopped.

def close(self) -> None:
352    def close(self) -> None:
353        """Release native observers and the expiration handler. Safe to call more than once."""
354
355        self._close_with_native_handle(None)

Release native observers and the expiration handler. Safe to call more than once.

@dataclass(slots=True)
class AwdlConfig:
 94@dataclass(slots=True)
 95class AwdlConfig:
 96    """Apple Wireless Direct Link transport settings.
 97
 98    Not supported on all platforms; enabling this has no effect where
 99    AWDL is unavailable.
100    """
101
102    enabled: bool = False
103
104    def to_cbor_object(self) -> dict[str, Any]:
105        """Encode this config as a CBOR-compatible mapping."""
106
107        return {"enabled": self.enabled}
108
109    @classmethod
110    def from_cbor_object(cls, value: Mapping[str, Any]) -> AwdlConfig:
111        """Build an :class:`AwdlConfig` from a decoded CBOR mapping."""
112
113        return cls(enabled=_bool(value.get("enabled", False), "awdl.enabled"))

Apple Wireless Direct Link transport settings.

Not supported on all platforms; enabling this has no effect where AWDL is unavailable.

AwdlConfig(enabled: bool = False)
enabled: bool
def to_cbor_object(self) -> dict[str, typing.Any]:
104    def to_cbor_object(self) -> dict[str, Any]:
105        """Encode this config as a CBOR-compatible mapping."""
106
107        return {"enabled": self.enabled}

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> AwdlConfig:
109    @classmethod
110    def from_cbor_object(cls, value: Mapping[str, Any]) -> AwdlConfig:
111        """Build an :class:`AwdlConfig` from a decoded CBOR mapping."""
112
113        return cls(enabled=_bool(value.get("enabled", False), "awdl.enabled"))

Build an AwdlConfig from a decoded CBOR mapping.

@dataclass(slots=True)
class BluetoothLEConfig:
116@dataclass(slots=True)
117class BluetoothLEConfig:
118    """Bluetooth Low Energy transport settings."""
119
120    enabled: bool = False
121
122    def to_cbor_object(self) -> dict[str, Any]:
123        """Encode this config as a CBOR-compatible mapping."""
124
125        return {"enabled": self.enabled}
126
127    @classmethod
128    def from_cbor_object(cls, value: Mapping[str, Any]) -> BluetoothLEConfig:
129        """Build a :class:`BluetoothLEConfig` from a decoded CBOR mapping."""
130
131        return cls(enabled=_bool(value.get("enabled", False), "bluetooth_le.enabled"))

Bluetooth Low Energy transport settings.

BluetoothLEConfig(enabled: bool = False)
enabled: bool
def to_cbor_object(self) -> dict[str, typing.Any]:
122    def to_cbor_object(self) -> dict[str, Any]:
123        """Encode this config as a CBOR-compatible mapping."""
124
125        return {"enabled": self.enabled}

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> BluetoothLEConfig:
127    @classmethod
128    def from_cbor_object(cls, value: Mapping[str, Any]) -> BluetoothLEConfig:
129        """Build a :class:`BluetoothLEConfig` from a decoded CBOR mapping."""
130
131        return cls(enabled=_bool(value.get("enabled", False), "bluetooth_le.enabled"))

Build a BluetoothLEConfig from a decoded CBOR mapping.

@dataclass(frozen=True, slots=True)
class Connection:
105@dataclass(frozen=True, slots=True)
106class Connection:
107    """One mesh connection between two peers."""
108
109    id: str
110    connection_type: ConnectionType
111    peer_key1: str
112    peer_key2: str
113
114    @classmethod
115    def from_mapping(cls, value: Mapping[str, Any]) -> Connection:
116        """Build a :class:`Connection` from a decoded presence-graph mapping."""
117
118        return cls(
119            id=str(value.get("id", "")),
120            connection_type=ConnectionType(str(value.get("connectionType"))),
121            peer_key1=str(value.get("peerKeyString1", "")),
122            peer_key2=str(value.get("peerKeyString2", "")),
123        )

One mesh connection between two peers.

Connection( id: str, connection_type: ConnectionType, peer_key1: str, peer_key2: str)
id: str
connection_type: ConnectionType
peer_key1: str
peer_key2: str
@classmethod
def from_mapping(cls, value: Mapping[str, typing.Any]) -> Connection:
114    @classmethod
115    def from_mapping(cls, value: Mapping[str, Any]) -> Connection:
116        """Build a :class:`Connection` from a decoded presence-graph mapping."""
117
118        return cls(
119            id=str(value.get("id", "")),
120            connection_type=ConnectionType(str(value.get("connectionType"))),
121            peer_key1=str(value.get("peerKeyString1", "")),
122            peer_key2=str(value.get("peerKeyString2", "")),
123        )

Build a Connection from a decoded presence-graph mapping.

class ConnectionRequest:
225class ConnectionRequest:
226    """An owned incoming connection request passed to the user handler.
227
228    If this request is closed, garbage-collected, or otherwise dropped
229    without an explicit authorization decision, the native layer treats it
230    as denied by default.
231    """
232
233    def __init__(self, handle: Any) -> None:
234        self._handle = handle
235        self._closed = False
236        self._lock = ProcessLocalRLock()
237        free = process_bound_cleanup(get_ffi().dittoffi_connection_request_free)
238        self._finalizer = weakref.finalize(self, _finalize, free, handle)
239
240    def _native(self) -> Any:
241        with self._lock:
242            if self._closed or not self._handle:
243                raise DittoClosedError("ConnectionRequest is closed")
244            return self._handle
245
246    @property
247    def peer_key(self) -> str:
248        """The connecting peer's unique key. Empty for a peer on an older SDK version."""
249
250        with self._lock:
251            ffi = get_ffi()
252            return (
253                consume_string(ffi.dittoffi_connection_request_peer_key_string(self._native()), ffi)
254                or ""
255            )
256
257    @staticmethod
258    def _borrowed_json(value: Any) -> str:
259        if not value.ptr or not value.len:
260            return "{}"
261        return ctypes.string_at(value.ptr, value.len).decode("utf-8")
262
263    @property
264    def peer_metadata_json_string(self) -> str:
265        """The connecting peer's declared metadata, as a raw JSON object string."""
266
267        with self._lock:
268            return self._borrowed_json(
269                get_ffi().dittoffi_connection_request_peer_metadata_json(self._native())
270            )
271
272    @property
273    def peer_metadata(self) -> dict[str, Any]:
274        """The connecting peer's declared metadata, parsed from JSON."""
275
276        value = json.loads(self.peer_metadata_json_string)
277        return dict(value) if isinstance(value, Mapping) else {}
278
279    @property
280    def identity_service_metadata_json_string(self) -> str:
281        """Identity-service metadata for the connecting peer, as a raw JSON object string.
282
283        Declared by the identity service that processed the peer's login.
284        """
285
286        with self._lock:
287            return self._borrowed_json(
288                get_ffi().dittoffi_connection_request_identity_service_metadata_json(self._native())
289            )
290
291    @property
292    def identity_service_metadata(self) -> dict[str, Any]:
293        """Identity-service metadata for the connecting peer, parsed from JSON."""
294
295        value = json.loads(self.identity_service_metadata_json_string)
296        return dict(value) if isinstance(value, Mapping) else {}
297
298    @property
299    def connection_type(self) -> ConnectionType:
300        """The transport carrying this connection request."""
301
302        with self._lock:
303            return ConnectionType.from_ffi(
304                get_ffi().dittoffi_connection_request_connection_type(self._native())
305            )
306
307    def _respond(self, authorization: ConnectionRequestAuthorization) -> None:
308        with self._lock:
309            if self._closed:
310                return
311            self._closed = True
312            self._finalizer.detach()
313            handle, self._handle = self._handle, None
314        ffi = get_ffi()
315        try:
316            ffi.dittoffi_connection_request_authorize(handle, int(authorization))
317        finally:
318            ffi.dittoffi_connection_request_free(handle)
319
320    def close(self) -> None:
321        """Deny this request, if not already authorized or denied. Safe to call more than once."""
322
323        self._respond(ConnectionRequestAuthorization.DENY)

An owned incoming connection request passed to the user handler.

If this request is closed, garbage-collected, or otherwise dropped without an explicit authorization decision, the native layer treats it as denied by default.

ConnectionRequest(handle: Any)
233    def __init__(self, handle: Any) -> None:
234        self._handle = handle
235        self._closed = False
236        self._lock = ProcessLocalRLock()
237        free = process_bound_cleanup(get_ffi().dittoffi_connection_request_free)
238        self._finalizer = weakref.finalize(self, _finalize, free, handle)
peer_key: str
246    @property
247    def peer_key(self) -> str:
248        """The connecting peer's unique key. Empty for a peer on an older SDK version."""
249
250        with self._lock:
251            ffi = get_ffi()
252            return (
253                consume_string(ffi.dittoffi_connection_request_peer_key_string(self._native()), ffi)
254                or ""
255            )

The connecting peer's unique key. Empty for a peer on an older SDK version.

peer_metadata_json_string: str
263    @property
264    def peer_metadata_json_string(self) -> str:
265        """The connecting peer's declared metadata, as a raw JSON object string."""
266
267        with self._lock:
268            return self._borrowed_json(
269                get_ffi().dittoffi_connection_request_peer_metadata_json(self._native())
270            )

The connecting peer's declared metadata, as a raw JSON object string.

peer_metadata: dict[str, typing.Any]
272    @property
273    def peer_metadata(self) -> dict[str, Any]:
274        """The connecting peer's declared metadata, parsed from JSON."""
275
276        value = json.loads(self.peer_metadata_json_string)
277        return dict(value) if isinstance(value, Mapping) else {}

The connecting peer's declared metadata, parsed from JSON.

identity_service_metadata_json_string: str
279    @property
280    def identity_service_metadata_json_string(self) -> str:
281        """Identity-service metadata for the connecting peer, as a raw JSON object string.
282
283        Declared by the identity service that processed the peer's login.
284        """
285
286        with self._lock:
287            return self._borrowed_json(
288                get_ffi().dittoffi_connection_request_identity_service_metadata_json(self._native())
289            )

Identity-service metadata for the connecting peer, as a raw JSON object string.

Declared by the identity service that processed the peer's login.

identity_service_metadata: dict[str, typing.Any]
291    @property
292    def identity_service_metadata(self) -> dict[str, Any]:
293        """Identity-service metadata for the connecting peer, parsed from JSON."""
294
295        value = json.loads(self.identity_service_metadata_json_string)
296        return dict(value) if isinstance(value, Mapping) else {}

Identity-service metadata for the connecting peer, parsed from JSON.

connection_type: ConnectionType
298    @property
299    def connection_type(self) -> ConnectionType:
300        """The transport carrying this connection request."""
301
302        with self._lock:
303            return ConnectionType.from_ffi(
304                get_ffi().dittoffi_connection_request_connection_type(self._native())
305            )

The transport carrying this connection request.

def close(self) -> None:
320    def close(self) -> None:
321        """Deny this request, if not already authorized or denied. Safe to call more than once."""
322
323        self._respond(ConnectionRequestAuthorization.DENY)

Deny this request, if not already authorized or denied. Safe to call more than once.

class ConnectionRequestAuthorization(enum.IntEnum):
218class ConnectionRequestAuthorization(IntEnum):
219    """The local peer's decision on an incoming :class:`ConnectionRequest`."""
220
221    DENY = 0
222    ALLOW = 1

The local peer's decision on an incoming ConnectionRequest.

class ConnectionType(builtins.str, enum.Enum):
47class ConnectionType(str, Enum):
48    """The transport type carrying a presence connection.
49
50    Values mirror the core connection-type enum.
51    """
52
53    BLUETOOTH = "Bluetooth"
54    ACCESS_POINT = "AccessPoint"
55    P2P_WIFI = "P2PWiFi"
56    WEB_SOCKET = "WebSocket"
57    # Represents a reliable UDP multicast connection (in beta). This transport
58    # should only be enabled in coordination with Ditto support.
59    MULTICAST = "Multicast"
60
61    @classmethod
62    def from_ffi(cls, value: int) -> ConnectionType:
63        """Map a native connection-type integer to a :class:`ConnectionType`."""
64
65        try:
66            return (
67                cls.BLUETOOTH,
68                cls.ACCESS_POINT,
69                cls.P2P_WIFI,
70                cls.WEB_SOCKET,
71                cls.MULTICAST,
72            )[int(value)]
73        except (IndexError, TypeError) as error:
74            raise DittoError(f"Unknown connection type: {value!r}") from error

The transport type carrying a presence connection.

Values mirror the core connection-type enum.

BLUETOOTH = <ConnectionType.BLUETOOTH: 'Bluetooth'>
ACCESS_POINT = <ConnectionType.ACCESS_POINT: 'AccessPoint'>
P2P_WIFI = <ConnectionType.P2P_WIFI: 'P2PWiFi'>
WEB_SOCKET = <ConnectionType.WEB_SOCKET: 'WebSocket'>
MULTICAST = <ConnectionType.MULTICAST: 'Multicast'>
@classmethod
def from_ffi(cls, value: int) -> ConnectionType:
61    @classmethod
62    def from_ffi(cls, value: int) -> ConnectionType:
63        """Map a native connection-type integer to a :class:`ConnectionType`."""
64
65        try:
66            return (
67                cls.BLUETOOTH,
68                cls.ACCESS_POINT,
69                cls.P2P_WIFI,
70                cls.WEB_SOCKET,
71                cls.MULTICAST,
72            )[int(value)]
73        except (IndexError, TypeError) as error:
74            raise DittoError(f"Unknown connection type: {value!r}") from error

Map a native connection-type integer to a ConnectionType.

@dataclass(frozen=True, slots=True)
class Diff:
21@dataclass(frozen=True, slots=True)
22class Diff:
23    """The result of comparing two successive lists of query-result items.
24
25    ``insertions`` and ``updates`` are sets of indexes into the new array
26    of items; ``deletions`` is a set of indexes into the old array.
27    ``moves`` is a tuple of ``(from_index, to_index)`` pairs — each
28    ``from_index`` is the item's index in the old array and each
29    ``to_index`` is its index in the new array.
30    """
31
32    insertions: frozenset[int]
33    deletions: frozenset[int]
34    updates: frozenset[int]
35    moves: tuple[tuple[int, int], ...]
36
37    @classmethod
38    def from_mapping(cls, value: dict[str, Any]) -> Diff:
39        """Build a :class:`Diff` from a decoded CBOR mapping."""
40
41        return cls(
42            frozenset(value.get("insertions", ())),
43            frozenset(value.get("deletions", ())),
44            frozenset(value.get("updates", ())),
45            tuple(tuple(move) for move in value.get("moves", ())),
46        )

The result of comparing two successive lists of query-result items.

insertions and updates are sets of indexes into the new array of items; deletions is a set of indexes into the old array. moves is a tuple of (from_index, to_index) pairs — each from_index is the item's index in the old array and each to_index is its index in the new array.

Diff( insertions: frozenset[int], deletions: frozenset[int], updates: frozenset[int], moves: tuple[tuple[int, int], ...])
insertions: frozenset[int]
deletions: frozenset[int]
updates: frozenset[int]
moves: tuple[tuple[int, int], ...]
@classmethod
def from_mapping(cls, value: dict[str, typing.Any]) -> Diff:
37    @classmethod
38    def from_mapping(cls, value: dict[str, Any]) -> Diff:
39        """Build a :class:`Diff` from a decoded CBOR mapping."""
40
41        return cls(
42            frozenset(value.get("insertions", ())),
43            frozenset(value.get("deletions", ())),
44            frozenset(value.get("updates", ())),
45            tuple(tuple(move) for move in value.get("moves", ())),
46        )

Build a Diff from a decoded CBOR mapping.

class Differ:
 56class Differ:
 57    """Calculates diffs between successive lists of query-result items.
 58
 59    Commonly used alongside a store observer to diff each newly delivered
 60    query result against the previous one. The differ starts with no
 61    items, so the first call to :meth:`diff` always reports every item
 62    as an insertion.
 63
 64    Item identity defaults to the ``_id`` field. Pass
 65    ``identity_key_paths`` to identify items by other fields instead —
 66    unlike the other Ditto SDKs, Python exposes this as a public
 67    constructor parameter.
 68
 69    After :meth:`close`, :meth:`diff` and :attr:`identity_key_paths`
 70    raise :class:`~ditto.errors.DittoClosedError`.
 71    """
 72
 73    def __init__(self, identity_key_paths: Iterable[str] | None = None) -> None:
 74        self._closed = False
 75        ffi = get_ffi()
 76        if identity_key_paths is None:
 77            self._handle = ffi.dittoffi_differ_new()
 78        else:
 79            from ._ffi._bindings import slice_ref_char_const_ptr_t
 80            from ._internal.errors import check_result
 81
 82            values = tuple(utf8(path) for path in identity_key_paths)
 83            pointers = (ctypes.c_char_p * len(values))(*values)
 84            refs = slice_ref_char_const_ptr_t(pointers, len(values))
 85            result = ffi.dittoffi_differ_new_with_identity_key_paths_throws(refs)
 86            self._handle = check_result(result)
 87        self._free_native = process_bound_cleanup(ffi.dittoffi_differ_free)
 88        self._finalizer = weakref.finalize(self, _finalize_differ, self._free_native, self._handle)
 89
 90    def _native(self) -> Any:
 91        if self._closed or not self._handle:
 92            raise DittoClosedError("Differ is closed")
 93        return self._handle
 94
 95    @property
 96    def identity_key_paths(self) -> tuple[str, ...]:
 97        """The key paths used to identify items across diffs, in order."""
 98
 99        ffi = get_ffi()
100        handle = self._native()
101        count = int(ffi.dittoffi_differ_identity_key_path_count(handle))
102        return tuple(
103            consume_string(ffi.dittoffi_differ_identity_key_path_at(handle, index)) or ""
104            for index in range(count)
105        )
106
107    def diff(self, items: Iterable[QueryResultItem]) -> Diff:
108        """Compute the diff of ``items`` against the previously provided items."""
109        from ._ffi._bindings import (
110            dittoffi_query_result_item_t,
111            slice_ref_dittoffi_query_result_item_ptr_t,
112        )
113
114        handle = self._native()
115        values = tuple(items)
116        pointer_type = ctypes.POINTER(dittoffi_query_result_item_t)
117        pointers = (pointer_type * len(values))(*(item._ffi_handle for item in values))
118        refs = slice_ref_dittoffi_query_result_item_ptr_t(pointers, len(values))
119        encoded = consume_boxed_bytes(get_ffi().dittoffi_differ_diff(handle, refs))
120        return Diff.from_mapping(cbor2.loads(encoded))
121
122    def close(self) -> None:
123        """Release the native differ handle. Safe to call more than once."""
124
125        if self._closed:
126            return
127        self._closed = True
128        self._finalizer.detach()
129        self._free_native(self._handle)
130        self._handle = None
131
132    def __enter__(self) -> Differ:
133        return self
134
135    def __exit__(self, *_: object) -> None:
136        self.close()

Calculates diffs between successive lists of query-result items.

Commonly used alongside a store observer to diff each newly delivered query result against the previous one. The differ starts with no items, so the first call to diff() always reports every item as an insertion.

Item identity defaults to the _id field. Pass identity_key_paths to identify items by other fields instead — unlike the other Ditto SDKs, Python exposes this as a public constructor parameter.

After close(), diff() and identity_key_paths raise ~ditto.errors.DittoClosedError.

Differ(identity_key_paths: Iterable[str] | None = None)
73    def __init__(self, identity_key_paths: Iterable[str] | None = None) -> None:
74        self._closed = False
75        ffi = get_ffi()
76        if identity_key_paths is None:
77            self._handle = ffi.dittoffi_differ_new()
78        else:
79            from ._ffi._bindings import slice_ref_char_const_ptr_t
80            from ._internal.errors import check_result
81
82            values = tuple(utf8(path) for path in identity_key_paths)
83            pointers = (ctypes.c_char_p * len(values))(*values)
84            refs = slice_ref_char_const_ptr_t(pointers, len(values))
85            result = ffi.dittoffi_differ_new_with_identity_key_paths_throws(refs)
86            self._handle = check_result(result)
87        self._free_native = process_bound_cleanup(ffi.dittoffi_differ_free)
88        self._finalizer = weakref.finalize(self, _finalize_differ, self._free_native, self._handle)
identity_key_paths: tuple[str, ...]
 95    @property
 96    def identity_key_paths(self) -> tuple[str, ...]:
 97        """The key paths used to identify items across diffs, in order."""
 98
 99        ffi = get_ffi()
100        handle = self._native()
101        count = int(ffi.dittoffi_differ_identity_key_path_count(handle))
102        return tuple(
103            consume_string(ffi.dittoffi_differ_identity_key_path_at(handle, index)) or ""
104            for index in range(count)
105        )

The key paths used to identify items across diffs, in order.

def diff( self, items: Iterable[QueryResultItem]) -> Diff:
107    def diff(self, items: Iterable[QueryResultItem]) -> Diff:
108        """Compute the diff of ``items`` against the previously provided items."""
109        from ._ffi._bindings import (
110            dittoffi_query_result_item_t,
111            slice_ref_dittoffi_query_result_item_ptr_t,
112        )
113
114        handle = self._native()
115        values = tuple(items)
116        pointer_type = ctypes.POINTER(dittoffi_query_result_item_t)
117        pointers = (pointer_type * len(values))(*(item._ffi_handle for item in values))
118        refs = slice_ref_dittoffi_query_result_item_ptr_t(pointers, len(values))
119        encoded = consume_boxed_bytes(get_ffi().dittoffi_differ_diff(handle, refs))
120        return Diff.from_mapping(cbor2.loads(encoded))

Compute the diff of items against the previously provided items.

def close(self) -> None:
122    def close(self) -> None:
123        """Release the native differ handle. Safe to call more than once."""
124
125        if self._closed:
126            return
127        self._closed = True
128        self._finalizer.detach()
129        self._free_native(self._handle)
130        self._handle = None

Release the native differ handle. Safe to call more than once.

class DiskUsage:
204class DiskUsage:
205    """Reports disk usage for Ditto or one of its persistence components.
206
207    ``item`` returns a point-in-time snapshot; use ``observe()`` for a
208    live-updating stream.
209    """
210
211    def __init__(
212        self, ditto: Ditto, component: DiskUsageComponent = DiskUsageComponent.ROOT
213    ) -> None:
214        self._ditto = weakref.ref(ditto)
215        self._component = DiskUsageComponent(component)
216
217    def _handle(self) -> Any:
218        owner = self._ditto()
219        if owner is None:
220            raise DittoClosedError("DiskUsage's Ditto instance is closed")
221        return owner._native_handle
222
223    @property
224    def component(self) -> DiskUsageComponent:
225        """The :class:`DiskUsageComponent` this instance reports on."""
226
227        return self._component
228
229    @property
230    def item(self) -> DiskUsageItem:
231        """A point-in-time :class:`DiskUsageItem` snapshot of this component's disk usage."""
232
233        ffi = get_ffi()
234        data = consume_boxed_bytes(ffi.ditto_disk_usage(self._handle(), int(self._component)), ffi)
235        return DiskUsageItem.from_cbor(data)
236
237    def observe(self, handler: Callable[[DiskUsageItem], Any]) -> DiskUsageObserver:
238        """Start observing this component's on-disk footprint.
239
240        ``handler`` is invoked once immediately with the current
241        :class:`DiskUsageItem` tree, and again whenever a monitored file or
242        directory changes size. Call :meth:`DiskUsageObserver.stop` on the
243        returned observer (or use it as a context manager) to cancel.
244        """
245
246        if not callable(handler):
247            raise TypeError("DiskUsage.observe requires a callable handler")
248        loop = asyncio.get_running_loop()
249        ffi = get_ffi()
250        ditto_handle = self._handle()
251        key = registry.register(_DiskUsagePayload(loop, handler))
252        handle: Any | None = None
253        try:
254            handle = ffi.ditto_register_disk_usage_callback(
255                ditto_handle,
256                int(self._component),
257                ctypes.c_void_p(key),
258                _retain_disk_usage_payload,
259                release_registry_entry,
260                _disk_usage_event,
261            )
262            if not handle:
263                raise DittoError("Failed to register a disk-usage observer")
264            return DiskUsageObserver(handle, key)
265        except BaseException:
266            try:
267                if handle:
268                    ffi.ditto_release_disk_usage_callback(handle)
269            except BaseException:
270                _log.exception("Failed to release a partially registered disk-usage observer")
271            finally:
272                # Native release normally invokes the registered callback; this
273                # fallback is deliberately idempotent.
274                registry.release(key)
275            raise

Reports disk usage for Ditto or one of its persistence components.

item returns a point-in-time snapshot; use observe() for a live-updating stream.

DiskUsage( ditto: Ditto, component: DiskUsageComponent = <DiskUsageComponent.ROOT: 0>)
211    def __init__(
212        self, ditto: Ditto, component: DiskUsageComponent = DiskUsageComponent.ROOT
213    ) -> None:
214        self._ditto = weakref.ref(ditto)
215        self._component = DiskUsageComponent(component)
component: DiskUsageComponent
223    @property
224    def component(self) -> DiskUsageComponent:
225        """The :class:`DiskUsageComponent` this instance reports on."""
226
227        return self._component

The DiskUsageComponent this instance reports on.

item: DiskUsageItem
229    @property
230    def item(self) -> DiskUsageItem:
231        """A point-in-time :class:`DiskUsageItem` snapshot of this component's disk usage."""
232
233        ffi = get_ffi()
234        data = consume_boxed_bytes(ffi.ditto_disk_usage(self._handle(), int(self._component)), ffi)
235        return DiskUsageItem.from_cbor(data)

A point-in-time DiskUsageItem snapshot of this component's disk usage.

def observe( self, handler: Callable[[DiskUsageItem], typing.Any]) -> DiskUsageObserver:
237    def observe(self, handler: Callable[[DiskUsageItem], Any]) -> DiskUsageObserver:
238        """Start observing this component's on-disk footprint.
239
240        ``handler`` is invoked once immediately with the current
241        :class:`DiskUsageItem` tree, and again whenever a monitored file or
242        directory changes size. Call :meth:`DiskUsageObserver.stop` on the
243        returned observer (or use it as a context manager) to cancel.
244        """
245
246        if not callable(handler):
247            raise TypeError("DiskUsage.observe requires a callable handler")
248        loop = asyncio.get_running_loop()
249        ffi = get_ffi()
250        ditto_handle = self._handle()
251        key = registry.register(_DiskUsagePayload(loop, handler))
252        handle: Any | None = None
253        try:
254            handle = ffi.ditto_register_disk_usage_callback(
255                ditto_handle,
256                int(self._component),
257                ctypes.c_void_p(key),
258                _retain_disk_usage_payload,
259                release_registry_entry,
260                _disk_usage_event,
261            )
262            if not handle:
263                raise DittoError("Failed to register a disk-usage observer")
264            return DiskUsageObserver(handle, key)
265        except BaseException:
266            try:
267                if handle:
268                    ffi.ditto_release_disk_usage_callback(handle)
269            except BaseException:
270                _log.exception("Failed to release a partially registered disk-usage observer")
271            finally:
272                # Native release normally invokes the registered callback; this
273                # fallback is deliberately idempotent.
274                registry.release(key)
275            raise

Start observing this component's on-disk footprint.

handler is invoked once immediately with the current DiskUsageItem tree, and again whenever a monitored file or directory changes size. Call DiskUsageObserver.stop() on the returned observer (or use it as a context manager) to cancel.

class DiskUsageComponent(enum.IntEnum):
35class DiskUsageComponent(IntEnum):
36    """A separately observable part of Ditto's persistence directory."""
37
38    ROOT = 0  # The whole Ditto working (persistence) directory.
39    STORE = 1  # The store component.
40    AUTH = 2  # The auth component.
41    REPLICATION = 3  # The replication component.
42    ATTACHMENT = 4  # The attachment component.

A separately observable part of Ditto's persistence directory.

REPLICATION = <DiskUsageComponent.REPLICATION: 3>
ATTACHMENT = <DiskUsageComponent.ATTACHMENT: 4>
@dataclass(frozen=True, slots=True)
class DiskUsageItem:
 69@dataclass(frozen=True, slots=True)
 70class DiskUsageItem:
 71    """A point-in-time snapshot of one entry in Ditto's persistence directory tree."""
 72
 73    file_type: FileType
 74    """Whether this entry is a directory, a file, or a symlink."""
 75
 76    path: str
 77    """This entry's path, relative to the persistence directory."""
 78
 79    size_in_bytes: int
 80    """Size in bytes; for a directory, its own size plus the recursive sum of its descendants."""
 81
 82    children: tuple[DiskUsageItem, ...] | None = None
 83    """Child entries for a directory (may be an empty tuple), or ``None`` for a file or symlink."""
 84
 85    @classmethod
 86    def from_mapping(cls, value: Mapping[str, Any]) -> DiskUsageItem:
 87        """Build a :class:`DiskUsageItem` tree from a decoded CBOR mapping."""
 88
 89        raw_children = value.get("children")
 90        children = (
 91            tuple(cls.from_mapping(child) for child in raw_children)
 92            if raw_children is not None
 93            else None
 94        )
 95        return cls(
 96            file_type=_file_type(value.get("fs_type")),
 97            path=str(value.get("path", "")),
 98            size_in_bytes=int(value.get("size_in_bytes", 0)),
 99            children=children,
100        )
101
102    @classmethod
103    def from_cbor(cls, data: bytes) -> DiskUsageItem:
104        """Decode a :class:`DiskUsageItem` tree from raw CBOR bytes."""
105
106        value = cbor2.loads(data)
107        if not isinstance(value, Mapping):
108            raise DittoError("The native disk-usage result did not contain a map")
109        return cls.from_mapping(value)

A point-in-time snapshot of one entry in Ditto's persistence directory tree.

DiskUsageItem( file_type: FileType, path: str, size_in_bytes: int, children: tuple[DiskUsageItem, ...] | None = None)
file_type: FileType

Whether this entry is a directory, a file, or a symlink.

path: str

This entry's path, relative to the persistence directory.

size_in_bytes: int

Size in bytes; for a directory, its own size plus the recursive sum of its descendants.

children: tuple[DiskUsageItem, ...] | None

Child entries for a directory (may be an empty tuple), or None for a file or symlink.

@classmethod
def from_mapping(cls, value: Mapping[str, typing.Any]) -> DiskUsageItem:
 85    @classmethod
 86    def from_mapping(cls, value: Mapping[str, Any]) -> DiskUsageItem:
 87        """Build a :class:`DiskUsageItem` tree from a decoded CBOR mapping."""
 88
 89        raw_children = value.get("children")
 90        children = (
 91            tuple(cls.from_mapping(child) for child in raw_children)
 92            if raw_children is not None
 93            else None
 94        )
 95        return cls(
 96            file_type=_file_type(value.get("fs_type")),
 97            path=str(value.get("path", "")),
 98            size_in_bytes=int(value.get("size_in_bytes", 0)),
 99            children=children,
100        )

Build a DiskUsageItem tree from a decoded CBOR mapping.

@classmethod
def from_cbor(cls, data: bytes) -> DiskUsageItem:
102    @classmethod
103    def from_cbor(cls, data: bytes) -> DiskUsageItem:
104        """Decode a :class:`DiskUsageItem` tree from raw CBOR bytes."""
105
106        value = cbor2.loads(data)
107        if not isinstance(value, Mapping):
108            raise DittoError("The native disk-usage result did not contain a map")
109        return cls.from_mapping(value)

Decode a DiskUsageItem tree from raw CBOR bytes.

class DiskUsageObserver:
155class DiskUsageObserver:
156    """A cancellable disk-usage change observer."""
157
158    def __init__(self, handle: Any, registry_key: int) -> None:
159        self._handle = handle
160        self._registry_key = registry_key
161        self._closed = False
162        self._release_native = process_bound_cleanup(get_ffi().ditto_release_disk_usage_callback)
163        self._finalizer = weakref.finalize(
164            self,
165            _finalize,
166            _dispose_disk_usage_observer,
167            self._release_native,
168            registry.release,
169            handle,
170            registry_key,
171        )
172
173    @property
174    def closed(self) -> bool:
175        """Whether the observer has been stopped and its native resources released."""
176
177        return self._closed
178
179    def stop(self) -> None:
180        """Stop observing and release native resources. Safe to call more than once."""
181
182        if self._closed:
183            return
184        self._closed = True
185        self._finalizer.detach()
186        handle, self._handle = self._handle, None
187        _dispose_disk_usage_observer(
188            self._release_native,
189            registry.release,
190            handle,
191            self._registry_key,
192        )
193
194    cancel = stop
195    close = stop
196
197    def __enter__(self) -> DiskUsageObserver:
198        return self
199
200    def __exit__(self, *_: object) -> None:
201        self.stop()

A cancellable disk-usage change observer.

DiskUsageObserver(handle: Any, registry_key: int)
158    def __init__(self, handle: Any, registry_key: int) -> None:
159        self._handle = handle
160        self._registry_key = registry_key
161        self._closed = False
162        self._release_native = process_bound_cleanup(get_ffi().ditto_release_disk_usage_callback)
163        self._finalizer = weakref.finalize(
164            self,
165            _finalize,
166            _dispose_disk_usage_observer,
167            self._release_native,
168            registry.release,
169            handle,
170            registry_key,
171        )
closed: bool
173    @property
174    def closed(self) -> bool:
175        """Whether the observer has been stopped and its native resources released."""
176
177        return self._closed

Whether the observer has been stopped and its native resources released.

def stop(self) -> None:
179    def stop(self) -> None:
180        """Stop observing and release native resources. Safe to call more than once."""
181
182        if self._closed:
183            return
184        self._closed = True
185        self._finalizer.detach()
186        handle, self._handle = self._handle, None
187        _dispose_disk_usage_observer(
188            self._release_native,
189            registry.release,
190            handle,
191            self._registry_key,
192        )

Stop observing and release native resources. Safe to call more than once.

def cancel(self) -> None:
179    def stop(self) -> None:
180        """Stop observing and release native resources. Safe to call more than once."""
181
182        if self._closed:
183            return
184        self._closed = True
185        self._finalizer.detach()
186        handle, self._handle = self._handle, None
187        _dispose_disk_usage_observer(
188            self._release_native,
189            registry.release,
190            handle,
191            self._registry_key,
192        )

Stop observing and release native resources. Safe to call more than once.

def close(self) -> None:
179    def stop(self) -> None:
180        """Stop observing and release native resources. Safe to call more than once."""
181
182        if self._closed:
183            return
184        self._closed = True
185        self._finalizer.detach()
186        handle, self._handle = self._handle, None
187        _dispose_disk_usage_observer(
188            self._release_native,
189            registry.release,
190            handle,
191            self._registry_key,
192        )

Stop observing and release native resources. Safe to call more than once.

class Ditto:
129class Ditto(metaclass=_DittoMeta):
130    """A local Ditto database peer."""
131
132    def __init__(self, config: DittoConfig, handle: Any, loop: asyncio.AbstractEventLoop) -> None:
133        self.config = config
134        self._requires_offline_license_token = isinstance(
135            config.connect, DittoConfigConnectSmallPeersOnly
136        )
137        self._handle = handle
138        self._creator_pid = os.getpid()
139        self._closed = False
140        self._closing = False
141        self._active_native_calls = 0
142        self._close_lock = threading.RLock()
143        self._close_condition = threading.Condition(self._close_lock)
144        self._device_name = platform.node() or "python-peer"
145        self.store = Store(self)
146        self.sync = Sync(self)
147
148        from .disk_usage import DiskUsage
149        from .presence import Presence
150        from .small_peer_info import SmallPeerInfo
151
152        self.presence = Presence(self)
153        self.disk_usage = DiskUsage(self)
154        self.small_peer_info = SmallPeerInfo(self)
155        if isinstance(config.connect, DittoConfigConnectServer):
156            from .auth import Authenticator
157
158            self.auth: Authenticator | None = Authenticator(self, loop)
159        else:
160            self.auth = None
161        with _instances_lock:
162            _instances.append(weakref.ref(self))
163
164    @classmethod
165    def open(
166        cls: type[T],
167        config: DittoConfig | None = None,
168        persistence_directory: str | Path | None = None,
169    ) -> _OpenRequest[T]:
170        """Open a Ditto peer.
171
172        ``persistence_directory`` is used as the parent of the actual
173        persistence directory whenever ``config.persistence_directory`` is
174        unset or relative; it defaults to the current working directory.
175
176        Raises:
177            DittoPersistenceDirectoryLockedError: another instance already
178                holds the lock on the resolved persistence directory.
179            DittoInvalidConfigError: ``config`` failed Core's validation.
180        """
181
182        return _OpenRequest(cls, config, persistence_directory)
183
184    @classmethod
185    async def _open(
186        cls: type[T],
187        config: DittoConfig | None,
188        persistence_directory: str | Path | None = None,
189    ) -> T:
190        if _forked_child:
191            raise DittoClosedError(_FORK_ERROR)
192        # DittoConfig is intentionally mutable. Snapshot it at the native-open
193        # boundary so subsequent caller mutations cannot change either the
194        # configuration Core receives or the value exposed by ``Ditto.config``.
195        # deepcopy does not re-run __post_init__ validation; this is safe only
196        # because to_cbor_object() re-validates before the snapshot crosses
197        # the FFI below.
198        config_snapshot = copy.deepcopy(config if config is not None else DittoConfig.default())
199        loop = asyncio.get_running_loop()
200        # Debug libdittoffi builds have deep native stacks. Set this before the
201        # event loop creates the executor thread that performs the blocking open.
202        if threading.stack_size() < 4 * 1024 * 1024:
203            threading.stack_size(4 * 1024 * 1024)
204
205        initialize_runtime()
206        ffi = get_ffi()
207        encoded = borrowed_bytes(config_snapshot.to_cbor())
208        root_directory = utf8(
209            str(persistence_directory) if persistence_directory is not None else os.getcwd()
210        )
211
212        def invoke() -> Any:
213            result = ffi.dittoffi_ditto_open_throws(
214                encoded.slice,
215                1,  # TransportConfigMode.PlatformIndependent
216                root_directory,
217            )
218            return check_result(result, ffi)
219
220        # Core's synchronous factory has complete error semantics (including
221        # persistence-lock failures). Run it on the executor so Python's API
222        # remains asyncio-native without blocking the event loop.
223        ownership_lock = threading.Lock()
224        cancelled = False
225        claimed = False
226        disposed = False
227        executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ditto-open")
228        native_future = executor.submit(invoke)
229
230        def take_orphaned(
231            completed: ConcurrentFuture[Any],
232        ) -> Any | None:
233            nonlocal disposed
234            with ownership_lock:
235                if not completed.done() or not cancelled or claimed or disposed:
236                    return None
237                disposed = True
238            try:
239                return completed.result()
240            except BaseException:
241                return None
242
243        def dispose_handle(orphaned: Any) -> None:
244            try:
245                try:
246                    ffi.ditto_shutdown(orphaned)
247                finally:
248                    ffi.ditto_free(orphaned)
249            except BaseException:
250                pass
251
252        def completed(completed_future: ConcurrentFuture[Any]) -> None:
253            try:
254                orphaned = take_orphaned(completed_future)
255                if orphaned is not None:
256                    dispose_handle(orphaned)
257            finally:
258                executor.shutdown(wait=False)
259
260        native_future.add_done_callback(completed)
261        try:
262            handle = await asyncio.wrap_future(native_future, loop=loop)
263        except asyncio.CancelledError:
264            with ownership_lock:
265                cancelled = True
266            orphaned = take_orphaned(native_future)
267            if orphaned is not None:
268                # Never make cancellation wait for native shutdown.
269                threading.Thread(
270                    target=dispose_handle,
271                    args=(orphaned,),
272                    name="ditto-open-cancel-cleanup",
273                    daemon=True,
274                ).start()
275            raise
276        with ownership_lock:
277            claimed = True
278        try:
279            return cls(config_snapshot, handle, loop)
280        except BaseException:
281            cleanup = asyncio.create_task(run_blocking(dispose_handle, handle))
282            try:
283                await asyncio.shield(cleanup)
284            except asyncio.CancelledError:
285                # Shielding leaves the task running, and the executor call owns
286                # the handle until shutdown/free completes.
287                cleanup.add_done_callback(
288                    lambda task: task.exception() if not task.cancelled() else None
289                )
290                raise
291            raise
292
293    @staticmethod
294    def version() -> str:
295        """Return the linked Ditto Core library's semantic version string."""
296
297        initialize_runtime()
298        return consume_string(get_ffi().dittoffi_get_sdk_semver()) or ""
299
300    @property
301    def closed(self) -> bool:
302        """Whether this instance is closed, closing, or unusable after a fork."""
303
304        return self._closing or self._closed or self._creator_pid != os.getpid()
305
306    @property
307    def _native_handle(self) -> Any:
308        with self._close_condition:
309            if self._creator_pid != os.getpid():
310                raise DittoClosedError(_FORK_ERROR)
311            if self._closing or self._closed or not self._handle:
312                raise DittoClosedError("Ditto is closed")
313            self._active_native_calls += 1
314            handle = self._handle
315        try:
316            return _NativeHandleLease(self, handle)
317        except BaseException:
318            self._release_native_call(self._creator_pid)
319            raise
320
321    def _release_native_call(self, creator_pid: int) -> None:
322        # A lease inherited across fork belongs to the parent process and must
323        # not mutate the child's freshly reset lifecycle state.
324        if creator_pid != os.getpid():
325            return
326        with self._close_condition:
327            self._active_native_calls -= 1
328            if self._active_native_calls == 0:
329                self._close_condition.notify_all()
330
331    def _poison_after_fork(self) -> None:
332        """Drop an inherited pointer without calling fork-unsafe native teardown."""
333
334        self._close_lock = threading.RLock()
335        self._close_condition = threading.Condition(self._close_lock)
336        self._active_native_calls = 0
337        self._handle = None
338        self._closing = False
339        self._closed = True
340
341    @property
342    def activated(self) -> bool:
343        """Whether this instance has an active, valid license.
344
345        If ``False``, sync will not function. Activate by calling
346        :meth:`set_offline_only_license_token` for a small-peers-only
347        configuration, or by completing an auth login for a server
348        configuration, before starting sync.
349        """
350        return bool(get_ffi().dittoffi_ditto_is_activated(self._native_handle))
351
352    @property
353    def absolute_persistence_directory(self) -> str:
354        r"""The actual, resolved directory Ditto uses for persistence.
355
356        An absolute ``config.persistence_directory`` is used unchanged. A
357        relative path is resolved against the ``persistence_directory``
358        argument passed to :meth:`Ditto.open` (the current working
359        directory if that argument was omitted). ``None`` resolves to
360        ``{root}/ditto-{database_id}``, with the database ID lowercased.
361
362        Stable for the lifetime of the instance. Do not read or write
363        files under this directory directly — its layout is managed by
364        Ditto and may change between versions. Log files may still be
365        written here after :meth:`close`. On Windows the path is returned
366        in its conventional form when possible; long paths and other
367        verbatim namespaces retain the ``\\?\`` prefix required to preserve
368        their identity.
369        """
370        path = (
371            consume_string(
372                get_ffi().dittoffi_ditto_absolute_persistence_directory(self._native_handle)
373            )
374            or ""
375        )
376        return _strip_windows_verbatim_prefix(path)
377
378    @property
379    def device_name(self) -> str:
380        """A short UTF-8 identifier for this peer, shown to others via presence.
381
382        Defaults to ``platform.node()`` (or ``"python-peer"`` if that
383        returns nothing). Need not be unique among peers. Changing this
384        property while sync is already active only takes effect the next
385        time :meth:`Sync.start` runs after a :meth:`Sync.stop` — at that
386        point Core truncates values over 24 UTF-8 bytes and this property
387        is updated to match.
388        """
389        return self._device_name
390
391    @device_name.setter
392    def device_name(self, value: str) -> None:
393        self._device_name = value
394
395    @property
396    def transport_config(self) -> TransportConfig:
397        """A snapshot of the transports this instance is configured to use.
398
399        Each read decodes a fresh :class:`TransportConfig` from Core —
400        mutating a nested field of the returned object has no effect on
401        Core until the whole object is written back through this property
402        (or via :meth:`update_transport_config`, which does a
403        read-modify-write for you). A freshly opened :class:`Ditto`
404        starts with every peer-to-peer transport enabled; a bare
405        ``TransportConfig()`` starts with all of them disabled.
406        """
407        value = consume_boxed_bytes(get_ffi().dittoffi_ditto_transport_config(self._native_handle))
408        return TransportConfig.from_cbor(value)
409
410    @transport_config.setter
411    def transport_config(self, value: TransportConfig) -> None:
412        encoded = borrowed_bytes(value.to_cbor())
413        check_result(
414            get_ffi().dittoffi_ditto_try_set_transport_config(
415                self._native_handle, encoded.slice, False
416            )
417        )
418
419    def update_transport_config(self, mutator: Callable[[TransportConfig], Any]) -> None:
420        """Read-modify-write helper for :attr:`transport_config`.
421
422        Calls ``mutator`` with the current :class:`TransportConfig`. If
423        ``mutator`` returns a :class:`TransportConfig`, that value is
424        written back; otherwise (for example, a mutator that changes the
425        passed-in config in place and returns ``None``) the passed-in,
426        possibly-mutated config is written back instead.
427        """
428        config = self.transport_config
429        replacement = mutator(config)
430        self.transport_config = replacement if isinstance(replacement, TransportConfig) else config
431
432    def set_offline_only_license_token(self, license_token: str) -> None:
433        """Activate this instance for offline, small-peers-only sync.
434
435        ``license_token`` comes from https://portal.ditto.live. Applies to
436        :class:`~ditto.config.DittoConfigConnectSmallPeersOnly`
437        configurations — sync will not function until activation
438        completes. For any other connect configuration this call is a
439        no-op that logs a warning: server-connected peers activate by
440        completing an auth login instead.
441        """
442        # Sibling parity (Swift guards, JS no-ops with a logged error):
443        # only small-peers-only configurations take an offline license token.
444        if not self._requires_offline_license_token:
445            _log.warning(
446                "set_offline_only_license_token is ignored for server connect "
447                "configurations; a server-connected peer activates by completing "
448                "an auth login instead"
449            )
450            return
451        check_result(
452            get_ffi().dittoffi_ditto_set_offline_only_license_token_throws(
453                self._native_handle, utf8(license_token)
454            )
455        )
456
457    def _close_blocking(self) -> None:
458        with self._close_condition:
459            if self._creator_pid != os.getpid():
460                self._poison_after_fork()
461                return
462            if self._closed:
463                return
464            if self._closing:
465                while not self._closed:
466                    self._close_condition.wait()
467                return
468            self._closing = True
469            while self._active_native_calls:
470                self._close_condition.wait()
471            handle, self._handle = self._handle, None
472
473        try:
474            ffi = get_ffi()
475            try:
476                if self.auth is not None:
477                    self.auth._close_with_native_handle(handle)
478                for fetcher in tuple(self.store.attachment_fetchers):
479                    fetcher.stop()
480                self.presence._close_with_native_handle(handle)
481            finally:
482                try:
483                    ffi.dittoffi_ditto_stop_sync(handle)
484                finally:
485                    try:
486                        ffi.ditto_shutdown(handle)
487                    finally:
488                        ffi.ditto_free(handle)
489        finally:
490            with self._close_condition:
491                self._closed = True
492                self._closing = False
493                self._close_condition.notify_all()
494
495    async def close(self) -> None:
496        """Stop sync and release native resources. Safe to call more than once."""
497
498        await run_blocking(self._close_blocking)
499
500    aclose = close
501
502    async def __aenter__(self: T) -> T:
503        return self
504
505    async def __aexit__(self, *_: object) -> None:
506        await self.close()

A local Ditto database peer.

Ditto( config: DittoConfig, handle: Any, loop: asyncio.events.AbstractEventLoop)
132    def __init__(self, config: DittoConfig, handle: Any, loop: asyncio.AbstractEventLoop) -> None:
133        self.config = config
134        self._requires_offline_license_token = isinstance(
135            config.connect, DittoConfigConnectSmallPeersOnly
136        )
137        self._handle = handle
138        self._creator_pid = os.getpid()
139        self._closed = False
140        self._closing = False
141        self._active_native_calls = 0
142        self._close_lock = threading.RLock()
143        self._close_condition = threading.Condition(self._close_lock)
144        self._device_name = platform.node() or "python-peer"
145        self.store = Store(self)
146        self.sync = Sync(self)
147
148        from .disk_usage import DiskUsage
149        from .presence import Presence
150        from .small_peer_info import SmallPeerInfo
151
152        self.presence = Presence(self)
153        self.disk_usage = DiskUsage(self)
154        self.small_peer_info = SmallPeerInfo(self)
155        if isinstance(config.connect, DittoConfigConnectServer):
156            from .auth import Authenticator
157
158            self.auth: Authenticator | None = Authenticator(self, loop)
159        else:
160            self.auth = None
161        with _instances_lock:
162            _instances.append(weakref.ref(self))
config
store
sync
presence
disk_usage
small_peer_info
@classmethod
def open( cls: type[~T], config: DittoConfig | None = None, persistence_directory: str | pathlib.Path | None = None) -> ditto.ditto._OpenRequest[~T]:
164    @classmethod
165    def open(
166        cls: type[T],
167        config: DittoConfig | None = None,
168        persistence_directory: str | Path | None = None,
169    ) -> _OpenRequest[T]:
170        """Open a Ditto peer.
171
172        ``persistence_directory`` is used as the parent of the actual
173        persistence directory whenever ``config.persistence_directory`` is
174        unset or relative; it defaults to the current working directory.
175
176        Raises:
177            DittoPersistenceDirectoryLockedError: another instance already
178                holds the lock on the resolved persistence directory.
179            DittoInvalidConfigError: ``config`` failed Core's validation.
180        """
181
182        return _OpenRequest(cls, config, persistence_directory)

Open a Ditto peer.

persistence_directory is used as the parent of the actual persistence directory whenever config.persistence_directory is unset or relative; it defaults to the current working directory.

Raises:
  • DittoPersistenceDirectoryLockedError: another instance already holds the lock on the resolved persistence directory.
  • DittoInvalidConfigError: config failed Core's validation.
@staticmethod
def version() -> str:
293    @staticmethod
294    def version() -> str:
295        """Return the linked Ditto Core library's semantic version string."""
296
297        initialize_runtime()
298        return consume_string(get_ffi().dittoffi_get_sdk_semver()) or ""

Return the linked Ditto Core library's semantic version string.

closed: bool
300    @property
301    def closed(self) -> bool:
302        """Whether this instance is closed, closing, or unusable after a fork."""
303
304        return self._closing or self._closed or self._creator_pid != os.getpid()

Whether this instance is closed, closing, or unusable after a fork.

activated: bool
341    @property
342    def activated(self) -> bool:
343        """Whether this instance has an active, valid license.
344
345        If ``False``, sync will not function. Activate by calling
346        :meth:`set_offline_only_license_token` for a small-peers-only
347        configuration, or by completing an auth login for a server
348        configuration, before starting sync.
349        """
350        return bool(get_ffi().dittoffi_ditto_is_activated(self._native_handle))

Whether this instance has an active, valid license.

If False, sync will not function. Activate by calling set_offline_only_license_token() for a small-peers-only configuration, or by completing an auth login for a server configuration, before starting sync.

absolute_persistence_directory: str
352    @property
353    def absolute_persistence_directory(self) -> str:
354        r"""The actual, resolved directory Ditto uses for persistence.
355
356        An absolute ``config.persistence_directory`` is used unchanged. A
357        relative path is resolved against the ``persistence_directory``
358        argument passed to :meth:`Ditto.open` (the current working
359        directory if that argument was omitted). ``None`` resolves to
360        ``{root}/ditto-{database_id}``, with the database ID lowercased.
361
362        Stable for the lifetime of the instance. Do not read or write
363        files under this directory directly — its layout is managed by
364        Ditto and may change between versions. Log files may still be
365        written here after :meth:`close`. On Windows the path is returned
366        in its conventional form when possible; long paths and other
367        verbatim namespaces retain the ``\\?\`` prefix required to preserve
368        their identity.
369        """
370        path = (
371            consume_string(
372                get_ffi().dittoffi_ditto_absolute_persistence_directory(self._native_handle)
373            )
374            or ""
375        )
376        return _strip_windows_verbatim_prefix(path)

The actual, resolved directory Ditto uses for persistence.

An absolute config.persistence_directory is used unchanged. A relative path is resolved against the persistence_directory argument passed to Ditto.open() (the current working directory if that argument was omitted). None resolves to {root}/ditto-{database_id}, with the database ID lowercased.

Stable for the lifetime of the instance. Do not read or write files under this directory directly — its layout is managed by Ditto and may change between versions. Log files may still be written here after close(). On Windows the path is returned in its conventional form when possible; long paths and other verbatim namespaces retain the \\?\ prefix required to preserve their identity.

device_name: str
378    @property
379    def device_name(self) -> str:
380        """A short UTF-8 identifier for this peer, shown to others via presence.
381
382        Defaults to ``platform.node()`` (or ``"python-peer"`` if that
383        returns nothing). Need not be unique among peers. Changing this
384        property while sync is already active only takes effect the next
385        time :meth:`Sync.start` runs after a :meth:`Sync.stop` — at that
386        point Core truncates values over 24 UTF-8 bytes and this property
387        is updated to match.
388        """
389        return self._device_name

A short UTF-8 identifier for this peer, shown to others via presence.

Defaults to platform.node() (or "python-peer" if that returns nothing). Need not be unique among peers. Changing this property while sync is already active only takes effect the next time Sync.start() runs after a Sync.stop() — at that point Core truncates values over 24 UTF-8 bytes and this property is updated to match.

transport_config: DittoTransportConfig
395    @property
396    def transport_config(self) -> TransportConfig:
397        """A snapshot of the transports this instance is configured to use.
398
399        Each read decodes a fresh :class:`TransportConfig` from Core —
400        mutating a nested field of the returned object has no effect on
401        Core until the whole object is written back through this property
402        (or via :meth:`update_transport_config`, which does a
403        read-modify-write for you). A freshly opened :class:`Ditto`
404        starts with every peer-to-peer transport enabled; a bare
405        ``TransportConfig()`` starts with all of them disabled.
406        """
407        value = consume_boxed_bytes(get_ffi().dittoffi_ditto_transport_config(self._native_handle))
408        return TransportConfig.from_cbor(value)

A snapshot of the transports this instance is configured to use.

Each read decodes a fresh TransportConfig from Core — mutating a nested field of the returned object has no effect on Core until the whole object is written back through this property (or via update_transport_config(), which does a read-modify-write for you). A freshly opened Ditto starts with every peer-to-peer transport enabled; a bare TransportConfig() starts with all of them disabled.

def update_transport_config( self, mutator: Callable[[DittoTransportConfig], typing.Any]) -> None:
419    def update_transport_config(self, mutator: Callable[[TransportConfig], Any]) -> None:
420        """Read-modify-write helper for :attr:`transport_config`.
421
422        Calls ``mutator`` with the current :class:`TransportConfig`. If
423        ``mutator`` returns a :class:`TransportConfig`, that value is
424        written back; otherwise (for example, a mutator that changes the
425        passed-in config in place and returns ``None``) the passed-in,
426        possibly-mutated config is written back instead.
427        """
428        config = self.transport_config
429        replacement = mutator(config)
430        self.transport_config = replacement if isinstance(replacement, TransportConfig) else config

Read-modify-write helper for transport_config.

Calls mutator with the current TransportConfig. If mutator returns a TransportConfig, that value is written back; otherwise (for example, a mutator that changes the passed-in config in place and returns None) the passed-in, possibly-mutated config is written back instead.

def set_offline_only_license_token(self, license_token: str) -> None:
432    def set_offline_only_license_token(self, license_token: str) -> None:
433        """Activate this instance for offline, small-peers-only sync.
434
435        ``license_token`` comes from https://portal.ditto.live. Applies to
436        :class:`~ditto.config.DittoConfigConnectSmallPeersOnly`
437        configurations — sync will not function until activation
438        completes. For any other connect configuration this call is a
439        no-op that logs a warning: server-connected peers activate by
440        completing an auth login instead.
441        """
442        # Sibling parity (Swift guards, JS no-ops with a logged error):
443        # only small-peers-only configurations take an offline license token.
444        if not self._requires_offline_license_token:
445            _log.warning(
446                "set_offline_only_license_token is ignored for server connect "
447                "configurations; a server-connected peer activates by completing "
448                "an auth login instead"
449            )
450            return
451        check_result(
452            get_ffi().dittoffi_ditto_set_offline_only_license_token_throws(
453                self._native_handle, utf8(license_token)
454            )
455        )

Activate this instance for offline, small-peers-only sync.

license_token comes from https://portal.ditto.live. Applies to ~ditto.config.DittoConfigConnectSmallPeersOnly configurations — sync will not function until activation completes. For any other connect configuration this call is a no-op that logs a warning: server-connected peers activate by completing an auth login instead.

async def close(self) -> None:
495    async def close(self) -> None:
496        """Stop sync and release native resources. Safe to call more than once."""
497
498        await run_blocking(self._close_blocking)

Stop sync and release native resources. Safe to call more than once.

async def aclose(self) -> None:
495    async def close(self) -> None:
496        """Stop sync and release native resources. Safe to call more than once."""
497
498        await run_blocking(self._close_blocking)

Stop sync and release native resources. Safe to call more than once.

class DittoAlreadyExistsIOError(ditto.DittoIOError):
217class DittoAlreadyExistsIOError(DittoIOError):
218    """A file or directory already exists."""

A file or directory already exists.

class DittoAttachmentError(ditto.DittoError):
181class DittoAttachmentError(DittoError):
182    """Base class for attachment-related failures."""

Base class for attachment-related failures.

class DittoAttachmentFileNotFoundError(ditto.DittoAttachmentError):
189class DittoAttachmentFileNotFoundError(DittoAttachmentError):
190    """The source file for an attachment could not be found."""

The source file for an attachment could not be found.

class DittoAttachmentFilePermissionDeniedError(ditto.DittoAttachmentError):
201class DittoAttachmentFilePermissionDeniedError(DittoAttachmentError):
202    """An attachment file operation was denied by the operating system."""

An attachment file operation was denied by the operating system.

class DittoAttachmentNotFoundError(ditto.DittoAttachmentError):
185class DittoAttachmentNotFoundError(DittoAttachmentError):
186    """An attachment could not be found."""

An attachment could not be found.

class DittoAttachmentTokenInvalidError(ditto.DittoAttachmentError):
193class DittoAttachmentTokenInvalidError(DittoAttachmentError):
194    """An attachment token is invalid."""

An attachment token is invalid.

class DittoAuthenticationError(ditto.DittoError):
233class DittoAuthenticationError(DittoError):
234    """Base class for authentication failures."""
235
236    def __init__(
237        self,
238        message: str,
239        *,
240        client_info_json: Any | None = None,
241    ) -> None:
242        super().__init__(message)
243        self.client_info_json = client_info_json

Base class for authentication failures.

DittoAuthenticationError(message: str, *, client_info_json: Any | None = None)
236    def __init__(
237        self,
238        message: str,
239        *,
240        client_info_json: Any | None = None,
241    ) -> None:
242        super().__init__(message)
243        self.client_info_json = client_info_json
client_info_json
class DittoClosedError(ditto.DittoError):
61class DittoClosedError(DittoError):
62    """An operation was attempted on an already-closed SDK object."""

An operation was attempted on an already-closed SDK object.

@dataclass(slots=True)
class DittoConfig:
194@dataclass(slots=True)
195class DittoConfig:
196    """All configuration required to open a Ditto instance.
197
198    ``experimental`` is deliberately not exposed as public mutable state.  The
199    v5 FFI schema nevertheless requires an ``experimental`` map, so
200    :meth:`to_cbor_object` always emits an empty one.
201
202    ``persistence_directory`` accepts three kinds of value: an absolute
203    path, used unchanged; a relative path, resolved against the
204    ``persistence_directory`` argument passed to :meth:`Ditto.open` (the
205    current working directory if that argument was omitted); or ``None``,
206    which resolves to ``{root}/ditto-{database_id}``. Read the final
207    resolved path from :attr:`Ditto.absolute_persistence_directory`.
208
209    ``system_parameters`` configures Core before query, sync, networking,
210    subscriptions, and other subsystems initialize during :meth:`Ditto.open`.
211    Parameter names and constraints are owned by Core. Use ``ALTER SYSTEM``
212    for changes after the instance has opened.
213    """
214
215    DEFAULT_DATABASE_ID: ClassVar[str] = "00000000-0000-0000-0000-000000000000"
216    default_database_id: ClassVar[str] = DEFAULT_DATABASE_ID
217
218    database_id: str
219    connect: DittoConfigConnection
220    persistence_directory: str | None = None
221    system_parameters: DittoSystemParameters = field(default_factory=dict)
222
223    def __post_init__(self) -> None:
224        self._validate()
225
226    def _validate(self) -> None:
227        if not isinstance(self.database_id, str):
228            raise TypeError("database_id must be a string")
229        if self.connect is None:
230            raise TypeError("connect cannot be None")
231        if not isinstance(
232            self.connect, (DittoConfigConnectServer, DittoConfigConnectSmallPeersOnly)
233        ):
234            raise TypeError("connect must be a DittoConfigConnect")
235        if self.persistence_directory is not None and not isinstance(
236            self.persistence_directory, str
237        ):
238            raise TypeError("persistence_directory must be a string or None")
239        _validate_system_parameters(self.system_parameters)
240
241    @classmethod
242    def default(cls) -> DittoConfig:
243        """Return the same minimal quick-start configuration as Ditto Core."""
244
245        return cls(
246            database_id=cls.DEFAULT_DATABASE_ID,
247            connect=DittoConfigConnect.small_peers_only(),
248        )
249
250    def to_cbor_object(self) -> dict[str, Any]:
251        """Return a mapping matching ``DittoConfig.schema.json``.
252
253        ``legacy_persistence_directory`` is intentionally absent: Python has
254        no v4 install location from which to migrate.
255        """
256
257        # DittoConfig is intentionally mutable, so construction-time
258        # validation alone is not enough to protect the native open boundary.
259        self._validate()
260        value: dict[str, Any] = {
261            "database_id": self.database_id,
262            "connect": self.connect.to_cbor_object(),
263            "experimental": {},
264        }
265        if self.persistence_directory is not None:
266            value["persistence_directory"] = self.persistence_directory
267        if self.system_parameters:
268            value["system_parameters"] = self.system_parameters
269        return value
270
271    @classmethod
272    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoConfig:
273        """Construct a configuration from a decoded CBOR mapping."""
274
275        if not isinstance(value, Mapping):
276            raise DittoInvalidConfigError("DittoConfig must be a CBOR map.")
277
278        if "database_id" not in value:
279            raise DittoInvalidConfigError("DittoConfig is missing required 'database_id'.")
280        database_id = value["database_id"]
281        if not isinstance(database_id, str):
282            raise DittoInvalidConfigError("DittoConfig.database_id must be a string.")
283
284        connect_value = value.get("connect")
285        if not isinstance(connect_value, Mapping):
286            raise DittoInvalidConfigError("DittoConfig is missing required 'connect'.")
287
288        persistence_directory = value.get("persistence_directory")
289        if persistence_directory is not None and not isinstance(persistence_directory, str):
290            raise DittoInvalidConfigError("DittoConfig.persistence_directory must be a string.")
291
292        system_parameters = value.get("system_parameters", {})
293
294        return cls(
295            database_id=database_id,
296            connect=DittoConfigConnect.from_cbor_object(connect_value),
297            persistence_directory=persistence_directory,
298            system_parameters=system_parameters,
299        )
300
301    def to_cbor(self) -> bytes:
302        """Encode this configuration as canonical CBOR for the FFI."""
303
304        return cbor2.dumps(self.to_cbor_object(), canonical=True)
305
306    to_cbor_bytes = to_cbor
307
308    @classmethod
309    def from_cbor(
310        cls,
311        value: bytes | bytearray | memoryview | Mapping[str, Any],
312    ) -> DittoConfig:
313        """Decode a configuration from CBOR bytes or an existing mapping."""
314
315        if isinstance(value, Mapping):
316            return cls.from_cbor_object(value)
317        if not isinstance(value, (bytes, bytearray, memoryview)):
318            raise TypeError("value must be CBOR bytes or a mapping")
319        try:
320            decoded = cbor2.loads(bytes(value))
321        except (cbor2.CBORDecodeError, ValueError) as error:
322            raise DittoInvalidCborError("Could not decode DittoConfig CBOR.") from error
323        if not isinstance(decoded, Mapping):
324            raise DittoInvalidConfigError("DittoConfig CBOR must contain a map.")
325        return cls.from_cbor_object(decoded)
326
327    from_cbor_bytes = from_cbor

All configuration required to open a Ditto instance.

experimental is deliberately not exposed as public mutable state. The v5 FFI schema nevertheless requires an experimental map, so to_cbor_object() always emits an empty one.

persistence_directory accepts three kinds of value: an absolute path, used unchanged; a relative path, resolved against the persistence_directory argument passed to Ditto.open() (the current working directory if that argument was omitted); or None, which resolves to {root}/ditto-{database_id}. Read the final resolved path from Ditto.absolute_persistence_directory.

system_parameters configures Core before query, sync, networking, subscriptions, and other subsystems initialize during Ditto.open(). Parameter names and constraints are owned by Core. Use ALTER SYSTEM for changes after the instance has opened.

DittoConfig( database_id: str, connect: DittoConfigConnectServer | DittoConfigConnectSmallPeersOnly, persistence_directory: str | None = None, system_parameters: dict[str, bool | int | float | str | list[bool | int | float | str | list[ForwardRef('DittoSystemParameterValue')] | dict[str, ForwardRef('DittoSystemParameterValue')]] | dict[str, bool | int | float | str | list[ForwardRef('DittoSystemParameterValue')] | dict[str, ForwardRef('DittoSystemParameterValue')]]] = <factory>)
DEFAULT_DATABASE_ID: ClassVar[str] = '00000000-0000-0000-0000-000000000000'
default_database_id: ClassVar[str] = '00000000-0000-0000-0000-000000000000'
database_id: str
persistence_directory: str | None
system_parameters: dict[str, bool | int | float | str | list[bool | int | float | str | list[ForwardRef('DittoSystemParameterValue')] | dict[str, ForwardRef('DittoSystemParameterValue')]] | dict[str, bool | int | float | str | list[ForwardRef('DittoSystemParameterValue')] | dict[str, ForwardRef('DittoSystemParameterValue')]]]
@classmethod
def default(cls) -> DittoConfig:
241    @classmethod
242    def default(cls) -> DittoConfig:
243        """Return the same minimal quick-start configuration as Ditto Core."""
244
245        return cls(
246            database_id=cls.DEFAULT_DATABASE_ID,
247            connect=DittoConfigConnect.small_peers_only(),
248        )

Return the same minimal quick-start configuration as Ditto Core.

def to_cbor_object(self) -> dict[str, typing.Any]:
250    def to_cbor_object(self) -> dict[str, Any]:
251        """Return a mapping matching ``DittoConfig.schema.json``.
252
253        ``legacy_persistence_directory`` is intentionally absent: Python has
254        no v4 install location from which to migrate.
255        """
256
257        # DittoConfig is intentionally mutable, so construction-time
258        # validation alone is not enough to protect the native open boundary.
259        self._validate()
260        value: dict[str, Any] = {
261            "database_id": self.database_id,
262            "connect": self.connect.to_cbor_object(),
263            "experimental": {},
264        }
265        if self.persistence_directory is not None:
266            value["persistence_directory"] = self.persistence_directory
267        if self.system_parameters:
268            value["system_parameters"] = self.system_parameters
269        return value

Return a mapping matching DittoConfig.schema.json.

legacy_persistence_directory is intentionally absent: Python has no v4 install location from which to migrate.

@classmethod
def from_cbor_object(cls, value: Mapping[str, typing.Any]) -> DittoConfig:
271    @classmethod
272    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoConfig:
273        """Construct a configuration from a decoded CBOR mapping."""
274
275        if not isinstance(value, Mapping):
276            raise DittoInvalidConfigError("DittoConfig must be a CBOR map.")
277
278        if "database_id" not in value:
279            raise DittoInvalidConfigError("DittoConfig is missing required 'database_id'.")
280        database_id = value["database_id"]
281        if not isinstance(database_id, str):
282            raise DittoInvalidConfigError("DittoConfig.database_id must be a string.")
283
284        connect_value = value.get("connect")
285        if not isinstance(connect_value, Mapping):
286            raise DittoInvalidConfigError("DittoConfig is missing required 'connect'.")
287
288        persistence_directory = value.get("persistence_directory")
289        if persistence_directory is not None and not isinstance(persistence_directory, str):
290            raise DittoInvalidConfigError("DittoConfig.persistence_directory must be a string.")
291
292        system_parameters = value.get("system_parameters", {})
293
294        return cls(
295            database_id=database_id,
296            connect=DittoConfigConnect.from_cbor_object(connect_value),
297            persistence_directory=persistence_directory,
298            system_parameters=system_parameters,
299        )

Construct a configuration from a decoded CBOR mapping.

def to_cbor(self) -> bytes:
301    def to_cbor(self) -> bytes:
302        """Encode this configuration as canonical CBOR for the FFI."""
303
304        return cbor2.dumps(self.to_cbor_object(), canonical=True)

Encode this configuration as canonical CBOR for the FFI.

def to_cbor_bytes(self) -> bytes:
301    def to_cbor(self) -> bytes:
302        """Encode this configuration as canonical CBOR for the FFI."""
303
304        return cbor2.dumps(self.to_cbor_object(), canonical=True)

Encode this configuration as canonical CBOR for the FFI.

@classmethod
def from_cbor( cls, value: bytes | bytearray | memoryview | Mapping[str, Any]) -> DittoConfig:
308    @classmethod
309    def from_cbor(
310        cls,
311        value: bytes | bytearray | memoryview | Mapping[str, Any],
312    ) -> DittoConfig:
313        """Decode a configuration from CBOR bytes or an existing mapping."""
314
315        if isinstance(value, Mapping):
316            return cls.from_cbor_object(value)
317        if not isinstance(value, (bytes, bytearray, memoryview)):
318            raise TypeError("value must be CBOR bytes or a mapping")
319        try:
320            decoded = cbor2.loads(bytes(value))
321        except (cbor2.CBORDecodeError, ValueError) as error:
322            raise DittoInvalidCborError("Could not decode DittoConfig CBOR.") from error
323        if not isinstance(decoded, Mapping):
324            raise DittoInvalidConfigError("DittoConfig CBOR must contain a map.")
325        return cls.from_cbor_object(decoded)

Decode a configuration from CBOR bytes or an existing mapping.

@classmethod
def from_cbor_bytes( cls, value: bytes | bytearray | memoryview | Mapping[str, Any]) -> DittoConfig:
308    @classmethod
309    def from_cbor(
310        cls,
311        value: bytes | bytearray | memoryview | Mapping[str, Any],
312    ) -> DittoConfig:
313        """Decode a configuration from CBOR bytes or an existing mapping."""
314
315        if isinstance(value, Mapping):
316            return cls.from_cbor_object(value)
317        if not isinstance(value, (bytes, bytearray, memoryview)):
318            raise TypeError("value must be CBOR bytes or a mapping")
319        try:
320            decoded = cbor2.loads(bytes(value))
321        except (cbor2.CBORDecodeError, ValueError) as error:
322            raise DittoInvalidCborError("Could not decode DittoConfig CBOR.") from error
323        if not isinstance(decoded, Mapping):
324            raise DittoInvalidConfigError("DittoConfig CBOR must contain a map.")
325        return cls.from_cbor_object(decoded)

Decode a configuration from CBOR bytes or an existing mapping.

class DittoConfigConnect:
16class DittoConfigConnect:
17    """Describes how a Ditto instance connects to other peers.
18
19    This is the construction namespace and ``isinstance`` base for the two
20    concrete records; typed surfaces use the closed
21    :data:`DittoConfigConnection` union instead of polymorphism.
22    Construct a value with :meth:`server` or :meth:`small_peers_only`.
23    """
24
25    Server: ClassVar[type[DittoConfigConnectServer]]
26    SmallPeersOnly: ClassVar[type[DittoConfigConnectSmallPeersOnly]]
27
28    @staticmethod
29    def server(url: str) -> DittoConfigConnectServer:
30        """Connect through a Ditto server at *url*.
31
32        Requires setting ``ditto.auth.expiration_handler`` before calling
33        :meth:`Sync.start`; otherwise it raises
34        :class:`~ditto.errors.DittoExpirationHandlerMissingError`.
35        """
36
37        return DittoConfigConnectServer(url=url)
38
39    @staticmethod
40    def small_peers_only(
41        private_key: str | None = None,
42    ) -> DittoConfigConnectSmallPeersOnly:
43        """Connect only to other small peers, optionally with a private key.
44
45        ``private_key``, if given, is used as a shared secret to
46        authenticate peer-to-peer connections. The default, ``None``,
47        means peer-to-peer traffic is unencrypted in transit.
48        """
49
50        return DittoConfigConnectSmallPeersOnly(private_key=private_key)
51
52    @staticmethod
53    def from_cbor_object(value: Mapping[str, Any]) -> DittoConfigConnection:
54        """Decode a connection record from a CBOR-compatible mapping."""
55
56        if not isinstance(value, Mapping):
57            raise DittoInvalidConfigError("DittoConfig.connect must be a CBOR map.")
58
59        connect_type = value.get("type")
60        if connect_type == "server":
61            url = value.get("url")
62            if not isinstance(url, str):
63                raise DittoInvalidConfigError("A server connection requires a string 'url'.")
64            return DittoConfigConnect.server(url)
65
66        if connect_type == "small_peers_only":
67            private_key = value.get("private_key")
68            if private_key is not None and not isinstance(private_key, str):
69                raise DittoInvalidConfigError("A small-peers-only 'private_key' must be a string.")
70            return DittoConfigConnect.small_peers_only(private_key)
71
72        raise ValueError(f"Unknown connection type: {connect_type}")

Describes how a Ditto instance connects to other peers.

This is the construction namespace and isinstance base for the two concrete records; typed surfaces use the closed DittoConfigConnection union instead of polymorphism. Construct a value with server() or small_peers_only().

Server: ClassVar[type[DittoConfigConnectServer]] = <class 'DittoConfigConnectServer'>
SmallPeersOnly: ClassVar[type[DittoConfigConnectSmallPeersOnly]] = <class 'DittoConfigConnectSmallPeersOnly'>
@staticmethod
def server(url: str) -> DittoConfigConnectServer:
28    @staticmethod
29    def server(url: str) -> DittoConfigConnectServer:
30        """Connect through a Ditto server at *url*.
31
32        Requires setting ``ditto.auth.expiration_handler`` before calling
33        :meth:`Sync.start`; otherwise it raises
34        :class:`~ditto.errors.DittoExpirationHandlerMissingError`.
35        """
36
37        return DittoConfigConnectServer(url=url)

Connect through a Ditto server at url.

Requires setting ditto.auth.expiration_handler before calling Sync.start(); otherwise it raises ~ditto.errors.DittoExpirationHandlerMissingError.

@staticmethod
def small_peers_only( private_key: str | None = None) -> DittoConfigConnectSmallPeersOnly:
39    @staticmethod
40    def small_peers_only(
41        private_key: str | None = None,
42    ) -> DittoConfigConnectSmallPeersOnly:
43        """Connect only to other small peers, optionally with a private key.
44
45        ``private_key``, if given, is used as a shared secret to
46        authenticate peer-to-peer connections. The default, ``None``,
47        means peer-to-peer traffic is unencrypted in transit.
48        """
49
50        return DittoConfigConnectSmallPeersOnly(private_key=private_key)

Connect only to other small peers, optionally with a private key.

private_key, if given, is used as a shared secret to authenticate peer-to-peer connections. The default, None, means peer-to-peer traffic is unencrypted in transit.

@staticmethod
def from_cbor_object( value: Mapping[str, typing.Any]) -> DittoConfigConnectServer | DittoConfigConnectSmallPeersOnly:
52    @staticmethod
53    def from_cbor_object(value: Mapping[str, Any]) -> DittoConfigConnection:
54        """Decode a connection record from a CBOR-compatible mapping."""
55
56        if not isinstance(value, Mapping):
57            raise DittoInvalidConfigError("DittoConfig.connect must be a CBOR map.")
58
59        connect_type = value.get("type")
60        if connect_type == "server":
61            url = value.get("url")
62            if not isinstance(url, str):
63                raise DittoInvalidConfigError("A server connection requires a string 'url'.")
64            return DittoConfigConnect.server(url)
65
66        if connect_type == "small_peers_only":
67            private_key = value.get("private_key")
68            if private_key is not None and not isinstance(private_key, str):
69                raise DittoInvalidConfigError("A small-peers-only 'private_key' must be a string.")
70            return DittoConfigConnect.small_peers_only(private_key)
71
72        raise ValueError(f"Unknown connection type: {connect_type}")

Decode a connection record from a CBOR-compatible mapping.

@dataclass(frozen=True, slots=True)
class DittoConfigConnectServer(ditto.DittoConfigConnect):
75@dataclass(frozen=True, slots=True)
76class DittoConfigConnectServer(DittoConfigConnect):
77    """A connection to a Ditto server."""
78
79    url: str
80
81    def __post_init__(self) -> None:
82        if not isinstance(self.url, str) or not self.url:
83            raise ValueError("url must be a non-empty string")
84
85    def to_cbor_object(self) -> dict[str, Any]:
86        """Encode this connection as a CBOR-compatible mapping."""
87
88        return {"type": "server", "url": self.url}

A connection to a Ditto server.

DittoConfigConnectServer(url: str)
url: str
def to_cbor_object(self) -> dict[str, typing.Any]:
85    def to_cbor_object(self) -> dict[str, Any]:
86        """Encode this connection as a CBOR-compatible mapping."""
87
88        return {"type": "server", "url": self.url}

Encode this connection as a CBOR-compatible mapping.

@dataclass(frozen=True, slots=True)
class DittoConfigConnectSmallPeersOnly(ditto.DittoConfigConnect):
 91@dataclass(frozen=True, slots=True)
 92class DittoConfigConnectSmallPeersOnly(DittoConfigConnect):
 93    """A connection limited to other Ditto small peers.
 94
 95    ``private_key``, if given, is used as a shared secret to authenticate
 96    peer-to-peer connections. The default, ``None``, means peer-to-peer
 97    traffic is unencrypted in transit.
 98    """
 99
100    private_key: str | None = field(default=None, repr=False)
101
102    def __post_init__(self) -> None:
103        if self.private_key is not None and not isinstance(self.private_key, str):
104            raise TypeError("private_key must be a string or None")
105
106    def to_cbor_object(self) -> dict[str, Any]:
107        """Encode this connection as a CBOR-compatible mapping."""
108
109        value: dict[str, Any] = {"type": "small_peers_only"}
110        if self.private_key is not None:
111            value["private_key"] = self.private_key
112        return value

A connection limited to other Ditto small peers.

private_key, if given, is used as a shared secret to authenticate peer-to-peer connections. The default, None, means peer-to-peer traffic is unencrypted in transit.

DittoConfigConnectSmallPeersOnly(private_key: str | None = None)
private_key: str | None
def to_cbor_object(self) -> dict[str, typing.Any]:
106    def to_cbor_object(self) -> dict[str, Any]:
107        """Encode this connection as a CBOR-compatible mapping."""
108
109        value: dict[str, Any] = {"type": "small_peers_only"}
110        if self.private_key is not None:
111            value["private_key"] = self.private_key
112        return value

Encode this connection as a CBOR-compatible mapping.

@dataclass(slots=True)
class DittoConnect:
289@dataclass(slots=True)
290class DittoConnect:
291    """Known remote endpoints to which Ditto should connect.
292
293    ``tcp_servers`` entries are strings of the form ``"host:port"``.
294    ``websocket_urls`` entries are URLs of the form
295    ``"wss://some.example.com"``. ``retry_interval`` is the delay before
296    retrying a failed connection attempt; it is encoded to Core as a
297    whole number of milliseconds, so it must be non-negative and fit in
298    32 bits.
299    """
300
301    tcp_servers: set[str] = field(default_factory=set)
302    websocket_urls: set[str] = field(default_factory=set)
303    retry_interval: timedelta = timedelta(seconds=5)
304
305    def to_cbor_object(self) -> dict[str, Any]:
306        """Encode this config as a CBOR-compatible mapping."""
307
308        return {
309            "tcp_servers": sorted(self.tcp_servers),
310            "websocket_urls": sorted(self.websocket_urls),
311            "retry_interval": _duration_milliseconds(self.retry_interval),
312        }
313
314    @classmethod
315    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoConnect:
316        """Build a :class:`DittoConnect` from a decoded CBOR mapping."""
317
318        retry_milliseconds = _uint(
319            value.get("retry_interval", 5_000),
320            "connect.retry_interval",
321            0xFFFF_FFFE,
322        )
323        return cls(
324            tcp_servers=_string_set(value.get("tcp_servers", []), "connect.tcp_servers"),
325            websocket_urls=_string_set(value.get("websocket_urls", []), "connect.websocket_urls"),
326            retry_interval=timedelta(milliseconds=retry_milliseconds),
327        )

Known remote endpoints to which Ditto should connect.

tcp_servers entries are strings of the form "host:port". websocket_urls entries are URLs of the form "wss://some.example.com". retry_interval is the delay before retrying a failed connection attempt; it is encoded to Core as a whole number of milliseconds, so it must be non-negative and fit in 32 bits.

DittoConnect( tcp_servers: set[str] = <factory>, websocket_urls: set[str] = <factory>, retry_interval: datetime.timedelta = datetime.timedelta(seconds=5))
tcp_servers: set[str]
websocket_urls: set[str]
retry_interval: datetime.timedelta
def to_cbor_object(self) -> dict[str, typing.Any]:
305    def to_cbor_object(self) -> dict[str, Any]:
306        """Encode this config as a CBOR-compatible mapping."""
307
308        return {
309            "tcp_servers": sorted(self.tcp_servers),
310            "websocket_urls": sorted(self.websocket_urls),
311            "retry_interval": _duration_milliseconds(self.retry_interval),
312        }

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> DittoConnect:
314    @classmethod
315    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoConnect:
316        """Build a :class:`DittoConnect` from a decoded CBOR mapping."""
317
318        retry_milliseconds = _uint(
319            value.get("retry_interval", 5_000),
320            "connect.retry_interval",
321            0xFFFF_FFFE,
322        )
323        return cls(
324            tcp_servers=_string_set(value.get("tcp_servers", []), "connect.tcp_servers"),
325            websocket_urls=_string_set(value.get("websocket_urls", []), "connect.websocket_urls"),
326            retry_interval=timedelta(milliseconds=retry_milliseconds),
327        )

Build a DittoConnect from a decoded CBOR mapping.

class DittoCrdtError(ditto.DittoStoreError):
85class DittoCrdtError(DittoStoreError):
86    """A CRDT operation failed."""

A CRDT operation failed.

class DittoDepthLimitExceededValidationError(ditto.DittoValidationError):
153class DittoDepthLimitExceededValidationError(DittoValidationError):
154    """Dictionary-like input exceeded a Ditto nesting-depth limit."""

Dictionary-like input exceeded a Ditto nesting-depth limit.

class DittoEncryptionError(ditto.DittoError):
109class DittoEncryptionError(DittoError):
110    """A store could not be opened because of an encryption problem."""

A store could not be opened because of an encryption problem.

class DittoEncryptionKeyInvalidError(ditto.DittoEncryptionError):
121class DittoEncryptionKeyInvalidError(DittoEncryptionError):
122    """The provided encryption key does not match the store on disk.
123
124    An encrypted store whose contents are corrupt fails key validation the
125    same way, so severe corruption of an encrypted store can also surface
126    as this error.
127    """

The provided encryption key does not match the store on disk.

An encrypted store whose contents are corrupt fails key validation the same way, so severe corruption of an encrypted store can also surface as this error.

class DittoEncryptionKeyRequiredError(ditto.DittoEncryptionError):
113class DittoEncryptionKeyRequiredError(DittoEncryptionError):
114    """The store on disk is encrypted but no encryption key was provided."""

The store on disk is encrypted but no encryption key was provided.

class DittoEncryptionKeyUnexpectedError(ditto.DittoEncryptionError):
117class DittoEncryptionKeyUnexpectedError(DittoEncryptionError):
118    """An encryption key was provided but the store on disk is not encrypted."""

An encryption key was provided but the store on disk is not encrypted.

class DittoEncryptionMetadataCorruptError(ditto.DittoEncryptionError):
138class DittoEncryptionMetadataCorruptError(DittoEncryptionError):
139    """The store's encryption metadata is corrupt or inconsistent with the store on disk.
140
141    Recovery is to wipe and re-sync.
142    """

The store's encryption metadata is corrupt or inconsistent with the store on disk.

Recovery is to wipe and re-sync.

class DittoEncryptionUnsupportedSchemeError(ditto.DittoEncryptionError):
130class DittoEncryptionUnsupportedSchemeError(DittoEncryptionError):
131    """The store's on-disk encryption scheme was written by a newer SDK.
132
133    The scheme is not supported by this build. Upgrade the SDK, or wipe and
134    re-sync.
135    """

The store's on-disk encryption scheme was written by a newer SDK.

The scheme is not supported by this build. Upgrade the SDK, or wipe and re-sync.

class DittoError(builtins.Exception):
17class DittoError(Exception):
18    """Base class for errors reported by Ditto or the Python SDK."""

Base class for errors reported by Ditto or the Python SDK.

class DittoExpirationHandlerMissingError(ditto.DittoAuthenticationError):
246class DittoExpirationHandlerMissingError(DittoAuthenticationError):
247    """Server sync requires an authentication expiration handler."""

Server sync requires an authentication expiration handler.

class DittoFailedToCreateAttachmentError(ditto.DittoError):
205class DittoFailedToCreateAttachmentError(DittoError):
206    """Creating an attachment failed.
207
208    This directly subclasses :class:`DittoError` for parity with the v5 .NET
209    hierarchy.
210    """

Creating an attachment failed.

This directly subclasses DittoError for parity with the v5 .NET hierarchy.

class DittoFailedToFetchAttachmentError(ditto.DittoAttachmentError):
197class DittoFailedToFetchAttachmentError(DittoAttachmentError):
198    """An attachment fetch failed."""

An attachment fetch failed.

@dataclass(slots=True)
class DittoGlobal:
472@dataclass(slots=True)
473class DittoGlobal:
474    """Settings shared by all transports.
475
476    ``sync_group`` partitions the mesh so that peer-to-peer connections
477    only form automatically between peers in the same group. This is a
478    performance optimization, not a security control: an explicit
479    :class:`DittoConnect` connection still syncs across groups regardless
480    of ``sync_group``. Use Ditto's permissions system to restrict access
481    to data.
482
483    ``routing_hint`` is a best-effort hint that peers sharing the same
484    value should be routed similarly for efficiency. It never affects
485    the data returned by a query.
486    """
487
488    NO_PREFERRED_ROUTE_HINT: ClassVar[int] = 0
489
490    sync_group: int = 0
491    routing_hint: int = NO_PREFERRED_ROUTE_HINT
492
493    def to_cbor_object(self) -> dict[str, Any]:
494        """Encode this config as a CBOR-compatible mapping."""
495
496        return {
497            "sync_group": self.sync_group,
498            "routing_hint": self.routing_hint,
499        }
500
501    @classmethod
502    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoGlobal:
503        """Build a :class:`DittoGlobal` from a decoded CBOR mapping."""
504
505        return cls(
506            sync_group=_uint(value.get("sync_group", 0), "global.sync_group", 0xFFFF_FFFF),
507            routing_hint=_uint(
508                value.get("routing_hint", cls.NO_PREFERRED_ROUTE_HINT),
509                "global.routing_hint",
510                0xFFFF_FFFF,
511            ),
512        )

Settings shared by all transports.

sync_group partitions the mesh so that peer-to-peer connections only form automatically between peers in the same group. This is a performance optimization, not a security control: an explicit DittoConnect connection still syncs across groups regardless of sync_group. Use Ditto's permissions system to restrict access to data.

routing_hint is a best-effort hint that peers sharing the same value should be routed similarly for efficiency. It never affects the data returned by a query.

DittoGlobal(sync_group: int = 0, routing_hint: int = 0)
NO_PREFERRED_ROUTE_HINT: ClassVar[int] = 0
sync_group: int
routing_hint: int
def to_cbor_object(self) -> dict[str, typing.Any]:
493    def to_cbor_object(self) -> dict[str, Any]:
494        """Encode this config as a CBOR-compatible mapping."""
495
496        return {
497            "sync_group": self.sync_group,
498            "routing_hint": self.routing_hint,
499        }

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> DittoGlobal:
501    @classmethod
502    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoGlobal:
503        """Build a :class:`DittoGlobal` from a decoded CBOR mapping."""
504
505        return cls(
506            sync_group=_uint(value.get("sync_group", 0), "global.sync_group", 0xFFFF_FFFF),
507            routing_hint=_uint(
508                value.get("routing_hint", cls.NO_PREFERRED_ROUTE_HINT),
509                "global.routing_hint",
510                0xFFFF_FFFF,
511            ),
512        )

Build a DittoGlobal from a decoded CBOR mapping.

@dataclass(slots=True)
class DittoHttpListenConfig:
361@dataclass(slots=True)
362class DittoHttpListenConfig:
363    """HTTP/WebSocket listener and identity-provider settings.
364
365    ``websocket_sync`` has security implications — see
366    https://docs.ditto.live before enabling it. ``tls_key_path`` and
367    ``tls_certificate_path`` (PEM-formatted file paths) must be set
368    together; if either is left unset, the listener serves plain HTTP.
369    """
370
371    enabled: bool = False
372    interface_ip: str = "[::]"
373    port: int = 80
374    websocket_sync: bool = False
375    tls_key_path: str | None = None
376    tls_certificate_path: str | None = None
377    identity_provider: bool = False
378    identity_provider_signing_key: str | None = field(default=None, repr=False)
379    identity_provider_verifying_keys: list[str] = field(default_factory=list)
380    identity_provider_ca_key: str | None = field(default=None, repr=False)
381
382    def to_cbor_object(self) -> dict[str, Any]:
383        """Encode this config as a CBOR-compatible mapping."""
384
385        value: dict[str, Any] = {
386            "enabled": self.enabled,
387            "interface_ip": self.interface_ip,
388            "port": self.port,
389            "websocket_sync": self.websocket_sync,
390            "identity_provider": self.identity_provider,
391        }
392        if self.tls_key_path:
393            value["tls_key_path"] = self.tls_key_path
394        if self.tls_certificate_path:
395            value["tls_certificate_path"] = self.tls_certificate_path
396        if self.identity_provider_signing_key:
397            value["identity_provider_signing_key"] = self.identity_provider_signing_key
398        if self.identity_provider_verifying_keys:
399            value["identity_provider_verifying_keys"] = list(self.identity_provider_verifying_keys)
400        if self.identity_provider_ca_key:
401            value["identity_provider_ca_key"] = self.identity_provider_ca_key
402        return value
403
404    @classmethod
405    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoHttpListenConfig:
406        """Build a :class:`DittoHttpListenConfig` from a decoded CBOR mapping."""
407
408        identity_provider = value.get("identity_provider", value.get("is_identity_provider", False))
409        identity_provider_ca_key = value.get("identity_provider_ca_key", value.get("ca_key"))
410        return cls(
411            enabled=_bool(value.get("enabled", False), "listen.http.enabled"),
412            interface_ip=_str(
413                value.get("interface_ip", "[::]"),
414                "listen.http.interface_ip",
415            ),
416            port=_uint(value.get("port", 80), "listen.http.port", 0xFFFF),
417            websocket_sync=_bool(
418                value.get("websocket_sync", False),
419                "listen.http.websocket_sync",
420            ),
421            tls_key_path=_optional_str(value.get("tls_key_path"), "listen.http.tls_key_path"),
422            tls_certificate_path=_optional_str(
423                value.get("tls_certificate_path"),
424                "listen.http.tls_certificate_path",
425            ),
426            identity_provider=_bool(identity_provider, "listen.http.identity_provider"),
427            identity_provider_signing_key=_optional_str(
428                value.get("identity_provider_signing_key"),
429                "listen.http.identity_provider_signing_key",
430            ),
431            identity_provider_verifying_keys=_string_list(
432                value.get("identity_provider_verifying_keys", []),
433                "listen.http.identity_provider_verifying_keys",
434            ),
435            identity_provider_ca_key=_optional_str(
436                identity_provider_ca_key,
437                "listen.http.identity_provider_ca_key",
438            ),
439        )

HTTP/WebSocket listener and identity-provider settings.

websocket_sync has security implications — see https://docs.ditto.live before enabling it. tls_key_path and tls_certificate_path (PEM-formatted file paths) must be set together; if either is left unset, the listener serves plain HTTP.

DittoHttpListenConfig( enabled: bool = False, interface_ip: str = '[::]', port: int = 80, websocket_sync: bool = False, tls_key_path: str | None = None, tls_certificate_path: str | None = None, identity_provider: bool = False, identity_provider_signing_key: str | None = None, identity_provider_verifying_keys: list[str] = <factory>, identity_provider_ca_key: str | None = None)
enabled: bool
interface_ip: str
port: int
websocket_sync: bool
tls_key_path: str | None
tls_certificate_path: str | None
identity_provider: bool
identity_provider_signing_key: str | None
identity_provider_verifying_keys: list[str]
identity_provider_ca_key: str | None
def to_cbor_object(self) -> dict[str, typing.Any]:
382    def to_cbor_object(self) -> dict[str, Any]:
383        """Encode this config as a CBOR-compatible mapping."""
384
385        value: dict[str, Any] = {
386            "enabled": self.enabled,
387            "interface_ip": self.interface_ip,
388            "port": self.port,
389            "websocket_sync": self.websocket_sync,
390            "identity_provider": self.identity_provider,
391        }
392        if self.tls_key_path:
393            value["tls_key_path"] = self.tls_key_path
394        if self.tls_certificate_path:
395            value["tls_certificate_path"] = self.tls_certificate_path
396        if self.identity_provider_signing_key:
397            value["identity_provider_signing_key"] = self.identity_provider_signing_key
398        if self.identity_provider_verifying_keys:
399            value["identity_provider_verifying_keys"] = list(self.identity_provider_verifying_keys)
400        if self.identity_provider_ca_key:
401            value["identity_provider_ca_key"] = self.identity_provider_ca_key
402        return value

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> DittoHttpListenConfig:
404    @classmethod
405    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoHttpListenConfig:
406        """Build a :class:`DittoHttpListenConfig` from a decoded CBOR mapping."""
407
408        identity_provider = value.get("identity_provider", value.get("is_identity_provider", False))
409        identity_provider_ca_key = value.get("identity_provider_ca_key", value.get("ca_key"))
410        return cls(
411            enabled=_bool(value.get("enabled", False), "listen.http.enabled"),
412            interface_ip=_str(
413                value.get("interface_ip", "[::]"),
414                "listen.http.interface_ip",
415            ),
416            port=_uint(value.get("port", 80), "listen.http.port", 0xFFFF),
417            websocket_sync=_bool(
418                value.get("websocket_sync", False),
419                "listen.http.websocket_sync",
420            ),
421            tls_key_path=_optional_str(value.get("tls_key_path"), "listen.http.tls_key_path"),
422            tls_certificate_path=_optional_str(
423                value.get("tls_certificate_path"),
424                "listen.http.tls_certificate_path",
425            ),
426            identity_provider=_bool(identity_provider, "listen.http.identity_provider"),
427            identity_provider_signing_key=_optional_str(
428                value.get("identity_provider_signing_key"),
429                "listen.http.identity_provider_signing_key",
430            ),
431            identity_provider_verifying_keys=_string_list(
432                value.get("identity_provider_verifying_keys", []),
433                "listen.http.identity_provider_verifying_keys",
434            ),
435            identity_provider_ca_key=_optional_str(
436                identity_provider_ca_key,
437                "listen.http.identity_provider_ca_key",
438            ),
439        )

Build a DittoHttpListenConfig from a decoded CBOR mapping.

class DittoIOError(ditto.DittoError):
213class DittoIOError(DittoError):
214    """Base class for filesystem I/O failures reported by Ditto."""

Base class for filesystem I/O failures reported by Ditto.

class DittoInvalidCborError(ditto.DittoValidationError):
169class DittoInvalidCborError(DittoValidationError):
170    """Input did not contain valid CBOR data."""

Input did not contain valid CBOR data.

class DittoInvalidConfigError(ditto.DittoValidationError):
173class DittoInvalidConfigError(DittoValidationError):
174    """A :class:`DittoConfig` is invalid."""

A DittoConfig is invalid.

class DittoInvalidJsonError(ditto.DittoValidationError):
165class DittoInvalidJsonError(DittoValidationError):
166    """Input did not contain valid JSON data."""

Input did not contain valid JSON data.

class DittoInvalidTransportConfigError(ditto.DittoValidationError):
177class DittoInvalidTransportConfigError(DittoValidationError):
178    """A :class:`DittoTransportConfig` is invalid."""

A DittoTransportConfig is invalid.

@dataclass(slots=True)
class DittoLanConfig:
207@dataclass(slots=True)
208class DittoLanConfig:
209    """Local-area-network discovery and transport settings."""
210
211    enabled: bool = False
212    mdns_enabled: bool = True
213    multicast_enabled: bool = True
214
215    def to_cbor_object(self) -> dict[str, Any]:
216        """Encode this config as a CBOR-compatible mapping."""
217
218        return {
219            "enabled": self.enabled,
220            "mdns_enabled": self.mdns_enabled,
221            "multicast_enabled": self.multicast_enabled,
222        }
223
224    @classmethod
225    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoLanConfig:
226        """Build a :class:`DittoLanConfig` from a decoded CBOR mapping."""
227
228        return cls(
229            enabled=_bool(value.get("enabled", False), "lan.enabled"),
230            mdns_enabled=_bool(value.get("mdns_enabled", True), "lan.mdns_enabled"),
231            multicast_enabled=_bool(
232                value.get("multicast_enabled", True),
233                "lan.multicast_enabled",
234            ),
235        )

Local-area-network discovery and transport settings.

DittoLanConfig( enabled: bool = False, mdns_enabled: bool = True, multicast_enabled: bool = True)
enabled: bool
mdns_enabled: bool
multicast_enabled: bool
def to_cbor_object(self) -> dict[str, typing.Any]:
215    def to_cbor_object(self) -> dict[str, Any]:
216        """Encode this config as a CBOR-compatible mapping."""
217
218        return {
219            "enabled": self.enabled,
220            "mdns_enabled": self.mdns_enabled,
221            "multicast_enabled": self.multicast_enabled,
222        }

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> DittoLanConfig:
224    @classmethod
225    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoLanConfig:
226        """Build a :class:`DittoLanConfig` from a decoded CBOR mapping."""
227
228        return cls(
229            enabled=_bool(value.get("enabled", False), "lan.enabled"),
230            mdns_enabled=_bool(value.get("mdns_enabled", True), "lan.mdns_enabled"),
231            multicast_enabled=_bool(
232                value.get("multicast_enabled", True),
233                "lan.multicast_enabled",
234            ),
235        )

Build a DittoLanConfig from a decoded CBOR mapping.

@dataclass(slots=True)
class DittoListen:
442@dataclass(slots=True)
443class DittoListen:
444    """Incoming transport listener settings.
445
446    Advanced usage that most deployments do not need. Misconfiguration
447    can create an insecure listener — see https://docs.ditto.live before
448    enabling.
449    """
450
451    tcp: DittoTcpListenConfig = field(default_factory=DittoTcpListenConfig)
452    http: DittoHttpListenConfig = field(default_factory=DittoHttpListenConfig)
453
454    def to_cbor_object(self) -> dict[str, Any]:
455        """Encode this config as a CBOR-compatible mapping."""
456
457        return {
458            "tcp": self.tcp.to_cbor_object(),
459            "http": self.http.to_cbor_object(),
460        }
461
462    @classmethod
463    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoListen:
464        """Build a :class:`DittoListen` from a decoded CBOR mapping."""
465
466        return cls(
467            tcp=DittoTcpListenConfig.from_cbor_object(_map(value.get("tcp", {}), "listen.tcp")),
468            http=DittoHttpListenConfig.from_cbor_object(_map(value.get("http", {}), "listen.http")),
469        )

Incoming transport listener settings.

Advanced usage that most deployments do not need. Misconfiguration can create an insecure listener — see https://docs.ditto.live before enabling.

DittoListen( tcp: DittoTcpListenConfig = <factory>, http: DittoHttpListenConfig = <factory>)
def to_cbor_object(self) -> dict[str, typing.Any]:
454    def to_cbor_object(self) -> dict[str, Any]:
455        """Encode this config as a CBOR-compatible mapping."""
456
457        return {
458            "tcp": self.tcp.to_cbor_object(),
459            "http": self.http.to_cbor_object(),
460        }

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> DittoListen:
462    @classmethod
463    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoListen:
464        """Build a :class:`DittoListen` from a decoded CBOR mapping."""
465
466        return cls(
467            tcp=DittoTcpListenConfig.from_cbor_object(_map(value.get("tcp", {}), "listen.tcp")),
468            http=DittoHttpListenConfig.from_cbor_object(_map(value.get("http", {}), "listen.http")),
469        )

Build a DittoListen from a decoded CBOR mapping.

class DittoLogger:
117class DittoLogger(metaclass=_LoggerMeta):
118    """Static process-global logging controls.
119
120    Ditto continues writing to its on-disk rotating log directory after this
121    process closes a Ditto instance: that directory is rooted at the
122    persistence directory of whichever instance was most recently opened,
123    and it is not safe to delete until a new instance has been opened (which
124    redirects the process-global log destination) or the process has exited.
125    """
126
127    @staticmethod
128    async def export_to_file(path: str) -> int:
129        """Export Ditto's on-disk logs to a gzip-compressed JSON-lines file.
130
131        Ditto continuously collects DEBUG+ log records on disk, independently
132        of ``is_enabled`` and ``minimum_log_level`` (unless the
133        ``DITTO_DISABLE_ALWAYS_DEBUG_ON_DISK_LOGS`` environment variable is
134        set, in which case on-disk collection follows those settings),
135        discarding old records
136        under a retention window (15 MB across up to 15 rotated files, and up
137        to roughly 15 days, by default). This operates on the process-global
138        log buffer rooted at the persistence directory of whichever Ditto
139        instance was most recently opened in this process — it is not scoped
140        to a particular instance handle.
141
142        ``path`` must not already exist, and its parent directory must
143        already exist; ``.jsonl.gz`` is the recommended extension.
144
145        Args:
146            path: Destination file path for the exported log data.
147
148        Returns:
149            The number of bytes written.
150
151        Raises:
152            DittoAlreadyExistsIOError: ``path`` already exists.
153            DittoNotFoundIOError: the parent directory does not exist.
154            DittoPermissionDeniedIOError: denied by filesystem permissions.
155            DittoOperationFailedIOError: the export failed for another
156                filesystem-related reason.
157        """
158
159        from ._ffi._bindings import BoxDynFnMut1_void_dittoffi_result_uint64_t
160        from ._internal.aio import native_future
161
162        initialize_runtime()
163        ffi = get_ffi()
164        path_bytes = utf8(path)
165        return await native_future(
166            BoxDynFnMut1_void_dittoffi_result_uint64_t,
167            lambda continuation: ffi.dittoffi_logger_try_export_to_file_async(
168                path_bytes, continuation
169            ),
170            lambda result: int(check_result(result, ffi)),
171        )
172
173    @staticmethod
174    def log(level: LogLevel, message: str) -> None:
175        """Emit a message through Ditto's logging pipeline at the given LogLevel.
176
177        Subject to ``is_enabled`` and ``minimum_log_level``.
178        """
179
180        initialize_runtime()
181        get_ffi().ditto_log(int(level), utf8(message))

Static process-global logging controls.

Ditto continues writing to its on-disk rotating log directory after this process closes a Ditto instance: that directory is rooted at the persistence directory of whichever instance was most recently opened, and it is not safe to delete until a new instance has been opened (which redirects the process-global log destination) or the process has exited.

@staticmethod
async def export_to_file(path: str) -> int:
127    @staticmethod
128    async def export_to_file(path: str) -> int:
129        """Export Ditto's on-disk logs to a gzip-compressed JSON-lines file.
130
131        Ditto continuously collects DEBUG+ log records on disk, independently
132        of ``is_enabled`` and ``minimum_log_level`` (unless the
133        ``DITTO_DISABLE_ALWAYS_DEBUG_ON_DISK_LOGS`` environment variable is
134        set, in which case on-disk collection follows those settings),
135        discarding old records
136        under a retention window (15 MB across up to 15 rotated files, and up
137        to roughly 15 days, by default). This operates on the process-global
138        log buffer rooted at the persistence directory of whichever Ditto
139        instance was most recently opened in this process — it is not scoped
140        to a particular instance handle.
141
142        ``path`` must not already exist, and its parent directory must
143        already exist; ``.jsonl.gz`` is the recommended extension.
144
145        Args:
146            path: Destination file path for the exported log data.
147
148        Returns:
149            The number of bytes written.
150
151        Raises:
152            DittoAlreadyExistsIOError: ``path`` already exists.
153            DittoNotFoundIOError: the parent directory does not exist.
154            DittoPermissionDeniedIOError: denied by filesystem permissions.
155            DittoOperationFailedIOError: the export failed for another
156                filesystem-related reason.
157        """
158
159        from ._ffi._bindings import BoxDynFnMut1_void_dittoffi_result_uint64_t
160        from ._internal.aio import native_future
161
162        initialize_runtime()
163        ffi = get_ffi()
164        path_bytes = utf8(path)
165        return await native_future(
166            BoxDynFnMut1_void_dittoffi_result_uint64_t,
167            lambda continuation: ffi.dittoffi_logger_try_export_to_file_async(
168                path_bytes, continuation
169            ),
170            lambda result: int(check_result(result, ffi)),
171        )

Export Ditto's on-disk logs to a gzip-compressed JSON-lines file.

Ditto continuously collects DEBUG+ log records on disk, independently of is_enabled and minimum_log_level (unless the DITTO_DISABLE_ALWAYS_DEBUG_ON_DISK_LOGS environment variable is set, in which case on-disk collection follows those settings), discarding old records under a retention window (15 MB across up to 15 rotated files, and up to roughly 15 days, by default). This operates on the process-global log buffer rooted at the persistence directory of whichever Ditto instance was most recently opened in this process — it is not scoped to a particular instance handle.

path must not already exist, and its parent directory must already exist; .jsonl.gz is the recommended extension.

Arguments:
  • path: Destination file path for the exported log data.
Returns:

The number of bytes written.

Raises:
  • DittoAlreadyExistsIOError: path already exists.
  • DittoNotFoundIOError: the parent directory does not exist.
  • DittoPermissionDeniedIOError: denied by filesystem permissions.
  • DittoOperationFailedIOError: the export failed for another filesystem-related reason.
@staticmethod
def log(level: LogLevel, message: str) -> None:
173    @staticmethod
174    def log(level: LogLevel, message: str) -> None:
175        """Emit a message through Ditto's logging pipeline at the given LogLevel.
176
177        Subject to ``is_enabled`` and ``minimum_log_level``.
178        """
179
180        initialize_runtime()
181        get_ffi().ditto_log(int(level), utf8(message))

Emit a message through Ditto's logging pipeline at the given LogLevel.

Subject to is_enabled and minimum_log_level.

class DittoNotADictionaryValidationError(ditto.DittoValidationError):
157class DittoNotADictionaryValidationError(DittoValidationError):
158    """A mapping was required but another type was supplied."""

A mapping was required but another type was supplied.

class DittoNotFoundIOError(ditto.DittoIOError):
221class DittoNotFoundIOError(DittoIOError):
222    """A file or directory could not be found."""

A file or directory could not be found.

class DittoNotJsonCompatibleError(ditto.DittoValidationError):
161class DittoNotJsonCompatibleError(DittoValidationError):
162    """A value could not be represented as JSON."""

A value could not be represented as JSON.

class DittoOperationFailedIOError(ditto.DittoIOError):
229class DittoOperationFailedIOError(DittoIOError):
230    """An I/O operation failed for an otherwise unspecified reason."""

An I/O operation failed for an otherwise unspecified reason.

@dataclass(slots=True)
class DittoPeerToPeer:
238@dataclass(slots=True)
239class DittoPeerToPeer:
240    """Transports used to discover and connect to nearby small peers."""
241
242    lan: DittoLanConfig = field(default_factory=DittoLanConfig)
243    awdl: AwdlConfig = field(default_factory=AwdlConfig)
244    wifi_aware: WifiAwareConfig = field(default_factory=WifiAwareConfig)
245    bluetooth_le: BluetoothLEConfig = field(default_factory=BluetoothLEConfig)
246    #: Private beta configuration for reliable UDP multicast transport. See
247    #: :class:`MulticastBetaConfig`.
248    multicast_beta: MulticastBetaConfig = field(default_factory=MulticastBetaConfig)
249
250    def to_cbor_object(self) -> dict[str, Any]:
251        """Encode this config as a CBOR-compatible mapping."""
252
253        return {
254            "lan": self.lan.to_cbor_object(),
255            "awdl": self.awdl.to_cbor_object(),
256            "wifi_aware": self.wifi_aware.to_cbor_object(),
257            "bluetooth_le": self.bluetooth_le.to_cbor_object(),
258            "multicast": self.multicast_beta.to_cbor_object(),
259        }
260
261    @classmethod
262    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoPeerToPeer:
263        """Build a :class:`DittoPeerToPeer` from a decoded CBOR mapping."""
264
265        return cls(
266            lan=DittoLanConfig.from_cbor_object(_map(value.get("lan", {}), "peer_to_peer.lan")),
267            awdl=AwdlConfig.from_cbor_object(_map(value.get("awdl", {}), "peer_to_peer.awdl")),
268            wifi_aware=WifiAwareConfig.from_cbor_object(
269                _map(
270                    value.get("wifi_aware", {}),
271                    "peer_to_peer.wifi_aware",
272                )
273            ),
274            bluetooth_le=BluetoothLEConfig.from_cbor_object(
275                _map(
276                    value.get("bluetooth_le", {}),
277                    "peer_to_peer.bluetooth_le",
278                )
279            ),
280            # The multicast field may be omitted from transport config
281            # snapshots. Decode it as the disabled default so reads stay
282            # compatible.
283            multicast_beta=MulticastBetaConfig.from_cbor_object(
284                _map(value.get("multicast", {}), "peer_to_peer.multicast")
285            ),
286        )

Transports used to discover and connect to nearby small peers.

DittoPeerToPeer( lan: DittoLanConfig = <factory>, awdl: AwdlConfig = <factory>, wifi_aware: WifiAwareConfig = <factory>, bluetooth_le: BluetoothLEConfig = <factory>, multicast_beta: MulticastBetaConfig = <factory>)
awdl: AwdlConfig
wifi_aware: WifiAwareConfig
bluetooth_le: BluetoothLEConfig
multicast_beta: MulticastBetaConfig
def to_cbor_object(self) -> dict[str, typing.Any]:
250    def to_cbor_object(self) -> dict[str, Any]:
251        """Encode this config as a CBOR-compatible mapping."""
252
253        return {
254            "lan": self.lan.to_cbor_object(),
255            "awdl": self.awdl.to_cbor_object(),
256            "wifi_aware": self.wifi_aware.to_cbor_object(),
257            "bluetooth_le": self.bluetooth_le.to_cbor_object(),
258            "multicast": self.multicast_beta.to_cbor_object(),
259        }

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> DittoPeerToPeer:
261    @classmethod
262    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoPeerToPeer:
263        """Build a :class:`DittoPeerToPeer` from a decoded CBOR mapping."""
264
265        return cls(
266            lan=DittoLanConfig.from_cbor_object(_map(value.get("lan", {}), "peer_to_peer.lan")),
267            awdl=AwdlConfig.from_cbor_object(_map(value.get("awdl", {}), "peer_to_peer.awdl")),
268            wifi_aware=WifiAwareConfig.from_cbor_object(
269                _map(
270                    value.get("wifi_aware", {}),
271                    "peer_to_peer.wifi_aware",
272                )
273            ),
274            bluetooth_le=BluetoothLEConfig.from_cbor_object(
275                _map(
276                    value.get("bluetooth_le", {}),
277                    "peer_to_peer.bluetooth_le",
278                )
279            ),
280            # The multicast field may be omitted from transport config
281            # snapshots. Decode it as the disabled default so reads stay
282            # compatible.
283            multicast_beta=MulticastBetaConfig.from_cbor_object(
284                _map(value.get("multicast", {}), "peer_to_peer.multicast")
285            ),
286        )

Build a DittoPeerToPeer from a decoded CBOR mapping.

class DittoPermissionDeniedIOError(ditto.DittoIOError):
225class DittoPermissionDeniedIOError(DittoIOError):
226    """An I/O operation failed because permission was denied."""

An I/O operation failed because permission was denied.

class DittoPersistenceDirectoryLockedError(ditto.DittoStoreError):
105class DittoPersistenceDirectoryLockedError(DittoStoreError):
106    """The persistence directory is already in use by another Ditto."""

The persistence directory is already in use by another Ditto.

class DittoQueryArgumentsInvalidError(ditto.DittoStoreError):
93class DittoQueryArgumentsInvalidError(DittoStoreError):
94    """The arguments supplied to a DQL statement are invalid."""

The arguments supplied to a DQL statement are invalid.

class DittoQueryError(ditto.DittoStoreError):
81class DittoQueryError(DittoStoreError):
82    """A query failed during execution."""

A query failed during execution.

class DittoQueryInvalidError(ditto.DittoStoreError):
89class DittoQueryInvalidError(DittoStoreError):
90    """A DQL statement could not be compiled."""

A DQL statement could not be compiled.

class DittoQueryNotSupportedError(ditto.DittoStoreError):
101class DittoQueryNotSupportedError(DittoStoreError):
102    """A valid DQL statement uses an unsupported feature."""

A valid DQL statement uses an unsupported feature.

class DittoSizeLimitExceededValidationError(ditto.DittoValidationError):
149class DittoSizeLimitExceededValidationError(DittoValidationError):
150    """Input exceeded a Ditto size limit."""

Input exceeded a Ditto size limit.

class DittoStoreBackendError(ditto.DittoStoreError):
77class DittoStoreBackendError(DittoStoreError):
78    """The storage backend reported an error."""

The storage backend reported an error.

class DittoStoreDocumentNotFoundError(ditto.DittoStoreError):
73class DittoStoreDocumentNotFoundError(DittoStoreError):
74    """A requested document could not be found."""

A requested document could not be found.

class DittoStoreError(ditto.DittoError):
69class DittoStoreError(DittoError):
70    """Base class for store-related failures."""

Base class for store-related failures.

DittoSystemParameterValue = bool | int | float | str | list['DittoSystemParameterValue'] | dict[str, 'DittoSystemParameterValue']
DittoSystemParameters = dict[str, bool | int | float | str | list['DittoSystemParameterValue'] | dict[str, 'DittoSystemParameterValue']]
@dataclass(slots=True)
class DittoTcpListenConfig:
330@dataclass(slots=True)
331class DittoTcpListenConfig:
332    """Raw TCP listener settings."""
333
334    enabled: bool = False
335    interface_ip: str = "[::]"
336    port: int = 4040
337
338    def to_cbor_object(self) -> dict[str, Any]:
339        """Encode this config as a CBOR-compatible mapping."""
340
341        return {
342            "enabled": self.enabled,
343            "interface_ip": self.interface_ip,
344            "port": self.port,
345        }
346
347    @classmethod
348    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoTcpListenConfig:
349        """Build a :class:`DittoTcpListenConfig` from a decoded CBOR mapping."""
350
351        return cls(
352            enabled=_bool(value.get("enabled", False), "listen.tcp.enabled"),
353            interface_ip=_str(
354                value.get("interface_ip", "[::]"),
355                "listen.tcp.interface_ip",
356            ),
357            port=_uint(value.get("port", 4040), "listen.tcp.port", 0xFFFF),
358        )

Raw TCP listener settings.

DittoTcpListenConfig(enabled: bool = False, interface_ip: str = '[::]', port: int = 4040)
enabled: bool
interface_ip: str
port: int
def to_cbor_object(self) -> dict[str, typing.Any]:
338    def to_cbor_object(self) -> dict[str, Any]:
339        """Encode this config as a CBOR-compatible mapping."""
340
341        return {
342            "enabled": self.enabled,
343            "interface_ip": self.interface_ip,
344            "port": self.port,
345        }

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> DittoTcpListenConfig:
347    @classmethod
348    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoTcpListenConfig:
349        """Build a :class:`DittoTcpListenConfig` from a decoded CBOR mapping."""
350
351        return cls(
352            enabled=_bool(value.get("enabled", False), "listen.tcp.enabled"),
353            interface_ip=_str(
354                value.get("interface_ip", "[::]"),
355                "listen.tcp.interface_ip",
356            ),
357            port=_uint(value.get("port", 4040), "listen.tcp.port", 0xFFFF),
358        )

Build a DittoTcpListenConfig from a decoded CBOR mapping.

class DittoTransactionReadOnlyError(ditto.DittoStoreError):
97class DittoTransactionReadOnlyError(DittoStoreError):
98    """A mutating DQL statement was used in a read-only transaction."""

A mutating DQL statement was used in a read-only transaction.

@dataclass(slots=True)
class DittoTransportConfig:
515@dataclass(slots=True)
516class DittoTransportConfig:
517    """The transports Ditto should use for discovery and synchronization.
518
519    A newly constructed value has every peer-to-peer transport disabled.  The
520    nested records are mutable so callers can update them directly, matching
521    Python's normal configuration style without the .NET builder split.
522    """
523
524    peer_to_peer: DittoPeerToPeer = field(default_factory=DittoPeerToPeer)
525    connect: DittoConnect = field(default_factory=DittoConnect)
526    listen: DittoListen = field(default_factory=DittoListen)
527    global_: DittoGlobal = field(default_factory=DittoGlobal)
528
529    @property
530    def global_config(self) -> DittoGlobal:
531        """Alias for :attr:`global_` that avoids Python's ``global`` keyword."""
532
533        return self.global_
534
535    @global_config.setter
536    def global_config(self, value: DittoGlobal) -> None:
537        self.global_ = value
538
539    def copy(self) -> DittoTransportConfig:
540        """Return a deep copy suitable for a safe update callback."""
541
542        return deepcopy(self)
543
544    def enable_all_peer_to_peer(self) -> DittoTransportConfig:
545        """Enable every stable peer-to-peer transport in place and return ``self``.
546
547        The C# SDK returns a rebuilt immutable configuration here. Python's
548        transport records are deliberately mutable, so an in-place fluent
549        mutator is the natural equivalent. Use :meth:`copy` first when the
550        original value must remain unchanged.
551
552        This method does not affect the reliable UDP multicast transport.
553        Configure :attr:`DittoPeerToPeer.multicast_beta` separately.
554        """
555
556        self.peer_to_peer.lan.enabled = True
557        self.peer_to_peer.awdl.enabled = True
558        self.peer_to_peer.bluetooth_le.enabled = True
559        self.peer_to_peer.wifi_aware.enabled = True
560        return self
561
562    def to_cbor_object(self) -> dict[str, Any]:
563        """Encode this config as a CBOR-compatible mapping."""
564
565        return {
566            "peer_to_peer": self.peer_to_peer.to_cbor_object(),
567            "connect": self.connect.to_cbor_object(),
568            "listen": self.listen.to_cbor_object(),
569            "global": self.global_.to_cbor_object(),
570        }
571
572    @classmethod
573    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoTransportConfig:
574        """Build a :class:`DittoTransportConfig` from a decoded CBOR mapping."""
575
576        if not isinstance(value, Mapping):
577            raise DittoInvalidTransportConfigError("DittoTransportConfig must be a CBOR map.")
578        return cls(
579            peer_to_peer=DittoPeerToPeer.from_cbor_object(
580                _map(value.get("peer_to_peer", {}), "peer_to_peer")
581            ),
582            connect=DittoConnect.from_cbor_object(_map(value.get("connect", {}), "connect")),
583            listen=DittoListen.from_cbor_object(_map(value.get("listen", {}), "listen")),
584            global_=DittoGlobal.from_cbor_object(_map(value.get("global", {}), "global")),
585        )
586
587    def to_cbor(self) -> bytes:
588        """Encode this transport configuration as canonical CBOR."""
589
590        return cbor2.dumps(self.to_cbor_object(), canonical=True)
591
592    to_cbor_bytes = to_cbor
593
594    @classmethod
595    def from_cbor(
596        cls,
597        value: bytes | bytearray | memoryview | Mapping[str, Any],
598    ) -> DittoTransportConfig:
599        """Decode a transport configuration from CBOR or a mapping."""
600
601        if isinstance(value, Mapping):
602            return cls.from_cbor_object(value)
603        if not isinstance(value, (bytes, bytearray, memoryview)):
604            raise TypeError("value must be CBOR bytes or a mapping")
605        try:
606            decoded = cbor2.loads(bytes(value))
607        except (cbor2.CBORDecodeError, ValueError) as error:
608            raise DittoInvalidCborError("Could not decode DittoTransportConfig CBOR.") from error
609        if not isinstance(decoded, Mapping):
610            raise DittoInvalidTransportConfigError("DittoTransportConfig CBOR must contain a map.")
611        return cls.from_cbor_object(decoded)
612
613    from_cbor_bytes = from_cbor

The transports Ditto should use for discovery and synchronization.

A newly constructed value has every peer-to-peer transport disabled. The nested records are mutable so callers can update them directly, matching Python's normal configuration style without the .NET builder split.

DittoTransportConfig( peer_to_peer: DittoPeerToPeer = <factory>, connect: DittoConnect = <factory>, listen: DittoListen = <factory>, global_: DittoGlobal = <factory>)
peer_to_peer: DittoPeerToPeer
connect: DittoConnect
listen: DittoListen
global_: DittoGlobal
global_config: DittoGlobal
529    @property
530    def global_config(self) -> DittoGlobal:
531        """Alias for :attr:`global_` that avoids Python's ``global`` keyword."""
532
533        return self.global_

Alias for global_ that avoids Python's global keyword.

def copy(self) -> DittoTransportConfig:
539    def copy(self) -> DittoTransportConfig:
540        """Return a deep copy suitable for a safe update callback."""
541
542        return deepcopy(self)

Return a deep copy suitable for a safe update callback.

def enable_all_peer_to_peer(self) -> DittoTransportConfig:
544    def enable_all_peer_to_peer(self) -> DittoTransportConfig:
545        """Enable every stable peer-to-peer transport in place and return ``self``.
546
547        The C# SDK returns a rebuilt immutable configuration here. Python's
548        transport records are deliberately mutable, so an in-place fluent
549        mutator is the natural equivalent. Use :meth:`copy` first when the
550        original value must remain unchanged.
551
552        This method does not affect the reliable UDP multicast transport.
553        Configure :attr:`DittoPeerToPeer.multicast_beta` separately.
554        """
555
556        self.peer_to_peer.lan.enabled = True
557        self.peer_to_peer.awdl.enabled = True
558        self.peer_to_peer.bluetooth_le.enabled = True
559        self.peer_to_peer.wifi_aware.enabled = True
560        return self

Enable every stable peer-to-peer transport in place and return self.

The C# SDK returns a rebuilt immutable configuration here. Python's transport records are deliberately mutable, so an in-place fluent mutator is the natural equivalent. Use copy() first when the original value must remain unchanged.

This method does not affect the reliable UDP multicast transport. Configure DittoPeerToPeer.multicast_beta separately.

def to_cbor_object(self) -> dict[str, typing.Any]:
562    def to_cbor_object(self) -> dict[str, Any]:
563        """Encode this config as a CBOR-compatible mapping."""
564
565        return {
566            "peer_to_peer": self.peer_to_peer.to_cbor_object(),
567            "connect": self.connect.to_cbor_object(),
568            "listen": self.listen.to_cbor_object(),
569            "global": self.global_.to_cbor_object(),
570        }

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> DittoTransportConfig:
572    @classmethod
573    def from_cbor_object(cls, value: Mapping[str, Any]) -> DittoTransportConfig:
574        """Build a :class:`DittoTransportConfig` from a decoded CBOR mapping."""
575
576        if not isinstance(value, Mapping):
577            raise DittoInvalidTransportConfigError("DittoTransportConfig must be a CBOR map.")
578        return cls(
579            peer_to_peer=DittoPeerToPeer.from_cbor_object(
580                _map(value.get("peer_to_peer", {}), "peer_to_peer")
581            ),
582            connect=DittoConnect.from_cbor_object(_map(value.get("connect", {}), "connect")),
583            listen=DittoListen.from_cbor_object(_map(value.get("listen", {}), "listen")),
584            global_=DittoGlobal.from_cbor_object(_map(value.get("global", {}), "global")),
585        )

Build a DittoTransportConfig from a decoded CBOR mapping.

def to_cbor(self) -> bytes:
587    def to_cbor(self) -> bytes:
588        """Encode this transport configuration as canonical CBOR."""
589
590        return cbor2.dumps(self.to_cbor_object(), canonical=True)

Encode this transport configuration as canonical CBOR.

def to_cbor_bytes(self) -> bytes:
587    def to_cbor(self) -> bytes:
588        """Encode this transport configuration as canonical CBOR."""
589
590        return cbor2.dumps(self.to_cbor_object(), canonical=True)

Encode this transport configuration as canonical CBOR.

@classmethod
def from_cbor( cls, value: bytes | bytearray | memoryview | Mapping[str, Any]) -> DittoTransportConfig:
594    @classmethod
595    def from_cbor(
596        cls,
597        value: bytes | bytearray | memoryview | Mapping[str, Any],
598    ) -> DittoTransportConfig:
599        """Decode a transport configuration from CBOR or a mapping."""
600
601        if isinstance(value, Mapping):
602            return cls.from_cbor_object(value)
603        if not isinstance(value, (bytes, bytearray, memoryview)):
604            raise TypeError("value must be CBOR bytes or a mapping")
605        try:
606            decoded = cbor2.loads(bytes(value))
607        except (cbor2.CBORDecodeError, ValueError) as error:
608            raise DittoInvalidCborError("Could not decode DittoTransportConfig CBOR.") from error
609        if not isinstance(decoded, Mapping):
610            raise DittoInvalidTransportConfigError("DittoTransportConfig CBOR must contain a map.")
611        return cls.from_cbor_object(decoded)

Decode a transport configuration from CBOR or a mapping.

@classmethod
def from_cbor_bytes( cls, value: bytes | bytearray | memoryview | Mapping[str, Any]) -> DittoTransportConfig:
594    @classmethod
595    def from_cbor(
596        cls,
597        value: bytes | bytearray | memoryview | Mapping[str, Any],
598    ) -> DittoTransportConfig:
599        """Decode a transport configuration from CBOR or a mapping."""
600
601        if isinstance(value, Mapping):
602            return cls.from_cbor_object(value)
603        if not isinstance(value, (bytes, bytearray, memoryview)):
604            raise TypeError("value must be CBOR bytes or a mapping")
605        try:
606            decoded = cbor2.loads(bytes(value))
607        except (cbor2.CBORDecodeError, ValueError) as error:
608            raise DittoInvalidCborError("Could not decode DittoTransportConfig CBOR.") from error
609        if not isinstance(decoded, Mapping):
610            raise DittoInvalidTransportConfigError("DittoTransportConfig CBOR must contain a map.")
611        return cls.from_cbor_object(decoded)

Decode a transport configuration from CBOR or a mapping.

class DittoTransportsInitializationError(ditto.DittoError):
250class DittoTransportsInitializationError(DittoError):
251    """The native transport subsystem failed to initialize."""

The native transport subsystem failed to initialize.

class DittoUnsupportedError(ditto.DittoError):
65class DittoUnsupportedError(DittoError):
66    """The requested operation is not supported."""

The requested operation is not supported.

class DittoValidationError(ditto.DittoError):
145class DittoValidationError(DittoError):
146    """Base class for input-validation failures."""

Base class for input-validation failures.

class DocumentId:
 44class DocumentId:
 45    """A canonical, typed Ditto document identifier."""
 46
 47    __slots__ = ("_cbor", "_value")
 48
 49    def __init__(self, value: Any, *, _cbor: bytes | None = None) -> None:
 50        if _cbor is None:
 51            if value is None or not _valid_document_id(value):
 52                raise DittoValidationError(
 53                    "Document IDs must be non-null and contain only bool, int, str, bytes, "
 54                    "lists, null nested values, and string-keyed maps"
 55                )
 56            encoded = cbor2.dumps(value, canonical=True)
 57            if len(encoded) > 256:
 58                raise DittoValidationError("A document ID may not exceed 256 encoded bytes")
 59            self._cbor = encoded
 60            self._value = value
 61        else:
 62            self._cbor = bytes(_cbor)
 63            self._value = cbor2.loads(self._cbor)
 64            # The internal CBOR path is also reachable through from_cbor with
 65            # external bytes; hold it to the same shape rules as direct
 66            # construction.
 67            if self._value is None or not _valid_document_id(self._value):
 68                raise DittoValidationError(
 69                    "Document IDs must be non-null and contain only bool, int, str, bytes, "
 70                    "lists, null nested values, and string-keyed maps"
 71                )
 72
 73    @classmethod
 74    def from_cbor(cls, data: bytes) -> DocumentId:
 75        """Decode a :class:`DocumentId` from its canonical CBOR encoding."""
 76
 77        return cls(None, _cbor=data)
 78
 79    @property
 80    def value(self) -> Any:
 81        """The decoded native Python value (bool, int, str, bytes, list, dict, or None)."""
 82
 83        return self._value
 84
 85    @property
 86    def cbor(self) -> bytes:
 87        """The canonical CBOR encoding of this document ID."""
 88
 89        return self._cbor
 90
 91    @property
 92    def string(self) -> str | None:
 93        """The value as a string, or ``None`` if it is not a string."""
 94
 95        return self._value if isinstance(self._value, str) else None
 96
 97    @property
 98    def string_value(self) -> str:
 99        """The value as a string, or an empty string if it is not a string."""
100
101        return self.string or ""
102
103    @property
104    def int_value(self) -> int:
105        """The value as an int, or ``0`` if it is not an int (bools do not count)."""
106
107        if isinstance(self._value, int) and not isinstance(self._value, bool):
108            return self._value
109        return 0
110
111    @property
112    def int32_value(self) -> int:
113        """The value as a 32-bit signed int, or ``0`` if not an int or out of range."""
114
115        value = self.int_value
116        return value if -(2**31) <= value < 2**31 else 0
117
118    @property
119    def int64_value(self) -> int:
120        """The value as a 64-bit signed int, or ``0`` if not an int or out of range."""
121
122        value = self.int_value
123        return value if -(2**63) <= value < 2**63 else 0
124
125    @property
126    def uint32_value(self) -> int:
127        """The value as a 32-bit unsigned int, or ``0`` if not an int or out of range."""
128
129        value = self.int_value
130        return value if 0 <= value < 2**32 else 0
131
132    @property
133    def uint64_value(self) -> int:
134        """The value as a 64-bit unsigned int, or ``0`` if not an int or out of range."""
135
136        value = self.int_value
137        return value if 0 <= value < 2**64 else 0
138
139    @property
140    def bool_value(self) -> bool:
141        """The value as a bool, or ``False`` if it is not a bool."""
142
143        return self._value if isinstance(self._value, bool) else False
144
145    boolean_value = bool_value
146
147    @property
148    def list(self) -> list[Any] | None:
149        """The value as a list, or ``None`` if it is not a list."""
150
151        return self._value if isinstance(self._value, list) else None
152
153    @property
154    def list_value(self) -> builtins.list[Any]:
155        """The value as a list, or an empty list if it is not a list."""
156
157        return self.list or []
158
159    @property
160    def dictionary(self) -> dict[str, Any] | None:
161        """The value as a dict, or ``None`` if it is not a dict."""
162
163        return self._value if isinstance(self._value, dict) else None
164
165    @property
166    def dictionary_value(self) -> dict[str, Any]:
167        """The value as a dict, or an empty dict if it is not a dict."""
168
169        return self.dictionary or {}
170
171    def __eq__(self, other: object) -> bool:
172        return isinstance(other, DocumentId) and self._cbor == other._cbor
173
174    def __hash__(self) -> int:
175        return hash(self._cbor)
176
177    def __str__(self) -> str:
178        if isinstance(self._value, str):
179            return json.dumps(self._value, ensure_ascii=False)
180        if self._value is None:
181            return "null"
182        if self._value is True:
183            return "true"
184        if self._value is False:
185            return "false"
186        if isinstance(self._value, int):
187            return str(self._value)
188        return json.dumps(self._value, ensure_ascii=False, separators=(",", ":"), default=list)

A canonical, typed Ditto document identifier.

DocumentId(value: Any, *, _cbor: bytes | None = None)
49    def __init__(self, value: Any, *, _cbor: bytes | None = None) -> None:
50        if _cbor is None:
51            if value is None or not _valid_document_id(value):
52                raise DittoValidationError(
53                    "Document IDs must be non-null and contain only bool, int, str, bytes, "
54                    "lists, null nested values, and string-keyed maps"
55                )
56            encoded = cbor2.dumps(value, canonical=True)
57            if len(encoded) > 256:
58                raise DittoValidationError("A document ID may not exceed 256 encoded bytes")
59            self._cbor = encoded
60            self._value = value
61        else:
62            self._cbor = bytes(_cbor)
63            self._value = cbor2.loads(self._cbor)
64            # The internal CBOR path is also reachable through from_cbor with
65            # external bytes; hold it to the same shape rules as direct
66            # construction.
67            if self._value is None or not _valid_document_id(self._value):
68                raise DittoValidationError(
69                    "Document IDs must be non-null and contain only bool, int, str, bytes, "
70                    "lists, null nested values, and string-keyed maps"
71                )
@classmethod
def from_cbor(cls, data: bytes) -> DocumentId:
73    @classmethod
74    def from_cbor(cls, data: bytes) -> DocumentId:
75        """Decode a :class:`DocumentId` from its canonical CBOR encoding."""
76
77        return cls(None, _cbor=data)

Decode a DocumentId from its canonical CBOR encoding.

value: Any
79    @property
80    def value(self) -> Any:
81        """The decoded native Python value (bool, int, str, bytes, list, dict, or None)."""
82
83        return self._value

The decoded native Python value (bool, int, str, bytes, list, dict, or None).

cbor: bytes
85    @property
86    def cbor(self) -> bytes:
87        """The canonical CBOR encoding of this document ID."""
88
89        return self._cbor

The canonical CBOR encoding of this document ID.

string: str | None
91    @property
92    def string(self) -> str | None:
93        """The value as a string, or ``None`` if it is not a string."""
94
95        return self._value if isinstance(self._value, str) else None

The value as a string, or None if it is not a string.

string_value: str
 97    @property
 98    def string_value(self) -> str:
 99        """The value as a string, or an empty string if it is not a string."""
100
101        return self.string or ""

The value as a string, or an empty string if it is not a string.

int_value: int
103    @property
104    def int_value(self) -> int:
105        """The value as an int, or ``0`` if it is not an int (bools do not count)."""
106
107        if isinstance(self._value, int) and not isinstance(self._value, bool):
108            return self._value
109        return 0

The value as an int, or 0 if it is not an int (bools do not count).

int32_value: int
111    @property
112    def int32_value(self) -> int:
113        """The value as a 32-bit signed int, or ``0`` if not an int or out of range."""
114
115        value = self.int_value
116        return value if -(2**31) <= value < 2**31 else 0

The value as a 32-bit signed int, or 0 if not an int or out of range.

int64_value: int
118    @property
119    def int64_value(self) -> int:
120        """The value as a 64-bit signed int, or ``0`` if not an int or out of range."""
121
122        value = self.int_value
123        return value if -(2**63) <= value < 2**63 else 0

The value as a 64-bit signed int, or 0 if not an int or out of range.

uint32_value: int
125    @property
126    def uint32_value(self) -> int:
127        """The value as a 32-bit unsigned int, or ``0`` if not an int or out of range."""
128
129        value = self.int_value
130        return value if 0 <= value < 2**32 else 0

The value as a 32-bit unsigned int, or 0 if not an int or out of range.

uint64_value: int
132    @property
133    def uint64_value(self) -> int:
134        """The value as a 64-bit unsigned int, or ``0`` if not an int or out of range."""
135
136        value = self.int_value
137        return value if 0 <= value < 2**64 else 0

The value as a 64-bit unsigned int, or 0 if not an int or out of range.

bool_value: bool
139    @property
140    def bool_value(self) -> bool:
141        """The value as a bool, or ``False`` if it is not a bool."""
142
143        return self._value if isinstance(self._value, bool) else False

The value as a bool, or False if it is not a bool.

boolean_value: bool
139    @property
140    def bool_value(self) -> bool:
141        """The value as a bool, or ``False`` if it is not a bool."""
142
143        return self._value if isinstance(self._value, bool) else False

The value as a bool, or False if it is not a bool.

list: 'list[Any] | None'
147    @property
148    def list(self) -> list[Any] | None:
149        """The value as a list, or ``None`` if it is not a list."""
150
151        return self._value if isinstance(self._value, list) else None

The value as a list, or None if it is not a list.

list_value: list[typing.Any]
153    @property
154    def list_value(self) -> builtins.list[Any]:
155        """The value as a list, or an empty list if it is not a list."""
156
157        return self.list or []

The value as a list, or an empty list if it is not a list.

dictionary: dict[str, Any] | None
159    @property
160    def dictionary(self) -> dict[str, Any] | None:
161        """The value as a dict, or ``None`` if it is not a dict."""
162
163        return self._value if isinstance(self._value, dict) else None

The value as a dict, or None if it is not a dict.

dictionary_value: dict[str, typing.Any]
165    @property
166    def dictionary_value(self) -> dict[str, Any]:
167        """The value as a dict, or an empty dict if it is not a dict."""
168
169        return self.dictionary or {}

The value as a dict, or an empty dict if it is not a dict.

class FileType(builtins.str, enum.Enum):
45class FileType(str, Enum):
46    """The kind of filesystem entry a :class:`DiskUsageItem` represents."""
47
48    DIRECTORY = "Directory"
49    FILE = "File"
50    SYMLINK = "SymLink"

The kind of filesystem entry a DiskUsageItem represents.

DIRECTORY = <FileType.DIRECTORY: 'Directory'>
FILE = <FileType.FILE: 'File'>
class LogLevel(enum.IntEnum):
19class LogLevel(IntEnum):
20    """Log record severity levels, from most (``ERROR``) to least (``VERBOSE``) severe."""
21
22    ERROR = 1
23    WARNING = 2
24    INFO = 3
25    DEBUG = 4
26    VERBOSE = 5

Log record severity levels, from most (ERROR) to least (VERBOSE) severe.

ERROR = <LogLevel.ERROR: 1>
WARNING = <LogLevel.WARNING: 2>
INFO = <LogLevel.INFO: 3>
DEBUG = <LogLevel.DEBUG: 4>
VERBOSE = <LogLevel.VERBOSE: 5>
@dataclass(slots=True)
class MulticastBetaConfig:
156@dataclass(slots=True)
157class MulticastBetaConfig:
158    """Configuration for reliable UDP multicast transport.
159
160    Use this configuration to enable peer-to-peer data synchronization over a
161    multicast group.
162
163    This does not configure multicast-based LAN discovery. Use
164    :attr:`DittoLanConfig.multicast_enabled` for that setting.
165
166    This feature is in private beta and should only be used in coordination
167    with Ditto support.
168    """
169
170    #: Whether reliable UDP multicast transport is enabled. This private beta
171    #: feature should only be enabled in coordination with Ditto support.
172    enabled: bool = False
173    group_address: str = "224.1.2.3"
174    port: int = 6003
175    interface_name: str | None = None
176
177    def to_cbor_object(self) -> dict[str, Any]:
178        """Encode this config as a CBOR-compatible mapping."""
179
180        value: dict[str, Any] = {
181            "enabled": self.enabled,
182            "group_address": self.group_address,
183            "port": self.port,
184        }
185        if self.interface_name is not None:
186            value["interface"] = self.interface_name
187        return value
188
189    @classmethod
190    def from_cbor_object(cls, value: Mapping[str, Any]) -> MulticastBetaConfig:
191        """Build a :class:`MulticastBetaConfig` from a decoded CBOR mapping."""
192
193        return cls(
194            enabled=_bool(value.get("enabled", False), "multicast.enabled"),
195            group_address=_str(
196                value.get("group_address", "224.1.2.3"),
197                "multicast.group_address",
198            ),
199            port=_uint(value.get("port", 6003), "multicast.port", 0xFFFF),
200            interface_name=_optional_str(
201                value.get("interface"),
202                "multicast.interface",
203            ),
204        )

Configuration for reliable UDP multicast transport.

Use this configuration to enable peer-to-peer data synchronization over a multicast group.

This does not configure multicast-based LAN discovery. Use DittoLanConfig.multicast_enabled for that setting.

This feature is in private beta and should only be used in coordination with Ditto support.

MulticastBetaConfig( enabled: bool = False, group_address: str = '224.1.2.3', port: int = 6003, interface_name: str | None = None)
enabled: bool
group_address: str
port: int
interface_name: str | None
def to_cbor_object(self) -> dict[str, typing.Any]:
177    def to_cbor_object(self) -> dict[str, Any]:
178        """Encode this config as a CBOR-compatible mapping."""
179
180        value: dict[str, Any] = {
181            "enabled": self.enabled,
182            "group_address": self.group_address,
183            "port": self.port,
184        }
185        if self.interface_name is not None:
186            value["interface"] = self.interface_name
187        return value

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> MulticastBetaConfig:
189    @classmethod
190    def from_cbor_object(cls, value: Mapping[str, Any]) -> MulticastBetaConfig:
191        """Build a :class:`MulticastBetaConfig` from a decoded CBOR mapping."""
192
193        return cls(
194            enabled=_bool(value.get("enabled", False), "multicast.enabled"),
195            group_address=_str(
196                value.get("group_address", "224.1.2.3"),
197                "multicast.group_address",
198            ),
199            port=_uint(value.get("port", 6003), "multicast.port", 0xFFFF),
200            interface_name=_optional_str(
201                value.get("interface"),
202                "multicast.interface",
203            ),
204        )

Build a MulticastBetaConfig from a decoded CBOR mapping.

@dataclass(frozen=True, slots=True)
class Peer:
126@dataclass(frozen=True, slots=True)
127class Peer:
128    """A peer known to the local mesh, with its connections and declared metadata."""
129
130    peer_key: str
131    """Unique identifier for this peer.
132
133    Empty for a peer not yet updated to the latest SDK version.
134    """
135
136    connections: tuple[Connection, ...]
137    device_name: str
138    is_connected_to_ditto_server: bool
139    os: PeerOperatingSystem | None = None
140    ditto_sdk_version: str | None = None
141    is_compatible: bool | None = None
142    peer_metadata: dict[str, Any] | None = None
143    """This peer's declared metadata.
144
145    May be empty when a peer first appears, and is populated once its
146    metadata has synced.
147    """
148
149    identity_service_metadata: dict[str, Any] | None = None
150
151    @classmethod
152    def from_mapping(cls, value: Mapping[str, Any]) -> Peer:
153        """Build a :class:`Peer` from a decoded presence-graph mapping."""
154
155        peer_metadata = value.get("peerMetadata", value.get("peerInfo", {}))
156        identity_metadata = value.get(
157            "identityServiceMetadata", value.get("identityServiceInfo", {})
158        )
159        return cls(
160            peer_key=str(value.get("peerKeyString", "")),
161            connections=tuple(
162                Connection.from_mapping(connection) for connection in value.get("connections", ())
163            ),
164            device_name=str(value.get("deviceName", "")),
165            is_connected_to_ditto_server=bool(value.get("isConnectedToDittoCloud", False)),
166            os=PeerOperatingSystem.parse(value.get("os")),
167            ditto_sdk_version=(
168                str(value["dittoSdkVersion"]) if value.get("dittoSdkVersion") is not None else None
169            ),
170            is_compatible=(
171                bool(value["isCompatible"]) if value.get("isCompatible") is not None else None
172            ),
173            peer_metadata=dict(peer_metadata) if isinstance(peer_metadata, Mapping) else {},
174            identity_service_metadata=(
175                dict(identity_metadata) if isinstance(identity_metadata, Mapping) else {}
176            ),
177        )

A peer known to the local mesh, with its connections and declared metadata.

Peer( peer_key: str, connections: tuple[Connection, ...], device_name: str, is_connected_to_ditto_server: bool, os: PeerOperatingSystem | None = None, ditto_sdk_version: str | None = None, is_compatible: bool | None = None, peer_metadata: dict[str, Any] | None = None, identity_service_metadata: dict[str, Any] | None = None)
peer_key: str

Unique identifier for this peer.

Empty for a peer not yet updated to the latest SDK version.

connections: tuple[Connection, ...]
device_name: str
is_connected_to_ditto_server: bool
os: PeerOperatingSystem | None
ditto_sdk_version: str | None
is_compatible: bool | None
peer_metadata: dict[str, Any] | None

This peer's declared metadata.

May be empty when a peer first appears, and is populated once its metadata has synced.

identity_service_metadata: dict[str, Any] | None
@classmethod
def from_mapping(cls, value: Mapping[str, typing.Any]) -> Peer:
151    @classmethod
152    def from_mapping(cls, value: Mapping[str, Any]) -> Peer:
153        """Build a :class:`Peer` from a decoded presence-graph mapping."""
154
155        peer_metadata = value.get("peerMetadata", value.get("peerInfo", {}))
156        identity_metadata = value.get(
157            "identityServiceMetadata", value.get("identityServiceInfo", {})
158        )
159        return cls(
160            peer_key=str(value.get("peerKeyString", "")),
161            connections=tuple(
162                Connection.from_mapping(connection) for connection in value.get("connections", ())
163            ),
164            device_name=str(value.get("deviceName", "")),
165            is_connected_to_ditto_server=bool(value.get("isConnectedToDittoCloud", False)),
166            os=PeerOperatingSystem.parse(value.get("os")),
167            ditto_sdk_version=(
168                str(value["dittoSdkVersion"]) if value.get("dittoSdkVersion") is not None else None
169            ),
170            is_compatible=(
171                bool(value["isCompatible"]) if value.get("isCompatible") is not None else None
172            ),
173            peer_metadata=dict(peer_metadata) if isinstance(peer_metadata, Mapping) else {},
174            identity_service_metadata=(
175                dict(identity_metadata) if isinstance(identity_metadata, Mapping) else {}
176            ),
177        )

Build a Peer from a decoded presence-graph mapping.

class PeerOperatingSystem(builtins.str, enum.Enum):
 77class PeerOperatingSystem(str, Enum):
 78    """A peer's operating system, as reported by that peer."""
 79
 80    GENERIC = "Generic"
 81    IOS = "iOS"
 82    TVOS = "tvOS"
 83    ANDROID = "Android"
 84    LINUX = "Linux"
 85    WINDOWS = "Windows"
 86    MACOS = "macOS"
 87    UNKNOWN = "Unknown"
 88
 89    @classmethod
 90    def parse(cls, value: Any) -> PeerOperatingSystem | None:
 91        """Parse a peer's reported OS string.
 92
 93        Returns ``None`` if ``value`` is ``None``, or :attr:`UNKNOWN` for any
 94        other unrecognized value.
 95        """
 96
 97        if value is None:
 98            return None
 99        try:
100            return cls(str(value))
101        except ValueError:
102            return cls.UNKNOWN

A peer's operating system, as reported by that peer.

GENERIC = <PeerOperatingSystem.GENERIC: 'Generic'>
IOS = <PeerOperatingSystem.IOS: 'iOS'>
TVOS = <PeerOperatingSystem.TVOS: 'tvOS'>
ANDROID = <PeerOperatingSystem.ANDROID: 'Android'>
LINUX = <PeerOperatingSystem.LINUX: 'Linux'>
WINDOWS = <PeerOperatingSystem.WINDOWS: 'Windows'>
MACOS = <PeerOperatingSystem.MACOS: 'macOS'>
UNKNOWN = <PeerOperatingSystem.UNKNOWN: 'Unknown'>
@classmethod
def parse(cls, value: Any) -> PeerOperatingSystem | None:
 89    @classmethod
 90    def parse(cls, value: Any) -> PeerOperatingSystem | None:
 91        """Parse a peer's reported OS string.
 92
 93        Returns ``None`` if ``value`` is ``None``, or :attr:`UNKNOWN` for any
 94        other unrecognized value.
 95        """
 96
 97        if value is None:
 98            return None
 99        try:
100            return cls(str(value))
101        except ValueError:
102            return cls.UNKNOWN

Parse a peer's reported OS string.

Returns None if value is None, or UNKNOWN for any other unrecognized value.

class Presence:
651class Presence:
652    """Provides the local peer's current view of mesh presence."""
653
654    peer_metadata_max_size_in_bytes = PEER_METADATA_MAX_SIZE_IN_BYTES
655
656    def __init__(self, ditto: Ditto) -> None:
657        self._ditto = weakref.ref(ditto)
658        self._closed = False
659        self._lock = ProcessLocalRLock()
660        self._observers: list[weakref.ReferenceType[PresenceObserver]] = []
661        self._connection_request_handler: Callable[[ConnectionRequest], Any] | None = None
662        self._connection_handler_key: int | None = None
663
664    def _owner(self) -> Ditto:
665        owner = self._ditto()
666        if self._closed or owner is None:
667            raise DittoClosedError("Presence is closed")
668        return owner
669
670    @property
671    def graph(self) -> PresenceGraph:
672        """The current presence graph, capturing all known peers and their connections.
673
674        Each access makes a fresh synchronous call into the native layer; the
675        result is a point-in-time snapshot, not a cached value.
676        """
677
678        ffi = get_ffi()
679        data = consume_boxed_bytes(ffi.dittoffi_presence_graph(self._owner()._native_handle), ffi)
680        return PresenceGraph.from_json(data)
681
682    @property
683    def peer_metadata_json_string(self) -> str:
684        """This peer's own declared metadata, as a raw JSON object string."""
685
686        ffi = get_ffi()
687        data = consume_boxed_bytes(
688            ffi.dittoffi_presence_peer_metadata_json(self._owner()._native_handle), ffi
689        )
690        return data.decode("utf-8")
691
692    @property
693    def peer_metadata(self) -> dict[str, Any]:
694        """This peer's own declared metadata, parsed from JSON."""
695
696        value = json.loads(self.peer_metadata_json_string)
697        if not isinstance(value, Mapping):
698            raise DittoError("The local peer metadata did not contain a JSON object")
699        return dict(value)
700
701    def set_peer_metadata(self, value: Mapping[str, Any]) -> None:
702        """Set this peer's metadata, visible to other peers via the presence graph.
703
704        Also visible to peers' connection-request handlers while they decide
705        whether to authorize a connection. The JSON encoding of ``value`` must
706        not exceed :data:`PEER_METADATA_MAX_SIZE_IN_BYTES` (4096) bytes.
707
708        Raises:
709            DittoNotJsonCompatibleError: ``value`` could not be serialized as
710                JSON.
711            DittoSizeLimitExceededValidationError: the encoded value exceeds
712                the size limit.
713        """
714
715        try:
716            encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
717        except (TypeError, ValueError, RecursionError) as error:
718            raise DittoNotJsonCompatibleError("JSON serialization failed.") from error
719        self.set_peer_metadata_json_string(encoded)
720
721    def set_peer_metadata_json_string(self, value: str) -> None:
722        """Set this peer's metadata from a pre-encoded JSON object string.
723
724        See :meth:`set_peer_metadata` for visibility and size-limit details.
725
726        Raises:
727            DittoInvalidJsonError: ``value`` is not valid JSON.
728            DittoNotADictionaryValidationError: ``value`` is valid JSON but
729                not an object.
730            DittoSizeLimitExceededValidationError: the encoded value exceeds
731                the size limit.
732        """
733
734        encoded = borrowed_bytes(value.encode("utf-8"))
735        check_result(
736            get_ffi().dittoffi_presence_set_peer_metadata_json_throws(
737                self._owner()._native_handle, encoded.slice
738            )
739        )
740
741    def register_observer(self, handler: Callable[[PresenceGraph], Any]) -> PresenceObserver:
742        """Register a handler invoked with the updated PresenceGraph on each change.
743
744        No initial call is made on registration; the first invocation happens
745        on the first presence change after registering.
746
747        Must be called from a running event loop; raises ``RuntimeError``
748        otherwise (see :func:`asyncio.get_running_loop`). The handler is
749        dispatched on that loop and may be a synchronous or async callable.
750
751        ``Presence`` keeps only a weak reference to the returned
752        :class:`PresenceObserver` — the caller must hold a strong reference
753        to it to keep receiving updates.
754        """
755
756        if not callable(handler):
757            raise TypeError("Presence.register_observer requires a callable handler")
758        loop = asyncio.get_running_loop()
759        key = registry.register(_PresenceObserverPayload(loop, handler))
760        callback = BoxDynFnMut1_void_slice_boxed_uint8_t(
761            ctypes.c_void_p(key), _presence_event, release_registry_entry
762        )
763        try:
764            handle = check_result(
765                get_ffi().dittoffi_presence_register_observer_throws(
766                    self._owner()._native_handle, callback
767                )
768            )
769            observer = PresenceObserver(self, handle, key)
770        except BaseException:
771            registry.release(key)
772            raise
773        with self._lock:
774            self._observers.append(weakref.ref(observer))
775        return observer
776
777    observe = register_observer
778
779    @property
780    def connection_request_handler(self) -> Callable[[ConnectionRequest], Any] | None:
781        """The current connection-request authorization handler, or ``None`` if unset."""
782
783        with self._lock:
784            return self._connection_request_handler
785
786    @connection_request_handler.setter
787    def connection_request_handler(
788        self, handler: Callable[[ConnectionRequest], Any] | None
789    ) -> None:
790        """Set (or clear) the callback that authorizes incoming connection requests.
791
792        Called for every incoming connection request with a
793        :class:`ConnectionRequest`; return or await a
794        :class:`ConnectionRequestAuthorization`. Dispatched on the event loop
795        that was running when this setter was called, and may be sync or
796        async. The native layer waits roughly 10 seconds for a response — a
797        handler that raises, runs past that timeout, or never responds
798        results in DENY for that request.
799
800        Passing ``None`` clears the handler. With no handler registered, no
801        authorization callback runs at all, so incoming connections are not
802        vetted by this mechanism — they are *not* automatically denied.
803        """
804
805        if handler is not None and not callable(handler):
806            raise TypeError("connection_request_handler must be callable or None")
807        owner = self._owner()
808        old_key: int | None
809        new_key: int | None = None
810        if handler is None:
811            ffi_handler = VirtualPtr__Erased_ptr_FfiConnectionRequestHandlerVTable_t()
812        else:
813            loop = asyncio.get_running_loop()
814            payload = _ConnectionHandlerPayload(loop, handler)
815            new_key = registry.register(payload)
816            payload.registry_key = new_key
817            ffi_handler = VirtualPtr__Erased_ptr_FfiConnectionRequestHandlerVTable_t(
818                ctypes.cast(ctypes.c_void_p(new_key), ctypes.POINTER(Erased_t)),
819                FfiConnectionRequestHandlerVTable_t(
820                    _release_connection_handler,
821                    _retain_connection_handler,
822                    _connection_request_event,
823                ),
824            )
825        with self._lock:
826            old_key = self._connection_handler_key
827            try:
828                get_ffi().dittoffi_presence_set_connection_request_handler(
829                    owner._native_handle, ffi_handler
830                )
831            except BaseException:
832                if new_key is not None:
833                    registry.release(new_key)
834                raise
835            self._connection_request_handler = handler
836            self._connection_handler_key = new_key
837        if old_key is not None:
838            # The native VirtualPtr normally invokes release synchronously.
839            old_payload = registry.get(old_key)
840            if isinstance(old_payload, _ConnectionHandlerPayload):
841                old_payload.stop_deliveries()
842            registry.release(old_key)
843
844    def close(self) -> None:
845        """Close all presence observers and release the connection-request handler.
846
847        Safe to call more than once.
848        """
849
850        self._close_with_native_handle(None)
851
852    def _close_with_native_handle(self, ditto_handle: Any | None) -> None:
853        """Close, unregistering the native handler through *ditto_handle*.
854
855        ``Ditto._close_blocking`` marks the peer as closing before calling
856        this, so ``owner._native_handle`` is unavailable; it passes the raw
857        handle it already detached (same pattern as
858        ``Authenticator._close_with_native_handle``).
859        """
860
861        with self._lock:
862            if self._closed:
863                return
864            observers = tuple(reference() for reference in self._observers)
865            self._observers.clear()
866            owner = self._ditto()
867            connection_key = self._connection_handler_key
868            self._connection_handler_key = None
869            self._connection_request_handler = None
870            self._closed = True
871        for observer in observers:
872            if observer is not None:
873                observer.close()
874        if connection_key is not None:
875            handle: Any | None = ditto_handle
876            if handle is None and owner is not None and not owner.closed:
877                handle = owner._native_handle
878            if handle is not None:
879                get_ffi().dittoffi_presence_set_connection_request_handler(
880                    handle,
881                    VirtualPtr__Erased_ptr_FfiConnectionRequestHandlerVTable_t(),
882                )
883            payload = registry.get(connection_key)
884            if isinstance(payload, _ConnectionHandlerPayload):
885                payload.stop_deliveries()
886            registry.release(connection_key)

Provides the local peer's current view of mesh presence.

Presence(ditto: Ditto)
656    def __init__(self, ditto: Ditto) -> None:
657        self._ditto = weakref.ref(ditto)
658        self._closed = False
659        self._lock = ProcessLocalRLock()
660        self._observers: list[weakref.ReferenceType[PresenceObserver]] = []
661        self._connection_request_handler: Callable[[ConnectionRequest], Any] | None = None
662        self._connection_handler_key: int | None = None
peer_metadata_max_size_in_bytes = 4096
graph: PresenceGraph
670    @property
671    def graph(self) -> PresenceGraph:
672        """The current presence graph, capturing all known peers and their connections.
673
674        Each access makes a fresh synchronous call into the native layer; the
675        result is a point-in-time snapshot, not a cached value.
676        """
677
678        ffi = get_ffi()
679        data = consume_boxed_bytes(ffi.dittoffi_presence_graph(self._owner()._native_handle), ffi)
680        return PresenceGraph.from_json(data)

The current presence graph, capturing all known peers and their connections.

Each access makes a fresh synchronous call into the native layer; the result is a point-in-time snapshot, not a cached value.

peer_metadata_json_string: str
682    @property
683    def peer_metadata_json_string(self) -> str:
684        """This peer's own declared metadata, as a raw JSON object string."""
685
686        ffi = get_ffi()
687        data = consume_boxed_bytes(
688            ffi.dittoffi_presence_peer_metadata_json(self._owner()._native_handle), ffi
689        )
690        return data.decode("utf-8")

This peer's own declared metadata, as a raw JSON object string.

peer_metadata: dict[str, typing.Any]
692    @property
693    def peer_metadata(self) -> dict[str, Any]:
694        """This peer's own declared metadata, parsed from JSON."""
695
696        value = json.loads(self.peer_metadata_json_string)
697        if not isinstance(value, Mapping):
698            raise DittoError("The local peer metadata did not contain a JSON object")
699        return dict(value)

This peer's own declared metadata, parsed from JSON.

def set_peer_metadata(self, value: Mapping[str, typing.Any]) -> None:
701    def set_peer_metadata(self, value: Mapping[str, Any]) -> None:
702        """Set this peer's metadata, visible to other peers via the presence graph.
703
704        Also visible to peers' connection-request handlers while they decide
705        whether to authorize a connection. The JSON encoding of ``value`` must
706        not exceed :data:`PEER_METADATA_MAX_SIZE_IN_BYTES` (4096) bytes.
707
708        Raises:
709            DittoNotJsonCompatibleError: ``value`` could not be serialized as
710                JSON.
711            DittoSizeLimitExceededValidationError: the encoded value exceeds
712                the size limit.
713        """
714
715        try:
716            encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
717        except (TypeError, ValueError, RecursionError) as error:
718            raise DittoNotJsonCompatibleError("JSON serialization failed.") from error
719        self.set_peer_metadata_json_string(encoded)

Set this peer's metadata, visible to other peers via the presence graph.

Also visible to peers' connection-request handlers while they decide whether to authorize a connection. The JSON encoding of value must not exceed PEER_METADATA_MAX_SIZE_IN_BYTES (4096) bytes.

Raises:
  • DittoNotJsonCompatibleError: value could not be serialized as JSON.
  • DittoSizeLimitExceededValidationError: the encoded value exceeds the size limit.
def set_peer_metadata_json_string(self, value: str) -> None:
721    def set_peer_metadata_json_string(self, value: str) -> None:
722        """Set this peer's metadata from a pre-encoded JSON object string.
723
724        See :meth:`set_peer_metadata` for visibility and size-limit details.
725
726        Raises:
727            DittoInvalidJsonError: ``value`` is not valid JSON.
728            DittoNotADictionaryValidationError: ``value`` is valid JSON but
729                not an object.
730            DittoSizeLimitExceededValidationError: the encoded value exceeds
731                the size limit.
732        """
733
734        encoded = borrowed_bytes(value.encode("utf-8"))
735        check_result(
736            get_ffi().dittoffi_presence_set_peer_metadata_json_throws(
737                self._owner()._native_handle, encoded.slice
738            )
739        )

Set this peer's metadata from a pre-encoded JSON object string.

See set_peer_metadata() for visibility and size-limit details.

Raises:
  • DittoInvalidJsonError: value is not valid JSON.
  • DittoNotADictionaryValidationError: value is valid JSON but not an object.
  • DittoSizeLimitExceededValidationError: the encoded value exceeds the size limit.
def register_observer( self, handler: Callable[[PresenceGraph], typing.Any]) -> PresenceObserver:
741    def register_observer(self, handler: Callable[[PresenceGraph], Any]) -> PresenceObserver:
742        """Register a handler invoked with the updated PresenceGraph on each change.
743
744        No initial call is made on registration; the first invocation happens
745        on the first presence change after registering.
746
747        Must be called from a running event loop; raises ``RuntimeError``
748        otherwise (see :func:`asyncio.get_running_loop`). The handler is
749        dispatched on that loop and may be a synchronous or async callable.
750
751        ``Presence`` keeps only a weak reference to the returned
752        :class:`PresenceObserver` — the caller must hold a strong reference
753        to it to keep receiving updates.
754        """
755
756        if not callable(handler):
757            raise TypeError("Presence.register_observer requires a callable handler")
758        loop = asyncio.get_running_loop()
759        key = registry.register(_PresenceObserverPayload(loop, handler))
760        callback = BoxDynFnMut1_void_slice_boxed_uint8_t(
761            ctypes.c_void_p(key), _presence_event, release_registry_entry
762        )
763        try:
764            handle = check_result(
765                get_ffi().dittoffi_presence_register_observer_throws(
766                    self._owner()._native_handle, callback
767                )
768            )
769            observer = PresenceObserver(self, handle, key)
770        except BaseException:
771            registry.release(key)
772            raise
773        with self._lock:
774            self._observers.append(weakref.ref(observer))
775        return observer

Register a handler invoked with the updated PresenceGraph on each change.

No initial call is made on registration; the first invocation happens on the first presence change after registering.

Must be called from a running event loop; raises RuntimeError otherwise (see asyncio.get_running_loop()). The handler is dispatched on that loop and may be a synchronous or async callable.

Presence keeps only a weak reference to the returned PresenceObserver — the caller must hold a strong reference to it to keep receiving updates.

def observe( self, handler: Callable[[PresenceGraph], typing.Any]) -> PresenceObserver:
741    def register_observer(self, handler: Callable[[PresenceGraph], Any]) -> PresenceObserver:
742        """Register a handler invoked with the updated PresenceGraph on each change.
743
744        No initial call is made on registration; the first invocation happens
745        on the first presence change after registering.
746
747        Must be called from a running event loop; raises ``RuntimeError``
748        otherwise (see :func:`asyncio.get_running_loop`). The handler is
749        dispatched on that loop and may be a synchronous or async callable.
750
751        ``Presence`` keeps only a weak reference to the returned
752        :class:`PresenceObserver` — the caller must hold a strong reference
753        to it to keep receiving updates.
754        """
755
756        if not callable(handler):
757            raise TypeError("Presence.register_observer requires a callable handler")
758        loop = asyncio.get_running_loop()
759        key = registry.register(_PresenceObserverPayload(loop, handler))
760        callback = BoxDynFnMut1_void_slice_boxed_uint8_t(
761            ctypes.c_void_p(key), _presence_event, release_registry_entry
762        )
763        try:
764            handle = check_result(
765                get_ffi().dittoffi_presence_register_observer_throws(
766                    self._owner()._native_handle, callback
767                )
768            )
769            observer = PresenceObserver(self, handle, key)
770        except BaseException:
771            registry.release(key)
772            raise
773        with self._lock:
774            self._observers.append(weakref.ref(observer))
775        return observer

Register a handler invoked with the updated PresenceGraph on each change.

No initial call is made on registration; the first invocation happens on the first presence change after registering.

Must be called from a running event loop; raises RuntimeError otherwise (see asyncio.get_running_loop()). The handler is dispatched on that loop and may be a synchronous or async callable.

Presence keeps only a weak reference to the returned PresenceObserver — the caller must hold a strong reference to it to keep receiving updates.

connection_request_handler: Callable[[ConnectionRequest], Any] | None
779    @property
780    def connection_request_handler(self) -> Callable[[ConnectionRequest], Any] | None:
781        """The current connection-request authorization handler, or ``None`` if unset."""
782
783        with self._lock:
784            return self._connection_request_handler

The current connection-request authorization handler, or None if unset.

def close(self) -> None:
844    def close(self) -> None:
845        """Close all presence observers and release the connection-request handler.
846
847        Safe to call more than once.
848        """
849
850        self._close_with_native_handle(None)

Close all presence observers and release the connection-request handler.

Safe to call more than once.

@dataclass(frozen=True, slots=True)
class PresenceGraph:
180@dataclass(frozen=True, slots=True)
181class PresenceGraph:
182    """A snapshot of all known peers and their connections, as seen by the local peer."""
183
184    local_peer: Peer
185    remote_peers: tuple[Peer, ...]
186
187    @classmethod
188    def from_mapping(cls, value: Mapping[str, Any]) -> PresenceGraph:
189        """Build a :class:`PresenceGraph` from a decoded mapping."""
190
191        local_peer = value.get("localPeer")
192        if not isinstance(local_peer, Mapping):
193            raise DittoError("The native presence graph did not contain a local peer")
194        return cls(
195            Peer.from_mapping(local_peer),
196            tuple(Peer.from_mapping(peer) for peer in value.get("remotePeers", ())),
197        )
198
199    @classmethod
200    def from_json(cls, value: str | bytes | bytearray | memoryview) -> PresenceGraph:
201        """Decode a :class:`PresenceGraph` from a raw JSON presence-graph document."""
202
203        parsed = json.loads(bytes(value) if isinstance(value, memoryview) else value)
204        if not isinstance(parsed, Mapping):
205            raise DittoError("The native presence graph did not contain a JSON object")
206        return cls.from_mapping(parsed)
207
208    @property
209    def all_connections_by_id(self) -> dict[str, Connection]:
210        """All connections across the local peer and remote peers, keyed by connection id."""
211
212        result: dict[str, Connection] = {}
213        for peer in (self.local_peer, *self.remote_peers):
214            result.update((connection.id, connection) for connection in peer.connections)
215        return result

A snapshot of all known peers and their connections, as seen by the local peer.

PresenceGraph( local_peer: Peer, remote_peers: tuple[Peer, ...])
local_peer: Peer
remote_peers: tuple[Peer, ...]
@classmethod
def from_mapping(cls, value: Mapping[str, typing.Any]) -> PresenceGraph:
187    @classmethod
188    def from_mapping(cls, value: Mapping[str, Any]) -> PresenceGraph:
189        """Build a :class:`PresenceGraph` from a decoded mapping."""
190
191        local_peer = value.get("localPeer")
192        if not isinstance(local_peer, Mapping):
193            raise DittoError("The native presence graph did not contain a local peer")
194        return cls(
195            Peer.from_mapping(local_peer),
196            tuple(Peer.from_mapping(peer) for peer in value.get("remotePeers", ())),
197        )

Build a PresenceGraph from a decoded mapping.

@classmethod
def from_json( cls, value: str | bytes | bytearray | memoryview) -> PresenceGraph:
199    @classmethod
200    def from_json(cls, value: str | bytes | bytearray | memoryview) -> PresenceGraph:
201        """Decode a :class:`PresenceGraph` from a raw JSON presence-graph document."""
202
203        parsed = json.loads(bytes(value) if isinstance(value, memoryview) else value)
204        if not isinstance(parsed, Mapping):
205            raise DittoError("The native presence graph did not contain a JSON object")
206        return cls.from_mapping(parsed)

Decode a PresenceGraph from a raw JSON presence-graph document.

all_connections_by_id: dict[str, Connection]
208    @property
209    def all_connections_by_id(self) -> dict[str, Connection]:
210        """All connections across the local peer and remote peers, keyed by connection id."""
211
212        result: dict[str, Connection] = {}
213        for peer in (self.local_peer, *self.remote_peers):
214            result.update((connection.id, connection) for connection in peer.connections)
215        return result

All connections across the local peer and remote peers, keyed by connection id.

class PresenceObserver:
374class PresenceObserver:
375    """A cancellable subscription created by :meth:`Presence.register_observer`."""
376
377    def __init__(self, presence: Presence, handle: Any, registry_key: int) -> None:
378        self._presence = weakref.ref(presence)
379        self._handle = handle
380        self._registry_key = registry_key
381        self._closed = False
382        self._lock = ProcessLocalRLock()
383        ffi = get_ffi()
384        self._cancel_native = process_bound_cleanup(ffi.dittoffi_presence_observer_cancel)
385        self._free_native = process_bound_cleanup(ffi.dittoffi_presence_observer_free)
386        self._finalizer = weakref.finalize(
387            self,
388            _finalize,
389            _dispose_presence_observer,
390            self._cancel_native,
391            self._free_native,
392            registry.release,
393            handle,
394            registry_key,
395        )
396        self._id = consume_boxed_bytes(ffi.dittoffi_presence_observer_id(handle), ffi)
397
398    @property
399    def id(self) -> bytes:
400        """This observer's unique identifier, assigned by native code."""
401
402        return self._id
403
404    @property
405    def is_cancelled(self) -> bool:
406        """Whether this observer has been cancelled or closed."""
407
408        with self._lock:
409            if self._closed or not self._handle:
410                return True
411            return bool(get_ffi().dittoffi_presence_observer_is_cancelled(self._handle))
412
413    def cancel(self) -> None:
414        """Cancel the subscription so no further presence updates are delivered."""
415
416        with self._lock:
417            if not self._closed and self._handle:
418                get_ffi().dittoffi_presence_observer_cancel(self._handle)
419
420    def close(self) -> None:
421        """Cancel the subscription and release native resources. Safe to call more than once."""
422
423        with self._lock:
424            if self._closed:
425                return
426            self._closed = True
427            self._finalizer.detach()
428            handle, self._handle = self._handle, None
429        _dispose_presence_observer(
430            self._cancel_native,
431            self._free_native,
432            registry.release,
433            handle,
434            self._registry_key,
435        )
436
437    stop = close
438
439    def __eq__(self, other: object) -> bool:
440        return isinstance(other, PresenceObserver) and self.id == other.id
441
442    def __hash__(self) -> int:
443        return hash(self.id)
444
445    def __enter__(self) -> PresenceObserver:
446        return self
447
448    def __exit__(self, *_: object) -> None:
449        self.close()

A cancellable subscription created by Presence.register_observer().

PresenceObserver(presence: Presence, handle: Any, registry_key: int)
377    def __init__(self, presence: Presence, handle: Any, registry_key: int) -> None:
378        self._presence = weakref.ref(presence)
379        self._handle = handle
380        self._registry_key = registry_key
381        self._closed = False
382        self._lock = ProcessLocalRLock()
383        ffi = get_ffi()
384        self._cancel_native = process_bound_cleanup(ffi.dittoffi_presence_observer_cancel)
385        self._free_native = process_bound_cleanup(ffi.dittoffi_presence_observer_free)
386        self._finalizer = weakref.finalize(
387            self,
388            _finalize,
389            _dispose_presence_observer,
390            self._cancel_native,
391            self._free_native,
392            registry.release,
393            handle,
394            registry_key,
395        )
396        self._id = consume_boxed_bytes(ffi.dittoffi_presence_observer_id(handle), ffi)
id: bytes
398    @property
399    def id(self) -> bytes:
400        """This observer's unique identifier, assigned by native code."""
401
402        return self._id

This observer's unique identifier, assigned by native code.

is_cancelled: bool
404    @property
405    def is_cancelled(self) -> bool:
406        """Whether this observer has been cancelled or closed."""
407
408        with self._lock:
409            if self._closed or not self._handle:
410                return True
411            return bool(get_ffi().dittoffi_presence_observer_is_cancelled(self._handle))

Whether this observer has been cancelled or closed.

def cancel(self) -> None:
413    def cancel(self) -> None:
414        """Cancel the subscription so no further presence updates are delivered."""
415
416        with self._lock:
417            if not self._closed and self._handle:
418                get_ffi().dittoffi_presence_observer_cancel(self._handle)

Cancel the subscription so no further presence updates are delivered.

def close(self) -> None:
420    def close(self) -> None:
421        """Cancel the subscription and release native resources. Safe to call more than once."""
422
423        with self._lock:
424            if self._closed:
425                return
426            self._closed = True
427            self._finalizer.detach()
428            handle, self._handle = self._handle, None
429        _dispose_presence_observer(
430            self._cancel_native,
431            self._free_native,
432            registry.release,
433            handle,
434            self._registry_key,
435        )

Cancel the subscription and release native resources. Safe to call more than once.

def stop(self) -> None:
420    def close(self) -> None:
421        """Cancel the subscription and release native resources. Safe to call more than once."""
422
423        with self._lock:
424            if self._closed:
425                return
426            self._closed = True
427            self._finalizer.detach()
428            handle, self._handle = self._handle, None
429        _dispose_presence_observer(
430            self._cancel_native,
431            self._free_native,
432            registry.release,
433            handle,
434            self._registry_key,
435        )

Cancel the subscription and release native resources. Safe to call more than once.

class QueryResult(collections.abc.Sequence[ditto.query_result.QueryResultItem]):
294class QueryResult(Sequence[QueryResultItem]):
295    """Owns a native DQL result and its individually owned item cursors.
296
297    A :class:`~collections.abc.Sequence` of :class:`QueryResultItem`: supports
298    ``len()``, indexing, slicing, and iteration. Item cursors are created
299    lazily on first access — inspecting only :attr:`commit_id` or
300    :meth:`mutated_document_ids` allocates no per-row native resources.
301    :meth:`close` (or use as a context manager) also closes any item cursors
302    that have already been materialized.
303    """
304
305    def __init__(self, handle: Any) -> None:
306        self._handle = handle
307        self._closed = False
308        self._lock = ProcessLocalRLock()
309        self._mutated_document_ids: tuple[DocumentId, ...] | None = None
310        self._finalizer = weakref.finalize(self, _free_query_result, handle)
311        # This constructor runs inside the native callback trampoline for
312        # observer deliveries. Item cursors are materialized lazily so a
313        # large delivery does not hold the native thread for one FFI round
314        # trip plus one finalizer registration per row.
315        self._count = int(get_ffi().dittoffi_query_result_item_count(handle))
316        self._items: tuple[QueryResultItem, ...] | None = None
317
318    @classmethod
319    def from_ffi_result(cls, result: Any) -> QueryResult:
320        """Build a :class:`QueryResult` from a native FFI result, raising on error."""
321
322        from ._internal.errors import check_result
323
324        return cls(check_result(result))
325
326    def _ensure_open(self) -> Any:
327        if self._closed:
328            raise DittoClosedError("QueryResult is closed")
329        return self._handle
330
331    def _materialized_items(self) -> tuple[QueryResultItem, ...]:
332        with self._lock:
333            if self._items is None:
334                handle = self._ensure_open()
335                ffi = get_ffi()
336                self._items = tuple(
337                    QueryResultItem(ffi.dittoffi_query_result_item_at(handle, index))
338                    for index in range(self._count)
339                )
340            return self._items
341
342    @property
343    def items(self) -> list[QueryResultItem]:
344        """All item cursors, materializing any not yet created."""
345
346        return list(self._materialized_items())
347
348    def mutated_document_ids(self) -> list[DocumentId]:
349        """The IDs of documents mutated by this query.
350
351        Empty for pure ``SELECT`` queries and for observer-delivered results.
352        Unlike some other SDKs, Python memoizes this on first call, so it is
353        safe to call repeatedly without recomputing.
354        """
355
356        # ``consume_boxed_bytes`` frees each native slice, so the IDs must be
357        # materialized exactly once and served from this cache afterwards.
358        with self._lock:
359            if self._mutated_document_ids is None:
360                handle = self._ensure_open()
361                ffi = get_ffi()
362                count = int(ffi.dittoffi_query_result_mutated_document_id_count(handle))
363                self._mutated_document_ids = tuple(
364                    DocumentId.from_cbor(
365                        consume_boxed_bytes(
366                            ffi.dittoffi_query_result_mutated_document_id_at(handle, index)
367                        )
368                    )
369                    for index in range(count)
370                )
371            return list(self._mutated_document_ids)
372
373    @property
374    def commit_id(self) -> int | None:
375        """The ID that uniquely identifies the local commit this query made.
376
377        Set for mutating queries once committed. ``None`` for pure ``SELECT``
378        queries, for observer-delivered results, and for a mutating query's
379        result inside an uncommitted transaction until the transaction
380        commits.
381        """
382
383        handle = self._ensure_open()
384        ffi = get_ffi()
385        if not ffi.dittoffi_query_result_has_commit_id(handle):
386            return None
387        return int(ffi.dittoffi_query_result_commit_id(handle))
388
389    def close(self) -> None:
390        """Release the native result handle and close any materialized item cursors.
391
392        Safe to call more than once.
393        """
394
395        with self._lock:
396            if self._closed:
397                return
398            self._closed = True
399            self._finalizer.detach()
400            handle = self._handle
401            self._handle = None
402            items = self._items or ()
403        if handle:
404            get_ffi().dittoffi_query_result_free(handle)
405        for item in items:
406            item.close()
407
408    def __len__(self) -> int:
409        self._ensure_open()
410        return self._count
411
412    @overload
413    def __getitem__(self, index: int) -> QueryResultItem: ...
414
415    @overload
416    def __getitem__(self, index: slice) -> tuple[QueryResultItem, ...]: ...
417
418    def __getitem__(self, index: int | slice) -> QueryResultItem | tuple[QueryResultItem, ...]:
419        return self._materialized_items()[index]
420
421    def __iter__(self) -> Iterator[QueryResultItem]:
422        return iter(self._materialized_items())
423
424    def __enter__(self) -> QueryResult:
425        return self
426
427    def __exit__(self, *_: object) -> None:
428        self.close()

Owns a native DQL result and its individually owned item cursors.

A ~collections.abc.Sequence of QueryResultItem: supports len(), indexing, slicing, and iteration. Item cursors are created lazily on first access — inspecting only commit_id or mutated_document_ids() allocates no per-row native resources. close() (or use as a context manager) also closes any item cursors that have already been materialized.

QueryResult(handle: Any)
305    def __init__(self, handle: Any) -> None:
306        self._handle = handle
307        self._closed = False
308        self._lock = ProcessLocalRLock()
309        self._mutated_document_ids: tuple[DocumentId, ...] | None = None
310        self._finalizer = weakref.finalize(self, _free_query_result, handle)
311        # This constructor runs inside the native callback trampoline for
312        # observer deliveries. Item cursors are materialized lazily so a
313        # large delivery does not hold the native thread for one FFI round
314        # trip plus one finalizer registration per row.
315        self._count = int(get_ffi().dittoffi_query_result_item_count(handle))
316        self._items: tuple[QueryResultItem, ...] | None = None
@classmethod
def from_ffi_result(cls, result: Any) -> QueryResult:
318    @classmethod
319    def from_ffi_result(cls, result: Any) -> QueryResult:
320        """Build a :class:`QueryResult` from a native FFI result, raising on error."""
321
322        from ._internal.errors import check_result
323
324        return cls(check_result(result))

Build a QueryResult from a native FFI result, raising on error.

items: list[QueryResultItem]
342    @property
343    def items(self) -> list[QueryResultItem]:
344        """All item cursors, materializing any not yet created."""
345
346        return list(self._materialized_items())

All item cursors, materializing any not yet created.

def mutated_document_ids(self) -> list[DocumentId]:
348    def mutated_document_ids(self) -> list[DocumentId]:
349        """The IDs of documents mutated by this query.
350
351        Empty for pure ``SELECT`` queries and for observer-delivered results.
352        Unlike some other SDKs, Python memoizes this on first call, so it is
353        safe to call repeatedly without recomputing.
354        """
355
356        # ``consume_boxed_bytes`` frees each native slice, so the IDs must be
357        # materialized exactly once and served from this cache afterwards.
358        with self._lock:
359            if self._mutated_document_ids is None:
360                handle = self._ensure_open()
361                ffi = get_ffi()
362                count = int(ffi.dittoffi_query_result_mutated_document_id_count(handle))
363                self._mutated_document_ids = tuple(
364                    DocumentId.from_cbor(
365                        consume_boxed_bytes(
366                            ffi.dittoffi_query_result_mutated_document_id_at(handle, index)
367                        )
368                    )
369                    for index in range(count)
370                )
371            return list(self._mutated_document_ids)

The IDs of documents mutated by this query.

Empty for pure SELECT queries and for observer-delivered results. Unlike some other SDKs, Python memoizes this on first call, so it is safe to call repeatedly without recomputing.

commit_id: int | None
373    @property
374    def commit_id(self) -> int | None:
375        """The ID that uniquely identifies the local commit this query made.
376
377        Set for mutating queries once committed. ``None`` for pure ``SELECT``
378        queries, for observer-delivered results, and for a mutating query's
379        result inside an uncommitted transaction until the transaction
380        commits.
381        """
382
383        handle = self._ensure_open()
384        ffi = get_ffi()
385        if not ffi.dittoffi_query_result_has_commit_id(handle):
386            return None
387        return int(ffi.dittoffi_query_result_commit_id(handle))

The ID that uniquely identifies the local commit this query made.

Set for mutating queries once committed. None for pure SELECT queries, for observer-delivered results, and for a mutating query's result inside an uncommitted transaction until the transaction commits.

def close(self) -> None:
389    def close(self) -> None:
390        """Release the native result handle and close any materialized item cursors.
391
392        Safe to call more than once.
393        """
394
395        with self._lock:
396            if self._closed:
397                return
398            self._closed = True
399            self._finalizer.detach()
400            handle = self._handle
401            self._handle = None
402            items = self._items or ()
403        if handle:
404            get_ffi().dittoffi_query_result_free(handle)
405        for item in items:
406            item.close()

Release the native result handle and close any materialized item cursors.

Safe to call more than once.

class QueryResultItem:
191class QueryResultItem:
192    """A cursor onto a lazily decoded row returned by DQL.
193
194    :attr:`value` decodes the row and caches the result. :meth:`dematerialize`
195    drops that cache without closing the item. :meth:`cbor_data` and
196    :meth:`json_string` re-fetch natively on every call and are not cached —
197    save the result yourself if it is used repeatedly. :meth:`close` (or use
198    as a context manager) releases the native handle; the garbage-collector
199    finalizer is a backstop only.
200    """
201
202    def __init__(self, handle: Any) -> None:
203        self._handle = handle
204        self._materialized: dict[str, Any] | None = None
205        self._lock = ProcessLocalRLock()
206        self._closed = False
207        self._finalizer = weakref.finalize(self, _free_query_result_item, handle)
208
209    @classmethod
210    def from_json(cls, value: str | Mapping[str, Any]) -> QueryResultItem:
211        """Build a :class:`QueryResultItem` from a JSON string or a JSON-compatible mapping."""
212
213        encoded = value if isinstance(value, str) else json.dumps(value)
214        borrowed = borrowed_bytes(encoded.encode("utf-8"))
215        result = get_ffi().dittoffi_query_result_item_new(borrowed.slice)
216        from ._internal.errors import check_result
217
218        return cls(check_result(result))
219
220    @property
221    def _ffi_handle(self) -> Any:
222        if self._closed:
223            raise DittoClosedError("QueryResultItem is closed")
224        return self._handle
225
226    @property
227    def value(self) -> dict[str, Any]:
228        """The decoded row, materializing and caching it first if needed."""
229
230        self.materialize()
231        if self._materialized is None:
232            raise DittoError("Internal error: a query result item failed to materialize")
233        return self._materialized
234
235    @property
236    def is_materialized(self) -> bool:
237        """Whether :attr:`value` has already decoded and cached this row."""
238
239        with self._lock:
240            return self._materialized is not None
241
242    def materialize(self) -> None:
243        """Decode this row and cache the result, if not already cached."""
244
245        with self._lock:
246            if self._materialized is None:
247                value = cbor2.loads(self.cbor_data())
248                if not isinstance(value, dict):
249                    raise DittoError("A query result item did not contain a map")
250                self._materialized = value
251
252    def dematerialize(self) -> None:
253        """Drop the cached decoded value, without closing the item."""
254
255        with self._lock:
256            self._materialized = None
257
258    def cbor_data(self) -> bytes:
259        """Fetch this row as CBOR bytes. Not cached — fetched natively each call."""
260
261        value = get_ffi().dittoffi_query_result_item_cbor(self._ffi_handle)
262        return consume_boxed_bytes(value)
263
264    def json_string(self) -> str:
265        """Fetch this row as a JSON string. Not cached — fetched natively each call."""
266
267        pointer = get_ffi().dittoffi_query_result_item_json(self._ffi_handle)
268        value = consume_string(pointer)
269        if value is None:
270            raise DittoError("Native query result item returned a null JSON string")
271        return value
272
273    def close(self) -> None:
274        """Release the native handle. The garbage-collector finalizer is a backstop."""
275
276        with self._lock:
277            if self._closed:
278                return
279            self._closed = True
280            self._finalizer.detach()
281            handle = self._handle
282            self._handle = None
283            self._materialized = None
284        if handle:
285            get_ffi().dittoffi_query_result_item_free(handle)
286
287    def __enter__(self) -> QueryResultItem:
288        return self
289
290    def __exit__(self, *_: object) -> None:
291        self.close()

A cursor onto a lazily decoded row returned by DQL.

value decodes the row and caches the result. dematerialize() drops that cache without closing the item. cbor_data() and json_string() re-fetch natively on every call and are not cached — save the result yourself if it is used repeatedly. close() (or use as a context manager) releases the native handle; the garbage-collector finalizer is a backstop only.

QueryResultItem(handle: Any)
202    def __init__(self, handle: Any) -> None:
203        self._handle = handle
204        self._materialized: dict[str, Any] | None = None
205        self._lock = ProcessLocalRLock()
206        self._closed = False
207        self._finalizer = weakref.finalize(self, _free_query_result_item, handle)
@classmethod
def from_json( cls, value: str | Mapping[str, Any]) -> QueryResultItem:
209    @classmethod
210    def from_json(cls, value: str | Mapping[str, Any]) -> QueryResultItem:
211        """Build a :class:`QueryResultItem` from a JSON string or a JSON-compatible mapping."""
212
213        encoded = value if isinstance(value, str) else json.dumps(value)
214        borrowed = borrowed_bytes(encoded.encode("utf-8"))
215        result = get_ffi().dittoffi_query_result_item_new(borrowed.slice)
216        from ._internal.errors import check_result
217
218        return cls(check_result(result))

Build a QueryResultItem from a JSON string or a JSON-compatible mapping.

value: dict[str, typing.Any]
226    @property
227    def value(self) -> dict[str, Any]:
228        """The decoded row, materializing and caching it first if needed."""
229
230        self.materialize()
231        if self._materialized is None:
232            raise DittoError("Internal error: a query result item failed to materialize")
233        return self._materialized

The decoded row, materializing and caching it first if needed.

is_materialized: bool
235    @property
236    def is_materialized(self) -> bool:
237        """Whether :attr:`value` has already decoded and cached this row."""
238
239        with self._lock:
240            return self._materialized is not None

Whether value has already decoded and cached this row.

def materialize(self) -> None:
242    def materialize(self) -> None:
243        """Decode this row and cache the result, if not already cached."""
244
245        with self._lock:
246            if self._materialized is None:
247                value = cbor2.loads(self.cbor_data())
248                if not isinstance(value, dict):
249                    raise DittoError("A query result item did not contain a map")
250                self._materialized = value

Decode this row and cache the result, if not already cached.

def dematerialize(self) -> None:
252    def dematerialize(self) -> None:
253        """Drop the cached decoded value, without closing the item."""
254
255        with self._lock:
256            self._materialized = None

Drop the cached decoded value, without closing the item.

def cbor_data(self) -> bytes:
258    def cbor_data(self) -> bytes:
259        """Fetch this row as CBOR bytes. Not cached — fetched natively each call."""
260
261        value = get_ffi().dittoffi_query_result_item_cbor(self._ffi_handle)
262        return consume_boxed_bytes(value)

Fetch this row as CBOR bytes. Not cached — fetched natively each call.

def json_string(self) -> str:
264    def json_string(self) -> str:
265        """Fetch this row as a JSON string. Not cached — fetched natively each call."""
266
267        pointer = get_ffi().dittoffi_query_result_item_json(self._ffi_handle)
268        value = consume_string(pointer)
269        if value is None:
270            raise DittoError("Native query result item returned a null JSON string")
271        return value

Fetch this row as a JSON string. Not cached — fetched natively each call.

def close(self) -> None:
273    def close(self) -> None:
274        """Release the native handle. The garbage-collector finalizer is a backstop."""
275
276        with self._lock:
277            if self._closed:
278                return
279            self._closed = True
280            self._finalizer.detach()
281            handle = self._handle
282            self._handle = None
283            self._materialized = None
284        if handle:
285            get_ffi().dittoffi_query_result_item_free(handle)

Release the native handle. The garbage-collector finalizer is a backstop.

class SmallPeerInfo:
 40class SmallPeerInfo:
 41    """Controls collection of the local peer's diagnostic information."""
 42
 43    def __init__(self, ditto: Ditto) -> None:
 44        self._ditto = weakref.ref(ditto)
 45
 46    def _handle(self) -> Any:
 47        owner = self._ditto()
 48        if owner is None:
 49            from .errors import DittoClosedError
 50
 51            raise DittoClosedError("SmallPeerInfo's Ditto instance is closed")
 52        return owner._native_handle
 53
 54    @property
 55    def is_enabled(self) -> bool:
 56        """Whether periodic small-peer-info collection is enabled."""
 57
 58        return bool(get_ffi().ditto_small_peer_info_get_is_enabled(self._handle()))
 59
 60    @is_enabled.setter
 61    def is_enabled(self, value: bool) -> None:
 62        get_ffi().ditto_small_peer_info_set_enabled(self._handle(), bool(value))
 63
 64    @property
 65    def metadata_json_string(self) -> str:
 66        """The validated JSON object included in each collected document.
 67
 68        Setting this value (or :attr:`metadata`) enforces: a JSON-serializable
 69        object, encoded to no more than 128 KB, with at most 64 levels of
 70        nesting.
 71
 72        Raises:
 73            DittoSizeLimitExceededValidationError: the encoded value exceeds
 74                the size limit.
 75            DittoDepthLimitExceededValidationError: the value nests deeper
 76                than the depth limit.
 77            DittoNotADictionaryValidationError: the value is not a JSON
 78                object (dict).
 79        """
 80
 81        ffi = get_ffi()
 82        value = consume_string(ffi.ditto_small_peer_info_get_metadata(self._handle()), ffi)
 83        if value is None:
 84            raise DittoError(
 85                "Internal inconsistency: small peer info returned a null metadata string"
 86            )
 87        return value
 88
 89    @metadata_json_string.setter
 90    def metadata_json_string(self, value: str) -> None:
 91        # A UTF-8 encoding is at least one byte per character, so an
 92        # over-limit character count rejects before encoding at all.
 93        if len(value) > METADATA_SIZE_BYTES_MAX:
 94            raise DittoSizeLimitExceededValidationError(
 95                f"Small-peer metadata may not exceed {METADATA_SIZE_BYTES_MAX} bytes when encoded"
 96            )
 97        encoded = utf8(value)
 98        if encoded is not None and len(encoded) > METADATA_SIZE_BYTES_MAX:
 99            raise DittoSizeLimitExceededValidationError(
100                f"Small-peer metadata may not exceed {METADATA_SIZE_BYTES_MAX} bytes when encoded"
101            )
102        ffi = get_ffi()
103        status = int(ffi.ditto_small_peer_info_set_metadata(self._handle(), encoded))
104        if status == 0:
105            return
106
107        message = legacy_error_message(ffi)
108        if status == -1:
109            raise DittoError(
110                "Internal inconsistency: the small-peer observability subsystem is unavailable"
111            )
112        if status == 1:
113            raise DittoSizeLimitExceededValidationError(
114                f"Validation error, size limit exceeded: {message}"
115            )
116        if status == 2:
117            raise DittoDepthLimitExceededValidationError(
118                f"Validation error, depth limit exceeded: {message}"
119            )
120        if status == 3:
121            raise DittoNotADictionaryValidationError(
122                f"Validation error, not a dictionary: {message}"
123            )
124        raise DittoFfiError(
125            "ditto_small_peer_info_set_metadata returned an unknown status code: " + message,
126            status_code=status,
127            internal_error_message=message,
128        )
129
130    @property
131    def metadata(self) -> dict[str, Any]:
132        """The validated metadata object, parsed from :attr:`metadata_json_string`.
133
134        See :attr:`metadata_json_string` for the size and nesting constraints
135        enforced when this is set.
136
137        Raises:
138            DittoNotJsonCompatibleError: the value could not be serialized as
139                JSON.
140            DittoNotADictionaryValidationError: the value is not a mapping.
141        """
142
143        value = json.loads(self.metadata_json_string)
144        if not isinstance(value, dict):
145            raise DittoError(
146                "Internal inconsistency: validated small-peer metadata was not an object"
147            )
148        return value
149
150    @metadata.setter
151    def metadata(self, value: Mapping[str, Any]) -> None:
152        if not isinstance(value, Mapping):
153            raise DittoNotADictionaryValidationError("Small-peer metadata must be a mapping")
154        self.metadata_json_string = _json_object(value)

Controls collection of the local peer's diagnostic information.

SmallPeerInfo(ditto: Ditto)
43    def __init__(self, ditto: Ditto) -> None:
44        self._ditto = weakref.ref(ditto)
is_enabled: bool
54    @property
55    def is_enabled(self) -> bool:
56        """Whether periodic small-peer-info collection is enabled."""
57
58        return bool(get_ffi().ditto_small_peer_info_get_is_enabled(self._handle()))

Whether periodic small-peer-info collection is enabled.

metadata_json_string: str
64    @property
65    def metadata_json_string(self) -> str:
66        """The validated JSON object included in each collected document.
67
68        Setting this value (or :attr:`metadata`) enforces: a JSON-serializable
69        object, encoded to no more than 128 KB, with at most 64 levels of
70        nesting.
71
72        Raises:
73            DittoSizeLimitExceededValidationError: the encoded value exceeds
74                the size limit.
75            DittoDepthLimitExceededValidationError: the value nests deeper
76                than the depth limit.
77            DittoNotADictionaryValidationError: the value is not a JSON
78                object (dict).
79        """
80
81        ffi = get_ffi()
82        value = consume_string(ffi.ditto_small_peer_info_get_metadata(self._handle()), ffi)
83        if value is None:
84            raise DittoError(
85                "Internal inconsistency: small peer info returned a null metadata string"
86            )
87        return value

The validated JSON object included in each collected document.

Setting this value (or metadata) enforces: a JSON-serializable object, encoded to no more than 128 KB, with at most 64 levels of nesting.

Raises:
  • DittoSizeLimitExceededValidationError: the encoded value exceeds the size limit.
  • DittoDepthLimitExceededValidationError: the value nests deeper than the depth limit.
  • DittoNotADictionaryValidationError: the value is not a JSON object (dict).
metadata: dict[str, typing.Any]
130    @property
131    def metadata(self) -> dict[str, Any]:
132        """The validated metadata object, parsed from :attr:`metadata_json_string`.
133
134        See :attr:`metadata_json_string` for the size and nesting constraints
135        enforced when this is set.
136
137        Raises:
138            DittoNotJsonCompatibleError: the value could not be serialized as
139                JSON.
140            DittoNotADictionaryValidationError: the value is not a mapping.
141        """
142
143        value = json.loads(self.metadata_json_string)
144        if not isinstance(value, dict):
145            raise DittoError(
146                "Internal inconsistency: validated small-peer metadata was not an object"
147            )
148        return value

The validated metadata object, parsed from metadata_json_string.

See metadata_json_string for the size and nesting constraints enforced when this is set.

Raises:
  • DittoNotJsonCompatibleError: the value could not be serialized as JSON.
  • DittoNotADictionaryValidationError: the value is not a mapping.
class Store:
 32class Store:
 33    """Executes DQL queries and manages observers, transactions, and attachments."""
 34
 35    def __init__(self, ditto: Ditto) -> None:
 36        from .disk_usage import DiskUsage, DiskUsageComponent
 37
 38        self._ditto = weakref.ref(ditto)
 39        self._attachment_fetchers: set[AttachmentFetcher] = set()
 40        self.disk_usage = DiskUsage(ditto, DiskUsageComponent.STORE)
 41
 42    def _owner(self) -> Ditto:
 43        owner = self._ditto()
 44        if owner is None or owner.closed:
 45            raise DittoClosedError("Store's Ditto instance is closed")
 46        return owner
 47
 48    async def execute(self, query: str, arguments: Mapping[str, Any] | None = None) -> QueryResult:
 49        """Execute a DQL query and return the matching items.
 50
 51        Only returns data already present in the local store — it does not wait
 52        for any sync subscription to catch up first. Use ``register_observer``
 53        if results should reflect data still arriving via sync.
 54
 55        Runs on the event loop's default executor; implicitly opens a one-off
 56        native transaction to execute the query.
 57
 58        Args:
 59            query: A DQL query string.
 60            arguments: Values keyed by placeholder name, without the leading
 61                ``:`` (e.g. ``{"mileage": 123}``). Values must be
 62                CBOR-encodable; an unsupported value type raises ``TypeError``
 63                locally, before any native call is made.
 64
 65        Returns:
 66            A :class:`QueryResult`. Close it (or use it as a context manager)
 67            when done — it and each :class:`QueryResultItem` own native
 68            resources.
 69
 70        Raises:
 71            DittoQueryInvalidError: ``query`` is not valid DQL.
 72            DittoQueryArgumentsInvalidError: ``arguments`` failed native-side
 73                validation.
 74            DittoStoreError: other query execution failures.
 75
 76        Warning:
 77            Calling this from inside a :meth:`transaction` scope may deadlock —
 78            it opens its own transaction against the same store.
 79        """
 80
 81        owner = self._owner()
 82
 83        def invoke() -> QueryResult:
 84            encoded = borrowed_bytes(encode_query_args(arguments))
 85            result = get_ffi().dittoffi_try_exec_statement(
 86                owner._native_handle, utf8(query), encoded.slice
 87            )
 88            return QueryResult.from_ffi_result(result)
 89
 90        return await run_blocking(invoke)
 91
 92    def register_observer(
 93        self,
 94        query: str,
 95        arguments: Mapping[str, Any] | StoreObserverHandler | None = None,
 96        on_change: StoreObserverHandler | None = None,
 97        *,
 98        manual_signal_next: bool = False,
 99    ) -> StoreObserver:
100        """Register a handler that is called whenever the query result changes.
101
102        When ``manual_signal_next`` is true, the handler also receives a
103        ``signal_next`` function and must call it when ready for another
104        callback. See :meth:`register_observer_with_signal_next` for how Ditto
105        handles updates while waiting for this signal.
106
107        Use a :class:`Differ` to compute diffs between consecutive results.
108
109        Raises:
110            DittoQueryInvalidError: ``query`` is not valid DQL.
111            DittoQueryArgumentsInvalidError: ``arguments`` failed native-side
112                validation.
113            DittoQueryNotSupportedError: ``query`` uses a construct that is
114                not supported for observers.
115
116        Warning:
117            If a handler runs longer than 30 seconds without returning (or,
118            with manual signalling, without calling ``signal_next``), Ditto
119            logs a warning that the observer stream is stalled.
120        """
121        from ._internal.observers import register_store_observer
122
123        if callable(arguments) and on_change is None:
124            on_change = arguments
125            arguments = None
126        if on_change is None:
127            raise TypeError("register_observer requires an on_change handler")
128        observer_arguments = cast(Mapping[str, Any] | None, arguments)
129        return register_store_observer(
130            self._owner(),
131            query,
132            observer_arguments,
133            on_change,
134            manual_signal_next=manual_signal_next,
135        )
136
137    def register_observer_with_signal_next(
138        self,
139        query: str,
140        arguments: Mapping[str, Any] | StoreObserverHandler | None = None,
141        on_change: StoreObserverHandler | None = None,
142    ) -> StoreObserver:
143        """Register a handler that signals when it is ready for another callback.
144
145        The handler receives the current query result and a ``signal_next``
146        function. Call ``signal_next`` when the handler is ready for another
147        callback. This is a mechanism for handling backpressure when updates
148        arrive faster than the handler can process them. Until the function is
149        called, Ditto pauses further callbacks while continuing to apply changes
150        to the local store. If multiple relevant changes occur while callbacks
151        are paused, Ditto does not queue a callback for each change. After the
152        function is called, Ditto invokes the handler at most once with the
153        latest result, and only if that result differs from the last result
154        delivered.
155
156        The positional parameter order follows :meth:`register_observer`; the
157        handler may also be passed positionally in place of ``arguments``.
158
159        Raises:
160            DittoQueryInvalidError: ``query`` is not valid DQL.
161            DittoQueryArgumentsInvalidError: ``arguments`` failed native-side
162                validation.
163            DittoQueryNotSupportedError: ``query`` uses a construct that is
164                not supported for observers.
165        """
166        return self.register_observer(query, arguments, on_change, manual_signal_next=True)
167
168    @property
169    def observers(self) -> tuple[StoreObserver, ...]:
170        """All currently active store observers."""
171
172        from ._ffi._bindings import dittoffi_store_observer_t
173
174        owner = self._owner()
175        ffi = get_ffi()
176        vector = ffi.dittoffi_store_observers(owner._native_handle)
177        try:
178            # Indexing a ctypes ``T **`` produces pointer objects backed by the
179            # corresponding slot in the native vector. ``free_sparse`` keeps
180            # the observer allocations alive but frees that slot array, so a
181            # pointer returned directly from ``vector.ptr[index]`` becomes a
182            # dangling view. Copy the address value and reconstruct an
183            # independent typed pointer before releasing the sparse vector.
184            handles = []
185            for index in range(vector.len):
186                address = ctypes.cast(vector.ptr[index], ctypes.c_void_p).value
187                if address:
188                    handles.append(
189                        ctypes.cast(
190                            ctypes.c_void_p(address),
191                            ctypes.POINTER(dittoffi_store_observer_t),
192                        )
193                    )
194            return tuple(StoreObserver(owner, handle) for handle in handles)
195        finally:
196            ffi.dittoffi_store_observers_free_sparse(vector)
197
198    @overload
199    async def transaction(
200        self,
201        scope: Callable[[Transaction], Awaitable[TransactionCompletionAction]],
202        hint: str | None = None,
203        *,
204        is_read_only: bool = False,
205    ) -> TransactionCompletionAction: ...
206
207    @overload
208    async def transaction(
209        self,
210        scope: Callable[[Transaction], TransactionCompletionAction],
211        hint: str | None = None,
212        *,
213        is_read_only: bool = False,
214    ) -> TransactionCompletionAction: ...
215
216    @overload
217    async def transaction(
218        self,
219        scope: Callable[[Transaction], Awaitable[T]],
220        hint: str | None = None,
221        *,
222        is_read_only: bool = False,
223    ) -> T: ...
224
225    @overload
226    async def transaction(
227        self,
228        scope: Callable[[Transaction], T],
229        hint: str | None = None,
230        *,
231        is_read_only: bool = False,
232    ) -> T: ...
233
234    async def transaction(
235        self,
236        scope: Callable[[Transaction], Any],
237        hint: str | None = None,
238        *,
239        is_read_only: bool = False,
240    ) -> Any:
241        """Run a synchronous or asynchronous callable in a native transaction.
242
243        Ordinary return values are propagated after committing. Returning a
244        :class:`~ditto.TransactionCompletionAction` explicitly selects that
245        completion action and returns Core's resulting completion action.
246
247        Only one read-write transaction runs at a time; a second one waits
248        until the first completes. Pass ``is_read_only=True`` to run
249        concurrently with other read-only transactions — mutating queries
250        then raise DittoTransactionReadOnlyError.
251
252        An exception that escapes ``scope`` rolls back the transaction and
253        then propagates. An exception caught inside ``scope`` does not roll
254        back the transaction.
255
256        Warning:
257            Calling :meth:`execute` or opening a nested transaction inside
258            ``scope`` may deadlock — each opens its own transaction against
259            the same store.
260        """
261
262        from .transaction import run_transaction
263
264        return await run_transaction(self, scope, hint=hint, is_read_only=is_read_only)
265
266    async def new_attachment(
267        self, path: str, metadata: Mapping[str, str] | None = None
268    ) -> Attachment:
269        """Copy a local file's contents into Ditto's local attachment store.
270
271        Awaitable; runs off the event loop thread and does not block it.
272
273        Raises:
274            DittoAttachmentFileNotFoundError: ``path`` does not exist.
275            DittoAttachmentFilePermissionDeniedError: ``path`` could not be
276                read.
277            DittoFailedToCreateAttachmentError: Native attachment creation
278                failed for another reason.
279
280        Example:
281            Insert the returned attachment into a document::
282
283                attachment = await store.new_attachment(path, {"content-type": "text/plain"})
284                await store.execute(
285                    "INSERT INTO COLLECTION files (payload ATTACHMENT) DOCUMENTS (:document)",
286                    {"document": {"_id": "example-file", "payload": attachment}},
287                )
288        """
289        from .attachment import new_attachment
290
291        return await new_attachment(self._owner(), path, metadata)
292
293    def fetch_attachment(
294        self, token: Mapping[str, Any], on_event: Callable[[AttachmentFetchEvent], Any]
295    ) -> AttachmentFetcher:
296        """Begin fetching an attachment's data from a remote peer.
297
298        ``token`` is the dict representation of an attachment as it appears
299        in a query result item's document data (keys ``id``, ``len``,
300        ``metadata``). ``on_event`` is invoked on the event loop that is
301        active when this method is called, even for events delivered from a
302        native thread.
303
304        Returns an :class:`~ditto.attachment.AttachmentFetcher`.
305        """
306        from .attachment import AttachmentFetcher
307
308        return AttachmentFetcher(self._owner(), token, on_event)
309
310    @property
311    def attachment_fetchers(self) -> frozenset[AttachmentFetcher]:
312        """All :class:`~ditto.attachment.AttachmentFetcher` instances currently tracked."""
313
314        return frozenset(self._attachment_fetchers)

Executes DQL queries and manages observers, transactions, and attachments.

Store(ditto: Ditto)
35    def __init__(self, ditto: Ditto) -> None:
36        from .disk_usage import DiskUsage, DiskUsageComponent
37
38        self._ditto = weakref.ref(ditto)
39        self._attachment_fetchers: set[AttachmentFetcher] = set()
40        self.disk_usage = DiskUsage(ditto, DiskUsageComponent.STORE)
disk_usage
async def execute( self, query: str, arguments: Mapping[str, Any] | None = None) -> QueryResult:
48    async def execute(self, query: str, arguments: Mapping[str, Any] | None = None) -> QueryResult:
49        """Execute a DQL query and return the matching items.
50
51        Only returns data already present in the local store — it does not wait
52        for any sync subscription to catch up first. Use ``register_observer``
53        if results should reflect data still arriving via sync.
54
55        Runs on the event loop's default executor; implicitly opens a one-off
56        native transaction to execute the query.
57
58        Args:
59            query: A DQL query string.
60            arguments: Values keyed by placeholder name, without the leading
61                ``:`` (e.g. ``{"mileage": 123}``). Values must be
62                CBOR-encodable; an unsupported value type raises ``TypeError``
63                locally, before any native call is made.
64
65        Returns:
66            A :class:`QueryResult`. Close it (or use it as a context manager)
67            when done — it and each :class:`QueryResultItem` own native
68            resources.
69
70        Raises:
71            DittoQueryInvalidError: ``query`` is not valid DQL.
72            DittoQueryArgumentsInvalidError: ``arguments`` failed native-side
73                validation.
74            DittoStoreError: other query execution failures.
75
76        Warning:
77            Calling this from inside a :meth:`transaction` scope may deadlock —
78            it opens its own transaction against the same store.
79        """
80
81        owner = self._owner()
82
83        def invoke() -> QueryResult:
84            encoded = borrowed_bytes(encode_query_args(arguments))
85            result = get_ffi().dittoffi_try_exec_statement(
86                owner._native_handle, utf8(query), encoded.slice
87            )
88            return QueryResult.from_ffi_result(result)
89
90        return await run_blocking(invoke)

Execute a DQL query and return the matching items.

Only returns data already present in the local store — it does not wait for any sync subscription to catch up first. Use register_observer if results should reflect data still arriving via sync.

Runs on the event loop's default executor; implicitly opens a one-off native transaction to execute the query.

Arguments:
  • query: A DQL query string.
  • arguments: Values keyed by placeholder name, without the leading : (e.g. {"mileage": 123}). Values must be CBOR-encodable; an unsupported value type raises TypeError locally, before any native call is made.
Returns:

A QueryResult. Close it (or use it as a context manager) when done — it and each QueryResultItem own native resources.

Raises:
  • DittoQueryInvalidError: query is not valid DQL.
  • DittoQueryArgumentsInvalidError: arguments failed native-side validation.
  • DittoStoreError: other query execution failures.
Warning:

Calling this from inside a transaction() scope may deadlock — it opens its own transaction against the same store.

def register_observer( self, query: str, arguments: Mapping[str, Any] | Callable[[QueryResult], Any] | Callable[[QueryResult, Callable[[], None]], Any] | None = None, on_change: Callable[[QueryResult], Any] | Callable[[QueryResult, Callable[[], None]], Any] | None = None, *, manual_signal_next: bool = False) -> StoreObserver:
 92    def register_observer(
 93        self,
 94        query: str,
 95        arguments: Mapping[str, Any] | StoreObserverHandler | None = None,
 96        on_change: StoreObserverHandler | None = None,
 97        *,
 98        manual_signal_next: bool = False,
 99    ) -> StoreObserver:
100        """Register a handler that is called whenever the query result changes.
101
102        When ``manual_signal_next`` is true, the handler also receives a
103        ``signal_next`` function and must call it when ready for another
104        callback. See :meth:`register_observer_with_signal_next` for how Ditto
105        handles updates while waiting for this signal.
106
107        Use a :class:`Differ` to compute diffs between consecutive results.
108
109        Raises:
110            DittoQueryInvalidError: ``query`` is not valid DQL.
111            DittoQueryArgumentsInvalidError: ``arguments`` failed native-side
112                validation.
113            DittoQueryNotSupportedError: ``query`` uses a construct that is
114                not supported for observers.
115
116        Warning:
117            If a handler runs longer than 30 seconds without returning (or,
118            with manual signalling, without calling ``signal_next``), Ditto
119            logs a warning that the observer stream is stalled.
120        """
121        from ._internal.observers import register_store_observer
122
123        if callable(arguments) and on_change is None:
124            on_change = arguments
125            arguments = None
126        if on_change is None:
127            raise TypeError("register_observer requires an on_change handler")
128        observer_arguments = cast(Mapping[str, Any] | None, arguments)
129        return register_store_observer(
130            self._owner(),
131            query,
132            observer_arguments,
133            on_change,
134            manual_signal_next=manual_signal_next,
135        )

Register a handler that is called whenever the query result changes.

When manual_signal_next is true, the handler also receives a signal_next function and must call it when ready for another callback. See register_observer_with_signal_next() for how Ditto handles updates while waiting for this signal.

Use a Differ to compute diffs between consecutive results.

Raises:
  • DittoQueryInvalidError: query is not valid DQL.
  • DittoQueryArgumentsInvalidError: arguments failed native-side validation.
  • DittoQueryNotSupportedError: query uses a construct that is not supported for observers.
Warning:

If a handler runs longer than 30 seconds without returning (or, with manual signalling, without calling signal_next), Ditto logs a warning that the observer stream is stalled.

def register_observer_with_signal_next( self, query: str, arguments: Mapping[str, Any] | Callable[[QueryResult], Any] | Callable[[QueryResult, Callable[[], None]], Any] | None = None, on_change: Callable[[QueryResult], Any] | Callable[[QueryResult, Callable[[], None]], Any] | None = None) -> StoreObserver:
137    def register_observer_with_signal_next(
138        self,
139        query: str,
140        arguments: Mapping[str, Any] | StoreObserverHandler | None = None,
141        on_change: StoreObserverHandler | None = None,
142    ) -> StoreObserver:
143        """Register a handler that signals when it is ready for another callback.
144
145        The handler receives the current query result and a ``signal_next``
146        function. Call ``signal_next`` when the handler is ready for another
147        callback. This is a mechanism for handling backpressure when updates
148        arrive faster than the handler can process them. Until the function is
149        called, Ditto pauses further callbacks while continuing to apply changes
150        to the local store. If multiple relevant changes occur while callbacks
151        are paused, Ditto does not queue a callback for each change. After the
152        function is called, Ditto invokes the handler at most once with the
153        latest result, and only if that result differs from the last result
154        delivered.
155
156        The positional parameter order follows :meth:`register_observer`; the
157        handler may also be passed positionally in place of ``arguments``.
158
159        Raises:
160            DittoQueryInvalidError: ``query`` is not valid DQL.
161            DittoQueryArgumentsInvalidError: ``arguments`` failed native-side
162                validation.
163            DittoQueryNotSupportedError: ``query`` uses a construct that is
164                not supported for observers.
165        """
166        return self.register_observer(query, arguments, on_change, manual_signal_next=True)

Register a handler that signals when it is ready for another callback.

The handler receives the current query result and a signal_next function. Call signal_next when the handler is ready for another callback. This is a mechanism for handling backpressure when updates arrive faster than the handler can process them. Until the function is called, Ditto pauses further callbacks while continuing to apply changes to the local store. If multiple relevant changes occur while callbacks are paused, Ditto does not queue a callback for each change. After the function is called, Ditto invokes the handler at most once with the latest result, and only if that result differs from the last result delivered.

The positional parameter order follows register_observer(); the handler may also be passed positionally in place of arguments.

Raises:
  • DittoQueryInvalidError: query is not valid DQL.
  • DittoQueryArgumentsInvalidError: arguments failed native-side validation.
  • DittoQueryNotSupportedError: query uses a construct that is not supported for observers.
observers: tuple[StoreObserver, ...]
168    @property
169    def observers(self) -> tuple[StoreObserver, ...]:
170        """All currently active store observers."""
171
172        from ._ffi._bindings import dittoffi_store_observer_t
173
174        owner = self._owner()
175        ffi = get_ffi()
176        vector = ffi.dittoffi_store_observers(owner._native_handle)
177        try:
178            # Indexing a ctypes ``T **`` produces pointer objects backed by the
179            # corresponding slot in the native vector. ``free_sparse`` keeps
180            # the observer allocations alive but frees that slot array, so a
181            # pointer returned directly from ``vector.ptr[index]`` becomes a
182            # dangling view. Copy the address value and reconstruct an
183            # independent typed pointer before releasing the sparse vector.
184            handles = []
185            for index in range(vector.len):
186                address = ctypes.cast(vector.ptr[index], ctypes.c_void_p).value
187                if address:
188                    handles.append(
189                        ctypes.cast(
190                            ctypes.c_void_p(address),
191                            ctypes.POINTER(dittoffi_store_observer_t),
192                        )
193                    )
194            return tuple(StoreObserver(owner, handle) for handle in handles)
195        finally:
196            ffi.dittoffi_store_observers_free_sparse(vector)

All currently active store observers.

async def transaction( self, scope: Callable[[Transaction], typing.Any], hint: str | None = None, *, is_read_only: bool = False) -> Any:
234    async def transaction(
235        self,
236        scope: Callable[[Transaction], Any],
237        hint: str | None = None,
238        *,
239        is_read_only: bool = False,
240    ) -> Any:
241        """Run a synchronous or asynchronous callable in a native transaction.
242
243        Ordinary return values are propagated after committing. Returning a
244        :class:`~ditto.TransactionCompletionAction` explicitly selects that
245        completion action and returns Core's resulting completion action.
246
247        Only one read-write transaction runs at a time; a second one waits
248        until the first completes. Pass ``is_read_only=True`` to run
249        concurrently with other read-only transactions — mutating queries
250        then raise DittoTransactionReadOnlyError.
251
252        An exception that escapes ``scope`` rolls back the transaction and
253        then propagates. An exception caught inside ``scope`` does not roll
254        back the transaction.
255
256        Warning:
257            Calling :meth:`execute` or opening a nested transaction inside
258            ``scope`` may deadlock — each opens its own transaction against
259            the same store.
260        """
261
262        from .transaction import run_transaction
263
264        return await run_transaction(self, scope, hint=hint, is_read_only=is_read_only)

Run a synchronous or asynchronous callable in a native transaction.

Ordinary return values are propagated after committing. Returning a ~ditto.TransactionCompletionAction explicitly selects that completion action and returns Core's resulting completion action.

Only one read-write transaction runs at a time; a second one waits until the first completes. Pass is_read_only=True to run concurrently with other read-only transactions — mutating queries then raise DittoTransactionReadOnlyError.

An exception that escapes scope rolls back the transaction and then propagates. An exception caught inside scope does not roll back the transaction.

Warning:

Calling execute() or opening a nested transaction inside scope may deadlock — each opens its own transaction against the same store.

async def new_attachment( self, path: str, metadata: Mapping[str, str] | None = None) -> Attachment:
266    async def new_attachment(
267        self, path: str, metadata: Mapping[str, str] | None = None
268    ) -> Attachment:
269        """Copy a local file's contents into Ditto's local attachment store.
270
271        Awaitable; runs off the event loop thread and does not block it.
272
273        Raises:
274            DittoAttachmentFileNotFoundError: ``path`` does not exist.
275            DittoAttachmentFilePermissionDeniedError: ``path`` could not be
276                read.
277            DittoFailedToCreateAttachmentError: Native attachment creation
278                failed for another reason.
279
280        Example:
281            Insert the returned attachment into a document::
282
283                attachment = await store.new_attachment(path, {"content-type": "text/plain"})
284                await store.execute(
285                    "INSERT INTO COLLECTION files (payload ATTACHMENT) DOCUMENTS (:document)",
286                    {"document": {"_id": "example-file", "payload": attachment}},
287                )
288        """
289        from .attachment import new_attachment
290
291        return await new_attachment(self._owner(), path, metadata)

Copy a local file's contents into Ditto's local attachment store.

Awaitable; runs off the event loop thread and does not block it.

Raises:
  • DittoAttachmentFileNotFoundError: path does not exist.
  • DittoAttachmentFilePermissionDeniedError: path could not be read.
  • DittoFailedToCreateAttachmentError: Native attachment creation failed for another reason.
Example:

Insert the returned attachment into a document::

attachment = await store.new_attachment(path, {"content-type": "text/plain"})
await store.execute(
    "INSERT INTO COLLECTION files (payload ATTACHMENT) DOCUMENTS (:document)",
    {"document": {"_id": "example-file", "payload": attachment}},
)
def fetch_attachment( self, token: Mapping[str, typing.Any], on_event: Callable[[AttachmentFetchEvent], typing.Any]) -> AttachmentFetcher:
293    def fetch_attachment(
294        self, token: Mapping[str, Any], on_event: Callable[[AttachmentFetchEvent], Any]
295    ) -> AttachmentFetcher:
296        """Begin fetching an attachment's data from a remote peer.
297
298        ``token`` is the dict representation of an attachment as it appears
299        in a query result item's document data (keys ``id``, ``len``,
300        ``metadata``). ``on_event`` is invoked on the event loop that is
301        active when this method is called, even for events delivered from a
302        native thread.
303
304        Returns an :class:`~ditto.attachment.AttachmentFetcher`.
305        """
306        from .attachment import AttachmentFetcher
307
308        return AttachmentFetcher(self._owner(), token, on_event)

Begin fetching an attachment's data from a remote peer.

token is the dict representation of an attachment as it appears in a query result item's document data (keys id, len, metadata). on_event is invoked on the event loop that is active when this method is called, even for events delivered from a native thread.

Returns an ~ditto.attachment.AttachmentFetcher.

attachment_fetchers: frozenset[AttachmentFetcher]
310    @property
311    def attachment_fetchers(self) -> frozenset[AttachmentFetcher]:
312        """All :class:`~ditto.attachment.AttachmentFetcher` instances currently tracked."""
313
314        return frozenset(self._attachment_fetchers)

All ~ditto.attachment.AttachmentFetcher instances currently tracked.

class StoreObserver:
165class StoreObserver:
166    """Invokes a handler whenever results for its registered query change.
167
168    Remains active — continuing to invoke its handler — until
169    :meth:`cancel` is called or the owning :class:`~ditto.ditto.Ditto`
170    closes. Holds only a weak reference to its owner.
171    """
172
173    def __init__(self, ditto: Ditto, handle: Any) -> None:
174        self._ditto = weakref.ref(ditto)
175        self._handle = handle
176        self._closed = False
177        self._lock = ProcessLocalRLock()
178        self._free_native = process_bound_cleanup(get_ffi().dittoffi_store_observer_free)
179        self._finalizer = weakref.finalize(self, _finalize, self._free_native, handle)
180
181    def _native(self) -> Any:
182        if self._closed or not self._handle:
183            raise DittoClosedError("StoreObserver is closed")
184        return self._handle
185
186    @property
187    def ditto(self) -> Ditto | None:
188        """The owning :class:`~ditto.ditto.Ditto` instance, or ``None`` if garbage collected."""
189
190        return self._ditto()
191
192    @property
193    def query_string(self) -> str:
194        """This observer's DQL query string."""
195
196        with self._lock:
197            return (
198                consume_string(get_ffi().dittoffi_store_observer_query_string(self._native())) or ""
199            )
200
201    @property
202    def query_arguments_cbor_data(self) -> bytes | None:
203        """This observer's query arguments as raw CBOR bytes, or ``None`` if none."""
204
205        with self._lock:
206            value = consume_boxed_bytes(
207                get_ffi().dittoffi_store_observer_query_arguments_cbor(self._native())
208            )
209            return value or None
210
211    @property
212    def query_arguments(self) -> dict[str, Any] | None:
213        """This observer's query arguments, decoded from CBOR, or ``None`` if none."""
214
215        value = self.query_arguments_cbor_data
216        return cbor2.loads(value) if value else None
217
218    @property
219    def query_arguments_json_string(self) -> str | None:
220        """This observer's query arguments as a JSON string, or ``None`` if none."""
221
222        with self._lock:
223            value = consume_boxed_bytes(
224                get_ffi().dittoffi_store_observer_query_arguments_json(self._native())
225            )
226            return value.decode("utf-8") if value else None
227
228    @property
229    def is_cancelled(self) -> bool:
230        """Whether the native observer has been cancelled or its owner is gone."""
231
232        with self._lock:
233            if self._closed or self._ditto() is None:
234                return True
235            return bool(get_ffi().dittoffi_store_observer_is_cancelled(self._native()))
236
237    @property
238    def id(self) -> bytes:
239        """This observer's unique identifier, assigned by native code."""
240
241        with self._lock:
242            return consume_boxed_bytes(get_ffi().dittoffi_store_observer_id(self._native()))
243
244    def cancel(self) -> None:
245        """Stop invoking this observer's handler for future query changes.
246
247        Removes the native observation, but does not free this object's
248        native handle — call :meth:`close` too when you are done with
249        this observer.
250        """
251        with self._lock:
252            if not self._closed and self._handle:
253                get_ffi().dittoffi_store_observer_cancel(self._handle)
254
255    def close(self) -> None:
256        """Release this object's local native handle.
257
258        Does not cancel the observation — the native observer keeps
259        invoking the handler until :meth:`cancel` is also called.
260        """
261        with self._lock:
262            if self._closed:
263                return
264            self._closed = True
265            self._finalizer.detach()
266            handle = self._handle
267            self._handle = None
268        if handle:
269            self._free_native(handle)
270
271    def __eq__(self, other: object) -> bool:
272        return isinstance(other, StoreObserver) and self.id == other.id
273
274    def __hash__(self) -> int:
275        return hash(self.id)
276
277    def __enter__(self) -> StoreObserver:
278        return self
279
280    def __exit__(self, *_: object) -> None:
281        self.close()

Invokes a handler whenever results for its registered query change.

Remains active — continuing to invoke its handler — until cancel() is called or the owning ~ditto.ditto.Ditto closes. Holds only a weak reference to its owner.

StoreObserver(ditto: Ditto, handle: Any)
173    def __init__(self, ditto: Ditto, handle: Any) -> None:
174        self._ditto = weakref.ref(ditto)
175        self._handle = handle
176        self._closed = False
177        self._lock = ProcessLocalRLock()
178        self._free_native = process_bound_cleanup(get_ffi().dittoffi_store_observer_free)
179        self._finalizer = weakref.finalize(self, _finalize, self._free_native, handle)
ditto: Ditto | None
186    @property
187    def ditto(self) -> Ditto | None:
188        """The owning :class:`~ditto.ditto.Ditto` instance, or ``None`` if garbage collected."""
189
190        return self._ditto()

The owning ~ditto.ditto.Ditto instance, or None if garbage collected.

query_string: str
192    @property
193    def query_string(self) -> str:
194        """This observer's DQL query string."""
195
196        with self._lock:
197            return (
198                consume_string(get_ffi().dittoffi_store_observer_query_string(self._native())) or ""
199            )

This observer's DQL query string.

query_arguments_cbor_data: bytes | None
201    @property
202    def query_arguments_cbor_data(self) -> bytes | None:
203        """This observer's query arguments as raw CBOR bytes, or ``None`` if none."""
204
205        with self._lock:
206            value = consume_boxed_bytes(
207                get_ffi().dittoffi_store_observer_query_arguments_cbor(self._native())
208            )
209            return value or None

This observer's query arguments as raw CBOR bytes, or None if none.

query_arguments: dict[str, Any] | None
211    @property
212    def query_arguments(self) -> dict[str, Any] | None:
213        """This observer's query arguments, decoded from CBOR, or ``None`` if none."""
214
215        value = self.query_arguments_cbor_data
216        return cbor2.loads(value) if value else None

This observer's query arguments, decoded from CBOR, or None if none.

query_arguments_json_string: str | None
218    @property
219    def query_arguments_json_string(self) -> str | None:
220        """This observer's query arguments as a JSON string, or ``None`` if none."""
221
222        with self._lock:
223            value = consume_boxed_bytes(
224                get_ffi().dittoffi_store_observer_query_arguments_json(self._native())
225            )
226            return value.decode("utf-8") if value else None

This observer's query arguments as a JSON string, or None if none.

is_cancelled: bool
228    @property
229    def is_cancelled(self) -> bool:
230        """Whether the native observer has been cancelled or its owner is gone."""
231
232        with self._lock:
233            if self._closed or self._ditto() is None:
234                return True
235            return bool(get_ffi().dittoffi_store_observer_is_cancelled(self._native()))

Whether the native observer has been cancelled or its owner is gone.

id: bytes
237    @property
238    def id(self) -> bytes:
239        """This observer's unique identifier, assigned by native code."""
240
241        with self._lock:
242            return consume_boxed_bytes(get_ffi().dittoffi_store_observer_id(self._native()))

This observer's unique identifier, assigned by native code.

def cancel(self) -> None:
244    def cancel(self) -> None:
245        """Stop invoking this observer's handler for future query changes.
246
247        Removes the native observation, but does not free this object's
248        native handle — call :meth:`close` too when you are done with
249        this observer.
250        """
251        with self._lock:
252            if not self._closed and self._handle:
253                get_ffi().dittoffi_store_observer_cancel(self._handle)

Stop invoking this observer's handler for future query changes.

Removes the native observation, but does not free this object's native handle — call close() too when you are done with this observer.

def close(self) -> None:
255    def close(self) -> None:
256        """Release this object's local native handle.
257
258        Does not cancel the observation — the native observer keeps
259        invoking the handler until :meth:`cancel` is also called.
260        """
261        with self._lock:
262            if self._closed:
263                return
264            self._closed = True
265            self._finalizer.detach()
266            handle = self._handle
267            self._handle = None
268        if handle:
269            self._free_native(handle)

Release this object's local native handle.

Does not cancel the observation — the native observer keeps invoking the handler until cancel() is also called.

class Sync:
143class Sync:
144    """Access to a Ditto instance's sync functionality.
145
146    Holds only a weak reference to its owning
147    :class:`~ditto.ditto.Ditto` instance. Most operations raise
148    :class:`~ditto.errors.DittoClosedError` once the owner is closed or
149    garbage-collected; :attr:`is_active` and :meth:`stop` instead treat a
150    closed or collected owner as inactive.
151    """
152
153    def __init__(self, ditto: Ditto) -> None:
154        self._ditto = weakref.ref(ditto)
155        self._closed = False
156
157    def _owner(self) -> Ditto:
158        owner = self._ditto()
159        if self._closed or owner is None or owner.closed:
160            raise DittoClosedError("Sync is closed")
161        return owner
162
163    @property
164    def is_active(self) -> bool:
165        """Whether sync is currently active."""
166        owner = self._ditto()
167        if self._closed or owner is None or owner.closed:
168            return False
169        return bool(get_ffi().dittoffi_ditto_is_sync_active(owner._native_handle))
170
171    @property
172    def subscriptions(self) -> tuple[SyncSubscription, ...]:
173        """All currently active sync subscriptions."""
174        from ._ffi._bindings import dittoffi_sync_subscription_t
175
176        owner = self._owner()
177        ffi = get_ffi()
178        vector = ffi.dittoffi_sync_subscriptions(owner._native_handle)
179        try:
180            handles = []
181            for index in range(vector.len):
182                address = ctypes.cast(vector.ptr[index], ctypes.c_void_p).value
183                if address:
184                    handles.append(
185                        ctypes.cast(
186                            ctypes.c_void_p(address),
187                            ctypes.POINTER(dittoffi_sync_subscription_t),
188                        )
189                    )
190            return tuple(SyncSubscription(owner, handle) for handle in handles)
191        finally:
192            ffi.dittoffi_sync_subscriptions_free_sparse(vector)
193
194    def start(self) -> None:
195        """Start sync with the instance's configured transports.
196
197        When the instance's connection mode is
198        :meth:`~ditto.config.DittoConfigConnect.server`, an
199        ``ditto.auth.expiration_handler`` must be set first, otherwise
200        this raises
201        :class:`~ditto.errors.DittoExpirationHandlerMissingError`.
202
203        Calling this while sync is not already active pushes the current
204        :attr:`~ditto.ditto.Ditto.device_name` through Core, which
205        truncates values over 24 UTF-8 bytes, and updates that property
206        to match.
207        """
208        owner = self._owner()
209        ffi = get_ffi()
210        if not self.is_active:
211            truncated = consume_string(
212                ffi.ditto_set_device_name(owner._native_handle, utf8(owner.device_name)), ffi
213            )
214            if truncated is None:
215                raise DittoError("ditto_set_device_name returned null")
216            owner._device_name = truncated
217        check_result(ffi.dittoffi_ditto_try_start_sync(owner._native_handle), ffi)
218
219    def stop(self) -> None:
220        """Stop all network transports.
221
222        You may continue to use the store locally, but no data will sync
223        to or from other peers.
224        """
225        if self._closed:
226            return
227        owner = self._ditto()
228        if owner is not None and not owner.closed:
229            get_ffi().dittoffi_ditto_stop_sync(owner._native_handle)
230
231    def register_subscription(
232        self, query: str, arguments: Mapping[str, Any] | None = None
233    ) -> SyncSubscription:
234        """Configure Ditto to sync documents matching ``query`` from other peers.
235
236        ``query`` must be a ``SELECT`` query. The returned
237        :class:`SyncSubscription` remains active until
238        :meth:`SyncSubscription.cancel` is called or the owning
239        :class:`~ditto.ditto.Ditto` closes.
240
241        Raises:
242            DittoQueryInvalidError: ``query`` is not valid DQL.
243            DittoQueryArgumentsInvalidError: ``arguments`` failed
244                native-side validation.
245            DittoQueryNotSupportedError: ``query`` is not a ``SELECT``
246                query.
247        """
248        owner = self._owner()
249        encoded = borrowed_bytes(encode_query_args(arguments))
250        result = get_ffi().dittoffi_sync_register_subscription_throws(
251            owner._native_handle, utf8(query), encoded.slice
252        )
253        return SyncSubscription(owner, check_result(result))
254
255    def close(self) -> None:
256        """Stop sync and mark this object closed. Safe to call more than once."""
257
258        if self._closed:
259            return
260        try:
261            self.stop()
262        finally:
263            self._closed = True

Access to a Ditto instance's sync functionality.

Holds only a weak reference to its owning ~ditto.ditto.Ditto instance. Most operations raise ~ditto.errors.DittoClosedError once the owner is closed or garbage-collected; is_active and stop() instead treat a closed or collected owner as inactive.

Sync(ditto: Ditto)
153    def __init__(self, ditto: Ditto) -> None:
154        self._ditto = weakref.ref(ditto)
155        self._closed = False
is_active: bool
163    @property
164    def is_active(self) -> bool:
165        """Whether sync is currently active."""
166        owner = self._ditto()
167        if self._closed or owner is None or owner.closed:
168            return False
169        return bool(get_ffi().dittoffi_ditto_is_sync_active(owner._native_handle))

Whether sync is currently active.

subscriptions: tuple[SyncSubscription, ...]
171    @property
172    def subscriptions(self) -> tuple[SyncSubscription, ...]:
173        """All currently active sync subscriptions."""
174        from ._ffi._bindings import dittoffi_sync_subscription_t
175
176        owner = self._owner()
177        ffi = get_ffi()
178        vector = ffi.dittoffi_sync_subscriptions(owner._native_handle)
179        try:
180            handles = []
181            for index in range(vector.len):
182                address = ctypes.cast(vector.ptr[index], ctypes.c_void_p).value
183                if address:
184                    handles.append(
185                        ctypes.cast(
186                            ctypes.c_void_p(address),
187                            ctypes.POINTER(dittoffi_sync_subscription_t),
188                        )
189                    )
190            return tuple(SyncSubscription(owner, handle) for handle in handles)
191        finally:
192            ffi.dittoffi_sync_subscriptions_free_sparse(vector)

All currently active sync subscriptions.

def start(self) -> None:
194    def start(self) -> None:
195        """Start sync with the instance's configured transports.
196
197        When the instance's connection mode is
198        :meth:`~ditto.config.DittoConfigConnect.server`, an
199        ``ditto.auth.expiration_handler`` must be set first, otherwise
200        this raises
201        :class:`~ditto.errors.DittoExpirationHandlerMissingError`.
202
203        Calling this while sync is not already active pushes the current
204        :attr:`~ditto.ditto.Ditto.device_name` through Core, which
205        truncates values over 24 UTF-8 bytes, and updates that property
206        to match.
207        """
208        owner = self._owner()
209        ffi = get_ffi()
210        if not self.is_active:
211            truncated = consume_string(
212                ffi.ditto_set_device_name(owner._native_handle, utf8(owner.device_name)), ffi
213            )
214            if truncated is None:
215                raise DittoError("ditto_set_device_name returned null")
216            owner._device_name = truncated
217        check_result(ffi.dittoffi_ditto_try_start_sync(owner._native_handle), ffi)

Start sync with the instance's configured transports.

When the instance's connection mode is ~ditto.config.DittoConfigConnect.server(), an ditto.auth.expiration_handler must be set first, otherwise this raises ~ditto.errors.DittoExpirationHandlerMissingError.

Calling this while sync is not already active pushes the current ~ditto.ditto.Ditto.device_name through Core, which truncates values over 24 UTF-8 bytes, and updates that property to match.

def stop(self) -> None:
219    def stop(self) -> None:
220        """Stop all network transports.
221
222        You may continue to use the store locally, but no data will sync
223        to or from other peers.
224        """
225        if self._closed:
226            return
227        owner = self._ditto()
228        if owner is not None and not owner.closed:
229            get_ffi().dittoffi_ditto_stop_sync(owner._native_handle)

Stop all network transports.

You may continue to use the store locally, but no data will sync to or from other peers.

def register_subscription( self, query: str, arguments: Mapping[str, Any] | None = None) -> SyncSubscription:
231    def register_subscription(
232        self, query: str, arguments: Mapping[str, Any] | None = None
233    ) -> SyncSubscription:
234        """Configure Ditto to sync documents matching ``query`` from other peers.
235
236        ``query`` must be a ``SELECT`` query. The returned
237        :class:`SyncSubscription` remains active until
238        :meth:`SyncSubscription.cancel` is called or the owning
239        :class:`~ditto.ditto.Ditto` closes.
240
241        Raises:
242            DittoQueryInvalidError: ``query`` is not valid DQL.
243            DittoQueryArgumentsInvalidError: ``arguments`` failed
244                native-side validation.
245            DittoQueryNotSupportedError: ``query`` is not a ``SELECT``
246                query.
247        """
248        owner = self._owner()
249        encoded = borrowed_bytes(encode_query_args(arguments))
250        result = get_ffi().dittoffi_sync_register_subscription_throws(
251            owner._native_handle, utf8(query), encoded.slice
252        )
253        return SyncSubscription(owner, check_result(result))

Configure Ditto to sync documents matching query from other peers.

query must be a SELECT query. The returned SyncSubscription remains active until SyncSubscription.cancel() is called or the owning ~ditto.ditto.Ditto closes.

Raises:
  • DittoQueryInvalidError: query is not valid DQL.
  • DittoQueryArgumentsInvalidError: arguments failed native-side validation.
  • DittoQueryNotSupportedError: query is not a SELECT query.
def close(self) -> None:
255    def close(self) -> None:
256        """Stop sync and mark this object closed. Safe to call more than once."""
257
258        if self._closed:
259            return
260        try:
261            self.stop()
262        finally:
263            self._closed = True

Stop sync and mark this object closed. Safe to call more than once.

class SyncSubscription:
 29class SyncSubscription:
 30    """An active sync subscription for a DQL query.
 31
 32    Remains active — continuing to sync matching documents from other
 33    peers — until :meth:`cancel` is called or the owning
 34    :class:`~ditto.ditto.Ditto` closes. Holds only a weak reference to
 35    its owner.
 36    """
 37
 38    def __init__(self, ditto: Ditto, handle: Any) -> None:
 39        self._ditto = weakref.ref(ditto)
 40        self._handle = handle
 41        self._closed = False
 42        self._lock = ProcessLocalRLock()
 43        self._free_native = process_bound_cleanup(get_ffi().dittoffi_sync_subscription_free)
 44        self._finalizer = weakref.finalize(self, _finalize, self._free_native, handle)
 45
 46    def _native(self) -> Any:
 47        if self._closed or not self._handle:
 48            raise DittoClosedError("SyncSubscription is closed")
 49        return self._handle
 50
 51    @property
 52    def ditto(self) -> Ditto | None:
 53        """The owning :class:`~ditto.ditto.Ditto` instance, or ``None`` if garbage collected."""
 54
 55        return self._ditto()
 56
 57    @property
 58    def query_string(self) -> str:
 59        """This subscription's DQL query string."""
 60
 61        pointer = get_ffi().dittoffi_sync_subscription_query_string(self._native())
 62        return consume_string(pointer) or ""
 63
 64    @property
 65    def query_arguments_cbor_data(self) -> bytes | None:
 66        """This subscription's query arguments as raw CBOR bytes, or ``None`` if none."""
 67
 68        value = consume_boxed_bytes(
 69            get_ffi().dittoffi_sync_subscription_query_arguments_cbor(self._native())
 70        )
 71        return value or None
 72
 73    @property
 74    def query_arguments(self) -> dict[str, Any] | None:
 75        """This subscription's query arguments, decoded from CBOR, or ``None`` if none."""
 76
 77        value = self.query_arguments_cbor_data
 78        return cbor2.loads(value) if value else None
 79
 80    @property
 81    def query_arguments_json_string(self) -> str | None:
 82        """This subscription's query arguments as a JSON string, or ``None`` if none."""
 83
 84        value = consume_boxed_bytes(
 85            get_ffi().dittoffi_sync_subscription_query_arguments_json(self._native())
 86        )
 87        return value.decode("utf-8") if value else None
 88
 89    @property
 90    def is_cancelled(self) -> bool:
 91        """Whether the native subscription has been cancelled or its owner is gone."""
 92
 93        if self._closed or self._ditto() is None:
 94            return True
 95        return bool(get_ffi().dittoffi_sync_subscription_is_cancelled(self._native()))
 96
 97    @property
 98    def id(self) -> bytes:
 99        """This subscription's unique identifier, assigned by native code."""
100
101        return consume_boxed_bytes(get_ffi().dittoffi_sync_subscription_id(self._native()))
102
103    def cancel(self) -> None:
104        """Stop receiving updates for this subscription's query.
105
106        Removes the native subscription, so Ditto stops syncing matching
107        documents, but does not free this object's native handle — call
108        :meth:`close` too when you are done with this subscription.
109        """
110        with self._lock:
111            if not self._closed and self._handle:
112                get_ffi().dittoffi_sync_subscription_cancel(self._handle)
113
114    def close(self) -> None:
115        """Release this object's local native handle.
116
117        Does not cancel the subscription — the native subscription keeps
118        syncing matching documents until :meth:`cancel` is also called.
119        """
120        with self._lock:
121            if self._closed:
122                return
123            self._closed = True
124            self._finalizer.detach()
125            handle = self._handle
126            self._handle = None
127        if handle:
128            self._free_native(handle)
129
130    def __eq__(self, other: object) -> bool:
131        return isinstance(other, SyncSubscription) and self.id == other.id
132
133    def __hash__(self) -> int:
134        return hash(self.id)
135
136    def __enter__(self) -> SyncSubscription:
137        return self
138
139    def __exit__(self, *_: object) -> None:
140        self.close()

An active sync subscription for a DQL query.

Remains active — continuing to sync matching documents from other peers — until cancel() is called or the owning ~ditto.ditto.Ditto closes. Holds only a weak reference to its owner.

SyncSubscription(ditto: Ditto, handle: Any)
38    def __init__(self, ditto: Ditto, handle: Any) -> None:
39        self._ditto = weakref.ref(ditto)
40        self._handle = handle
41        self._closed = False
42        self._lock = ProcessLocalRLock()
43        self._free_native = process_bound_cleanup(get_ffi().dittoffi_sync_subscription_free)
44        self._finalizer = weakref.finalize(self, _finalize, self._free_native, handle)
ditto: Ditto | None
51    @property
52    def ditto(self) -> Ditto | None:
53        """The owning :class:`~ditto.ditto.Ditto` instance, or ``None`` if garbage collected."""
54
55        return self._ditto()

The owning ~ditto.ditto.Ditto instance, or None if garbage collected.

query_string: str
57    @property
58    def query_string(self) -> str:
59        """This subscription's DQL query string."""
60
61        pointer = get_ffi().dittoffi_sync_subscription_query_string(self._native())
62        return consume_string(pointer) or ""

This subscription's DQL query string.

query_arguments_cbor_data: bytes | None
64    @property
65    def query_arguments_cbor_data(self) -> bytes | None:
66        """This subscription's query arguments as raw CBOR bytes, or ``None`` if none."""
67
68        value = consume_boxed_bytes(
69            get_ffi().dittoffi_sync_subscription_query_arguments_cbor(self._native())
70        )
71        return value or None

This subscription's query arguments as raw CBOR bytes, or None if none.

query_arguments: dict[str, Any] | None
73    @property
74    def query_arguments(self) -> dict[str, Any] | None:
75        """This subscription's query arguments, decoded from CBOR, or ``None`` if none."""
76
77        value = self.query_arguments_cbor_data
78        return cbor2.loads(value) if value else None

This subscription's query arguments, decoded from CBOR, or None if none.

query_arguments_json_string: str | None
80    @property
81    def query_arguments_json_string(self) -> str | None:
82        """This subscription's query arguments as a JSON string, or ``None`` if none."""
83
84        value = consume_boxed_bytes(
85            get_ffi().dittoffi_sync_subscription_query_arguments_json(self._native())
86        )
87        return value.decode("utf-8") if value else None

This subscription's query arguments as a JSON string, or None if none.

is_cancelled: bool
89    @property
90    def is_cancelled(self) -> bool:
91        """Whether the native subscription has been cancelled or its owner is gone."""
92
93        if self._closed or self._ditto() is None:
94            return True
95        return bool(get_ffi().dittoffi_sync_subscription_is_cancelled(self._native()))

Whether the native subscription has been cancelled or its owner is gone.

id: bytes
 97    @property
 98    def id(self) -> bytes:
 99        """This subscription's unique identifier, assigned by native code."""
100
101        return consume_boxed_bytes(get_ffi().dittoffi_sync_subscription_id(self._native()))

This subscription's unique identifier, assigned by native code.

def cancel(self) -> None:
103    def cancel(self) -> None:
104        """Stop receiving updates for this subscription's query.
105
106        Removes the native subscription, so Ditto stops syncing matching
107        documents, but does not free this object's native handle — call
108        :meth:`close` too when you are done with this subscription.
109        """
110        with self._lock:
111            if not self._closed and self._handle:
112                get_ffi().dittoffi_sync_subscription_cancel(self._handle)

Stop receiving updates for this subscription's query.

Removes the native subscription, so Ditto stops syncing matching documents, but does not free this object's native handle — call close() too when you are done with this subscription.

def close(self) -> None:
114    def close(self) -> None:
115        """Release this object's local native handle.
116
117        Does not cancel the subscription — the native subscription keeps
118        syncing matching documents until :meth:`cancel` is also called.
119        """
120        with self._lock:
121            if self._closed:
122                return
123            self._closed = True
124            self._finalizer.detach()
125            handle = self._handle
126            self._handle = None
127        if handle:
128            self._free_native(handle)

Release this object's local native handle.

Does not cancel the subscription — the native subscription keeps syncing matching documents until cancel() is also called.

class Transaction:
144class Transaction:
145    """A native transaction that groups DQL executions atomically.
146
147    Obtained via the scope callback passed to :meth:`Store.transaction`; do
148    not construct directly. Closing or garbage-collecting a transaction that
149    was never completed triggers a best-effort asynchronous rollback.
150
151    Warning:
152        Calling :meth:`Store.execute` or opening a nested transaction inside
153        a scope may deadlock — each opens its own transaction against the
154        same store.
155    """
156
157    def __init__(self, store: Store, handle: Any) -> None:
158        self.store = store
159        self._handle = handle
160        self._closed = False
161        self._completed = False
162        self._free_native = process_bound_cleanup(get_ffi().dittoffi_transaction_free)
163        self._complete_native = process_bound_cleanup(
164            get_ffi().dittoffi_transaction_complete_async_throws
165        )
166        self._finalizer = weakref.finalize(
167            self,
168            _finalize_transaction,
169            self._free_native,
170            self._complete_native,
171            handle,
172            store._ditto,
173        )
174
175    def _native(self) -> Any:
176        if self._closed or not self._handle:
177            raise DittoClosedError("Transaction is closed")
178        return self._handle
179
180    @property
181    def info(self) -> TransactionInfo:
182        """A snapshot of this transaction's id, hint, and read-only setting."""
183
184        value = consume_boxed_bytes(get_ffi().dittoffi_transaction_info(self._native()))
185        decoded = cbor2.loads(value)
186        return TransactionInfo(
187            id=decoded["id"],
188            hint=decoded.get("hint"),
189            is_read_only=bool(decoded.get("is_read_only", False)),
190        )
191
192    async def execute(self, query: str, arguments: Mapping[str, Any] | None = None) -> QueryResult:
193        """Execute a DQL query within this transaction and return the matching items.
194
195        Only returns data already present in the local store, combined with
196        this transaction's own uncommitted writes.
197
198        Completes via a native asynchronous callback; does not block the
199        event loop.
200
201        Args:
202            query: A DQL query string.
203            arguments: Values keyed by placeholder name, without the leading
204                ``:`` (e.g. ``{"mileage": 123}``). Values must be
205                CBOR-encodable; an unsupported value type raises ``TypeError``
206                locally, before any native call is made.
207
208        Returns:
209            A :class:`~ditto.query_result.QueryResult`. Close it (or use it
210            as a context manager) when done — it and each
211            :class:`~ditto.query_result.QueryResultItem` own native
212            resources.
213
214        Raises:
215            DittoQueryInvalidError: ``query`` is not valid DQL.
216            DittoQueryArgumentsInvalidError: ``arguments`` failed native-side
217                validation.
218            DittoTransactionReadOnlyError: This transaction is read-only and
219                ``query`` mutates data.
220            DittoStoreError: other query execution failures.
221        """
222        from ._ffi._bindings import (
223            BoxDynFnMut1_void_dittoffi_result_dittoffi_query_result_ptr_t,
224        )
225
226        encoded = borrowed_bytes(encode_query_args(arguments))
227        query_bytes = utf8(query)
228        ffi = get_ffi()
229        return await native_future(
230            BoxDynFnMut1_void_dittoffi_result_dittoffi_query_result_ptr_t,
231            lambda continuation: ffi.dittoffi_transaction_execute_async_throws(
232                self._native(), query_bytes, encoded.slice, continuation
233            ),
234            QueryResult.from_ffi_result,
235        )
236
237    async def complete(self, action: TransactionCompletionAction) -> TransactionCompletionAction:
238        """Complete this transaction with the given commit or rollback action.
239
240        Usually you don't call this directly — return the desired
241        :class:`TransactionCompletionAction` (or a plain value, or raise) from
242        your :meth:`Store.transaction` scope and it is called for you exactly
243        once. Calling it yourself inside the scope causes a second, redundant
244        completion attempt.
245        """
246        from ._ffi._bindings import (
247            BoxDynFnMut1_void_dittoffi_result_dittoffi_transaction_completion_action_t,
248        )
249
250        ffi = get_ffi()
251        completed = await native_future(
252            BoxDynFnMut1_void_dittoffi_result_dittoffi_transaction_completion_action_t,
253            lambda continuation: self._complete_native(self._native(), int(action), continuation),
254            lambda result: TransactionCompletionAction(check_result(result, ffi)),
255        )
256        self._completed = True
257        return completed
258
259    def close(self) -> None:
260        """Release native resources for this transaction.
261
262        If the transaction was never completed, rolls it back first — a
263        best-effort operation that is asynchronous and does not wait for the
264        rollback to finish. Safe to call more than once.
265        """
266
267        if self._closed:
268            return
269        self._closed = True
270        self._finalizer.detach()
271        handle = self._handle
272        self._handle = None
273        try:
274            owner = self.store._owner()
275        except DittoClosedError:
276            return
277        if handle and not owner.closed:
278            if self._completed:
279                self._free_native(handle)
280            else:
281                _rollback_transaction_handle(self._complete_native, self._free_native, handle)
282
283    def __enter__(self) -> Transaction:
284        return self
285
286    def __exit__(self, *_: object) -> None:
287        self.close()

A native transaction that groups DQL executions atomically.

Obtained via the scope callback passed to Store.transaction(); do not construct directly. Closing or garbage-collecting a transaction that was never completed triggers a best-effort asynchronous rollback.

Warning:

Calling Store.execute() or opening a nested transaction inside a scope may deadlock — each opens its own transaction against the same store.

Transaction(store: Store, handle: Any)
157    def __init__(self, store: Store, handle: Any) -> None:
158        self.store = store
159        self._handle = handle
160        self._closed = False
161        self._completed = False
162        self._free_native = process_bound_cleanup(get_ffi().dittoffi_transaction_free)
163        self._complete_native = process_bound_cleanup(
164            get_ffi().dittoffi_transaction_complete_async_throws
165        )
166        self._finalizer = weakref.finalize(
167            self,
168            _finalize_transaction,
169            self._free_native,
170            self._complete_native,
171            handle,
172            store._ditto,
173        )
store
info: TransactionInfo
180    @property
181    def info(self) -> TransactionInfo:
182        """A snapshot of this transaction's id, hint, and read-only setting."""
183
184        value = consume_boxed_bytes(get_ffi().dittoffi_transaction_info(self._native()))
185        decoded = cbor2.loads(value)
186        return TransactionInfo(
187            id=decoded["id"],
188            hint=decoded.get("hint"),
189            is_read_only=bool(decoded.get("is_read_only", False)),
190        )

A snapshot of this transaction's id, hint, and read-only setting.

async def execute( self, query: str, arguments: Mapping[str, Any] | None = None) -> QueryResult:
192    async def execute(self, query: str, arguments: Mapping[str, Any] | None = None) -> QueryResult:
193        """Execute a DQL query within this transaction and return the matching items.
194
195        Only returns data already present in the local store, combined with
196        this transaction's own uncommitted writes.
197
198        Completes via a native asynchronous callback; does not block the
199        event loop.
200
201        Args:
202            query: A DQL query string.
203            arguments: Values keyed by placeholder name, without the leading
204                ``:`` (e.g. ``{"mileage": 123}``). Values must be
205                CBOR-encodable; an unsupported value type raises ``TypeError``
206                locally, before any native call is made.
207
208        Returns:
209            A :class:`~ditto.query_result.QueryResult`. Close it (or use it
210            as a context manager) when done — it and each
211            :class:`~ditto.query_result.QueryResultItem` own native
212            resources.
213
214        Raises:
215            DittoQueryInvalidError: ``query`` is not valid DQL.
216            DittoQueryArgumentsInvalidError: ``arguments`` failed native-side
217                validation.
218            DittoTransactionReadOnlyError: This transaction is read-only and
219                ``query`` mutates data.
220            DittoStoreError: other query execution failures.
221        """
222        from ._ffi._bindings import (
223            BoxDynFnMut1_void_dittoffi_result_dittoffi_query_result_ptr_t,
224        )
225
226        encoded = borrowed_bytes(encode_query_args(arguments))
227        query_bytes = utf8(query)
228        ffi = get_ffi()
229        return await native_future(
230            BoxDynFnMut1_void_dittoffi_result_dittoffi_query_result_ptr_t,
231            lambda continuation: ffi.dittoffi_transaction_execute_async_throws(
232                self._native(), query_bytes, encoded.slice, continuation
233            ),
234            QueryResult.from_ffi_result,
235        )

Execute a DQL query within this transaction and return the matching items.

Only returns data already present in the local store, combined with this transaction's own uncommitted writes.

Completes via a native asynchronous callback; does not block the event loop.

Arguments:
  • query: A DQL query string.
  • arguments: Values keyed by placeholder name, without the leading : (e.g. {"mileage": 123}). Values must be CBOR-encodable; an unsupported value type raises TypeError locally, before any native call is made.
Returns:

A ~ditto.query_result.QueryResult. Close it (or use it as a context manager) when done — it and each ~ditto.query_result.QueryResultItem own native resources.

Raises:
  • DittoQueryInvalidError: query is not valid DQL.
  • DittoQueryArgumentsInvalidError: arguments failed native-side validation.
  • DittoTransactionReadOnlyError: This transaction is read-only and query mutates data.
  • DittoStoreError: other query execution failures.
async def complete( self, action: TransactionCompletionAction) -> TransactionCompletionAction:
237    async def complete(self, action: TransactionCompletionAction) -> TransactionCompletionAction:
238        """Complete this transaction with the given commit or rollback action.
239
240        Usually you don't call this directly — return the desired
241        :class:`TransactionCompletionAction` (or a plain value, or raise) from
242        your :meth:`Store.transaction` scope and it is called for you exactly
243        once. Calling it yourself inside the scope causes a second, redundant
244        completion attempt.
245        """
246        from ._ffi._bindings import (
247            BoxDynFnMut1_void_dittoffi_result_dittoffi_transaction_completion_action_t,
248        )
249
250        ffi = get_ffi()
251        completed = await native_future(
252            BoxDynFnMut1_void_dittoffi_result_dittoffi_transaction_completion_action_t,
253            lambda continuation: self._complete_native(self._native(), int(action), continuation),
254            lambda result: TransactionCompletionAction(check_result(result, ffi)),
255        )
256        self._completed = True
257        return completed

Complete this transaction with the given commit or rollback action.

Usually you don't call this directly — return the desired TransactionCompletionAction (or a plain value, or raise) from your Store.transaction() scope and it is called for you exactly once. Calling it yourself inside the scope causes a second, redundant completion attempt.

def close(self) -> None:
259    def close(self) -> None:
260        """Release native resources for this transaction.
261
262        If the transaction was never completed, rolls it back first — a
263        best-effort operation that is asynchronous and does not wait for the
264        rollback to finish. Safe to call more than once.
265        """
266
267        if self._closed:
268            return
269        self._closed = True
270        self._finalizer.detach()
271        handle = self._handle
272        self._handle = None
273        try:
274            owner = self.store._owner()
275        except DittoClosedError:
276            return
277        if handle and not owner.closed:
278            if self._completed:
279                self._free_native(handle)
280            else:
281                _rollback_transaction_handle(self._complete_native, self._free_native, handle)

Release native resources for this transaction.

If the transaction was never completed, rolls it back first — a best-effort operation that is asynchronous and does not wait for the rollback to finish. Safe to call more than once.

class TransactionCompletionAction(enum.IntEnum):
33class TransactionCompletionAction(IntEnum):
34    """Returned from a :meth:`Store.transaction` scope to choose the outcome.
35
36    A scope that returns normally commits by default; a scope that raises
37    rolls back by default. Return one of these values to select explicitly.
38    """
39
40    COMMIT = 0  # Commit the transaction.
41    ROLLBACK = 1  # Roll back the transaction.

Returned from a Store.transaction() scope to choose the outcome.

A scope that returns normally commits by default; a scope that raises rolls back by default. Return one of these values to select explicitly.

@dataclass(frozen=True, slots=True)
class TransactionInfo:
44@dataclass(frozen=True, slots=True)
45class TransactionInfo:
46    """Snapshot of a :class:`Transaction`'s identity and options."""
47
48    id: str
49    """A globally unique ID for this transaction."""
50
51    hint: str | None
52    """The optional hint passed to :meth:`Store.transaction`, if any."""
53
54    is_read_only: bool
55    """Whether a mutating DQL statement raises DittoTransactionReadOnlyError."""

Snapshot of a Transaction's identity and options.

TransactionInfo(id: str, hint: str | None, is_read_only: bool)
id: str

A globally unique ID for this transaction.

hint: str | None

The optional hint passed to Store.transaction(), if any.

is_read_only: bool

Whether a mutating DQL statement raises DittoTransactionReadOnlyError.

TransportConfig = <class 'DittoTransportConfig'>
@dataclass(slots=True)
class WifiAwareConfig:
134@dataclass(slots=True)
135class WifiAwareConfig:
136    """Wi-Fi Aware transport settings.
137
138    Not supported on all platforms; enabling this has no effect where
139    Wi-Fi Aware (Android) is unavailable.
140    """
141
142    enabled: bool = False
143
144    def to_cbor_object(self) -> dict[str, Any]:
145        """Encode this config as a CBOR-compatible mapping."""
146
147        return {"enabled": self.enabled}
148
149    @classmethod
150    def from_cbor_object(cls, value: Mapping[str, Any]) -> WifiAwareConfig:
151        """Build a :class:`WifiAwareConfig` from a decoded CBOR mapping."""
152
153        return cls(enabled=_bool(value.get("enabled", False), "wifi_aware.enabled"))

Wi-Fi Aware transport settings.

Not supported on all platforms; enabling this has no effect where Wi-Fi Aware (Android) is unavailable.

WifiAwareConfig(enabled: bool = False)
enabled: bool
def to_cbor_object(self) -> dict[str, typing.Any]:
144    def to_cbor_object(self) -> dict[str, Any]:
145        """Encode this config as a CBOR-compatible mapping."""
146
147        return {"enabled": self.enabled}

Encode this config as a CBOR-compatible mapping.

@classmethod
def from_cbor_object( cls, value: Mapping[str, typing.Any]) -> WifiAwareConfig:
149    @classmethod
150    def from_cbor_object(cls, value: Mapping[str, Any]) -> WifiAwareConfig:
151        """Build a :class:`WifiAwareConfig` from a decoded CBOR mapping."""
152
153        return cls(enabled=_bool(value.get("enabled", False), "wifi_aware.enabled"))

Build a WifiAwareConfig from a decoded CBOR mapping.

__version__ = '5.2.0.dev0'