cultcache-py 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ from .backing_store import BackingStore, CultCacheEnvelope
2
+ from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember
3
+ from .cache import CultCache, CultCacheBuilder
4
+ from .documents import (
5
+ DatabaseEntryField,
6
+ DocumentDefinition,
7
+ database_entry_field,
8
+ define_database_entry_type,
9
+ define_document_registry,
10
+ define_document_type,
11
+ )
12
+ from .stores import JsonLinesBackingStore, SingleFileMessagePackBackingStore
13
+
14
+ __all__ = [
15
+ "BackingStore",
16
+ "CultCache",
17
+ "CultCacheBuilder",
18
+ "CultCacheEnvelope",
19
+ "CultCacheSchemaCatalogEntry",
20
+ "CultCacheSchemaCatalogMember",
21
+ "DatabaseEntryField",
22
+ "DocumentDefinition",
23
+ "JsonLinesBackingStore",
24
+ "SingleFileMessagePackBackingStore",
25
+ "database_entry_field",
26
+ "define_database_entry_type",
27
+ "define_document_registry",
28
+ "define_document_type",
29
+ ]
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Protocol
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class CultCacheSchemaCatalogMember:
10
+ slot: int
11
+ member_name: str
12
+ type_name: str
13
+ is_reference: bool = False
14
+ is_many: bool = False
15
+ target_schema_name: str | None = None
16
+ is_name: bool = False
17
+ index_alias: str | None = None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CultCacheSchemaCatalogEntry:
22
+ schema_id: str
23
+ schema_name: str
24
+ schema_version: str
25
+ content_hash: str
26
+ canonical_schema_json: str
27
+ compatible_schema_ids: tuple[str, ...] = field(default_factory=tuple)
28
+ members: tuple[CultCacheSchemaCatalogMember, ...] = field(default_factory=tuple)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CultCacheEnvelope:
33
+ key: str
34
+ type: str
35
+ payload: bytes
36
+ stored_at: str
37
+ schema_id: str | None = None
38
+ catalog_entry: CultCacheSchemaCatalogEntry | None = None
39
+
40
+ @classmethod
41
+ def create(
42
+ cls,
43
+ *,
44
+ key: str,
45
+ type: str,
46
+ payload: bytes,
47
+ schema_id: str | None = None,
48
+ catalog_entry: CultCacheSchemaCatalogEntry | None = None,
49
+ ) -> "CultCacheEnvelope":
50
+ return cls(
51
+ key=key,
52
+ type=type,
53
+ payload=payload,
54
+ stored_at=datetime.now(timezone.utc).isoformat(),
55
+ schema_id=schema_id,
56
+ catalog_entry=catalog_entry,
57
+ )
58
+
59
+
60
+ class BackingStore(Protocol):
61
+ def pull_all(self) -> list[CultCacheEnvelope]:
62
+ ...
63
+
64
+ def push(self, envelope: CultCacheEnvelope) -> None:
65
+ ...
66
+
67
+ def delete(self, type: str, key: str) -> None:
68
+ ...
69
+
70
+ def push_all(self, envelopes: list[CultCacheEnvelope]) -> None:
71
+ existing = {
72
+ (envelope.type, envelope.key): envelope for envelope in self.pull_all()
73
+ }
74
+ for envelope in envelopes:
75
+ existing[(envelope.type, envelope.key)] = envelope
76
+ self._replace_all(list(existing.values()))
77
+
78
+ def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
79
+ ...
cultcache_py/cache.py ADDED
@@ -0,0 +1,383 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ from .backing_store import BackingStore, CultCacheEnvelope
7
+ from .documents import DocumentDefinition, extract_value
8
+
9
+ T = TypeVar("T")
10
+ GLOBAL_KEY = "__global__"
11
+
12
+
13
+ class CultCacheError(RuntimeError):
14
+ pass
15
+
16
+
17
+ @dataclass
18
+ class _State:
19
+ documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
20
+ documents_by_schema_name: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
21
+ values: dict[str, dict[str, Any]] = field(default_factory=dict)
22
+ envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict)
23
+ stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict)
24
+ generic_stores: list[BackingStore] = field(default_factory=list)
25
+ name_extractors: dict[str, str | Any] = field(default_factory=dict)
26
+ index_extractors: dict[str, dict[str, str | Any]] = field(default_factory=dict)
27
+ names: dict[tuple[str, str], str] = field(default_factory=dict)
28
+ indexes: dict[tuple[str, str, str], str] = field(default_factory=dict)
29
+
30
+
31
+ class CultCacheBuilder:
32
+ def __init__(self) -> None:
33
+ self._cache = CultCache()
34
+
35
+ def register_document_type(self, document: DocumentDefinition[Any]) -> "CultCacheBuilder":
36
+ self._cache.register_document_type(document)
37
+ return self
38
+
39
+ def register_registry(self, documents: list[DocumentDefinition[Any]] | tuple[DocumentDefinition[Any], ...]) -> "CultCacheBuilder":
40
+ self._cache.register_registry(documents)
41
+ return self
42
+
43
+ def register_name_lookup(self, document: DocumentDefinition[Any], extractor: str | Any) -> "CultCacheBuilder":
44
+ self._cache.register_name_lookup(document, extractor)
45
+ return self
46
+
47
+ def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> "CultCacheBuilder":
48
+ self._cache.register_index(document, index, extractor)
49
+ return self
50
+
51
+ def add_backing_store(self, store: BackingStore, types: list[str] | tuple[str, ...] | set[str]) -> "CultCacheBuilder":
52
+ self._cache.add_backing_store(store, types)
53
+ return self
54
+
55
+ def add_generic_store(self, store: BackingStore) -> "CultCacheBuilder":
56
+ self._cache.add_generic_store(store)
57
+ return self
58
+
59
+ def build(self) -> "CultCache":
60
+ return self._cache
61
+
62
+
63
+ class CultCache:
64
+ GLOBAL_KEY = GLOBAL_KEY
65
+
66
+ @classmethod
67
+ def builder(cls) -> CultCacheBuilder:
68
+ return CultCacheBuilder()
69
+
70
+ def __init__(self) -> None:
71
+ self._state = _State()
72
+
73
+ def register_document_type(self, document: DocumentDefinition[Any]) -> None:
74
+ if document.type in self._state.documents:
75
+ raise CultCacheError(f"Document type already registered: {document.type}")
76
+ schema_name = document.catalog_entry().schema_name
77
+ if schema_name in self._state.documents_by_schema_name:
78
+ raise CultCacheError(f"Document schema name already registered: {schema_name}")
79
+ self._state.documents[document.type] = document
80
+ self._state.documents_by_schema_name[schema_name] = document
81
+ if document.name is not None:
82
+ self.register_name_lookup(document, document.name)
83
+ for index, extractor in document.indexes.items():
84
+ self.register_index(document, index, extractor)
85
+
86
+ def register_registry(self, documents: list[DocumentDefinition[Any]] | tuple[DocumentDefinition[Any], ...]) -> None:
87
+ for document in documents:
88
+ self.register_document_type(document)
89
+
90
+ def register_name_lookup(self, document: DocumentDefinition[Any], extractor: str | Any) -> None:
91
+ self._assert_registered(document)
92
+ self._state.name_extractors[document.type] = extractor
93
+ self._rebuild_indexes()
94
+
95
+ def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None:
96
+ self._assert_registered(document)
97
+ self._state.index_extractors.setdefault(document.type, {})[index] = extractor
98
+ self._rebuild_indexes()
99
+
100
+ def add_backing_store(self, store: BackingStore, types: list[str] | tuple[str, ...] | set[str]) -> None:
101
+ for type in types:
102
+ self._state.stores_by_type.setdefault(type, []).append(store)
103
+
104
+ def add_generic_store(self, store: BackingStore) -> None:
105
+ self._state.generic_stores.append(store)
106
+
107
+ def pull_all_backing_stores(self) -> None:
108
+ self._state.values.clear()
109
+ self._state.envelopes.clear()
110
+ seen_globals: set[str] = set()
111
+ for store in [*self._all_specific_stores(), *self._state.generic_stores]:
112
+ for envelope in store.pull_all():
113
+ document = self._resolve_document_for_envelope(envelope)
114
+ if document is None:
115
+ raise CultCacheError(f"Unknown persisted document type: {envelope.type}")
116
+ if envelope.type != document.type:
117
+ envelope = CultCacheEnvelope(
118
+ key=envelope.key,
119
+ type=document.type,
120
+ payload=envelope.payload,
121
+ stored_at=envelope.stored_at,
122
+ schema_id=envelope.schema_id,
123
+ catalog_entry=envelope.catalog_entry,
124
+ )
125
+ if document.global_document:
126
+ if envelope.type in seen_globals and envelope.key == GLOBAL_KEY:
127
+ raise CultCacheError(f"Duplicate global document for type: {envelope.type}")
128
+ seen_globals.add(envelope.type)
129
+ value = document.decode_payload(envelope.payload)
130
+ self._state.values.setdefault(envelope.type, {})[envelope.key] = value
131
+ self._state.envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
132
+ self._rebuild_indexes()
133
+
134
+ def get(self, document: DocumentDefinition[T], key: str) -> T | None:
135
+ self._assert_registered(document)
136
+ values = self._state.values.get(document.type)
137
+ return None if values is None else values.get(key)
138
+
139
+ def get_required(self, document: DocumentDefinition[T], key: str) -> T:
140
+ value = self.get(document, key)
141
+ if value is None:
142
+ raise CultCacheError(f"Missing {document.type}:{key}")
143
+ return value
144
+
145
+ def get_all(self, document: DocumentDefinition[T]) -> list[T]:
146
+ self._assert_registered(document)
147
+ return list(self._state.values.get(document.type, {}).values())
148
+
149
+ def get_envelope(self, document: DocumentDefinition[Any], key: str) -> CultCacheEnvelope | None:
150
+ self._assert_registered(document)
151
+ envelopes = self._state.envelopes.get(document.type)
152
+ return None if envelopes is None else envelopes.get(key)
153
+
154
+ def get_required_envelope(self, document: DocumentDefinition[Any], key: str) -> CultCacheEnvelope:
155
+ envelope = self.get_envelope(document, key)
156
+ if envelope is None:
157
+ raise CultCacheError(f"Missing envelope {document.type}:{key}")
158
+ return envelope
159
+
160
+ def get_global(self, document: DocumentDefinition[T]) -> T | None:
161
+ self._assert_global(document)
162
+ return self.get(document, GLOBAL_KEY)
163
+
164
+ def get_required_global(self, document: DocumentDefinition[T]) -> T:
165
+ self._assert_global(document)
166
+ return self.get_required(document, GLOBAL_KEY)
167
+
168
+ def get_key_by_name(self, document: DocumentDefinition[Any], name: str) -> str | None:
169
+ self._assert_registered(document)
170
+ return self._state.names.get((document.type, name))
171
+
172
+ def get_by_name(self, document: DocumentDefinition[T], name: str) -> T | None:
173
+ key = self.get_key_by_name(document, name)
174
+ return None if key is None else self.get(document, key)
175
+
176
+ def get_key_by_index(self, document: DocumentDefinition[Any], index: str, value: str) -> str | None:
177
+ self._assert_registered(document)
178
+ return self._state.indexes.get((document.type, index, value))
179
+
180
+ def get_by_index(self, document: DocumentDefinition[T], index: str, value: str) -> T | None:
181
+ key = self.get_key_by_index(document, index, value)
182
+ return None if key is None else self.get(document, key)
183
+
184
+ def put(self, document: DocumentDefinition[T], key: str, value: T) -> None:
185
+ self._assert_registered(document)
186
+ if document.global_document and key != GLOBAL_KEY:
187
+ raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
188
+ values = self._state.values.setdefault(document.type, {})
189
+ envelopes = self._state.envelopes.setdefault(document.type, {})
190
+ old_value = values.get(key)
191
+ catalog_entry = document.catalog_entry()
192
+ envelope = CultCacheEnvelope.create(
193
+ key=key,
194
+ type=document.type,
195
+ payload=document.encode_payload(value),
196
+ schema_id=catalog_entry.schema_id,
197
+ catalog_entry=catalog_entry,
198
+ )
199
+ stores = self._stores_for_type(document.type)
200
+ for store in stores:
201
+ store.push(envelope)
202
+ if old_value is not None:
203
+ self._remove_value_indexes(document.type, key, old_value)
204
+ values[key] = value
205
+ envelopes[key] = envelope
206
+ self._add_value_indexes(document.type, key, value)
207
+
208
+ def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T:
209
+ self._assert_registered(document)
210
+ if envelope.type != document.type:
211
+ raise CultCacheError(
212
+ f"Envelope type {envelope.type} does not match document type {document.type}"
213
+ )
214
+ values = self._state.values.setdefault(document.type, {})
215
+ envelopes = self._state.envelopes.setdefault(document.type, {})
216
+ old_value = values.get(envelope.key)
217
+ value = document.decode_payload(envelope.payload)
218
+ for store in self._stores_for_type(document.type):
219
+ store.push(envelope)
220
+ if old_value is not None:
221
+ self._remove_value_indexes(document.type, envelope.key, old_value)
222
+ values[envelope.key] = value
223
+ envelopes[envelope.key] = envelope
224
+ self._add_value_indexes(document.type, envelope.key, value)
225
+ return value
226
+
227
+ def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]:
228
+ self._assert_registered(document)
229
+ values: list[T] = []
230
+ for envelope in envelopes:
231
+ if envelope.type != document.type:
232
+ raise CultCacheError(
233
+ f"Envelope type {envelope.type} does not match document type {document.type}"
234
+ )
235
+ if document.global_document and envelope.key != GLOBAL_KEY:
236
+ raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
237
+ values.append(document.decode_payload(envelope.payload))
238
+
239
+ stores = self._stores_for_type(document.type)
240
+ for store in stores:
241
+ store.push_all(envelopes)
242
+ values_by_key = self._state.values.setdefault(document.type, {})
243
+ envelopes_by_key = self._state.envelopes.setdefault(document.type, {})
244
+ for envelope, value in zip(envelopes, values):
245
+ old_value = values_by_key.get(envelope.key)
246
+ if old_value is not None:
247
+ self._remove_value_indexes(document.type, envelope.key, old_value)
248
+ values_by_key[envelope.key] = value
249
+ envelopes_by_key[envelope.key] = envelope
250
+ self._add_value_indexes(document.type, envelope.key, value)
251
+ return values
252
+
253
+ def put_global(self, document: DocumentDefinition[T], value: T) -> None:
254
+ self._assert_global(document)
255
+ self.put(document, GLOBAL_KEY, value)
256
+
257
+ def update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T:
258
+ current = self.get_required(document, key)
259
+ updated = updater(current)
260
+ self.put(document, key, updated)
261
+ return updated
262
+
263
+ def update_global(self, document: DocumentDefinition[T], updater: Any) -> T:
264
+ self._assert_global(document)
265
+ return self.update(document, GLOBAL_KEY, updater)
266
+
267
+ def delete(self, document: DocumentDefinition[Any], key: str) -> None:
268
+ self._assert_registered(document)
269
+ for store in self._stores_for_type(document.type):
270
+ store.delete(document.type, key)
271
+ values = self._state.values.get(document.type)
272
+ old_value = None if values is None else values.pop(key, None)
273
+ if values == {}:
274
+ self._state.values.pop(document.type, None)
275
+ envelopes = self._state.envelopes.get(document.type)
276
+ if envelopes is not None:
277
+ envelopes.pop(key, None)
278
+ if envelopes == {}:
279
+ self._state.envelopes.pop(document.type, None)
280
+ if old_value is not None:
281
+ self._remove_value_indexes(document.type, key, old_value)
282
+
283
+ def delete_global(self, document: DocumentDefinition[Any]) -> None:
284
+ self._assert_global(document)
285
+ self.delete(document, GLOBAL_KEY)
286
+
287
+ def snapshot(self) -> dict[str, dict[str, Any]]:
288
+ out: dict[str, dict[str, Any]] = {}
289
+ for type, values in self._state.values.items():
290
+ out[type] = dict(values)
291
+ return out
292
+
293
+ def snapshot_envelopes(self) -> list[CultCacheEnvelope]:
294
+ return [
295
+ envelope
296
+ for envelopes in self._state.envelopes.values()
297
+ for envelope in envelopes.values()
298
+ ]
299
+
300
+ def _stores_for_type(self, type: str) -> list[BackingStore]:
301
+ specific = self._state.stores_by_type.get(type)
302
+ return specific if specific else self._state.generic_stores
303
+
304
+ def _all_specific_stores(self) -> list[BackingStore]:
305
+ stores: list[BackingStore] = []
306
+ seen: set[int] = set()
307
+ for routed in self._state.stores_by_type.values():
308
+ for store in routed:
309
+ marker = id(store)
310
+ if marker not in seen:
311
+ stores.append(store)
312
+ seen.add(marker)
313
+ return stores
314
+
315
+ def _rebuild_indexes(self) -> None:
316
+ self._state.names.clear()
317
+ self._state.indexes.clear()
318
+ for type, values in self._state.values.items():
319
+ for key, value in values.items():
320
+ self._add_value_indexes(type, key, value)
321
+
322
+ def _add_value_indexes(self, type: str, key: str, value: Any) -> None:
323
+ name_extractor = self._state.name_extractors.get(type)
324
+ if name_extractor is not None:
325
+ name = extract_value(value, name_extractor)
326
+ if name is not None:
327
+ self._state.names[(type, str(name))] = key
328
+ for index, extractor in self._state.index_extractors.get(type, {}).items():
329
+ index_value = extract_value(value, extractor)
330
+ if index_value is not None:
331
+ self._state.indexes[(type, index, str(index_value))] = key
332
+
333
+ def _remove_value_indexes(self, type: str, key: str, value: Any) -> None:
334
+ name_extractor = self._state.name_extractors.get(type)
335
+ if name_extractor is not None:
336
+ name = extract_value(value, name_extractor)
337
+ if name is not None:
338
+ self._remove_name_index(type, key, str(name))
339
+ for index, extractor in self._state.index_extractors.get(type, {}).items():
340
+ index_value = extract_value(value, extractor)
341
+ if index_value is not None:
342
+ self._remove_secondary_index(type, key, index, str(index_value))
343
+
344
+ def _remove_name_index(self, type: str, key: str, name: str) -> None:
345
+ lookup_key = (type, name)
346
+ if self._state.names.get(lookup_key) != key:
347
+ return
348
+ self._state.names.pop(lookup_key, None)
349
+ name_extractor = self._state.name_extractors.get(type)
350
+ if name_extractor is None:
351
+ return
352
+ for candidate_key, candidate in self._state.values.get(type, {}).items():
353
+ candidate_name = extract_value(candidate, name_extractor)
354
+ if candidate_key != key and candidate_name is not None and str(candidate_name) == name:
355
+ self._state.names[lookup_key] = candidate_key
356
+
357
+ def _remove_secondary_index(self, type: str, key: str, index: str, value: str) -> None:
358
+ lookup_key = (type, index, value)
359
+ if self._state.indexes.get(lookup_key) != key:
360
+ return
361
+ self._state.indexes.pop(lookup_key, None)
362
+ extractor = self._state.index_extractors.get(type, {}).get(index)
363
+ if extractor is None:
364
+ return
365
+ for candidate_key, candidate in self._state.values.get(type, {}).items():
366
+ candidate_value = extract_value(candidate, extractor)
367
+ if candidate_key != key and candidate_value is not None and str(candidate_value) == value:
368
+ self._state.indexes[lookup_key] = candidate_key
369
+
370
+ def _assert_registered(self, document: DocumentDefinition[Any]) -> None:
371
+ if self._state.documents.get(document.type) is not document:
372
+ raise CultCacheError(f"Document type is not registered on this cache: {document.type}")
373
+
374
+ def _assert_global(self, document: DocumentDefinition[Any]) -> None:
375
+ self._assert_registered(document)
376
+ if not document.global_document:
377
+ raise CultCacheError(f"Document type is not global: {document.type}")
378
+
379
+ def _resolve_document_for_envelope(self, envelope: CultCacheEnvelope) -> DocumentDefinition[Any] | None:
380
+ document = self._state.documents.get(envelope.type)
381
+ if document is not None:
382
+ return document
383
+ return self._state.documents_by_schema_name.get(envelope.type)