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]
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.
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 {}))
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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.
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().
The provider identifier.
Must be non-empty, non-whitespace, and free of control characters — it is passed to native login as a C string.
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.
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().
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.
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.
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.
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()
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.
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).
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_jsonattribute carries the webhook'sclientInfopayload, if the webhook provided one.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
Size in bytes; for a directory, its own size plus the recursive sum of its descendants.
Child entries for a directory (may be an empty tuple), or None for a file or symlink.
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.
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.
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.
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 )
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.
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.
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.
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.
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.
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))
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:
configfailed Core's validation.
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.
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.
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.
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.
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.
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.
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.
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.
A file or directory already exists.
Base class for attachment-related failures.
189class DittoAttachmentFileNotFoundError(DittoAttachmentError): 190 """The source file for an attachment could not be found."""
The source file for an attachment could not be found.
201class DittoAttachmentFilePermissionDeniedError(DittoAttachmentError): 202 """An attachment file operation was denied by the operating system."""
An attachment file operation was denied by the operating system.
185class DittoAttachmentNotFoundError(DittoAttachmentError): 186 """An attachment could not be found."""
An attachment could not be found.
193class DittoAttachmentTokenInvalidError(DittoAttachmentError): 194 """An attachment token is invalid."""
An attachment token is invalid.
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.
61class DittoClosedError(DittoError): 62 """An operation was attempted on an already-closed SDK object."""
An operation was attempted on an already-closed SDK object.
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.
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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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.
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.
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.
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.
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.
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.
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.
A CRDT operation failed.
153class DittoDepthLimitExceededValidationError(DittoValidationError): 154 """Dictionary-like input exceeded a Ditto nesting-depth limit."""
Dictionary-like input exceeded a Ditto nesting-depth limit.
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.
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.
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.
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.
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.
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.
Base class for errors reported by Ditto or the Python SDK.
246class DittoExpirationHandlerMissingError(DittoAuthenticationError): 247 """Server sync requires an authentication expiration handler."""
Server sync requires an authentication expiration handler.
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.
197class DittoFailedToFetchAttachmentError(DittoAttachmentError): 198 """An attachment fetch failed."""
An attachment fetch failed.
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.
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.
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.
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.
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.
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.
213class DittoIOError(DittoError): 214 """Base class for filesystem I/O failures reported by Ditto."""
Base class for filesystem I/O failures reported by Ditto.
169class DittoInvalidCborError(DittoValidationError): 170 """Input did not contain valid CBOR data."""
Input did not contain valid CBOR data.
173class DittoInvalidConfigError(DittoValidationError): 174 """A :class:`DittoConfig` is invalid."""
A DittoConfig is invalid.
165class DittoInvalidJsonError(DittoValidationError): 166 """Input did not contain valid JSON data."""
Input did not contain valid JSON data.
177class DittoInvalidTransportConfigError(DittoValidationError): 178 """A :class:`DittoTransportConfig` is invalid."""
A DittoTransportConfig is invalid.
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.
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.
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.
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.
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.
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.
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.
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:
pathalready exists. - DittoNotFoundIOError: the parent directory does not exist.
- DittoPermissionDeniedIOError: denied by filesystem permissions.
- DittoOperationFailedIOError: the export failed for another filesystem-related reason.
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.
157class DittoNotADictionaryValidationError(DittoValidationError): 158 """A mapping was required but another type was supplied."""
A mapping was required but another type was supplied.
A file or directory could not be found.
161class DittoNotJsonCompatibleError(DittoValidationError): 162 """A value could not be represented as JSON."""
A value could not be represented as JSON.
229class DittoOperationFailedIOError(DittoIOError): 230 """An I/O operation failed for an otherwise unspecified reason."""
An I/O operation failed for an otherwise unspecified reason.
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.
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.
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.
225class DittoPermissionDeniedIOError(DittoIOError): 226 """An I/O operation failed because permission was denied."""
An I/O operation failed because permission was denied.
105class DittoPersistenceDirectoryLockedError(DittoStoreError): 106 """The persistence directory is already in use by another Ditto."""
The persistence directory is already in use by another Ditto.
93class DittoQueryArgumentsInvalidError(DittoStoreError): 94 """The arguments supplied to a DQL statement are invalid."""
The arguments supplied to a DQL statement are invalid.
A query failed during execution.
A DQL statement could not be compiled.
101class DittoQueryNotSupportedError(DittoStoreError): 102 """A valid DQL statement uses an unsupported feature."""
A valid DQL statement uses an unsupported feature.
149class DittoSizeLimitExceededValidationError(DittoValidationError): 150 """Input exceeded a Ditto size limit."""
Input exceeded a Ditto size limit.
The storage backend reported an error.
73class DittoStoreDocumentNotFoundError(DittoStoreError): 74 """A requested document could not be found."""
A requested document could not be found.
Base class for store-related failures.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
250class DittoTransportsInitializationError(DittoError): 251 """The native transport subsystem failed to initialize."""
The native transport subsystem failed to initialize.
The requested operation is not supported.
Base class for input-validation failures.
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.
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 )
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
Unique identifier for this peer.
Empty for a peer not yet updated to the latest SDK version.
This peer's declared metadata.
May be empty when a peer first appears, and is populated once its metadata has synced.
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.
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.
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
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.
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
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.
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.
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.
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:
valuecould not be serialized as JSON. - DittoSizeLimitExceededValidationError: the encoded value exceeds the size limit.
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:
valueis not valid JSON. - DittoNotADictionaryValidationError:
valueis valid JSON but not an object. - DittoSizeLimitExceededValidationError: the encoded value exceeds the size limit.
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.
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.
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.
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.
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.
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.
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.
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.
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().
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)
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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 raisesTypeErrorlocally, before any native call is made.
Returns:
A
QueryResult. Close it (or use it as a context manager) when done — it and eachQueryResultItemown native resources.
Raises:
- DittoQueryInvalidError:
queryis not valid DQL. - DittoQueryArgumentsInvalidError:
argumentsfailed 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.
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:
queryis not valid DQL. - DittoQueryArgumentsInvalidError:
argumentsfailed native-side validation. - DittoQueryNotSupportedError:
queryuses 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.
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:
queryis not valid DQL. - DittoQueryArgumentsInvalidError:
argumentsfailed native-side validation. - DittoQueryNotSupportedError:
queryuses a construct that is not supported for observers.
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.
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 insidescopemay deadlock — each opens its own transaction against the same store.
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:
pathdoes not exist. - DittoAttachmentFilePermissionDeniedError:
pathcould 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}}, )
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.
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.
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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
queryis not valid DQL. - DittoQueryArgumentsInvalidError:
argumentsfailed native-side validation. - DittoQueryNotSupportedError:
queryis not aSELECTquery.
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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 )
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.
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 raisesTypeErrorlocally, 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.QueryResultItemown native resources.
Raises:
- DittoQueryInvalidError:
queryis not valid DQL. - DittoQueryArgumentsInvalidError:
argumentsfailed native-side validation. - DittoTransactionReadOnlyError: This transaction is read-only and
querymutates data. - DittoStoreError: other query execution failures.
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.
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.
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.
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.
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.
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.
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.