Source code for contree_sdk.store.tree
"""Values exchanged by tree stores. Payloads contain JSON, never live SDK objects."""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass, field
from typing import Generic, TypeAlias, TypeVar
from uuid import uuid4
JSONScalar: TypeAlias = str | int | float | bool | None
JSONValue: TypeAlias = JSONScalar | list["JSONValue"] | dict[str, "JSONValue"]
NodeId: TypeAlias = str
T = TypeVar("T")
DEFAULT_PAGE_SIZE = 1000
MAX_NODE_BATCH_SIZE = 1000
def new_node_id() -> NodeId:
return uuid4().hex
def encode_data(data: dict[str, JSONValue]) -> str:
def check(value: JSONValue) -> None:
if isinstance(value, dict):
if any(not isinstance(key, str) for key in value):
raise TypeError("JSON object keys must be strings")
for item in value.values():
check(item)
elif isinstance(value, list):
for item in value:
check(item)
elif value is not None and not isinstance(value, (str, int, float, bool)):
raise TypeError("node data must contain JSON values")
if not isinstance(data, dict):
raise TypeError("node data must be a JSON object")
check(data)
return json.dumps(data, sort_keys=True, separators=(",", ":"), allow_nan=False)
[docs]
@dataclass(frozen=True, kw_only=True)
class Node:
parent_id: NodeId | None
kind: str
data: dict[str, JSONValue] = field(default_factory=dict)
id: NodeId = field(default_factory=new_node_id)
def __post_init__(self) -> None:
if not isinstance(self.id, str) or not self.id or not isinstance(self.kind, str) or not self.kind:
raise ValueError("node identity and kind must be nonempty strings")
if self.parent_id is not None and (not isinstance(self.parent_id, str) or not self.parent_id):
raise ValueError("parent must be a nonempty node identity or None")
if self.parent_id == self.id:
raise ValueError("node requires an identity, a kind, and a different parent")
object.__setattr__(self, "data", json.loads(encode_data(self.data)))
[docs]
@dataclass(frozen=True)
class SessionInfo:
session_id: str
kind: str
created_at: float
expires_at: float | None
[docs]
@dataclass(frozen=True)
class Page(Generic[T]):
items: tuple[T, ...]
next_cursor: str | None = None
[docs]
class NodeConflictError(ValueError):
"""An identity already names a different node, or the tree already has a root."""
def cursor_position(cursor: str | None, scope: tuple[object, ...], limit: int) -> str | None:
if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
raise ValueError("limit must be a positive integer")
if cursor is None:
return None
try:
value = json.loads(base64.urlsafe_b64decode(cursor.encode()))
valid = value["scope"] == list(scope) and isinstance(value["position"], str)
except (ValueError, KeyError, TypeError, UnicodeError) as error:
raise ValueError("cursor does not belong to this selection") from error
if not valid:
raise ValueError("cursor does not belong to this selection")
return value["position"]
def next_cursor(scope: tuple[object, ...], position: str) -> str:
return base64.urlsafe_b64encode(json.dumps({"scope": scope, "position": position}).encode()).decode()
def validate_node_ids(node_ids: tuple[NodeId, ...]) -> None:
if len(node_ids) > MAX_NODE_BATCH_SIZE:
raise ValueError(f"a node batch cannot exceed {MAX_NODE_BATCH_SIZE} identities")
if any(not isinstance(node_id, str) or not node_id for node_id in node_ids):
raise ValueError("node identities must be nonempty strings")