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/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