brainlessdb 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.
- brainless/__init__.py +57 -0
- brainless/bucket.py +127 -0
- brainless/client.py +150 -0
- brainless/collection.py +628 -0
- brainless/entity.py +572 -0
- brainless/py.typed +0 -0
- brainless/schema.py +164 -0
- brainlessdb-0.1.0.dist-info/METADATA +454 -0
- brainlessdb-0.1.0.dist-info/RECORD +11 -0
- brainlessdb-0.1.0.dist-info/WHEEL +5 -0
- brainlessdb-0.1.0.dist-info/top_level.txt +1 -0
brainless/entity.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""Entity wrapper with attribute access and dirty tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import fields, is_dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar, get_origin, get_type_hints
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from brainless.collection import Collection
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
L = TypeVar("L")
|
|
14
|
+
K = TypeVar("K")
|
|
15
|
+
V = TypeVar("V")
|
|
16
|
+
|
|
17
|
+
# Cache for tracked dataclass subclasses
|
|
18
|
+
_tracked_classes: dict[type, type] = {}
|
|
19
|
+
# Cache for nested tracked dataclass subclasses
|
|
20
|
+
_nested_tracked_classes: dict[type, type] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TrackedList(list[L]):
|
|
24
|
+
"""List that tracks mutations and syncs to parent.
|
|
25
|
+
|
|
26
|
+
When items are added, removed, or modified, the backing list in the
|
|
27
|
+
entity data is updated and mark_dirty is called.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
items: list[L],
|
|
33
|
+
backing_list: list[Any],
|
|
34
|
+
mark_dirty: Callable[[], None],
|
|
35
|
+
item_type: type | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(items)
|
|
38
|
+
self._backing_list = backing_list
|
|
39
|
+
self._mark_dirty = mark_dirty
|
|
40
|
+
self._item_type = item_type
|
|
41
|
+
|
|
42
|
+
def _to_dict(self, item: Any) -> Any:
|
|
43
|
+
"""Convert item to dict for storage."""
|
|
44
|
+
if is_dataclass(item) and not isinstance(item, type):
|
|
45
|
+
from dataclasses import asdict
|
|
46
|
+
return asdict(item)
|
|
47
|
+
return item
|
|
48
|
+
|
|
49
|
+
def _sync_and_dirty(self) -> None:
|
|
50
|
+
"""Sync to backing list and mark dirty."""
|
|
51
|
+
self._backing_list.clear()
|
|
52
|
+
self._backing_list.extend(self._to_dict(item) for item in list.__iter__(self))
|
|
53
|
+
self._mark_dirty()
|
|
54
|
+
|
|
55
|
+
def append(self, item: L) -> None:
|
|
56
|
+
super().append(item)
|
|
57
|
+
self._backing_list.append(self._to_dict(item))
|
|
58
|
+
self._mark_dirty()
|
|
59
|
+
|
|
60
|
+
def extend(self, items: list[L]) -> None:
|
|
61
|
+
super().extend(items)
|
|
62
|
+
self._backing_list.extend(self._to_dict(item) for item in items)
|
|
63
|
+
self._mark_dirty()
|
|
64
|
+
|
|
65
|
+
def insert(self, index: int, item: L) -> None:
|
|
66
|
+
super().insert(index, item)
|
|
67
|
+
self._backing_list.insert(index, self._to_dict(item))
|
|
68
|
+
self._mark_dirty()
|
|
69
|
+
|
|
70
|
+
def remove(self, item: L) -> None:
|
|
71
|
+
idx = self.index(item)
|
|
72
|
+
super().remove(item)
|
|
73
|
+
del self._backing_list[idx]
|
|
74
|
+
self._mark_dirty()
|
|
75
|
+
|
|
76
|
+
def pop(self, index: int = -1) -> L:
|
|
77
|
+
result = super().pop(index)
|
|
78
|
+
self._backing_list.pop(index)
|
|
79
|
+
self._mark_dirty()
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def clear(self) -> None:
|
|
83
|
+
super().clear()
|
|
84
|
+
self._backing_list.clear()
|
|
85
|
+
self._mark_dirty()
|
|
86
|
+
|
|
87
|
+
def __setitem__(self, index: int, item: L) -> None: # type: ignore[override]
|
|
88
|
+
super().__setitem__(index, item)
|
|
89
|
+
self._backing_list[index] = self._to_dict(item)
|
|
90
|
+
self._mark_dirty()
|
|
91
|
+
|
|
92
|
+
def __delitem__(self, index: int) -> None: # type: ignore[override]
|
|
93
|
+
super().__delitem__(index)
|
|
94
|
+
del self._backing_list[index]
|
|
95
|
+
self._mark_dirty()
|
|
96
|
+
|
|
97
|
+
def __iadd__(self, items: list[L]) -> "TrackedList[L]": # type: ignore[override]
|
|
98
|
+
self.extend(items)
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TrackedDict(dict[K, V]):
|
|
103
|
+
"""Dict that tracks mutations and syncs to parent.
|
|
104
|
+
|
|
105
|
+
When items are added, removed, or modified, the backing dict in the
|
|
106
|
+
entity data is updated and mark_dirty is called.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
items: dict[K, V],
|
|
112
|
+
backing_dict: dict[Any, Any],
|
|
113
|
+
mark_dirty: Callable[[], None],
|
|
114
|
+
value_type: type | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
super().__init__(items)
|
|
117
|
+
self._backing_dict = backing_dict
|
|
118
|
+
self._mark_dirty = mark_dirty
|
|
119
|
+
self._value_type = value_type
|
|
120
|
+
|
|
121
|
+
def _to_dict(self, item: Any) -> Any:
|
|
122
|
+
"""Convert item to dict for storage."""
|
|
123
|
+
if is_dataclass(item) and not isinstance(item, type):
|
|
124
|
+
from dataclasses import asdict
|
|
125
|
+
return asdict(item)
|
|
126
|
+
return item
|
|
127
|
+
|
|
128
|
+
def _to_key(self, key: Any) -> Any:
|
|
129
|
+
"""Convert key to storage format (stringify int keys for JSON)."""
|
|
130
|
+
return str(key) if isinstance(key, int) else key
|
|
131
|
+
|
|
132
|
+
def __setitem__(self, key: K, value: V) -> None:
|
|
133
|
+
super().__setitem__(key, value)
|
|
134
|
+
self._backing_dict[self._to_key(key)] = self._to_dict(value)
|
|
135
|
+
self._mark_dirty()
|
|
136
|
+
|
|
137
|
+
def __delitem__(self, key: K) -> None:
|
|
138
|
+
super().__delitem__(key)
|
|
139
|
+
del self._backing_dict[self._to_key(key)]
|
|
140
|
+
self._mark_dirty()
|
|
141
|
+
|
|
142
|
+
def pop(self, key: K, *args: Any) -> V:
|
|
143
|
+
result = super().pop(key, *args)
|
|
144
|
+
self._backing_dict.pop(self._to_key(key), None)
|
|
145
|
+
self._mark_dirty()
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
def popitem(self) -> tuple[K, V]:
|
|
149
|
+
key, value = super().popitem()
|
|
150
|
+
self._backing_dict.pop(self._to_key(key), None)
|
|
151
|
+
self._mark_dirty()
|
|
152
|
+
return key, value
|
|
153
|
+
|
|
154
|
+
def clear(self) -> None:
|
|
155
|
+
super().clear()
|
|
156
|
+
self._backing_dict.clear()
|
|
157
|
+
self._mark_dirty()
|
|
158
|
+
|
|
159
|
+
def update(self, other: dict[K, V] | None = None, **kwargs: V) -> None: # type: ignore[override]
|
|
160
|
+
if other:
|
|
161
|
+
super().update(other)
|
|
162
|
+
for k, v in other.items():
|
|
163
|
+
self._backing_dict[self._to_key(k)] = self._to_dict(v)
|
|
164
|
+
if kwargs:
|
|
165
|
+
super().update(kwargs) # type: ignore[arg-type]
|
|
166
|
+
for k, v in kwargs.items():
|
|
167
|
+
self._backing_dict[self._to_key(k)] = self._to_dict(v)
|
|
168
|
+
self._mark_dirty()
|
|
169
|
+
|
|
170
|
+
def setdefault(self, key: K, default: V = None) -> V: # type: ignore[assignment]
|
|
171
|
+
if key not in self:
|
|
172
|
+
self[key] = default
|
|
173
|
+
return self[key]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_hints(cls: type) -> dict[str, Any]:
|
|
177
|
+
"""Get type hints for a class, resolving forward references."""
|
|
178
|
+
try:
|
|
179
|
+
# Get the module where the class is defined for proper resolution
|
|
180
|
+
module = sys.modules.get(cls.__module__, None)
|
|
181
|
+
globalns = getattr(module, "__dict__", {}) if module else {}
|
|
182
|
+
return get_type_hints(cls, globalns=globalns)
|
|
183
|
+
except NameError:
|
|
184
|
+
# Fall back to raw annotations and try to resolve manually
|
|
185
|
+
return _resolve_annotations(cls)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolve_annotations(cls: type) -> dict[str, Any]:
|
|
189
|
+
"""Manually resolve string annotations by searching loaded modules."""
|
|
190
|
+
raw = getattr(cls, "__annotations__", {})
|
|
191
|
+
resolved: dict[str, Any] = {}
|
|
192
|
+
|
|
193
|
+
for name, hint in raw.items():
|
|
194
|
+
if isinstance(hint, str):
|
|
195
|
+
resolved[name] = _find_class(hint)
|
|
196
|
+
else:
|
|
197
|
+
resolved[name] = hint
|
|
198
|
+
|
|
199
|
+
return resolved
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _find_class(name: str) -> Any:
|
|
203
|
+
"""Find a class by name in loaded modules."""
|
|
204
|
+
# Handle generic types like list[Foo], Optional[Foo]
|
|
205
|
+
origin_match = name.split("[")[0].strip()
|
|
206
|
+
if origin_match in ("list", "List", "Optional", "Union"):
|
|
207
|
+
return name # Can't resolve generics easily, return as string
|
|
208
|
+
|
|
209
|
+
# Search all loaded modules for the class
|
|
210
|
+
for module in sys.modules.values():
|
|
211
|
+
if module is None:
|
|
212
|
+
continue
|
|
213
|
+
cls = getattr(module, name, None)
|
|
214
|
+
if cls is not None and isinstance(cls, type):
|
|
215
|
+
return cls
|
|
216
|
+
|
|
217
|
+
# Not found, return as string
|
|
218
|
+
return name
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _get_tracked_class(cls: type[T]) -> type[T]:
|
|
222
|
+
"""Get or create a tracked subclass of a dataclass.
|
|
223
|
+
|
|
224
|
+
The tracked subclass syncs attribute changes back to the Entity.
|
|
225
|
+
"""
|
|
226
|
+
if cls in _tracked_classes:
|
|
227
|
+
return _tracked_classes[cls]
|
|
228
|
+
|
|
229
|
+
class Tracked(cls): # type: ignore[valid-type,misc]
|
|
230
|
+
__slots__ = ("_brainless_entity",)
|
|
231
|
+
|
|
232
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
233
|
+
if name == "_brainless_entity":
|
|
234
|
+
object.__setattr__(self, name, value)
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
# Set on dataclass first
|
|
238
|
+
super().__setattr__(name, value)
|
|
239
|
+
|
|
240
|
+
# Sync to entity if tracked and field exists
|
|
241
|
+
entity = getattr(self, "_brainless_entity", None)
|
|
242
|
+
if entity is not None and name in entity._data:
|
|
243
|
+
entity[name] = value
|
|
244
|
+
|
|
245
|
+
Tracked.__name__ = f"Tracked{cls.__name__}"
|
|
246
|
+
Tracked.__qualname__ = f"Tracked{cls.__qualname__}"
|
|
247
|
+
_tracked_classes[cls] = Tracked
|
|
248
|
+
return Tracked # type: ignore[return-value]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _get_nested_tracked_class(cls: type[T]) -> type[T]:
|
|
252
|
+
"""Get or create a nested tracked subclass of a dataclass.
|
|
253
|
+
|
|
254
|
+
Nested tracked objects sync changes to their parent dict and call
|
|
255
|
+
a mark_dirty callback.
|
|
256
|
+
"""
|
|
257
|
+
if cls in _nested_tracked_classes:
|
|
258
|
+
return _nested_tracked_classes[cls]
|
|
259
|
+
|
|
260
|
+
class NestedTracked(cls): # type: ignore[valid-type,misc]
|
|
261
|
+
__slots__ = ("_brainless_parent_dict", "_brainless_mark_dirty")
|
|
262
|
+
|
|
263
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
264
|
+
if name in ("_brainless_parent_dict", "_brainless_mark_dirty"):
|
|
265
|
+
object.__setattr__(self, name, value)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# Set on dataclass first
|
|
269
|
+
super().__setattr__(name, value)
|
|
270
|
+
|
|
271
|
+
# Sync to parent dict and mark dirty
|
|
272
|
+
parent_dict = getattr(self, "_brainless_parent_dict", None)
|
|
273
|
+
mark_dirty = getattr(self, "_brainless_mark_dirty", None)
|
|
274
|
+
if parent_dict is not None and name in parent_dict:
|
|
275
|
+
parent_dict[name] = value
|
|
276
|
+
if mark_dirty is not None:
|
|
277
|
+
mark_dirty()
|
|
278
|
+
|
|
279
|
+
NestedTracked.__name__ = cls.__name__
|
|
280
|
+
NestedTracked.__qualname__ = cls.__qualname__
|
|
281
|
+
_nested_tracked_classes[cls] = NestedTracked
|
|
282
|
+
return NestedTracked # type: ignore[return-value]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _make_nested_tracked(
|
|
286
|
+
cls: type[T],
|
|
287
|
+
data: dict[str, Any],
|
|
288
|
+
mark_dirty: Callable[[], None],
|
|
289
|
+
) -> T:
|
|
290
|
+
"""Create a nested tracked dataclass instance.
|
|
291
|
+
|
|
292
|
+
Changes sync to the provided data dict and call mark_dirty.
|
|
293
|
+
"""
|
|
294
|
+
tracked_cls = _get_nested_tracked_class(cls)
|
|
295
|
+
hints = _get_hints(cls)
|
|
296
|
+
|
|
297
|
+
field_names = {f.name for f in fields(cls)}
|
|
298
|
+
kwargs: dict[str, Any] = {}
|
|
299
|
+
|
|
300
|
+
for name in field_names:
|
|
301
|
+
if name not in data:
|
|
302
|
+
continue
|
|
303
|
+
value = data[name]
|
|
304
|
+
field_type = hints.get(name)
|
|
305
|
+
|
|
306
|
+
# Recursively handle nested dataclasses
|
|
307
|
+
if field_type and is_dataclass(field_type) and isinstance(value, dict):
|
|
308
|
+
kwargs[name] = _make_nested_tracked(field_type, value, mark_dirty)
|
|
309
|
+
elif field_type and get_origin(field_type) is list:
|
|
310
|
+
args = getattr(field_type, "__args__", ())
|
|
311
|
+
if args and is_dataclass(args[0]) and isinstance(value, list):
|
|
312
|
+
tracked_items = [
|
|
313
|
+
_make_nested_tracked(args[0], item, mark_dirty)
|
|
314
|
+
if isinstance(item, dict) else item
|
|
315
|
+
for item in value
|
|
316
|
+
]
|
|
317
|
+
kwargs[name] = TrackedList(tracked_items, value, mark_dirty, args[0])
|
|
318
|
+
elif isinstance(value, list):
|
|
319
|
+
kwargs[name] = TrackedList(value.copy(), value, mark_dirty)
|
|
320
|
+
else:
|
|
321
|
+
kwargs[name] = value
|
|
322
|
+
elif field_type and get_origin(field_type) is dict:
|
|
323
|
+
args = getattr(field_type, "__args__", ())
|
|
324
|
+
if len(args) >= 2 and is_dataclass(args[1]) and isinstance(value, dict):
|
|
325
|
+
# dict[K, DataclassType] - track values
|
|
326
|
+
key_type = args[0]
|
|
327
|
+
val_type = args[1]
|
|
328
|
+
tracked_items = {}
|
|
329
|
+
for k, v in value.items():
|
|
330
|
+
# Convert key back to original type if needed
|
|
331
|
+
typed_key = int(k) if key_type is int and isinstance(k, str) else k
|
|
332
|
+
tracked_items[typed_key] = (
|
|
333
|
+
_make_nested_tracked(val_type, v, mark_dirty)
|
|
334
|
+
if isinstance(v, dict) else v
|
|
335
|
+
)
|
|
336
|
+
kwargs[name] = TrackedDict(tracked_items, value, mark_dirty, val_type)
|
|
337
|
+
elif isinstance(value, dict):
|
|
338
|
+
kwargs[name] = TrackedDict(value.copy(), value, mark_dirty)
|
|
339
|
+
else:
|
|
340
|
+
kwargs[name] = value
|
|
341
|
+
else:
|
|
342
|
+
kwargs[name] = value
|
|
343
|
+
|
|
344
|
+
instance = tracked_cls(**kwargs)
|
|
345
|
+
object.__setattr__(instance, "_brainless_parent_dict", data)
|
|
346
|
+
object.__setattr__(instance, "_brainless_mark_dirty", mark_dirty)
|
|
347
|
+
return instance
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _make_tracked_instance(cls: type[T], entity: "Entity") -> T:
|
|
351
|
+
"""Create a tracked dataclass instance linked to an Entity.
|
|
352
|
+
|
|
353
|
+
Changes to the instance's attributes are synced back to the Entity,
|
|
354
|
+
triggering dirty tracking and eventual persistence.
|
|
355
|
+
Handles nested dataclasses recursively.
|
|
356
|
+
"""
|
|
357
|
+
tracked_cls = _get_tracked_class(cls)
|
|
358
|
+
hints = _get_hints(cls)
|
|
359
|
+
|
|
360
|
+
field_names = {f.name for f in fields(cls)}
|
|
361
|
+
kwargs: dict[str, Any] = {}
|
|
362
|
+
|
|
363
|
+
def mark_dirty() -> None:
|
|
364
|
+
entity.mark_dirty()
|
|
365
|
+
|
|
366
|
+
for name in field_names:
|
|
367
|
+
if name == "uuid":
|
|
368
|
+
kwargs["uuid"] = entity._uuid
|
|
369
|
+
continue
|
|
370
|
+
if name not in entity._data:
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
value = entity._data[name]
|
|
374
|
+
field_type = hints.get(name)
|
|
375
|
+
|
|
376
|
+
# Recursively handle nested dataclasses
|
|
377
|
+
if field_type and is_dataclass(field_type) and isinstance(value, dict):
|
|
378
|
+
kwargs[name] = _make_nested_tracked(field_type, value, mark_dirty)
|
|
379
|
+
elif field_type and get_origin(field_type) is list:
|
|
380
|
+
args = getattr(field_type, "__args__", ())
|
|
381
|
+
if args and is_dataclass(args[0]) and isinstance(value, list):
|
|
382
|
+
tracked_items = [
|
|
383
|
+
_make_nested_tracked(args[0], item, mark_dirty)
|
|
384
|
+
if isinstance(item, dict) else item
|
|
385
|
+
for item in value
|
|
386
|
+
]
|
|
387
|
+
kwargs[name] = TrackedList(tracked_items, value, mark_dirty, args[0])
|
|
388
|
+
elif isinstance(value, list):
|
|
389
|
+
kwargs[name] = TrackedList(value.copy(), value, mark_dirty)
|
|
390
|
+
else:
|
|
391
|
+
kwargs[name] = value
|
|
392
|
+
elif field_type and get_origin(field_type) is dict:
|
|
393
|
+
args = getattr(field_type, "__args__", ())
|
|
394
|
+
if len(args) >= 2 and is_dataclass(args[1]) and isinstance(value, dict):
|
|
395
|
+
# dict[K, DataclassType] - track values
|
|
396
|
+
key_type = args[0]
|
|
397
|
+
val_type = args[1]
|
|
398
|
+
tracked_items = {}
|
|
399
|
+
for k, v in value.items():
|
|
400
|
+
# Convert key back to original type if needed
|
|
401
|
+
typed_key = int(k) if key_type is int and isinstance(k, str) else k
|
|
402
|
+
tracked_items[typed_key] = (
|
|
403
|
+
_make_nested_tracked(val_type, v, mark_dirty)
|
|
404
|
+
if isinstance(v, dict) else v
|
|
405
|
+
)
|
|
406
|
+
kwargs[name] = TrackedDict(tracked_items, value, mark_dirty, val_type)
|
|
407
|
+
elif isinstance(value, dict):
|
|
408
|
+
kwargs[name] = TrackedDict(value.copy(), value, mark_dirty)
|
|
409
|
+
else:
|
|
410
|
+
kwargs[name] = value
|
|
411
|
+
else:
|
|
412
|
+
kwargs[name] = value
|
|
413
|
+
|
|
414
|
+
instance = tracked_cls(**kwargs)
|
|
415
|
+
object.__setattr__(instance, "_brainless_entity", entity)
|
|
416
|
+
return instance
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _convert_to_dataclass(data: dict[str, Any], cls: type[T], uuid: str | None = None) -> T:
|
|
420
|
+
"""Recursively convert dict to dataclass, handling nested types.
|
|
421
|
+
|
|
422
|
+
If the dataclass has a 'uuid' field and uuid is provided, it will be
|
|
423
|
+
automatically populated.
|
|
424
|
+
"""
|
|
425
|
+
if not is_dataclass(cls):
|
|
426
|
+
raise TypeError(f"Expected dataclass, got {cls.__name__}")
|
|
427
|
+
|
|
428
|
+
hints = _get_hints(cls)
|
|
429
|
+
|
|
430
|
+
converted: dict[str, Any] = {}
|
|
431
|
+
|
|
432
|
+
for field_name, field_type in hints.items():
|
|
433
|
+
if field_name not in data:
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
value = data[field_name]
|
|
437
|
+
converted[field_name] = _convert_value(value, field_type)
|
|
438
|
+
|
|
439
|
+
# Auto-populate uuid if field exists in dataclass
|
|
440
|
+
if uuid is not None and "uuid" in hints and "uuid" not in converted:
|
|
441
|
+
converted["uuid"] = uuid
|
|
442
|
+
|
|
443
|
+
return cls(**converted)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _convert_value(value: Any, target_type: type) -> Any:
|
|
447
|
+
"""Convert a single value to target type."""
|
|
448
|
+
if value is None:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
# String annotations (unresolved forward refs) - pass through
|
|
452
|
+
if isinstance(target_type, str):
|
|
453
|
+
return value
|
|
454
|
+
|
|
455
|
+
origin = get_origin(target_type)
|
|
456
|
+
|
|
457
|
+
# Handle list[SomeType]
|
|
458
|
+
if origin is list:
|
|
459
|
+
args = getattr(target_type, "__args__", ())
|
|
460
|
+
if args and is_dataclass(args[0]):
|
|
461
|
+
return [_convert_to_dataclass(item, args[0]) for item in value]
|
|
462
|
+
return value
|
|
463
|
+
|
|
464
|
+
# Handle nested dataclass
|
|
465
|
+
if is_dataclass(target_type) and isinstance(value, dict):
|
|
466
|
+
return _convert_to_dataclass(value, target_type)
|
|
467
|
+
|
|
468
|
+
return value
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class Entity:
|
|
472
|
+
"""Wrapper around stored data with attribute access and dirty tracking
|
|
473
|
+
|
|
474
|
+
Provides dict-like and attribute access to fields. Automatically marks
|
|
475
|
+
the entity as dirty when values change, triggering background flush.
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
__slots__ = ("_collection", "_uuid", "_data", "_dirty")
|
|
479
|
+
|
|
480
|
+
def __init__(self, collection: Collection, uuid: str, data: dict[str, Any]) -> None:
|
|
481
|
+
object.__setattr__(self, "_collection", collection)
|
|
482
|
+
object.__setattr__(self, "_uuid", uuid)
|
|
483
|
+
object.__setattr__(self, "_data", data)
|
|
484
|
+
object.__setattr__(self, "_dirty", False)
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def uuid(self) -> str:
|
|
488
|
+
return self._uuid
|
|
489
|
+
|
|
490
|
+
@property
|
|
491
|
+
def data(self) -> dict[str, Any]:
|
|
492
|
+
"""Raw entity data (excludes uuid)."""
|
|
493
|
+
return self._data
|
|
494
|
+
|
|
495
|
+
@property
|
|
496
|
+
def dirty(self) -> bool:
|
|
497
|
+
return self._dirty
|
|
498
|
+
|
|
499
|
+
def mark_dirty(self) -> None:
|
|
500
|
+
"""Mark entity as modified, schedule for flush."""
|
|
501
|
+
if not self._dirty:
|
|
502
|
+
object.__setattr__(self, "_dirty", True)
|
|
503
|
+
self._collection.mark_dirty(self)
|
|
504
|
+
|
|
505
|
+
def mark_clean(self) -> None:
|
|
506
|
+
"""Mark entity as persisted."""
|
|
507
|
+
object.__setattr__(self, "_dirty", False)
|
|
508
|
+
|
|
509
|
+
def to_dict(self) -> dict[str, Any]:
|
|
510
|
+
"""Return copy of entity data including uuid."""
|
|
511
|
+
return {"uuid": self._uuid, **self._data}
|
|
512
|
+
|
|
513
|
+
def as_type(self, cls: type[T]) -> T:
|
|
514
|
+
"""Convert entity data to a dataclass instance
|
|
515
|
+
|
|
516
|
+
Handles nested dataclasses and lists of dataclasses.
|
|
517
|
+
If the dataclass has a 'uuid' field, it will be auto-populated.
|
|
518
|
+
|
|
519
|
+
@param cls: Target dataclass type
|
|
520
|
+
@return: Instance of cls populated with entity data
|
|
521
|
+
"""
|
|
522
|
+
return _convert_to_dataclass(self._data, cls, uuid=self._uuid)
|
|
523
|
+
|
|
524
|
+
def __getattr__(self, name: str) -> Any:
|
|
525
|
+
if name.startswith("_"):
|
|
526
|
+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
|
527
|
+
try:
|
|
528
|
+
return self._data[name]
|
|
529
|
+
except KeyError:
|
|
530
|
+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") from None
|
|
531
|
+
|
|
532
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
533
|
+
if name.startswith("_"):
|
|
534
|
+
object.__setattr__(self, name, value)
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
if name not in self._data:
|
|
538
|
+
raise AttributeError(f"Cannot add new field '{name}' to entity")
|
|
539
|
+
|
|
540
|
+
old_value = self._data[name]
|
|
541
|
+
if old_value != value:
|
|
542
|
+
self._data[name] = value
|
|
543
|
+
self._collection.on_field_change(self, name, old_value, value)
|
|
544
|
+
self.mark_dirty()
|
|
545
|
+
|
|
546
|
+
def __getitem__(self, key: str) -> Any:
|
|
547
|
+
if key == "uuid":
|
|
548
|
+
return self._uuid
|
|
549
|
+
return self._data[key]
|
|
550
|
+
|
|
551
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
552
|
+
if key == "uuid":
|
|
553
|
+
raise KeyError("Cannot modify uuid")
|
|
554
|
+
if key not in self._data:
|
|
555
|
+
raise KeyError(f"Unknown field '{key}'")
|
|
556
|
+
old_value = self._data[key]
|
|
557
|
+
if old_value != value:
|
|
558
|
+
self._data[key] = value
|
|
559
|
+
self._collection.on_field_change(self, key, old_value, value)
|
|
560
|
+
self.mark_dirty()
|
|
561
|
+
|
|
562
|
+
def __contains__(self, key: str) -> bool:
|
|
563
|
+
return key == "uuid" or key in self._data
|
|
564
|
+
|
|
565
|
+
def __repr__(self) -> str:
|
|
566
|
+
dirty_marker = "*" if self._dirty else ""
|
|
567
|
+
# Show first few fields for context
|
|
568
|
+
fields = [f"{k}={v!r}" for k, v in list(self._data.items())[:3]]
|
|
569
|
+
fields_str = ", ".join(fields)
|
|
570
|
+
if len(self._data) > 3:
|
|
571
|
+
fields_str += ", ..."
|
|
572
|
+
return f"<{self._collection.name}:{self._uuid[-8:]}{dirty_marker} {fields_str}>"
|
brainless/py.typed
ADDED
|
File without changes
|