statikk 0.1.12__py3-none-any.whl → 0.1.14__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.
- statikk/engine.py +6 -24
- statikk/models.py +118 -16
- statikk/typing.py +22 -1
- {statikk-0.1.12.dist-info → statikk-0.1.14.dist-info}/METADATA +1 -1
- statikk-0.1.14.dist-info/RECORD +12 -0
- statikk-0.1.12.dist-info/RECORD +0 -12
- {statikk-0.1.12.dist-info → statikk-0.1.14.dist-info}/WHEEL +0 -0
- {statikk-0.1.12.dist-info → statikk-0.1.14.dist-info}/licenses/LICENSE.txt +0 -0
- {statikk-0.1.12.dist-info → statikk-0.1.14.dist-info}/top_level.txt +0 -0
statikk/engine.py
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
import os
|
2
2
|
from datetime import datetime
|
3
|
-
from typing import Any, Dict, Type, Optional, List, Union
|
3
|
+
from typing import Any, Dict, Type, Optional, List, Union
|
4
4
|
|
5
5
|
import boto3
|
6
6
|
from botocore.config import Config
|
7
7
|
from pydantic.fields import FieldInfo
|
8
|
-
from boto3.dynamodb.conditions import ComparisonCondition
|
8
|
+
from boto3.dynamodb.conditions import ComparisonCondition
|
9
9
|
from boto3.dynamodb.types import TypeDeserializer, Decimal
|
10
10
|
|
11
|
-
from statikk.typing import T
|
11
|
+
from statikk.typing import T, inspect_optional_field
|
12
12
|
from statikk.conditions import Condition, Equals, BeginsWith
|
13
13
|
from statikk.expressions import UpdateExpressionBuilder
|
14
14
|
from statikk.models import (
|
@@ -246,6 +246,8 @@ class Table:
|
|
246
246
|
else:
|
247
247
|
batch.put(item)
|
248
248
|
|
249
|
+
model._session.reset()
|
250
|
+
|
249
251
|
def update_item(
|
250
252
|
self,
|
251
253
|
hash_key: str,
|
@@ -627,26 +629,6 @@ class Table:
|
|
627
629
|
data = self._serialize_item(enriched_item)
|
628
630
|
batch.put_item(Item=data)
|
629
631
|
|
630
|
-
def inspect_optional_field(self, model_class, field_name):
|
631
|
-
field_type = model_class.model_fields[field_name].annotation
|
632
|
-
|
633
|
-
is_optional = False
|
634
|
-
inner_type = field_type
|
635
|
-
|
636
|
-
if get_origin(field_type) is Union:
|
637
|
-
args = get_args(field_type)
|
638
|
-
if len(args) == 2 and args[1] is type(None):
|
639
|
-
is_optional = True
|
640
|
-
inner_type = args[0]
|
641
|
-
|
642
|
-
elif hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
|
643
|
-
args = getattr(field_type, "__args__", [])
|
644
|
-
if len(args) == 2 and args[1] is type(None):
|
645
|
-
is_optional = True
|
646
|
-
inner_type = args[0]
|
647
|
-
|
648
|
-
return (is_optional, inner_type)
|
649
|
-
|
650
632
|
def reconstruct_hierarchy(self, items: list[dict]) -> Optional[dict]:
|
651
633
|
"""
|
652
634
|
Reconstructs a hierarchical dictionary structure from a flat list of dictionaries
|
@@ -728,7 +710,7 @@ class Table:
|
|
728
710
|
if field_name.startswith("_"):
|
729
711
|
continue
|
730
712
|
|
731
|
-
is_optional, inner_type =
|
713
|
+
is_optional, inner_type = inspect_optional_field(parent_model_class, field_name)
|
732
714
|
|
733
715
|
field_type = inner_type if is_optional else field_info.annotation
|
734
716
|
|
statikk/models.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
from datetime import datetime
|
3
4
|
import typing
|
4
5
|
import logging
|
5
6
|
from uuid import uuid4
|
6
7
|
from typing import Optional, List, Any, Set, Type
|
7
|
-
from statikk.typing import T
|
8
|
+
from statikk.typing import T, inspect_optional_field
|
8
9
|
|
9
10
|
from boto3.dynamodb.conditions import ComparisonCondition
|
10
11
|
from pydantic import BaseModel, model_serializer, model_validator, Field, Extra
|
@@ -53,6 +54,43 @@ class IndexFieldConfig(BaseModel):
|
|
53
54
|
sk_fields: list[str] = []
|
54
55
|
|
55
56
|
|
57
|
+
class TreeStructureChange(BaseModel):
|
58
|
+
new_parent: Optional[Any]
|
59
|
+
new_parent_field_name: Optional[str]
|
60
|
+
subtree: Any
|
61
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
62
|
+
|
63
|
+
|
64
|
+
class Session(BaseModel):
|
65
|
+
_changes: list[TreeStructureChange] = []
|
66
|
+
|
67
|
+
def add_change(self, new_parent: Optional[T], new_parent_field_name: Optional[str], subtree: T):
|
68
|
+
self._changes.append(
|
69
|
+
TreeStructureChange(new_parent=new_parent, new_parent_field_name=new_parent_field_name, subtree=subtree)
|
70
|
+
)
|
71
|
+
|
72
|
+
def get_subtree_changes_by_parent_id(
|
73
|
+
self, parent_id: str, subtree_id: str, field_name: str
|
74
|
+
) -> Optional[TreeStructureChange]:
|
75
|
+
sorted_changes = sorted(self._changes, key=lambda change: change.timestamp, reverse=True)
|
76
|
+
return next(
|
77
|
+
filter(
|
78
|
+
lambda change: change.new_parent.id == parent_id
|
79
|
+
and change.subtree.id == subtree_id
|
80
|
+
and change.new_parent_field_name == field_name,
|
81
|
+
sorted_changes,
|
82
|
+
),
|
83
|
+
None,
|
84
|
+
)
|
85
|
+
|
86
|
+
def get_last_change_for(self, subtree_id):
|
87
|
+
sorted_changes = sorted(self._changes, key=lambda change: change.timestamp, reverse=True)
|
88
|
+
return next(filter(lambda change: change.subtree.id == subtree_id, sorted_changes), None)
|
89
|
+
|
90
|
+
def reset(self):
|
91
|
+
self._changes = []
|
92
|
+
|
93
|
+
|
56
94
|
class TrackingMixin:
|
57
95
|
_original_hash: int = Field(exclude=True)
|
58
96
|
|
@@ -164,6 +202,10 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
164
202
|
_model_types_in_hierarchy: dict[str, Type[DatabaseModel]] = {}
|
165
203
|
_should_delete: bool = False
|
166
204
|
_parent_changed: bool = False
|
205
|
+
_session = Session()
|
206
|
+
|
207
|
+
def __eq__(self, other):
|
208
|
+
return self.id == other.id
|
167
209
|
|
168
210
|
def is_parent_changed(self):
|
169
211
|
"""
|
@@ -182,6 +224,17 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
182
224
|
def type(cls) -> str:
|
183
225
|
return cls.__name__
|
184
226
|
|
227
|
+
@property
|
228
|
+
def should_track_session(self) -> bool:
|
229
|
+
"""
|
230
|
+
If set to True, subtree movements across the database model will be tracked in a session.
|
231
|
+
Use this if you need to move a subtree across multiple parent-child relationships within a single session.
|
232
|
+
Session is reset after each save.
|
233
|
+
"""
|
234
|
+
if self._parent is not None:
|
235
|
+
return self._parent.should_track_session
|
236
|
+
return False
|
237
|
+
|
185
238
|
@classmethod
|
186
239
|
def index_definitions(cls) -> dict[str, IndexFieldConfig]:
|
187
240
|
return {"main_index": IndexFieldConfig(pk_fields=[], sk_fields=[])}
|
@@ -296,6 +349,61 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
296
349
|
def change_parent_to(self, new_parent: DatabaseModel) -> T:
|
297
350
|
return self._table.reparent_subtree(self, new_parent)
|
298
351
|
|
352
|
+
def _remove_from_parent(self, parent, field_name, subtree):
|
353
|
+
is_optional, inner_type = inspect_optional_field(parent.__class__, field_name)
|
354
|
+
field_type = inner_type if is_optional else parent.model_fields[field_name].annotation
|
355
|
+
field = getattr(self, field_name)
|
356
|
+
if hasattr(field_type, "__origin__") and field_type.__origin__ == list:
|
357
|
+
if not isinstance(field, list):
|
358
|
+
setattr(self, field_name, [])
|
359
|
+
field.remove(next(filter(lambda item: item.id == subtree.id, getattr(parent, field_name)), None))
|
360
|
+
|
361
|
+
elif hasattr(field_type, "__origin__") and field_type.__origin__ == set:
|
362
|
+
if not isinstance(field, set):
|
363
|
+
setattr(self, field_name, set())
|
364
|
+
field.remove(next(filter(lambda item: item.id == subtree.id, getattr(parent, field_name)), None))
|
365
|
+
|
366
|
+
elif issubclass(field_type, DatabaseModel):
|
367
|
+
setattr(parent, field_name, None)
|
368
|
+
|
369
|
+
def add_child_node(self, field_name: str, child_node: DatabaseModel):
|
370
|
+
if not child_node.is_nested():
|
371
|
+
raise ValueError("Child node must be nested.")
|
372
|
+
|
373
|
+
if not hasattr(self, field_name):
|
374
|
+
raise ValueError(f"Field {field_name} does not exist on {self.__class__.__name__}")
|
375
|
+
|
376
|
+
if self.should_track_session:
|
377
|
+
previous_change = self._session.get_subtree_changes_by_parent_id(self.id, child_node.id, field_name)
|
378
|
+
if previous_change:
|
379
|
+
self._remove_from_parent(previous_change.new_parent, previous_change.new_parent_field_name, child_node)
|
380
|
+
|
381
|
+
is_optional, inner_type = inspect_optional_field(self.__class__, field_name)
|
382
|
+
field_type = inner_type if is_optional else self.model_fields[field_name].annotation
|
383
|
+
reparented = None
|
384
|
+
if hasattr(field_type, "__origin__") and field_type.__origin__ == list:
|
385
|
+
if not isinstance(getattr(self, field_name), list):
|
386
|
+
setattr(self, field_name, [])
|
387
|
+
reparented = child_node.change_parent_to(self)
|
388
|
+
getattr(self, field_name).append(reparented)
|
389
|
+
|
390
|
+
elif hasattr(field_type, "__origin__") and field_type.__origin__ == set:
|
391
|
+
if not isinstance(getattr(self, field_name), set):
|
392
|
+
setattr(self, field_name, set())
|
393
|
+
reparented = child_node.change_parent_to(self)
|
394
|
+
getattr(self, field_name).add(reparented)
|
395
|
+
|
396
|
+
elif issubclass(field_type, DatabaseModel):
|
397
|
+
reparented = child_node.change_parent_to(self)
|
398
|
+
setattr(self, field_name, reparented)
|
399
|
+
|
400
|
+
if reparented:
|
401
|
+
if self.should_track_session:
|
402
|
+
self._session.add_change(self, field_name, reparented)
|
403
|
+
return reparented
|
404
|
+
|
405
|
+
raise ValueError(f"Unsupported field type: {field_type}")
|
406
|
+
|
299
407
|
@classmethod
|
300
408
|
def scan(
|
301
409
|
cls,
|
@@ -340,9 +448,7 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
340
448
|
if self not in items:
|
341
449
|
items.append(self)
|
342
450
|
|
343
|
-
# Iterate through all fields of the model
|
344
451
|
for field_name, field_value in self:
|
345
|
-
# Skip fields that start with underscore (private fields)
|
346
452
|
if field_name.startswith("_"):
|
347
453
|
continue
|
348
454
|
|
@@ -352,7 +458,6 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
352
458
|
items.append(field_value)
|
353
459
|
field_value.split_to_simple_objects(items)
|
354
460
|
|
355
|
-
# Handle lists containing DatabaseModel instances
|
356
461
|
elif isinstance(field_value, list):
|
357
462
|
for item in field_value:
|
358
463
|
if hasattr(item, "__class__") and issubclass(item.__class__, DatabaseModel):
|
@@ -360,7 +465,6 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
360
465
|
items.append(item)
|
361
466
|
item.split_to_simple_objects(items)
|
362
467
|
|
363
|
-
# Handle sets containing DatabaseModel instances
|
364
468
|
elif isinstance(field_value, set):
|
365
469
|
for item in field_value:
|
366
470
|
if hasattr(item, "__class__") and issubclass(item.__class__, DatabaseModel):
|
@@ -368,15 +472,6 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
368
472
|
items.append(item)
|
369
473
|
item.split_to_simple_objects(items)
|
370
474
|
|
371
|
-
# Handle dictionaries that may contain DatabaseModel instances
|
372
|
-
elif isinstance(field_value, dict):
|
373
|
-
# Check dictionary values
|
374
|
-
for value in field_value.values():
|
375
|
-
if hasattr(value, "__class__") and issubclass(value.__class__, DatabaseModel):
|
376
|
-
if value not in items:
|
377
|
-
items.append(value)
|
378
|
-
value.split_to_simple_objects(items)
|
379
|
-
|
380
475
|
return items
|
381
476
|
|
382
477
|
def get_attribute(self, attribute_name: str):
|
@@ -407,11 +502,18 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
407
502
|
return self._model_types_in_hierarchy.get(name)
|
408
503
|
|
409
504
|
def _set_parent_to_field(
|
410
|
-
self,
|
505
|
+
self,
|
506
|
+
field: DatabaseModel,
|
507
|
+
field_name: str,
|
508
|
+
parent: DatabaseModel,
|
509
|
+
root: DatabaseModel,
|
510
|
+
force_override: bool = False,
|
411
511
|
):
|
412
512
|
if field._parent and not force_override:
|
413
513
|
return # Already set
|
414
514
|
field._parent = parent
|
515
|
+
if field.should_track_session:
|
516
|
+
field._session.add_change(parent, field_name, field)
|
415
517
|
root._model_types_in_hierarchy[field.type()] = type(field)
|
416
518
|
field.set_parent_references(root, force_override)
|
417
519
|
field.init_tracking()
|
@@ -421,7 +523,7 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
|
|
421
523
|
Sets parent references for all DatabaseModel objects in the hierarchy.
|
422
524
|
"""
|
423
525
|
for parent, field_name, model in self.traverse_hierarchy():
|
424
|
-
self._set_parent_to_field(model, parent, root, force_override)
|
526
|
+
self._set_parent_to_field(model, field_name, parent, root, force_override)
|
425
527
|
|
426
528
|
def traverse_hierarchy(self):
|
427
529
|
"""
|
statikk/typing.py
CHANGED
@@ -1,3 +1,24 @@
|
|
1
|
-
from typing import TypeVar
|
1
|
+
from typing import TypeVar, get_origin, Union, get_args
|
2
2
|
|
3
3
|
T = TypeVar("T", bound="DatabaseModel")
|
4
|
+
|
5
|
+
|
6
|
+
def inspect_optional_field(model_class, field_name):
|
7
|
+
field_type = model_class.model_fields[field_name].annotation
|
8
|
+
|
9
|
+
is_optional = False
|
10
|
+
inner_type = field_type
|
11
|
+
|
12
|
+
if get_origin(field_type) is Union:
|
13
|
+
args = get_args(field_type)
|
14
|
+
if len(args) == 2 and args[1] is type(None):
|
15
|
+
is_optional = True
|
16
|
+
inner_type = args[0]
|
17
|
+
|
18
|
+
elif hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
|
19
|
+
args = getattr(field_type, "__args__", [])
|
20
|
+
if len(args) == 2 and args[1] is type(None):
|
21
|
+
is_optional = True
|
22
|
+
inner_type = args[0]
|
23
|
+
|
24
|
+
return (is_optional, inner_type)
|
@@ -0,0 +1,12 @@
|
|
1
|
+
statikk/__init__.py,sha256=pH5i4Fj1tbXLqLtTVIdoojiplZssQn0nnud8-HXodRE,577
|
2
|
+
statikk/conditions.py,sha256=63FYMR-UUaE-ZJEb_8CU721CQTwhajq39-BbokmKeMA,2166
|
3
|
+
statikk/engine.py,sha256=-YmezdiYViz1sR60VL7ryDCvrhp-WWGVCtPAakKRzg8,32636
|
4
|
+
statikk/expressions.py,sha256=boAeGxZj2cDsXxoiX3IIEzfX9voSMQngi4-rE_jYeuE,12233
|
5
|
+
statikk/fields.py,sha256=LkMP5NnX7WS0HSLxI3Q-dMOrfaJ0SD7SayZxJU5Acgg,86
|
6
|
+
statikk/models.py,sha256=zLImQUtUURbAQ3gYAiotkypxaDUqAIiCL1edNzuey3k,20447
|
7
|
+
statikk/typing.py,sha256=laOlOpWOm9_sOj4hhdZnGTUZRiq8760_B9I9B3wBhz8,750
|
8
|
+
statikk-0.1.14.dist-info/licenses/LICENSE.txt,sha256=uSH_2Hpb2Bigy5_HhBliN2fZbBU64G3ERM5zzhKPUEE,1078
|
9
|
+
statikk-0.1.14.dist-info/METADATA,sha256=8NHWnMMS3Qy53_I9V3EDWOY6x3wTzQ_juoYSPS9QeII,3183
|
10
|
+
statikk-0.1.14.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
|
11
|
+
statikk-0.1.14.dist-info/top_level.txt,sha256=etKmBbjzIlLpSefXoiOfhWGEgvqUEALaFwCjFDBD9YI,8
|
12
|
+
statikk-0.1.14.dist-info/RECORD,,
|
statikk-0.1.12.dist-info/RECORD
DELETED
@@ -1,12 +0,0 @@
|
|
1
|
-
statikk/__init__.py,sha256=pH5i4Fj1tbXLqLtTVIdoojiplZssQn0nnud8-HXodRE,577
|
2
|
-
statikk/conditions.py,sha256=63FYMR-UUaE-ZJEb_8CU721CQTwhajq39-BbokmKeMA,2166
|
3
|
-
statikk/engine.py,sha256=JusQPQdn-ubQLXrspYcbq6erwS0M8Su3a5RQ6vF9GY8,33346
|
4
|
-
statikk/expressions.py,sha256=boAeGxZj2cDsXxoiX3IIEzfX9voSMQngi4-rE_jYeuE,12233
|
5
|
-
statikk/fields.py,sha256=LkMP5NnX7WS0HSLxI3Q-dMOrfaJ0SD7SayZxJU5Acgg,86
|
6
|
-
statikk/models.py,sha256=REipaPzo5dIq4InXbWuP48aDZdImO5fXGkALenbf7Ng,16257
|
7
|
-
statikk/typing.py,sha256=qfpegORcdODuILK3gvuD4SdcZA1a7Myn0yvscOLPHOM,68
|
8
|
-
statikk-0.1.12.dist-info/licenses/LICENSE.txt,sha256=uSH_2Hpb2Bigy5_HhBliN2fZbBU64G3ERM5zzhKPUEE,1078
|
9
|
-
statikk-0.1.12.dist-info/METADATA,sha256=JbwrEpAyalVVKYRLzI8WFZu2FgCEErk7iQwcBAMHhAo,3183
|
10
|
-
statikk-0.1.12.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
|
11
|
-
statikk-0.1.12.dist-info/top_level.txt,sha256=etKmBbjzIlLpSefXoiOfhWGEgvqUEALaFwCjFDBD9YI,8
|
12
|
-
statikk-0.1.12.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|