statikk 0.1.12__tar.gz → 0.1.14__tar.gz

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.
Files changed (44) hide show
  1. {statikk-0.1.12 → statikk-0.1.14}/PKG-INFO +1 -1
  2. {statikk-0.1.12 → statikk-0.1.14}/src/statikk/engine.py +6 -24
  3. {statikk-0.1.12 → statikk-0.1.14}/src/statikk/models.py +118 -16
  4. statikk-0.1.14/src/statikk/typing.py +24 -0
  5. {statikk-0.1.12 → statikk-0.1.14}/src/statikk.egg-info/PKG-INFO +1 -1
  6. {statikk-0.1.12 → statikk-0.1.14}/tests/test_engine.py +89 -1
  7. {statikk-0.1.12 → statikk-0.1.14}/tests/test_models.py +2 -0
  8. statikk-0.1.12/src/statikk/typing.py +0 -3
  9. {statikk-0.1.12 → statikk-0.1.14}/.coveragerc +0 -0
  10. {statikk-0.1.12 → statikk-0.1.14}/.gitignore +0 -0
  11. {statikk-0.1.12 → statikk-0.1.14}/.readthedocs.yml +0 -0
  12. {statikk-0.1.12 → statikk-0.1.14}/AUTHORS.rst +0 -0
  13. {statikk-0.1.12 → statikk-0.1.14}/CHANGELOG.rst +0 -0
  14. {statikk-0.1.12 → statikk-0.1.14}/CONTRIBUTING.rst +0 -0
  15. {statikk-0.1.12 → statikk-0.1.14}/LICENSE.txt +0 -0
  16. {statikk-0.1.12 → statikk-0.1.14}/README.rst +0 -0
  17. {statikk-0.1.12 → statikk-0.1.14}/assets/favicon.png +0 -0
  18. {statikk-0.1.12 → statikk-0.1.14}/assets/logo.png +0 -0
  19. {statikk-0.1.12 → statikk-0.1.14}/docs/Makefile +0 -0
  20. {statikk-0.1.12 → statikk-0.1.14}/docs/_static/.gitignore +0 -0
  21. {statikk-0.1.12 → statikk-0.1.14}/docs/authors.rst +0 -0
  22. {statikk-0.1.12 → statikk-0.1.14}/docs/changelog.rst +0 -0
  23. {statikk-0.1.12 → statikk-0.1.14}/docs/conf.py +0 -0
  24. {statikk-0.1.12 → statikk-0.1.14}/docs/contributing.rst +0 -0
  25. {statikk-0.1.12 → statikk-0.1.14}/docs/index.rst +0 -0
  26. {statikk-0.1.12 → statikk-0.1.14}/docs/license.rst +0 -0
  27. {statikk-0.1.12 → statikk-0.1.14}/docs/readme.rst +0 -0
  28. {statikk-0.1.12 → statikk-0.1.14}/docs/requirements.txt +0 -0
  29. {statikk-0.1.12 → statikk-0.1.14}/docs/usage.rst +0 -0
  30. {statikk-0.1.12 → statikk-0.1.14}/pyproject.toml +0 -0
  31. {statikk-0.1.12 → statikk-0.1.14}/setup.cfg +0 -0
  32. {statikk-0.1.12 → statikk-0.1.14}/setup.py +0 -0
  33. {statikk-0.1.12 → statikk-0.1.14}/src/statikk/__init__.py +0 -0
  34. {statikk-0.1.12 → statikk-0.1.14}/src/statikk/conditions.py +0 -0
  35. {statikk-0.1.12 → statikk-0.1.14}/src/statikk/expressions.py +0 -0
  36. {statikk-0.1.12 → statikk-0.1.14}/src/statikk/fields.py +0 -0
  37. {statikk-0.1.12 → statikk-0.1.14}/src/statikk.egg-info/SOURCES.txt +0 -0
  38. {statikk-0.1.12 → statikk-0.1.14}/src/statikk.egg-info/dependency_links.txt +0 -0
  39. {statikk-0.1.12 → statikk-0.1.14}/src/statikk.egg-info/not-zip-safe +0 -0
  40. {statikk-0.1.12 → statikk-0.1.14}/src/statikk.egg-info/requires.txt +0 -0
  41. {statikk-0.1.12 → statikk-0.1.14}/src/statikk.egg-info/top_level.txt +0 -0
  42. {statikk-0.1.12 → statikk-0.1.14}/tests/conftest.py +0 -0
  43. {statikk-0.1.12 → statikk-0.1.14}/tests/test_expressions.py +0 -0
  44. {statikk-0.1.12 → statikk-0.1.14}/tox.ini +0 -0
@@ -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
@@ -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
 
@@ -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
  """
@@ -0,0 +1,24 @@
1
+ from typing import TypeVar, get_origin, Union, get_args
2
+
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
@@ -1,6 +1,6 @@
1
1
  from _decimal import Decimal
2
2
  from datetime import datetime, timezone
3
- from typing import List, Optional
3
+ from typing import List, Optional, Type
4
4
 
5
5
  import pytest
6
6
  from boto3.dynamodb.conditions import Attr
@@ -24,6 +24,23 @@ from statikk.models import (
24
24
  )
25
25
 
26
26
 
27
+ def _create_default_dynamodb_table(models: list[Type[DatabaseModel]]):
28
+ mock_dynamodb().start()
29
+ table = Table(
30
+ name="my-dynamodb-table",
31
+ key_schema=KeySchema(hash_key="id"),
32
+ indexes=[
33
+ GSI(
34
+ name="main-index",
35
+ hash_key=Key(name="gsi_pk"),
36
+ sort_key=Key(name="gsi_sk"),
37
+ )
38
+ ],
39
+ models=models,
40
+ )
41
+ _create_dynamodb_table(table)
42
+
43
+
27
44
  class MyAwesomeModel(DatabaseModel):
28
45
  player_id: str
29
46
  tier: str
@@ -980,3 +997,74 @@ def test_rebuild_model_indexes():
980
997
  my_database_model.build_model_indexes()
981
998
  assert my_database_model.gsi_pk == "foo"
982
999
  assert my_database_model.gsi_sk == "MyDatabaseModel|bar"
1000
+
1001
+
1002
+ def test_add_child_node():
1003
+ class MyOtherNestedDatabaseModel(DatabaseModel):
1004
+ baz: str
1005
+
1006
+ @classmethod
1007
+ def is_nested(cls) -> bool:
1008
+ return True
1009
+
1010
+ @classmethod
1011
+ def index_definitions(cls) -> dict[str, IndexFieldConfig]:
1012
+ return {"main-index": IndexFieldConfig(sk_fields=["baz"])}
1013
+
1014
+ __hash__ = object.__hash__
1015
+
1016
+ class MyNestedDatabaseModel(DatabaseModel):
1017
+ bar: str
1018
+ other_nested: Optional[MyOtherNestedDatabaseModel] = None
1019
+ list_nested: list[MyOtherNestedDatabaseModel] = []
1020
+ set_nested: set[MyOtherNestedDatabaseModel] = {}
1021
+
1022
+ @classmethod
1023
+ def is_nested(cls) -> bool:
1024
+ return True
1025
+
1026
+ @classmethod
1027
+ def index_definitions(cls) -> dict[str, IndexFieldConfig]:
1028
+ return {"main-index": IndexFieldConfig(sk_fields=["bar"])}
1029
+
1030
+ class MyDatabaseModel(DatabaseModel):
1031
+ foo: str
1032
+ nested: MyNestedDatabaseModel
1033
+
1034
+ @classmethod
1035
+ def index_definitions(cls) -> dict[str, IndexFieldConfig]:
1036
+ return {"main-index": IndexFieldConfig(pk_fields=["foo"], sk_fields=[FIELD_STATIKK_TYPE])}
1037
+
1038
+ @property
1039
+ def should_track_session(self) -> bool:
1040
+ return True
1041
+
1042
+ _create_default_dynamodb_table([MyDatabaseModel, MyNestedDatabaseModel, MyOtherNestedDatabaseModel])
1043
+ my_database_model = MyDatabaseModel(foo="foo", nested=MyNestedDatabaseModel(bar="bar"))
1044
+ my_database_model.build_model_indexes()
1045
+ my_database_model.nested.add_child_node("other_nested", MyOtherNestedDatabaseModel(baz="baz"))
1046
+ my_database_model.nested.add_child_node("list_nested", MyOtherNestedDatabaseModel(baz="bazz"))
1047
+ my_database_model.nested.add_child_node("set_nested", MyOtherNestedDatabaseModel(baz="bazzz"))
1048
+ assert my_database_model.nested.other_nested.baz == "baz"
1049
+ assert my_database_model.nested.list_nested[0].baz == "bazz"
1050
+ set_nested_item = my_database_model.nested.set_nested.pop()
1051
+ assert set_nested_item.baz == "bazzz"
1052
+ assert set_nested_item._parent == my_database_model.nested
1053
+ assert set_nested_item.gsi_pk == set_nested_item._parent.gsi_pk
1054
+ assert set_nested_item.gsi_sk == "MyDatabaseModel|MyNestedDatabaseModel|bar|MyOtherNestedDatabaseModel|bazzz"
1055
+ assert my_database_model.nested.other_nested._parent == my_database_model.nested
1056
+ assert my_database_model.nested.list_nested[0]._parent == my_database_model.nested
1057
+ my_database_model.nested.add_child_node("set_nested", my_database_model.nested.list_nested[0])
1058
+ assert my_database_model.nested.list_nested[0]._parent_changed is True
1059
+ set_nested_new = my_database_model.nested.set_nested.pop()
1060
+ assert set_nested_new._parent_changed is False
1061
+ assert set_nested_new.gsi_sk == "MyDatabaseModel|MyNestedDatabaseModel|bar|MyOtherNestedDatabaseModel|bazz"
1062
+ my_database_model.nested.add_child_node("list_nested", set_nested_new)
1063
+ assert my_database_model.nested.list_nested[0]._parent_changed is False
1064
+ assert my_database_model.nested.list_nested[0]._parent == my_database_model.nested
1065
+ assert my_database_model.nested.list_nested[0].gsi_pk == "foo"
1066
+ assert (
1067
+ my_database_model.nested.list_nested[0].gsi_sk
1068
+ == "MyDatabaseModel|MyNestedDatabaseModel|bar|MyOtherNestedDatabaseModel|bazz"
1069
+ )
1070
+ assert set_nested_new._parent_changed is True
@@ -1,3 +1,5 @@
1
+ from typing import Optional
2
+
1
3
  from pydantic import BaseModel
2
4
  from statikk.models import DatabaseModel, IndexFieldConfig
3
5
 
@@ -1,3 +0,0 @@
1
- from typing import TypeVar
2
-
3
- T = TypeVar("T", bound="DatabaseModel")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes