CultLib/packages/cultnet-py/src/cultnet_py/replication.py
Meta Vens 95493862bd Split the Python runtime into cultcache-py, cultnet-py, cultmesh-py
One distribution named cultcache-py carried three libraries. Rust and
TypeScript layer them as cultcache < cultnet < cultmesh; Python now does
the same, one package per layer, module names unchanged so every
`from cultmesh_py import ...` keeps working.

Two imports ran against that layering and are resolved by moving code to
where the dependency direction says it lives, not by adding a shim:

- cultnet_py.interop_peer imported the Verse/peer catalog, authority
  lease, stream, and simulation-fact contracts from cultmesh_py. They are
  wire contracts a bare peer has to speak without owning a node, which is
  why cultnet-rs and cultnet-ts keep them in the net layer. They now live
  in cultnet_py.cultmesh_contracts; cultmesh_py re-exports them.
- cultcache_py.benchmark imported cultnet_py. It measures framed CultNet
  parse beside cache paths, so it and compare_csharp move to cultnet_py.
  verify checks all three packages and moves to cultmesh_py.

The single 6,693-line test module is split by what each test touches: a
test using any cultmesh_py name lands in cultmesh-py, else cultnet_py in
cultnet-py, else cultcache-py. Each suite runs with only its layer and the
layers below on the path, so a test that reaches up fails to import
rather than quietly binding the packages back together. 11 / 53 / 78 =
the same 142.

Workflows install all three; the publish job is one matrix over the
three packages keyed by tag prefix. The TS test harnesses put all three
src directories on PYTHONPATH. cultcache-py bumps to 0.2.0 because it no
longer contains cultnet_py or cultmesh_py; the new packages start at
0.1.0. Nothing was ever published, so no PyPI consumer moves.

  cultcache-py  11 tests, path: cultcache-py/src
  cultnet-py    53 tests, path: + cultnet-py/src
  cultmesh-py   78 tests, path: + cultmesh-py/src
  python -m cultmesh_py.verify -> ok, all five checks
  cultnet_py.interop_peer imports with cultmesh_py absent

Not run here: the cultnet-ts interop ring and cultcache-ts store matrix
need dotnet/kotlin peers; their PYTHONPATH change is four lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:10:47 +02:00

207 lines
8.1 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable
from cultcache_py import CultCache, CultCacheEnvelope
from cultcache_py.documents import DocumentDefinition
from .shard_log import CultNetShardLogResponse
from .snapshot import CultNetRawDocumentRecord, CultNetRawSnapshotResponse
@dataclass(frozen=True)
class CultNetAppliedRecord:
schema_id: str
record_key: str
change_kind: str
value: Any | None = None
def schema_document_map(documents: Iterable[DocumentDefinition[Any]]) -> dict[str, DocumentDefinition[Any]]:
mapped: dict[str, DocumentDefinition[Any]] = {}
for document in documents:
catalog_entry = document.catalog_entry()
mapped[catalog_entry.schema_id] = document
mapped[catalog_entry.schema_name] = document
mapped[catalog_entry.schema_version] = document
for compatible_schema_id in catalog_entry.compatible_schema_ids:
mapped[compatible_schema_id] = document
return mapped
def apply_raw_document_record(
cache: CultCache,
documents_by_schema_id: dict[str, DocumentDefinition[Any]],
record: dict[str, Any] | CultNetRawDocumentRecord,
) -> CultNetAppliedRecord:
wire = record.to_wire() if isinstance(record, CultNetRawDocumentRecord) else record
schema_id = str(wire["schemaId"])
document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record(
documents_by_schema_id,
schema_id,
wire,
)
envelope = raw_record_to_envelope(document, resolved_schema_id, wire)
value = cache.put_envelope(document, envelope)
return CultNetAppliedRecord(
schema_id=resolved_schema_id,
record_key=envelope.key,
change_kind="added",
value=value,
)
def raw_record_to_envelope(
document: DocumentDefinition[Any],
schema_id: str,
record: dict[str, Any],
) -> CultCacheEnvelope:
return CultCacheEnvelope(
key=str(record["recordKey"]),
type=document.type,
schema_id=schema_id,
payload=bytes(record["payload"]),
stored_at=str(record.get("storedAt") or ""),
catalog_entry=document.catalog_entry(),
)
def apply_raw_snapshot(
cache: CultCache,
documents: Iterable[DocumentDefinition[Any]],
response: dict[str, Any] | CultNetRawSnapshotResponse,
) -> list[CultNetAppliedRecord]:
response = response.to_wire() if isinstance(response, CultNetRawSnapshotResponse) else response
if response.get("schemaVersion") != "cultnet.snapshot_response_raw.v0":
raise ValueError(f"Expected cultnet.snapshot_response_raw.v0, received {response.get('schemaVersion')!r}")
documents_by_schema_id = schema_document_map(documents)
batches: dict[str, tuple[DocumentDefinition[Any], list[tuple[str, CultCacheEnvelope]]]] = {}
order: list[tuple[DocumentDefinition[Any], str, CultCacheEnvelope]] = []
for record in response.get("documents", []):
if not isinstance(record, dict):
continue
schema_id = str(record["schemaId"])
document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record(
documents_by_schema_id,
schema_id,
record,
)
envelope = raw_record_to_envelope(document, resolved_schema_id, record)
batch = batches.setdefault(document.type, (document, []))
batch[1].append((resolved_schema_id, envelope))
order.append((document, resolved_schema_id, envelope))
values_by_key: dict[tuple[str, str], Any] = {}
for document, entries in batches.values():
envelopes = [envelope for _, envelope in entries]
values = cache.put_envelopes(document, envelopes)
for (_, envelope), value in zip(entries, values):
values_by_key[(document.type, envelope.key)] = value
return [
CultNetAppliedRecord(
schema_id=schema_id,
record_key=envelope.key,
change_kind="added",
value=values_by_key[(document.type, envelope.key)],
)
for document, schema_id, envelope in order
]
def apply_shard_log_response(
cache: CultCache,
documents: Iterable[DocumentDefinition[Any]],
response: dict[str, Any] | CultNetShardLogResponse,
) -> list[CultNetAppliedRecord]:
response = response.to_wire() if isinstance(response, CultNetShardLogResponse) else response
if response.get("schemaVersion") != "cultnet.shard_log_response.v0":
raise ValueError(f"Expected cultnet.shard_log_response.v0, received {response.get('schemaVersion')!r}")
if response.get("resyncRequired") is True:
reason = response.get("reason") or "unspecified"
raise ValueError(f"Shard log response requires resync: {reason}")
documents_by_schema_id = schema_document_map(documents)
applied: list[CultNetAppliedRecord] = []
for entry in response.get("entries", []):
if not isinstance(entry, dict):
continue
change_kind = entry.get("changeKind")
if change_kind in ("added", "updated") and isinstance(entry.get("put"), dict):
put = entry["put"]
schema_id = str(put["document"]["schemaId"])
if not can_resolve_document_for_raw_record(documents_by_schema_id, schema_id, put["document"]):
continue
result = apply_raw_document_record(cache, documents_by_schema_id, put["document"])
applied.append(CultNetAppliedRecord(result.schema_id, result.record_key, str(change_kind), result.value))
elif change_kind == "removed" and isinstance(entry.get("delete"), dict):
delete = entry["delete"]
schema_id = str(delete["schemaId"])
record_key = str(delete["recordKey"])
if schema_id not in documents_by_schema_id:
continue
document = documents_by_schema_id[schema_id]
resolved_schema_id = document.catalog_entry().schema_id
cache.delete(document, record_key)
applied.append(CultNetAppliedRecord(resolved_schema_id, record_key, "removed", None))
return applied
def can_resolve_document_for_raw_record(
documents_by_schema_id: dict[str, DocumentDefinition[Any]],
schema_id: str,
record: dict[str, Any],
) -> bool:
try:
resolve_document_for_raw_record(documents_by_schema_id, schema_id, record)
return True
except KeyError:
return False
def resolve_document_for_raw_record(
documents_by_schema_id: dict[str, DocumentDefinition[Any]],
schema_id: str,
record: dict[str, Any],
) -> DocumentDefinition[Any]:
return resolve_document_and_schema_id_for_raw_record(documents_by_schema_id, schema_id, record)[0]
def resolve_document_and_schema_id_for_raw_record(
documents_by_schema_id: dict[str, DocumentDefinition[Any]],
schema_id: str,
record: dict[str, Any],
) -> tuple[DocumentDefinition[Any], str]:
document = documents_by_schema_id.get(schema_id)
if document is not None:
return document, schema_id
schema_version = _infer_schema_version_from_payload(bytes(record["payload"]))
schema_name = _infer_schema_name(schema_version) if schema_version is not None else None
if schema_name is not None and schema_name in documents_by_schema_id:
document = documents_by_schema_id[schema_name]
return document, document.catalog_entry().schema_id
raise KeyError(schema_id)
def _infer_schema_version_from_payload(payload: bytes) -> str | None:
try:
import msgpack # type: ignore
decoded = msgpack.unpackb(payload, raw=False)
except Exception:
return None
if isinstance(decoded, list) and decoded and isinstance(decoded[0], str):
return decoded[0]
if isinstance(decoded, dict):
value = decoded.get("schemaVersion", decoded.get("schema_version"))
return value if isinstance(value, str) else None
return None
def _infer_schema_name(schema_version: str) -> str | None:
marker = schema_version.rfind(".v")
if marker <= 0:
return None
version = schema_version[marker + 2:]
if not version or not version.isdigit():
return None
return schema_version[:marker]