statikk 0.0.9__tar.gz → 0.0.12__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 (41) hide show
  1. {statikk-0.0.9 → statikk-0.0.12}/PKG-INFO +4 -3
  2. {statikk-0.0.9 → statikk-0.0.12}/setup.cfg +3 -2
  3. {statikk-0.0.9 → statikk-0.0.12}/src/statikk/engine.py +57 -20
  4. {statikk-0.0.9 → statikk-0.0.12}/src/statikk/models.py +7 -3
  5. {statikk-0.0.9 → statikk-0.0.12}/src/statikk.egg-info/PKG-INFO +4 -3
  6. {statikk-0.0.9 → statikk-0.0.12}/src/statikk.egg-info/requires.txt +3 -2
  7. {statikk-0.0.9 → statikk-0.0.12}/tests/test_engine.py +80 -4
  8. {statikk-0.0.9 → statikk-0.0.12}/.coveragerc +0 -0
  9. {statikk-0.0.9 → statikk-0.0.12}/.gitignore +0 -0
  10. {statikk-0.0.9 → statikk-0.0.12}/.readthedocs.yml +0 -0
  11. {statikk-0.0.9 → statikk-0.0.12}/AUTHORS.rst +0 -0
  12. {statikk-0.0.9 → statikk-0.0.12}/CHANGELOG.rst +0 -0
  13. {statikk-0.0.9 → statikk-0.0.12}/CONTRIBUTING.rst +0 -0
  14. {statikk-0.0.9 → statikk-0.0.12}/LICENSE.txt +0 -0
  15. {statikk-0.0.9 → statikk-0.0.12}/README.rst +0 -0
  16. {statikk-0.0.9 → statikk-0.0.12}/assets/favicon.png +0 -0
  17. {statikk-0.0.9 → statikk-0.0.12}/assets/logo.png +0 -0
  18. {statikk-0.0.9 → statikk-0.0.12}/docs/Makefile +0 -0
  19. {statikk-0.0.9 → statikk-0.0.12}/docs/_static/.gitignore +0 -0
  20. {statikk-0.0.9 → statikk-0.0.12}/docs/authors.rst +0 -0
  21. {statikk-0.0.9 → statikk-0.0.12}/docs/changelog.rst +0 -0
  22. {statikk-0.0.9 → statikk-0.0.12}/docs/conf.py +0 -0
  23. {statikk-0.0.9 → statikk-0.0.12}/docs/contributing.rst +0 -0
  24. {statikk-0.0.9 → statikk-0.0.12}/docs/index.rst +0 -0
  25. {statikk-0.0.9 → statikk-0.0.12}/docs/license.rst +0 -0
  26. {statikk-0.0.9 → statikk-0.0.12}/docs/readme.rst +0 -0
  27. {statikk-0.0.9 → statikk-0.0.12}/docs/requirements.txt +0 -0
  28. {statikk-0.0.9 → statikk-0.0.12}/docs/usage.rst +0 -0
  29. {statikk-0.0.9 → statikk-0.0.12}/pyproject.toml +0 -0
  30. {statikk-0.0.9 → statikk-0.0.12}/setup.py +0 -0
  31. {statikk-0.0.9 → statikk-0.0.12}/src/statikk/__init__.py +0 -0
  32. {statikk-0.0.9 → statikk-0.0.12}/src/statikk/conditions.py +0 -0
  33. {statikk-0.0.9 → statikk-0.0.12}/src/statikk/expressions.py +0 -0
  34. {statikk-0.0.9 → statikk-0.0.12}/src/statikk.egg-info/SOURCES.txt +0 -0
  35. {statikk-0.0.9 → statikk-0.0.12}/src/statikk.egg-info/dependency_links.txt +0 -0
  36. {statikk-0.0.9 → statikk-0.0.12}/src/statikk.egg-info/not-zip-safe +0 -0
  37. {statikk-0.0.9 → statikk-0.0.12}/src/statikk.egg-info/top_level.txt +0 -0
  38. {statikk-0.0.9 → statikk-0.0.12}/tests/conftest.py +0 -0
  39. {statikk-0.0.9 → statikk-0.0.12}/tests/test_expressions.py +0 -0
  40. {statikk-0.0.9 → statikk-0.0.12}/tests/test_models.py +0 -0
  41. {statikk-0.0.9 → statikk-0.0.12}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: statikk
3
- Version: 0.0.9
3
+ Version: 0.0.12
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
@@ -14,13 +14,14 @@ Requires-Python: >=3.8
14
14
  Description-Content-Type: text/x-rst; charset=UTF-8
15
15
  License-File: LICENSE.txt
16
16
  Requires-Dist: importlib-metadata; python_version < "3.8"
17
- Requires-Dist: pydantic==2.3.0
17
+ Requires-Dist: pydantic==2.7.4
18
18
  Requires-Dist: boto3==1.28.43
19
+ Requires-Dist: aws-xray-sdk==2.14.0
19
20
  Provides-Extra: testing
20
21
  Requires-Dist: setuptools; extra == "testing"
21
22
  Requires-Dist: pytest; extra == "testing"
22
23
  Requires-Dist: pytest-cov; extra == "testing"
23
- Requires-Dist: moto[dynamodb]; extra == "testing"
24
+ Requires-Dist: moto[dynamodb]==4.2.14; extra == "testing"
24
25
 
25
26
  .. image:: ./assets/logo.png
26
27
  :alt: Statikk
@@ -24,8 +24,9 @@ package_dir =
24
24
  python_requires = >=3.8
25
25
  install_requires =
26
26
  importlib-metadata; python_version<"3.8"
27
- pydantic==2.3.0
27
+ pydantic==2.7.4
28
28
  boto3==1.28.43
29
+ aws-xray-sdk==2.14.0
29
30
 
30
31
  [options.packages.find]
31
32
  where = src
@@ -37,7 +38,7 @@ testing =
37
38
  setuptools
38
39
  pytest
39
40
  pytest-cov
40
- moto[dynamodb]
41
+ moto[dynamodb]==4.2.14
41
42
 
42
43
  [options.entry_points]
43
44
 
@@ -6,7 +6,7 @@ import boto3
6
6
  from botocore.config import Config
7
7
  from pydantic.fields import FieldInfo
8
8
  from boto3.dynamodb.conditions import ComparisonCondition, Key
9
- from boto3.dynamodb.types import TypeDeserializer
9
+ from boto3.dynamodb.types import TypeDeserializer, Decimal
10
10
 
11
11
  from statikk.conditions import Condition, Equals, BeginsWith
12
12
  from statikk.expressions import UpdateExpressionBuilder
@@ -19,6 +19,10 @@ from statikk.models import (
19
19
  KeySchema,
20
20
  )
21
21
 
22
+ from aws_xray_sdk.core import xray_recorder
23
+ from aws_xray_sdk.core import patch_all
24
+
25
+ patch_all()
22
26
 
23
27
  class InvalidIndexNameError(Exception):
24
28
  pass
@@ -54,19 +58,30 @@ class Table:
54
58
  model.set_table_ref(self)
55
59
  if "type" not in model.model_fields:
56
60
  model.model_fields["type"] = FieldInfo(annotation=str, default=model.model_type(), required=False)
61
+ self._client = None
62
+ self._dynamodb_table = None
57
63
 
58
64
  def _dynamodb_client(self):
59
- return boto3.client(
65
+ if self._client:
66
+ return self._client
67
+
68
+ self._client = boto3.client(
60
69
  "dynamodb",
61
70
  config=Config(region_name=os.environ.get("AWS_DEFAULT_REGION", "eu-west-1")),
62
71
  )
63
72
 
73
+ return self._client
74
+
64
75
  def _get_dynamodb_table(self):
76
+ if self._dynamodb_table:
77
+ return self._dynamodb_table
78
+
65
79
  dynamodb = boto3.resource(
66
80
  "dynamodb",
67
81
  config=Config(region_name=os.environ.get("AWS_DEFAULT_REGION", "eu-west-1")),
68
82
  )
69
- return dynamodb.Table(self.name)
83
+ self._dynamodb_table = dynamodb.Table(self.name)
84
+ return self._dynamodb_table
70
85
 
71
86
  def _to_dynamodb_type(self, type: Any):
72
87
  if type is str:
@@ -149,15 +164,13 @@ class Table:
149
164
  """Deletes the DynamoDB table."""
150
165
  self._dynamodb_client().delete_table(TableName=self.name)
151
166
 
152
- def delete_item(self, id: str):
153
- """
154
- Deletes an item from the database by id, using the partition key of the table.
155
- :param id: The id of the item to delete.
156
- """
157
- key = {self.key_schema.hash_key: id}
158
- self._get_dynamodb_table().delete_item(Key=key)
159
-
160
- def get_item(self, id: str, model_class: Type[DatabaseModel], sort_key: Optional[Any] = None):
167
+ def get_item(
168
+ self,
169
+ id: str,
170
+ model_class: Type[DatabaseModel],
171
+ sort_key: Optional[Any] = None,
172
+ consistent_read: bool = False,
173
+ ):
161
174
  """
162
175
  Returns an item from the database by id, using the partition key of the table.
163
176
  :param id: The id of the item to retrieve.
@@ -167,15 +180,22 @@ class Table:
167
180
  key = {self.key_schema.hash_key: id}
168
181
  if sort_key:
169
182
  key[self.key_schema.sort_key] = self._serialize_value(sort_key)
170
- raw_data = self._get_dynamodb_table().get_item(Key=key)
183
+ raw_data = self._get_dynamodb_table().get_item(Key=key, ConsistentRead=consistent_read)
171
184
  if "Item" not in raw_data:
172
185
  raise ItemNotFoundError(f"{model_class} with id '{id}' not found.")
173
186
  data = raw_data["Item"]
174
- del data["type"]
175
187
  for key, value in data.items():
176
188
  data[key] = self._deserialize_value(value, model_class.model_fields[key])
177
189
  return model_class(**data)
178
190
 
191
+ def delete_item(self, id: str):
192
+ """
193
+ Deletes an item from the database by id, using the partition key of the table.
194
+ :param id: The id of the item to delete.
195
+ """
196
+ key = {self.key_schema.hash_key: id}
197
+ self._get_dynamodb_table().delete_item(Key=key)
198
+
179
199
  def put_item(self, model: DatabaseModel) -> DatabaseModel:
180
200
  """
181
201
  Puts an item into the database.
@@ -207,7 +227,6 @@ class Table:
207
227
  """
208
228
  data = self._serialize_item(model)
209
229
  self._get_dynamodb_table().put_item(Item=data)
210
- del data["type"]
211
230
  for key, value in data.items():
212
231
  data[key] = self._deserialize_value(value, model.model_fields[key])
213
232
  return type(model)(**data)
@@ -231,6 +250,7 @@ class Table:
231
250
  def _find_changed_indexes():
232
251
  changed_index_values = set()
233
252
  for prefixed_attribute, value in expression_attribute_values.items():
253
+ expression_attribute_values[prefixed_attribute] = self._serialize_value(value)
234
254
  attribute = prefixed_attribute.replace(":", "")
235
255
  if model.model_fields[attribute].annotation is IndexSecondaryKeyField:
236
256
  idx_field = getattr(model, attribute)
@@ -313,13 +333,14 @@ class Table:
313
333
 
314
334
  while last_evaluated_key:
315
335
  items = self._get_dynamodb_table().query(**query_params)
316
- yield from [model_class(**item) for item in items["Items"]]
336
+ yield from [self._deserialize_item(item, model_class=model_class) for item in items["Items"]]
317
337
  last_evaluated_key = items.get("LastEvaluatedKey", False)
318
338
 
319
339
  def scan(
320
340
  self,
321
341
  model_class: Type[DatabaseModel],
322
342
  filter_condition: Optional[ComparisonCondition] = None,
343
+ consistent_read: bool = False,
323
344
  ):
324
345
  """
325
346
  Scans the database for items matching the provided filter condition. The method returns a list of items matching
@@ -328,7 +349,9 @@ class Table:
328
349
  :param model_class: The model class to use to deserialize the items.
329
350
  :param filter_condition: An optional filter condition to use for the query. See boto3.dynamodb.conditions.ComparisonCondition for more information.
330
351
  """
331
- query_params = {}
352
+ query_params = {
353
+ "ConsistentRead": consistent_read,
354
+ }
332
355
  if filter_condition:
333
356
  query_params["FilterExpression"] = filter_condition
334
357
  last_evaluated_key = True
@@ -366,7 +389,7 @@ class Table:
366
389
  else:
367
390
  results.extend(
368
391
  [
369
- model_class(**self._convert_dynamodb_to_python(item))
392
+ self._deserialize_item(self._convert_dynamodb_to_python(item), model_class)
370
393
  for item in response["Responses"][self.name]
371
394
  ]
372
395
  )
@@ -394,17 +417,29 @@ class Table:
394
417
  data = self._prepare_model_data(item, self.indexes)
395
418
  for key, value in data.items():
396
419
  data[key] = self._serialize_value(value)
397
- data["type"] = item.model_type()
398
420
  return data
399
421
 
422
+ def _deserialize_item(self, item: Dict[str, Any], model_class: Type[DatabaseModel]):
423
+ for key, value in item.items():
424
+ item[key] = self._deserialize_value(value, model_class.model_fields[key])
425
+ return model_class(**item)
426
+
400
427
  def _deserialize_value(self, value: Any, annotation: Any):
401
- if annotation is datetime or "datetime" in str(annotation):
428
+ if annotation is datetime or "datetime" in str(annotation) and value is not None:
402
429
  return datetime.fromtimestamp(int(value))
430
+ if annotation is float:
431
+ return float(value)
403
432
  return value
404
433
 
405
434
  def _serialize_value(self, value: Any):
406
435
  if isinstance(value, datetime):
407
436
  return int(value.timestamp())
437
+ if isinstance(value, float):
438
+ return Decimal(value)
439
+ if isinstance(value, list):
440
+ return [self._serialize_value(item) for item in value]
441
+ if isinstance(value, dict):
442
+ return {key: self._serialize_value(item) for key, item in value.items() if item is not None}
408
443
  return value
409
444
 
410
445
  def _set_index_fields(self, model: DatabaseModel | Type[DatabaseModel], idx: GSI):
@@ -422,6 +457,8 @@ class Table:
422
457
  if field_info.annotation is IndexSecondaryKeyField and idx.name in getattr(model, field_name).index_names
423
458
  ]
424
459
 
460
+ if len(sort_key_fields_unordered) == idx.sort_key is not None:
461
+ raise IncorrectSortKeyError(f"Model {model.__class__} does not have a sort key defined.")
425
462
  if sort_key_fields_unordered[0][1] is not None:
426
463
  sort_key_fields_unordered.sort(key=lambda x: x[1])
427
464
 
@@ -111,15 +111,19 @@ class DatabaseModel(BaseModel):
111
111
  )
112
112
 
113
113
  @classmethod
114
- def get(cls, id: str, sort_key: Optional[str] = None):
115
- return cls._table.get_item(id=id, model_class=cls, sort_key=sort_key)
114
+ def get(cls, id: str, sort_key: Optional[str] = None, consistent_read: bool = False):
115
+ return cls._table.get_item(id=id, model_class=cls, sort_key=sort_key, consistent_read=consistent_read)
116
116
 
117
117
  @classmethod
118
118
  def batch_get(cls, ids: List[str], batch_size: int = 100):
119
119
  return cls._table.batch_get_items(ids=ids, model_class=cls, batch_size=batch_size)
120
120
 
121
121
  @classmethod
122
- def scan(cls, filter_condition: Optional[ComparisonCondition] = None):
122
+ def scan(
123
+ cls,
124
+ filter_condition: Optional[ComparisonCondition] = None,
125
+ consistent_read: bool = False,
126
+ ):
123
127
  return cls._table.scan(model_class=cls, filter_condition=filter_condition)
124
128
 
125
129
  @staticmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: statikk
3
- Version: 0.0.9
3
+ Version: 0.0.12
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
@@ -14,13 +14,14 @@ Requires-Python: >=3.8
14
14
  Description-Content-Type: text/x-rst; charset=UTF-8
15
15
  License-File: LICENSE.txt
16
16
  Requires-Dist: importlib-metadata; python_version < "3.8"
17
- Requires-Dist: pydantic==2.3.0
17
+ Requires-Dist: pydantic==2.7.4
18
18
  Requires-Dist: boto3==1.28.43
19
+ Requires-Dist: aws-xray-sdk==2.14.0
19
20
  Provides-Extra: testing
20
21
  Requires-Dist: setuptools; extra == "testing"
21
22
  Requires-Dist: pytest; extra == "testing"
22
23
  Requires-Dist: pytest-cov; extra == "testing"
23
- Requires-Dist: moto[dynamodb]; extra == "testing"
24
+ Requires-Dist: moto[dynamodb]==4.2.14; extra == "testing"
24
25
 
25
26
  .. image:: ./assets/logo.png
26
27
  :alt: Statikk
@@ -1,5 +1,6 @@
1
- pydantic==2.3.0
1
+ pydantic==2.7.4
2
2
  boto3==1.28.43
3
+ aws-xray-sdk==2.14.0
3
4
 
4
5
  [:python_version < "3.8"]
5
6
  importlib-metadata
@@ -8,4 +9,4 @@ importlib-metadata
8
9
  setuptools
9
10
  pytest
10
11
  pytest-cov
11
- moto[dynamodb]
12
+ moto[dynamodb]==4.2.14
@@ -1,6 +1,7 @@
1
+ from _decimal import Decimal
1
2
  from datetime import datetime, timezone
2
- from typing import List
3
-
3
+ from typing import List, Optional
4
+ from pydantic import BaseModel
4
5
  import pytest
5
6
  from boto3.dynamodb.conditions import Attr
6
7
  from moto import mock_dynamodb
@@ -28,7 +29,8 @@ class MyAwesomeModel(DatabaseModel):
28
29
  name: str = "Foo"
29
30
  values: set = {1, 2, 3, 4}
30
31
  cost: int = 4
31
-
32
+ probability: float = 0.5
33
+ created_at: Optional[datetime] = None
32
34
 
33
35
  class SimpleModel(DatabaseModel):
34
36
  player_id: IndexPrimaryKeyField
@@ -89,6 +91,8 @@ def test_create_my_awesome_model():
89
91
  "values": {1, 2, 3, 4},
90
92
  "cost": 4,
91
93
  "type": "MyAwesomeModel",
94
+ "probability": 0.5,
95
+ "created_at": None
92
96
  }
93
97
  model_2 = MyAwesomeModel(id="foo-2", player_id="123", tier="EPIC", name="FooFoo")
94
98
  table.put_item(model_2)
@@ -102,6 +106,8 @@ def test_create_my_awesome_model():
102
106
  "values": {1, 2, 3, 4},
103
107
  "cost": 4,
104
108
  "type": "MyAwesomeModel",
109
+ "probability": 0.5,
110
+ "created_at": None
105
111
  }
106
112
  mock_dynamodb().stop()
107
113
 
@@ -340,7 +346,7 @@ def test_batch_get_items():
340
346
  models=[MyAwesomeModel],
341
347
  )
342
348
  _create_dynamodb_table(table)
343
- model = MyAwesomeModel(id="foo", player_id="123", tier="LEGENDARY")
349
+ model = MyAwesomeModel(id="foo", player_id="123", tier="LEGENDARY", created_at=datetime(2024, 7, 9))
344
350
  model_2 = MyAwesomeModel(id="foo-2", player_id="123", tier="LEGENDARY")
345
351
  table.put_item(model)
346
352
  table.put_item(model_2)
@@ -349,9 +355,11 @@ def test_batch_get_items():
349
355
  assert models[0].id == model.id
350
356
  assert models[0].model_type == model.model_type
351
357
  assert models[0].tier == model.tier
358
+ assert models[0].created_at == datetime(2024, 7, 9)
352
359
  assert models[1].id == model_2.id
353
360
  assert models[1].model_type == model_2.model_type
354
361
  assert models[1].tier == model_2.tier
362
+ assert models[1].created_at is None
355
363
  mock_dynamodb().stop()
356
364
 
357
365
 
@@ -744,3 +752,71 @@ def test_index_field_order_is_respected():
744
752
  model.save()
745
753
  item = table.get_item("123", ModelWithIndexOrdersDefined)
746
754
  assert item.gsi_sk == "ModelWithIndexOrdersDefined|EPIC|Mage"
755
+
756
+
757
+ def test_nested_models():
758
+ class InnerInnerModel(BaseModel):
759
+ baz: str
760
+
761
+ class InnerModel(BaseModel):
762
+ foo: str
763
+ values: List[datetime] = [
764
+ datetime(2023, 9, 9, 12, 0, 0),
765
+ datetime(2023, 9, 9, 13, 0, 0),
766
+ ]
767
+ cost: int = 5
768
+ inner_inner: InnerInnerModel
769
+
770
+ class NestedModel(DatabaseModel):
771
+ player_id: IndexPrimaryKeyField
772
+ unit_class: IndexSecondaryKeyField = IndexSecondaryKeyField(order=2)
773
+ tier: IndexSecondaryKeyField = IndexSecondaryKeyField(order=1)
774
+ name: str = "Foo"
775
+ values: set = {1, 2, 3, 4}
776
+ cost: int = 4
777
+ inner_model: InnerModel
778
+
779
+ mock_dynamodb().start()
780
+ table = Table(
781
+ name="my-dynamodb-table",
782
+ key_schema=KeySchema(hash_key="id"),
783
+ indexes=[
784
+ GSI(
785
+ name="main-index",
786
+ hash_key=Key(name="gsi_pk"),
787
+ sort_key=Key(name="gsi_sk"),
788
+ )
789
+ ],
790
+ models=[NestedModel],
791
+ )
792
+ _create_dynamodb_table(table)
793
+ model = NestedModel(
794
+ id="123",
795
+ player_id="456",
796
+ unit_class="Mage",
797
+ tier="EPIC",
798
+ inner_model=InnerModel(foo="bar", inner_inner=InnerInnerModel(baz="baz")),
799
+ )
800
+ model.save()
801
+ item = table.get_item("123", NestedModel)
802
+ assert item.model_dump() == {
803
+ "id": "123",
804
+ "player_id": "456",
805
+ "unit_class": "Mage",
806
+ "tier": "EPIC",
807
+ "name": "Foo",
808
+ "values": {Decimal("1"), Decimal("2"), Decimal("3"), Decimal("4")},
809
+ "cost": 4,
810
+ "inner_model": {
811
+ "foo": "bar",
812
+ "values": [
813
+ datetime(2023, 9, 9, 10, 0, tzinfo=timezone.utc),
814
+ datetime(2023, 9, 9, 11, 0, tzinfo=timezone.utc),
815
+ ],
816
+ "cost": 5,
817
+ "inner_inner": {"baz": "baz"},
818
+ },
819
+ "gsi_pk": "456",
820
+ "gsi_sk": "NestedModel|EPIC|Mage",
821
+ "type": "NestedModel",
822
+ }
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