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 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, get_type_hints, get_origin, get_args
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, Key
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 = self.inspect_optional_field(parent_model_class, field_name)
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, field: DatabaseModel, parent: DatabaseModel, root: DatabaseModel, force_override: bool = False
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statikk
3
- Version: 0.1.12
3
+ Version: 0.1.14
4
4
  Summary: statikk is a single table application (STA) library for DynamoDb.
5
5
  Home-page: https://github.com/terinia/statikk
6
6
  Author: Balint Biro
@@ -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,,
@@ -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,,