backpack-store 0.1.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.
- backpack_store/__init__.py +49 -0
- backpack_store/codecs.py +318 -0
- backpack_store/errors.py +53 -0
- backpack_store/host.py +45 -0
- backpack_store/integration.py +122 -0
- backpack_store/pydantic_codec.py +87 -0
- backpack_store/records.py +189 -0
- backpack_store/store.py +519 -0
- backpack_store-0.1.0.dist-info/METADATA +252 -0
- backpack_store-0.1.0.dist-info/RECORD +13 -0
- backpack_store-0.1.0.dist-info/WHEEL +4 -0
- backpack_store-0.1.0.dist-info/entry_points.txt +3 -0
- backpack_store-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Experimental typed local records with explicitly registered codecs."""
|
|
2
|
+
|
|
3
|
+
from .codecs import Codec, CodecRegistry, DataclassCodec, EncodedValue
|
|
4
|
+
from .errors import (
|
|
5
|
+
BackpackError,
|
|
6
|
+
CodecError,
|
|
7
|
+
DatabaseVersionError,
|
|
8
|
+
InvalidCursorError,
|
|
9
|
+
MigrationError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
RegistrationError,
|
|
12
|
+
RevisionConflictError,
|
|
13
|
+
StorageError,
|
|
14
|
+
StoreClosedError,
|
|
15
|
+
UnknownTypeError,
|
|
16
|
+
UnknownVersionError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
from .records import JSONObject, JSONValue, Provenance, RawRecord, Record
|
|
20
|
+
from .store import Backpack, ExportResult, FindPage, WriteResult
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Backpack",
|
|
24
|
+
"ExportResult",
|
|
25
|
+
"MigrationError",
|
|
26
|
+
"FindPage",
|
|
27
|
+
"WriteResult",
|
|
28
|
+
"BackpackError",
|
|
29
|
+
"Codec",
|
|
30
|
+
"CodecError",
|
|
31
|
+
"CodecRegistry",
|
|
32
|
+
"DataclassCodec",
|
|
33
|
+
"DatabaseVersionError",
|
|
34
|
+
"EncodedValue",
|
|
35
|
+
"InvalidCursorError",
|
|
36
|
+
"JSONObject",
|
|
37
|
+
"JSONValue",
|
|
38
|
+
"NotFoundError",
|
|
39
|
+
"Provenance",
|
|
40
|
+
"RawRecord",
|
|
41
|
+
"Record",
|
|
42
|
+
"RegistrationError",
|
|
43
|
+
"RevisionConflictError",
|
|
44
|
+
"StorageError",
|
|
45
|
+
"StoreClosedError",
|
|
46
|
+
"UnknownTypeError",
|
|
47
|
+
"UnknownVersionError",
|
|
48
|
+
"ValidationError",
|
|
49
|
+
]
|
backpack_store/codecs.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Trusted, explicit codecs; no stored name is ever treated as an import path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import types
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, fields, is_dataclass
|
|
9
|
+
from typing import (
|
|
10
|
+
Generic,
|
|
11
|
+
Literal,
|
|
12
|
+
Protocol,
|
|
13
|
+
TypeVar,
|
|
14
|
+
Union,
|
|
15
|
+
get_args,
|
|
16
|
+
get_origin,
|
|
17
|
+
get_type_hints,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .errors import (
|
|
21
|
+
CodecError,
|
|
22
|
+
MigrationError,
|
|
23
|
+
RegistrationError,
|
|
24
|
+
UnknownTypeError,
|
|
25
|
+
UnknownVersionError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
)
|
|
28
|
+
from .records import JSONObject, json_object, positive_integer, validate_type_key
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Codec(Protocol[T]):
|
|
34
|
+
type_key: str
|
|
35
|
+
schema_version: int
|
|
36
|
+
model_type: type[T]
|
|
37
|
+
|
|
38
|
+
def encode(self, value: T) -> JSONObject: ...
|
|
39
|
+
|
|
40
|
+
def decode(self, payload: JSONObject) -> T: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _model_fields(model_type: type) -> dict[str, object]:
|
|
44
|
+
hints = get_type_hints(model_type)
|
|
45
|
+
declared = fields(model_type)
|
|
46
|
+
if any(not field.init or field.name not in hints for field in declared):
|
|
47
|
+
raise RegistrationError("dataclass codecs require annotated constructor fields")
|
|
48
|
+
return {field.name: hints[field.name] for field in declared}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_type(annotation: object, stack: tuple[type, ...] = ()) -> None:
|
|
52
|
+
if annotation in (str, bool, int, float, type(None)):
|
|
53
|
+
return
|
|
54
|
+
origin, args = get_origin(annotation), get_args(annotation)
|
|
55
|
+
if origin is Literal:
|
|
56
|
+
if not args or any(type(v) not in (str, bool, int, type(None)) for v in args):
|
|
57
|
+
raise RegistrationError("Literal fields must contain JSON scalar choices")
|
|
58
|
+
return
|
|
59
|
+
if origin in (Union, types.UnionType):
|
|
60
|
+
for arg in args:
|
|
61
|
+
_check_type(arg, stack)
|
|
62
|
+
return
|
|
63
|
+
if origin in (list, tuple) and args:
|
|
64
|
+
for arg in args:
|
|
65
|
+
if arg is not Ellipsis:
|
|
66
|
+
_check_type(arg, stack)
|
|
67
|
+
return
|
|
68
|
+
if origin is dict and len(args) == 2 and args[0] is str:
|
|
69
|
+
_check_type(args[1], stack)
|
|
70
|
+
return
|
|
71
|
+
if isinstance(annotation, type) and is_dataclass(annotation):
|
|
72
|
+
if annotation in stack:
|
|
73
|
+
raise RegistrationError("recursive dataclasses require a custom codec")
|
|
74
|
+
for field_type in _model_fields(annotation).values():
|
|
75
|
+
_check_type(field_type, (*stack, annotation))
|
|
76
|
+
return
|
|
77
|
+
raise RegistrationError("dataclass fields must use concrete supported JSON types")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _convert(annotation: object, value: object, *, decode: bool, path: str) -> object:
|
|
81
|
+
def invalid() -> ValidationError:
|
|
82
|
+
return ValidationError(f"{path} does not match its declared type")
|
|
83
|
+
|
|
84
|
+
if annotation in (str, bool, int, type(None)):
|
|
85
|
+
if type(value) is not annotation:
|
|
86
|
+
raise invalid()
|
|
87
|
+
return value
|
|
88
|
+
if annotation is float:
|
|
89
|
+
if type(value) not in (int, float):
|
|
90
|
+
raise invalid()
|
|
91
|
+
try:
|
|
92
|
+
number = float(value)
|
|
93
|
+
except OverflowError as exc:
|
|
94
|
+
raise invalid() from exc
|
|
95
|
+
if not math.isfinite(number):
|
|
96
|
+
raise invalid()
|
|
97
|
+
return number
|
|
98
|
+
origin, args = get_origin(annotation), get_args(annotation)
|
|
99
|
+
if origin is Literal:
|
|
100
|
+
if not any(type(value) is type(option) and value == option for option in args):
|
|
101
|
+
raise invalid()
|
|
102
|
+
return value
|
|
103
|
+
if origin in (Union, types.UnionType):
|
|
104
|
+
for arg in args:
|
|
105
|
+
try:
|
|
106
|
+
return _convert(arg, value, decode=decode, path=path)
|
|
107
|
+
except ValidationError:
|
|
108
|
+
continue
|
|
109
|
+
raise invalid()
|
|
110
|
+
if origin in (list, tuple):
|
|
111
|
+
expected = list if decode or origin is list else tuple
|
|
112
|
+
if type(value) is not expected:
|
|
113
|
+
raise invalid()
|
|
114
|
+
if origin is tuple and not (len(args) == 2 and args[1] is Ellipsis):
|
|
115
|
+
if len(value) != len(args):
|
|
116
|
+
raise invalid()
|
|
117
|
+
element_types = args
|
|
118
|
+
else:
|
|
119
|
+
element_types = (args[0],) * len(value)
|
|
120
|
+
result = [
|
|
121
|
+
_convert(t, v, decode=decode, path=f"{path}[{i}]")
|
|
122
|
+
for i, (t, v) in enumerate(zip(element_types, value, strict=True))
|
|
123
|
+
]
|
|
124
|
+
return tuple(result) if decode and origin is tuple else result
|
|
125
|
+
if origin is dict:
|
|
126
|
+
if type(value) is not dict or any(type(k) is not str for k in value):
|
|
127
|
+
raise invalid()
|
|
128
|
+
return {
|
|
129
|
+
key: _convert(args[1], child, decode=decode, path=f"{path}.*")
|
|
130
|
+
for key, child in value.items()
|
|
131
|
+
}
|
|
132
|
+
if isinstance(annotation, type) and is_dataclass(annotation):
|
|
133
|
+
model_fields = _model_fields(annotation)
|
|
134
|
+
if decode:
|
|
135
|
+
if type(value) is not dict or set(value) != set(model_fields):
|
|
136
|
+
raise invalid()
|
|
137
|
+
kwargs = {
|
|
138
|
+
name: _convert(t, value[name], decode=True, path=f"{path}.{name}")
|
|
139
|
+
for name, t in model_fields.items()
|
|
140
|
+
}
|
|
141
|
+
try:
|
|
142
|
+
return annotation(**kwargs)
|
|
143
|
+
except Exception as exc: # noqa: BLE001 - trusted constructors are an explicit codec boundary
|
|
144
|
+
raise CodecError("dataclass construction failed") from exc
|
|
145
|
+
if type(value) is not annotation:
|
|
146
|
+
raise invalid()
|
|
147
|
+
return {
|
|
148
|
+
name: _convert(t, getattr(value, name), decode=False, path=f"{path}.{name}")
|
|
149
|
+
for name, t in model_fields.items()
|
|
150
|
+
}
|
|
151
|
+
raise invalid()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, init=False)
|
|
155
|
+
class DataclassCodec(Generic[T]):
|
|
156
|
+
"""Finite JSON for concrete dataclasses, nested dataclasses and typed containers."""
|
|
157
|
+
|
|
158
|
+
model_type: type[T]
|
|
159
|
+
type_key: str
|
|
160
|
+
schema_version: int
|
|
161
|
+
|
|
162
|
+
def __init__(self, model_type: type[T], *, type_key: str, schema_version: int = 1):
|
|
163
|
+
if not isinstance(model_type, type) or not is_dataclass(model_type):
|
|
164
|
+
raise RegistrationError("model_type must be a dataclass class")
|
|
165
|
+
validate_type_key(type_key)
|
|
166
|
+
positive_integer(schema_version, "schema_version")
|
|
167
|
+
try:
|
|
168
|
+
_check_type(model_type)
|
|
169
|
+
except (NameError, TypeError) as exc:
|
|
170
|
+
raise RegistrationError(
|
|
171
|
+
"dataclass annotations could not be resolved"
|
|
172
|
+
) from exc
|
|
173
|
+
object.__setattr__(self, "model_type", model_type)
|
|
174
|
+
object.__setattr__(self, "type_key", type_key)
|
|
175
|
+
object.__setattr__(self, "schema_version", schema_version)
|
|
176
|
+
|
|
177
|
+
def encode(self, value: T) -> JSONObject:
|
|
178
|
+
return json_object(
|
|
179
|
+
_convert(self.model_type, value, decode=False, path="payload")
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def decode(self, payload: JSONObject) -> T:
|
|
183
|
+
return _convert(
|
|
184
|
+
self.model_type, json_object(payload), decode=True, path="payload"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class EncodedValue:
|
|
190
|
+
type_key: str
|
|
191
|
+
schema_version: int
|
|
192
|
+
payload: JSONObject
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class CodecRegistry:
|
|
196
|
+
def __init__(self) -> None:
|
|
197
|
+
self._by_key: dict[str, Codec] = {}
|
|
198
|
+
self._by_type: dict[type, Codec] = {}
|
|
199
|
+
self._migrations: dict[tuple[str, int], Callable[[JSONObject], JSONObject]] = {}
|
|
200
|
+
|
|
201
|
+
def register(self, codec: Codec[T]) -> None:
|
|
202
|
+
try:
|
|
203
|
+
validate_type_key(codec.type_key)
|
|
204
|
+
positive_integer(codec.schema_version, "schema_version")
|
|
205
|
+
if (
|
|
206
|
+
not isinstance(codec.model_type, type)
|
|
207
|
+
or not callable(codec.encode)
|
|
208
|
+
or not callable(codec.decode)
|
|
209
|
+
):
|
|
210
|
+
raise RegistrationError(
|
|
211
|
+
"codec requires a model class and encode/decode methods"
|
|
212
|
+
)
|
|
213
|
+
except (AttributeError, ValidationError) as exc:
|
|
214
|
+
raise RegistrationError("codec identity is invalid") from exc
|
|
215
|
+
if codec.type_key in self._by_key or codec.model_type in self._by_type:
|
|
216
|
+
raise RegistrationError(
|
|
217
|
+
"codec type key or model type is already registered"
|
|
218
|
+
)
|
|
219
|
+
self._by_key[codec.type_key] = codec
|
|
220
|
+
self._by_type[codec.model_type] = codec
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def registered_keys(self) -> tuple[str, ...]:
|
|
224
|
+
return tuple(sorted(self._by_key))
|
|
225
|
+
|
|
226
|
+
def for_key(self, type_key: str) -> Codec:
|
|
227
|
+
validate_type_key(type_key)
|
|
228
|
+
try:
|
|
229
|
+
return self._by_key[type_key]
|
|
230
|
+
except KeyError as exc:
|
|
231
|
+
raise UnknownTypeError(
|
|
232
|
+
"register a trusted codec for the stored type_key"
|
|
233
|
+
) from exc
|
|
234
|
+
|
|
235
|
+
def for_type(self, model_type: type[T]) -> Codec[T]:
|
|
236
|
+
try:
|
|
237
|
+
return self._by_type[model_type]
|
|
238
|
+
except (KeyError, TypeError) as exc:
|
|
239
|
+
raise UnknownTypeError(
|
|
240
|
+
"register a trusted codec for this exact Python type"
|
|
241
|
+
) from exc
|
|
242
|
+
|
|
243
|
+
def encode(self, value: T) -> EncodedValue:
|
|
244
|
+
codec = self.for_type(type(value))
|
|
245
|
+
try:
|
|
246
|
+
payload = json_object(codec.encode(value))
|
|
247
|
+
except ValidationError:
|
|
248
|
+
raise
|
|
249
|
+
except Exception as exc: # noqa: BLE001 - custom codec messages may contain record data
|
|
250
|
+
raise CodecError("registered codec failed to encode the value") from exc
|
|
251
|
+
return EncodedValue(codec.type_key, codec.schema_version, payload)
|
|
252
|
+
|
|
253
|
+
def register_migration(
|
|
254
|
+
self,
|
|
255
|
+
type_key: str,
|
|
256
|
+
*,
|
|
257
|
+
from_version: int,
|
|
258
|
+
to_version: int,
|
|
259
|
+
migrate: Callable[[JSONObject], JSONObject],
|
|
260
|
+
) -> None:
|
|
261
|
+
codec = self.for_key(type_key)
|
|
262
|
+
try:
|
|
263
|
+
positive_integer(from_version, "from_version")
|
|
264
|
+
positive_integer(to_version, "to_version")
|
|
265
|
+
except ValidationError as exc:
|
|
266
|
+
raise RegistrationError(
|
|
267
|
+
"migration versions must be positive integers"
|
|
268
|
+
) from exc
|
|
269
|
+
if (
|
|
270
|
+
to_version != from_version + 1
|
|
271
|
+
or to_version > codec.schema_version
|
|
272
|
+
or not callable(migrate)
|
|
273
|
+
):
|
|
274
|
+
raise RegistrationError(
|
|
275
|
+
"migration must advance one version toward the registered codec"
|
|
276
|
+
)
|
|
277
|
+
key = (type_key, from_version)
|
|
278
|
+
if key in self._migrations:
|
|
279
|
+
raise RegistrationError(
|
|
280
|
+
"migration from this payload version is already registered"
|
|
281
|
+
)
|
|
282
|
+
self._migrations[key] = migrate
|
|
283
|
+
|
|
284
|
+
def _upgrade(self, type_key: str, version: int, payload: JSONObject) -> JSONObject:
|
|
285
|
+
codec = self.for_key(type_key)
|
|
286
|
+
positive_integer(version, "schema_version")
|
|
287
|
+
if version > codec.schema_version:
|
|
288
|
+
raise UnknownVersionError(
|
|
289
|
+
"stored payload is newer than the registered codec"
|
|
290
|
+
)
|
|
291
|
+
current = json_object(payload)
|
|
292
|
+
while version < codec.schema_version:
|
|
293
|
+
migrate = self._migrations.get((type_key, version))
|
|
294
|
+
if migrate is None:
|
|
295
|
+
raise UnknownVersionError(
|
|
296
|
+
"register every explicit payload migration to the current version"
|
|
297
|
+
)
|
|
298
|
+
try:
|
|
299
|
+
current = json_object(migrate(current))
|
|
300
|
+
except Exception: # noqa: BLE001 - sanitize trusted migration failures and reject invalid JSON
|
|
301
|
+
raise MigrationError(
|
|
302
|
+
"registered payload migration failed validation or execution"
|
|
303
|
+
) from None
|
|
304
|
+
version += 1
|
|
305
|
+
return current
|
|
306
|
+
|
|
307
|
+
def decode(self, type_key: str, schema_version: int, payload: JSONObject) -> object:
|
|
308
|
+
codec = self.for_key(type_key)
|
|
309
|
+
payload = self._upgrade(type_key, schema_version, payload)
|
|
310
|
+
try:
|
|
311
|
+
value = codec.decode(payload)
|
|
312
|
+
except (ValidationError, CodecError):
|
|
313
|
+
raise
|
|
314
|
+
except Exception as exc: # noqa: BLE001 - custom codec messages may contain record data
|
|
315
|
+
raise CodecError("registered codec failed to decode the payload") from exc
|
|
316
|
+
if type(value) is not codec.model_type:
|
|
317
|
+
raise CodecError("registered codec returned a different Python type")
|
|
318
|
+
return value
|
backpack_store/errors.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Actionable errors that do not include record payloads or configured secrets."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BackpackError(Exception):
|
|
5
|
+
"""Base error for storage, registration and record validation."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationError(BackpackError, ValueError):
|
|
9
|
+
"""A value does not satisfy the explicit record or codec contract."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RegistrationError(BackpackError):
|
|
13
|
+
"""A codec conflicts with an existing registration or is invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownTypeError(BackpackError):
|
|
17
|
+
"""No trusted codec is registered for a Python type or stable key."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnknownVersionError(BackpackError):
|
|
21
|
+
"""The registered codec cannot decode this payload schema version."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CodecError(BackpackError):
|
|
25
|
+
"""A trusted custom codec failed to encode or decode a value."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundError(BackpackError):
|
|
29
|
+
"""The requested local record does not exist."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RevisionConflictError(BackpackError):
|
|
33
|
+
"""The record changed after the caller's expected revision."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StorageError(BackpackError):
|
|
37
|
+
"""A database operation failed or stored data is malformed."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StoreClosedError(StorageError):
|
|
41
|
+
"""The owned database connection has already been closed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DatabaseVersionError(StorageError):
|
|
45
|
+
"""This database schema version is not supported."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidCursorError(ValidationError):
|
|
49
|
+
"""A find cursor is malformed or belongs to a different query."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MigrationError(CodecError):
|
|
53
|
+
"""An explicitly registered payload migration failed."""
|
backpack_store/host.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Local application binding; no default path, database discovery or model imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .store import Backpack
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from hidden_moves.adapters import CapabilityCatalog
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_catalog(
|
|
15
|
+
store: Backpack, names: Iterable[str], *, allow_writes: bool = False
|
|
16
|
+
) -> CapabilityCatalog:
|
|
17
|
+
"""Select operations on a configured store; writes require separate host intent."""
|
|
18
|
+
from hidden_moves import Moves, discover_providers, load_provider
|
|
19
|
+
from hidden_moves.adapters import CapabilityCatalog
|
|
20
|
+
|
|
21
|
+
from .integration import WRITE_CAPABILITIES
|
|
22
|
+
|
|
23
|
+
names = tuple(names)
|
|
24
|
+
if not names:
|
|
25
|
+
raise ValueError("Select at least one Backpack capability.")
|
|
26
|
+
if not allow_writes and any(name in WRITE_CAPABILITIES for name in names):
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"Select the local write profile explicitly with allow_writes=True."
|
|
29
|
+
)
|
|
30
|
+
entries = [entry for entry in discover_providers() if entry.name == "backpack"]
|
|
31
|
+
if len(entries) != 1:
|
|
32
|
+
raise ValueError("Install exactly one Backpack provider.")
|
|
33
|
+
moves = Moves(target=store)
|
|
34
|
+
load_provider(entries[0], moves.registry)
|
|
35
|
+
return CapabilityCatalog(moves, names)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def serve_store(
|
|
39
|
+
store: Backpack, names: Iterable[str], *, allow_writes: bool = False
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Serve selected tools over stdio; the calling application owns cleanup."""
|
|
42
|
+
from hidden_moves_mcp import MCPAdapter, serve_stdio
|
|
43
|
+
|
|
44
|
+
catalog = build_catalog(store, names, allow_writes=allow_writes)
|
|
45
|
+
await serve_stdio(MCPAdapter(catalog).server("backpack"))
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Optional selected tools around an explicitly configured local store."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from hidden_moves import MoveAnnotations, MoveSpec
|
|
7
|
+
|
|
8
|
+
from .errors import BackpackError, CodecError
|
|
9
|
+
from .records import Provenance, Record, json_object
|
|
10
|
+
from .store import Backpack, WriteResult
|
|
11
|
+
|
|
12
|
+
READ_CAPABILITIES = ("backpack.records.get", "backpack.records.find")
|
|
13
|
+
WRITE_CAPABILITIES = ("backpack.records.put", "backpack.records.delete")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RecordView:
|
|
18
|
+
id: str
|
|
19
|
+
type_key: str
|
|
20
|
+
schema_version: int
|
|
21
|
+
revision: int
|
|
22
|
+
payload: dict[str, Any]
|
|
23
|
+
created_at: str
|
|
24
|
+
updated_at: str
|
|
25
|
+
tags: list[str]
|
|
26
|
+
provenance: Provenance | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class RecordPage:
|
|
31
|
+
records: list[RecordView]
|
|
32
|
+
next_cursor: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _view(record: Record) -> RecordView:
|
|
36
|
+
return RecordView(
|
|
37
|
+
record.id,
|
|
38
|
+
record.type_key,
|
|
39
|
+
record.schema_version,
|
|
40
|
+
record.revision,
|
|
41
|
+
record.payload,
|
|
42
|
+
record.created_at,
|
|
43
|
+
record.updated_at,
|
|
44
|
+
list(record.tags),
|
|
45
|
+
record.provenance,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_record(store: Backpack, record_id: str) -> RecordView:
|
|
50
|
+
"""Read a local record through its explicitly registered codec."""
|
|
51
|
+
return _view(store.get_record(record_id))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def find_records(
|
|
55
|
+
store: Backpack,
|
|
56
|
+
type_key: str,
|
|
57
|
+
tags: list[str] | None = None,
|
|
58
|
+
limit: int = 50,
|
|
59
|
+
cursor: str | None = None,
|
|
60
|
+
) -> RecordPage:
|
|
61
|
+
"""Read a bounded page for one registered type and all requested exact tags."""
|
|
62
|
+
codec = store.codec_for_key(type_key)
|
|
63
|
+
page = store.find(
|
|
64
|
+
codec.model_type, tags=() if tags is None else tags, limit=limit, cursor=cursor
|
|
65
|
+
)
|
|
66
|
+
return RecordPage([_view(record) for record in page.records], page.next_cursor)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def put_record(
|
|
70
|
+
store: Backpack,
|
|
71
|
+
type_key: str,
|
|
72
|
+
payload: dict[str, Any],
|
|
73
|
+
tags: list[str] | None = None,
|
|
74
|
+
provenance: Provenance | None = None,
|
|
75
|
+
) -> WriteResult:
|
|
76
|
+
"""Insert a new local UUID after validating the current registered payload type."""
|
|
77
|
+
codec = store.codec_for_key(type_key)
|
|
78
|
+
try:
|
|
79
|
+
value = codec.decode(json_object(payload))
|
|
80
|
+
except BackpackError:
|
|
81
|
+
raise
|
|
82
|
+
except Exception: # noqa: BLE001 - trusted custom codec failures must not expose payloads
|
|
83
|
+
raise CodecError(
|
|
84
|
+
"registered codec failed to decode the supplied payload"
|
|
85
|
+
) from None
|
|
86
|
+
if type(value) is not codec.model_type:
|
|
87
|
+
raise CodecError("registered codec returned a different Python type")
|
|
88
|
+
return store.put(value, tags=() if tags is None else tags, provenance=provenance)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def delete_record(store: Backpack, record_id: str) -> bool:
|
|
92
|
+
"""Delete one explicit local UUID, reporting whether it existed."""
|
|
93
|
+
return store.delete(record_id)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def provide_moves() -> tuple[MoveSpec, ...]:
|
|
97
|
+
"""Advertise definitions without opening a database or choosing codecs."""
|
|
98
|
+
read = MoveAnnotations(
|
|
99
|
+
read_only=True, destructive=False, idempotent=True, external=False
|
|
100
|
+
)
|
|
101
|
+
insert = MoveAnnotations(
|
|
102
|
+
read_only=False, destructive=False, idempotent=False, external=False
|
|
103
|
+
)
|
|
104
|
+
delete = MoveAnnotations(
|
|
105
|
+
read_only=False, destructive=True, idempotent=True, external=False
|
|
106
|
+
)
|
|
107
|
+
return tuple(
|
|
108
|
+
MoveSpec(
|
|
109
|
+
name=name,
|
|
110
|
+
namespace="backpack.records",
|
|
111
|
+
func=operation,
|
|
112
|
+
bind_target=True,
|
|
113
|
+
target_types=(Backpack,),
|
|
114
|
+
annotations=annotations,
|
|
115
|
+
)
|
|
116
|
+
for name, operation, annotations in (
|
|
117
|
+
("get", get_record, read),
|
|
118
|
+
("find", find_records, read),
|
|
119
|
+
("put", put_record, insert),
|
|
120
|
+
("delete", delete_record, delete),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Optional Pydantic 2 codec; the core package never imports this extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
12
|
+
from pydantic import __version__ as pydantic_version
|
|
13
|
+
except ImportError:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"Install backpack-store with its [pydantic] extra to use this codec."
|
|
16
|
+
) from None
|
|
17
|
+
|
|
18
|
+
from .errors import CodecError, RegistrationError, ValidationError
|
|
19
|
+
from .records import JSONObject, json_object, positive_integer, validate_type_key
|
|
20
|
+
|
|
21
|
+
if not pydantic_version.startswith("2."):
|
|
22
|
+
raise ImportError("The Backpack Pydantic codec supports Pydantic 2 only.")
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T", bound=BaseModel)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, init=False)
|
|
28
|
+
class PydanticCodec(Generic[T]):
|
|
29
|
+
"""Strict JSON validation for one explicitly registered object-shaped model."""
|
|
30
|
+
|
|
31
|
+
model_type: type[T]
|
|
32
|
+
type_key: str
|
|
33
|
+
schema_version: int
|
|
34
|
+
|
|
35
|
+
def __init__(self, model_type: type[T], *, type_key: str, schema_version: int = 1):
|
|
36
|
+
if (
|
|
37
|
+
not isinstance(model_type, type)
|
|
38
|
+
or not issubclass(model_type, BaseModel)
|
|
39
|
+
or model_type is BaseModel
|
|
40
|
+
or model_type.__pydantic_root_model__
|
|
41
|
+
):
|
|
42
|
+
raise RegistrationError(
|
|
43
|
+
"model_type must be a concrete object-shaped Pydantic 2 model"
|
|
44
|
+
)
|
|
45
|
+
validate_type_key(type_key)
|
|
46
|
+
positive_integer(schema_version, "schema_version")
|
|
47
|
+
object.__setattr__(self, "model_type", model_type)
|
|
48
|
+
object.__setattr__(self, "type_key", type_key)
|
|
49
|
+
object.__setattr__(self, "schema_version", schema_version)
|
|
50
|
+
|
|
51
|
+
def encode(self, value: T) -> JSONObject:
|
|
52
|
+
if type(value) is not self.model_type:
|
|
53
|
+
raise ValidationError(
|
|
54
|
+
"value must have the exact registered Pydantic model type"
|
|
55
|
+
)
|
|
56
|
+
try:
|
|
57
|
+
payload = json_object(
|
|
58
|
+
value.model_dump(
|
|
59
|
+
mode="json", round_trip=True, by_alias=True, warnings="error"
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
except ValidationError:
|
|
63
|
+
raise
|
|
64
|
+
except Exception: # noqa: BLE001 - serializers may include private model values in errors
|
|
65
|
+
raise CodecError(
|
|
66
|
+
"registered Pydantic model could not produce a JSON object"
|
|
67
|
+
) from None
|
|
68
|
+
# Revalidate serialized data even when model_construct/mutation bypassed model validation.
|
|
69
|
+
self.decode(payload)
|
|
70
|
+
return payload
|
|
71
|
+
|
|
72
|
+
def decode(self, payload: JSONObject) -> T:
|
|
73
|
+
payload = json_object(payload)
|
|
74
|
+
try:
|
|
75
|
+
return self.model_type.model_validate_json(
|
|
76
|
+
json.dumps(payload, ensure_ascii=False, allow_nan=False),
|
|
77
|
+
strict=True,
|
|
78
|
+
extra="forbid",
|
|
79
|
+
)
|
|
80
|
+
except PydanticValidationError:
|
|
81
|
+
raise ValidationError(
|
|
82
|
+
"payload does not satisfy the registered Pydantic model"
|
|
83
|
+
) from None
|
|
84
|
+
except Exception: # noqa: BLE001 - trusted validators must not expose payloads in errors
|
|
85
|
+
raise CodecError(
|
|
86
|
+
"registered Pydantic model failed to validate the JSON payload"
|
|
87
|
+
) from None
|