CultLib/packages/cultcache-py/tests/test_cultcache.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

259 lines
10 KiB
Python

from __future__ import annotations
import tempfile
import unittest
from dataclasses import asdict, dataclass
from pathlib import Path
from cultcache_py import (
CultCache,
JsonLinesBackingStore,
SingleFileMessagePackBackingStore,
define_database_entry_type,
define_document_type,
)
from cultcache_py.interop import read_note, write_note
@dataclass
class Item:
name: str
category: str
value: int
def item_doc():
return define_document_type(
"item",
encode=lambda item: asdict(item),
decode=lambda raw: Item(**raw),
name="name",
indexes={"category": "category"},
)
class CultCacheTests(unittest.TestCase):
def test_round_trips_registered_documents(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
document = item_doc()
cache = (
CultCache.builder()
.register_document_type(document)
.add_generic_store(JsonLinesBackingStore(Path(tmp) / "cache.jsonl"))
.build()
)
cache.pull_all_backing_stores()
cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=50))
loaded = (
CultCache.builder()
.register_document_type(document)
.add_generic_store(JsonLinesBackingStore(Path(tmp) / "cache.jsonl"))
.build()
)
loaded.pull_all_backing_stores()
self.assertEqual(loaded.get_required(document, "item:potion").value, 50)
self.assertEqual(loaded.get_key_by_name(document, "Potion"), "item:potion")
self.assertEqual(loaded.get_by_index(document, "category", "Consumable").name, "Potion")
def test_global_document_uses_singleton_key(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
settings = define_document_type("settings", global_document=True)
cache = (
CultCache.builder()
.register_document_type(settings)
.add_generic_store(JsonLinesBackingStore(Path(tmp) / "cache.jsonl"))
.build()
)
cache.pull_all_backing_stores()
cache.put_global(settings, {"theme": "ash"})
self.assertEqual(cache.get_required_global(settings)["theme"], "ash")
def test_update_and_delete(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
document = item_doc()
cache = (
CultCache.builder()
.register_document_type(document)
.add_generic_store(JsonLinesBackingStore(Path(tmp) / "cache.jsonl"))
.build()
)
cache.pull_all_backing_stores()
cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=50))
cache.update(document, "item:potion", lambda item: Item(item.name, item.category, item.value + 5))
self.assertEqual(cache.get_required(document, "item:potion").value, 55)
cache.delete(document, "item:potion")
self.assertIsNone(cache.get(document, "item:potion"))
def test_incremental_lookup_updates_replace_stale_name_and_index_entries(self) -> None:
document = item_doc()
cache = CultCache()
cache.register_document_type(document)
cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=50))
cache.put(document, "item:potion", Item(name="Elixir", category="Rare", value=80))
self.assertIsNone(cache.get_key_by_name(document, "Potion"))
self.assertIsNone(cache.get_key_by_index(document, "category", "Consumable"))
self.assertEqual(cache.get_key_by_name(document, "Elixir"), "item:potion")
self.assertEqual(cache.get_by_index(document, "category", "Rare").name, "Elixir")
def test_incremental_lookup_delete_restores_duplicate_owner(self) -> None:
document = item_doc()
cache = CultCache()
cache.register_document_type(document)
cache.put(document, "item:first", Item(name="Potion", category="Consumable", value=50))
cache.put(document, "item:second", Item(name="Potion", category="Consumable", value=60))
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:second")
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:second")
cache.delete(document, "item:second")
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:first")
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:first")
def test_database_entry_formatter_uses_slot_indexed_messagepack_array(self) -> None:
try:
import msgpack # type: ignore
except ModuleNotFoundError:
self.skipTest("msgpack optional dependency is not installed")
@dataclass
class Settings:
theme: str
retries: int = 0
document = define_database_entry_type(
"settings",
[
("theme", 0),
("retries", 2, 0),
],
cls=Settings,
)
payload = document.encode_payload(Settings(theme="ash", retries=3))
self.assertEqual(msgpack.unpackb(payload, raw=False), ["ash", None, 3])
self.assertEqual(document.decode_payload(msgpack.packb(["ash"], use_bin_type=True)).retries, 0)
def test_document_catalog_entry_is_cached_after_first_derivation(self) -> None:
document = define_database_entry_type(
"settings",
[
("theme", 0),
("retries", 2, 0),
],
)
first = document.catalog_entry()
second = document.catalog_entry()
self.assertIs(first, second)
self.assertEqual([member.slot for member in first.members], [0, 2])
def test_messagepack_store_writes_v1_snapshot(self) -> None:
try:
import msgpack # type: ignore
except ModuleNotFoundError:
self.skipTest("msgpack optional dependency is not installed")
with tempfile.TemporaryDirectory() as tmp:
document = define_database_entry_type(
"settings",
[("theme", 0)],
schema_id="settings",
schema_name="settings",
schema_version="settings.v1",
)
store_path = Path(tmp) / "cache.msgpack"
cache = (
CultCache.builder()
.register_document_type(document)
.add_generic_store(SingleFileMessagePackBackingStore(store_path))
.build()
)
cache.pull_all_backing_stores()
cache.put(document, "app", {"theme": "ash"})
raw = msgpack.unpackb(store_path.read_bytes(), raw=False)
self.assertEqual(raw[0], "cultcache.store.v1")
self.assertEqual(raw[1][0][0], "settings")
self.assertEqual(raw[2][0][0], "app")
self.assertEqual(raw[2][0][1], "settings")
def test_messagepack_store_recovers_schema_stamped_record_missing_catalog_entry(self) -> None:
try:
import msgpack # type: ignore
except ModuleNotFoundError:
self.skipTest("msgpack optional dependency is not installed")
with tempfile.TemporaryDirectory() as tmp:
document = define_database_entry_type(
"runtime-policy",
[
("schema_version", 0),
("name", 1),
("value", 2),
],
schema_id="schema-current",
schema_name="tests.schema_stamped_entry",
schema_version="tests.schema_stamped_entry.v1",
)
store_path = Path(tmp) / "missing-catalog.msgpack"
store_path.write_bytes(msgpack.packb([
"cultcache.store.v1",
[],
[[
"record-1",
"sha256:stale-schema-id-from-cold-record",
"2026-06-25T12:00:00Z",
msgpack.packb([
"tests.schema_stamped_entry.v1",
"schema-stamped",
"still readable",
], use_bin_type=True),
]],
], use_bin_type=True))
cache = (
CultCache.builder()
.register_document_type(document)
.add_generic_store(SingleFileMessagePackBackingStore(store_path))
.build()
)
cache.pull_all_backing_stores()
self.assertEqual(cache.get_required(document, "record-1")["value"], "still readable")
envelope = cache.get_required_envelope(document, "record-1")
self.assertEqual(envelope.type, "runtime-policy")
self.assertEqual(envelope.schema_id, "sha256:stale-schema-id-from-cold-record")
def test_interop_cli_helpers_round_trip_v1_store(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store_path = str(Path(tmp) / "cache.msgpack")
written = write_note(store_path, "python-test")
loaded = read_note(store_path)
self.assertEqual(loaded["documentId"], written["documentId"])
self.assertEqual(loaded["authorRuntimeId"], "python-test")
self.assertIn("interop", loaded["tags"])
def test_cache_put_without_backing_store_keeps_in_memory_value(self) -> None:
document = define_database_entry_type(
"bench.memory_item",
[
("name", 0),
("value", 1, 0),
],
)
cache = CultCache()
cache.register_document_type(document)
cache.put(document, "item:one", {"name": "one", "value": 1})
self.assertEqual(cache.get(document, "item:one"), {"name": "one", "value": 1})
self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one")
if __name__ == "__main__":
unittest.main()