Dynamojo 0.2.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.
dynamojo/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ from .base import DynamojoBase
2
+ from .config import DynamojoConfig, JoinedAttribute
3
+ from .index import (
4
+ get_indexes,
5
+ Gsi,
6
+ Index,
7
+ IndexList,
8
+ IndexMap,
9
+ Lsi,
10
+ Mutator,
11
+ TableIndex,
12
+ )
13
+ from .utils import Delta
14
+
15
+
16
+ __all__ = [
17
+ "Delta",
18
+ "DynamojoBase",
19
+ "DynamojoConfig",
20
+ "get_indexes",
21
+ "Gsi",
22
+ "Index",
23
+ "IndexList",
24
+ "IndexMap",
25
+ "JoinedAttribute",
26
+ "Lsi",
27
+ "Mutator",
28
+ "TableIndex"
29
+ ]
30
+
dynamojo/base.py ADDED
@@ -0,0 +1,543 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+ from collections import UserDict
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass
6
+ from logging import getLogger
7
+ from typing import Any, Dict, List, TypeVar, Union
8
+
9
+ from boto3.dynamodb.conditions import (
10
+ AttributeBase,
11
+ ConditionBase,
12
+ ConditionExpressionBuilder,
13
+ Attr,
14
+ Key
15
+ )
16
+ from pydantic import BaseModel, PrivateAttr
17
+
18
+ from .boto import DYNAMOCLIENT
19
+ from .index import Index, Lsi
20
+ from .config import DynamojoConfig
21
+ from .exceptions import StaticAttributeError, IndexNotFoundError
22
+ from .utils import Delta, TYPE_SERIALIZER, TYPE_DESERIALIZER
23
+
24
+
25
+ class DynamojoBase(BaseModel):
26
+ """A class to use as a base for modeling objects to store in Dynamodb. This class
27
+ is intended to be inherited by another class, which actually defines the model.
28
+ """
29
+
30
+ #: All subclasses should override with their own config of the type `:class:dynamojo.config.DynamojoConfig`
31
+ _config: DynamojoConfig = PrivateAttr()
32
+ #: Original object
33
+ _original: DynamojoBase = PrivateAttr()
34
+
35
+ def __new__(cls: DynamojoModel, *_: List[Any], **__: Dict[Any, Any]) -> DynamojoModel:
36
+ if cls is DynamojoBase:
37
+ raise TypeError(f"{cls} must be subclassed")
38
+ return super().__new__(cls)
39
+
40
+ def __init__(self: DynamojoModel, **kwargs: Dict[str, Any]) -> None:
41
+
42
+ """Initialize a new object with any required or optional attributes"""
43
+ super().__init__(**kwargs)
44
+
45
+ for attr in self._config.joined_attributes:
46
+ if attr.attribute in self.dict():
47
+ raise AttributeError(
48
+ f"Attribute '{attr}' cannot be declared as a member and a joined field"
49
+ )
50
+ for attr in self._config.__index_keys__:
51
+ if attr in self.dict():
52
+ raise AttributeError(
53
+ f"Attribute {attr} is part of an index and cannot be declared directly. Use IndexMap() and an alias instead"
54
+ )
55
+
56
+ # TODO: Flush out these mutators.
57
+ for k, v in kwargs.items():
58
+ if k in self._config.mutators:
59
+ kwargs[k] = self._mutate_attribute(k, v)
60
+
61
+ self._original = deepcopy(self)
62
+
63
+ @property
64
+ def _deepdiff(self):
65
+ return Delta(new=self, old=self._original, deep=True)
66
+
67
+ @property
68
+ def _diff(self):
69
+ return Delta(new=self, old=self._original)
70
+
71
+ def __getattribute__(self: DynamojoModel, name: str) -> Any:
72
+ if super().__getattribute__("_config").__joined_attributes__.get(name):
73
+ return self._generate_joined_attribute(name)
74
+
75
+ return super().__getattribute__(name)
76
+
77
+ @property
78
+ def _has_changed(self):
79
+ return self._deepdiff.hasChanged
80
+
81
+ def __setattr__(self: DynamojoModel, field: str, val: Any) -> None:
82
+ # Mutations should happen first to allow for other dynamic fields to get their updated value
83
+ if field in self._config.mutators:
84
+ val = self._mutate_attribute(field, val)
85
+
86
+ if field in self._config.__joined_attributes__:
87
+ raise AttributeError(
88
+ f"Attribute '{field}' is a joined field and cannot be set directly"
89
+ )
90
+
91
+ if field in self._config.__index_keys__:
92
+ raise AttributeError(
93
+ f"Attribute '{field}' is an index key and cannot be set directly. Use IndexMap() to create an alias"
94
+ )
95
+
96
+ # Static fields can only be set once
97
+ if (
98
+ field in self._config.static_attributes
99
+ and hasattr(self, field)
100
+ and self.__getattribute__(field) != val
101
+ ):
102
+ raise StaticAttributeError(f"Attribute '{field}' is immutable.")
103
+
104
+ return super().__setattr__(field, val)
105
+
106
+ def _db_item(self) -> Dict[str, Any]:
107
+ """
108
+ Returns the item exactly as it is in the database. If self._config.store_aliases is False
109
+ then those aliases will be omitted.
110
+ """
111
+ item = {**self.dict(), **self.joined_attributes(), **self.index_attributes()}
112
+ if not self._config.store_aliases:
113
+ for attr in self._config.__index_aliases__.values():
114
+ if attr in item:
115
+ del item[attr]
116
+ return item
117
+
118
+ def _generate_joined_attribute(self: DynamojoModel, name: str) -> str:
119
+ """
120
+ Takes attribute names defined in self._config.joined_attributes and stores them with the values
121
+ of the corresponding attributes concatenated by JoinedAttribute.separator.
122
+ """
123
+ item = super().__getattribute__("dict")()
124
+ joiner = super().__getattribute__("_config").__joined_attributes__[name]
125
+ sources = joiner.fields
126
+ separator = joiner.separator
127
+ new_val = [item.get(source, "") for source in sources]
128
+ return separator.join(new_val)
129
+
130
+ @classmethod
131
+ def _get_index_from_attributes(
132
+ cls, partitionkey: str = None, sortkey: str = None
133
+ ) -> Index:
134
+ """
135
+ Returns an Index() object based on attributes being passed as arguments.
136
+ If multiple indexes match then the table index, if matched is returned first. If
137
+ not the table index then the first match in the list.
138
+ """
139
+ for x in cls._config.index_maps:
140
+ if x.index.name == "table":
141
+ table_index_map = x
142
+ break
143
+ matches = {}
144
+
145
+ for mapping in cls._config.index_maps:
146
+
147
+ if isinstance(mapping.index, Lsi):
148
+ pk = table_index_map.partitionkey
149
+ else:
150
+ pk = mapping.partitionkey
151
+
152
+ if hasattr(mapping, "sortkey"):
153
+ sk = mapping.sortkey
154
+ else:
155
+ sk = None
156
+
157
+ if (
158
+ # If we only had one key specified it HAS to be the partition
159
+ sortkey is None
160
+ and partitionkey == pk
161
+ ) or (
162
+ partitionkey is not None
163
+ and sortkey is not None
164
+ and (
165
+ pk == partitionkey
166
+ # Catch cases where our key is not composite (yuck!)
167
+ and (sk == sortkey or sk is None)
168
+ )
169
+ ):
170
+ matches[mapping.index.name] = mapping.index
171
+
172
+ if not matches:
173
+ raise IndexNotFoundError(
174
+ "Could not find a suitable index. Either specify a valid index or change the Condition statement"
175
+ )
176
+
177
+ return matches.get("table", list(matches.values())[0])
178
+
179
+ @classmethod
180
+ def _get_raw_condition_expression(
181
+ self,
182
+ exp: ConditionBase,
183
+ index: Union[Index, str] = None,
184
+ expression_type="KeyConditionExpression",
185
+ ):
186
+ """
187
+ Take a boto3.dynamodb.conditions.ConditionBase object and convert it to a dictionary suitable as the condition expression,
188
+ expression attribute names, and expression attribute values for an operation using the low-level dynamodb client.
189
+ This allows usage of boto3.dynamodb.conditions.Condition deconstruction to be used for the sake of automatically selecting
190
+ indexes. Placeholder prefixes are replaced so that attribute names and attribute values dicts can be merged together in queries
191
+ where multiple types of condition expressions are passed.
192
+ """
193
+ is_key_condition = expression_type == "KeyConditionExpression"
194
+ raw_exp = ConditionExpressionBuilder().build_expression(
195
+ exp, is_key_condition=is_key_condition
196
+ )
197
+
198
+ if is_key_condition:
199
+ attribute_names = list(raw_exp.attribute_name_placeholders.values())
200
+ if len(attribute_names) == 1:
201
+ attribute_names.append(None)
202
+
203
+ if isinstance(index, str):
204
+ index = self.get_index_by_name(index)
205
+
206
+ if index is None:
207
+ index = self._get_index_from_attributes(*attribute_names)
208
+
209
+ for placeholder, attr in raw_exp.attribute_name_placeholders.items():
210
+ if attr == attribute_names[0]:
211
+ raw_exp.attribute_name_placeholders[
212
+ placeholder
213
+ ] = index.partitionkey
214
+ if len(attribute_names) == 2 and attr == attribute_names[1]:
215
+ raw_exp.attribute_name_placeholders[placeholder] = index.sortkey
216
+
217
+ for k, v in raw_exp.attribute_value_placeholders.items():
218
+ raw_exp.attribute_value_placeholders[k] = TYPE_SERIALIZER.serialize(v)
219
+
220
+ opts = {
221
+ "ExpressionAttributeNames": {},
222
+ "ExpressionAttributeValues": {},
223
+ expression_type: raw_exp.condition_expression,
224
+ }
225
+
226
+ # We have to do the dance below to keep queries that use KeyConditionExpression
227
+ # and FilterExpressionfrom having their name/value placeholders clobber each other
228
+ # when the dicts are merged to for the full query args
229
+ original_name_prefix = "#n"
230
+ original_value_prefix = ":v"
231
+
232
+ if expression_type == "KeyConditionExpression":
233
+ new_name_prefix = "#key_name"
234
+ new_value_prefix = ":key_value"
235
+ if index.name != "table":
236
+ opts["IndexName"] = index.name
237
+
238
+ elif expression_type == "FilterExpression":
239
+ new_name_prefix = "#attribute_name"
240
+ new_value_prefix = ":attribute_value"
241
+
242
+ elif expression_type == "ConditionExpression":
243
+ new_name_prefix = "#condition_attribute_name"
244
+ new_value_prefix = ":condition_attribute_value"
245
+
246
+ else:
247
+ raise TypeError(
248
+ "Invalid Condition type. Must be one of KeyConditionExpression, ConditionExpression, or FilterExpression"
249
+ )
250
+
251
+ for key, val in raw_exp.attribute_name_placeholders.items():
252
+ new_key = key.replace(original_name_prefix, new_name_prefix)
253
+ opts["ExpressionAttributeNames"][new_key] = val
254
+ opts[expression_type] = opts[expression_type].replace(key, new_key)
255
+
256
+ for key, val in raw_exp.attribute_value_placeholders.items():
257
+ new_key = key.replace(original_value_prefix, new_value_prefix)
258
+ opts["ExpressionAttributeValues"][new_key] = val
259
+ opts[expression_type] = opts[expression_type].replace(key, new_key)
260
+
261
+ opts["TableName"] = self._config.table
262
+ return opts
263
+
264
+ @classmethod
265
+ def _construct_from_db(cls, item: Dict) -> DynamojoModel:
266
+ """
267
+ Rehydrates an object from an item out of the DB.
268
+ """
269
+ item = cls._deserialize_dynamo(item)
270
+ res = {}
271
+
272
+ for attr, val in item.items():
273
+ if not (
274
+ attr in cls._config.__index_keys__
275
+ or attr in cls._config.__joined_attributes__
276
+ ):
277
+ res[attr] = val
278
+
279
+ if not cls._config.store_aliases:
280
+ for index, alias in cls._config.__index_aliases__.items():
281
+ res[alias] = item[index]
282
+
283
+ return cls.construct(**(res))
284
+
285
+ def delete(self) -> None:
286
+ """
287
+ Deletes an item from the table
288
+ """
289
+ key = {
290
+ self._config.indexes.table.partitionkey: self.__index_values__[
291
+ self._config.indexes.table.partitionkey
292
+ ]
293
+ }
294
+
295
+ if self._config.indexes.table.is_composite:
296
+ key[self._config.indexes.table.sortkey] = self.__index_values__[
297
+ self._config.indexes.table.sortkey
298
+ ]
299
+
300
+ res = self._config.table.delete_item(Key=key)
301
+
302
+ return res
303
+
304
+ @staticmethod
305
+ def _deserialize_dynamo(data: Dict[str, Any]) -> Dict[str, Any]:
306
+ """
307
+ Deserializes the results from a low-level boto3 Dynamodb client query/get_item
308
+ into a standard dictionary.
309
+ """
310
+ return {k: TYPE_DESERIALIZER.deserialize(v) for k, v in data.items()}
311
+
312
+ @classmethod
313
+ def fetch(cls, pk: str, sk: str = None, **kwargs: Dict[str, Any]) -> DynamojoModel:
314
+ """
315
+ Returns a rehydrated object from the database
316
+ """
317
+
318
+ key = {cls._config.indexes.table.partitionkey: pk}
319
+
320
+ if cls._config.indexes.table.sortkey:
321
+ key[cls._config.indexes.table.sortkey] = sk
322
+
323
+ serialized_key = {k: TYPE_SERIALIZER.serialize(v) for k, v in key.items()}
324
+
325
+ opts = {"Key": serialized_key, "TableName": cls._config.table, **kwargs}
326
+
327
+ res = DYNAMOCLIENT.get_item(**opts)
328
+
329
+ if item := res.get("Item"):
330
+ return cls._construct_from_db(item)
331
+
332
+ @classmethod
333
+ def get_index_by_name(cls, name: str) -> Index:
334
+ """
335
+ Accepts a string as a name and returns an Index() object
336
+ """
337
+ try:
338
+ return cls._config.indexes[name]
339
+ except KeyError:
340
+ raise IndexNotFoundError(f"Index {name} does not exist")
341
+
342
+ def index_attributes(self) -> Dict[str, Any]:
343
+ """
344
+ Returns a dict containing index attributes as keys along with their set values
345
+ """
346
+ indexes = {}
347
+ for mapping in self._config.index_maps:
348
+ if hasattr(mapping, "partitionkey"):
349
+ indexes[mapping.index.partitionkey] = self.__getattribute__(
350
+ mapping.partitionkey
351
+ )
352
+ if hasattr(mapping, "sortkey"):
353
+ indexes[mapping.index.sortkey] = self.__getattribute__(mapping.sortkey)
354
+ return indexes
355
+
356
+ def item(self) -> Dict[str, Any]:
357
+ """
358
+ Returns a dict that contains declared attributes and attributes dynamically generated
359
+ by self._config.joined_attributes
360
+ """
361
+ return {**self.dict(), **self.joined_attributes()}
362
+
363
+ def joined_attributes(self) -> Dict[str, str]:
364
+ """
365
+ Returns a dict of attributes created dynamically by self._config.joined_attributes
366
+ """
367
+ return {
368
+ attr: self.__getattribute__(attr) for attr in self._config.__joined_attributes__
369
+ }
370
+
371
+ @classmethod
372
+ def _mutate_attribute(cls, field: str, val: Any) -> Any:
373
+ """
374
+ Returns the mutated value using the callable specified in cls._config.mutators for
375
+ an attribute.
376
+ """
377
+ return super().__setattr__(
378
+ field, cls._config.mutators[field].callable(field, val, cls)
379
+ )
380
+
381
+ def _prepare_db_item(self):
382
+ """
383
+ Serializes self.item() for storage in the database using the low-level dynamodb client.
384
+ """
385
+ item = {
386
+ k: TYPE_SERIALIZER.serialize(v)
387
+ for k, v in self._db_item().items()
388
+ }
389
+
390
+ if not self._config.store_aliases:
391
+ for alias in self._config.__index_aliases__.values():
392
+ if alias in item:
393
+ del item[alias]
394
+
395
+ return item
396
+
397
+ @classmethod
398
+ def query(
399
+ cls,
400
+ KeyConditionExpression: ConditionBase,
401
+ Index: Union[Index, str] = None,
402
+ FilterExpression: AttributeBase = None,
403
+ Limit: int = 1000,
404
+ ExclusiveStartKey: dict = None,
405
+ result_type: str = "standard",
406
+ **kwargs: Dict[str, Any],
407
+ ) -> QueryResults:
408
+ """
409
+ Runs a Dynamodb query using a condition from db.Index. The kwargs argument can be any
410
+ boto3.client("dynamodb").query() argument that is not explicitely defined in the signature.
411
+ """
412
+
413
+ if result_type not in ("standard", "deserialized", "raw"):
414
+ raise ValueError("Argument 'result_type' must be one of standard, raw, or deserialized")
415
+
416
+ opts = {**kwargs, "Limit": Limit}
417
+
418
+ opts.update(
419
+ cls._get_raw_condition_expression(exp=KeyConditionExpression, index=Index)
420
+ )
421
+
422
+ if FilterExpression is not None:
423
+ filter_opts = cls._get_raw_condition_expression(
424
+ exp=FilterExpression, expression_type="FilterExpression"
425
+ )
426
+ opts["ExpressionAttributeNames"].update(
427
+ filter_opts.pop("ExpressionAttributeNames")
428
+ )
429
+ opts["ExpressionAttributeValues"].update(
430
+ filter_opts.pop("ExpressionAttributeValues")
431
+ )
432
+ opts.update(filter_opts)
433
+
434
+ if ExclusiveStartKey is not None:
435
+ opts["ExclusiveStartKey"] = ExclusiveStartKey
436
+
437
+ msg = (
438
+ f"Querying with index `{opts['IndexName']}`"
439
+ if opts.get("IndexName")
440
+ else "Querying with table index"
441
+ )
442
+
443
+ getLogger().info(msg)
444
+
445
+ res = DYNAMOCLIENT.query(**opts)
446
+
447
+ res["Items"] = [cls._construct_from_db(item) for item in res["Items"]]
448
+ return QueryResults(**res)
449
+
450
+ def save(
451
+ self, ConditionExpression: ConditionBase = None, fail_on_exists: bool = True, **kwargs: Dict[str, Any]
452
+ ) -> None:
453
+ """
454
+ Stores our item in Dynamodb
455
+ """
456
+
457
+ table_pk = self._config.indexes.table.partitionkey
458
+ table_sk = self._config.indexes.table.sortkey
459
+
460
+
461
+ item = self._prepare_db_item()
462
+
463
+ opts = {"TableName": self._config.table, "Item": item, **kwargs}
464
+
465
+ if ConditionExpression:
466
+ exp = self._get_raw_condition_expression(
467
+ ConditionExpression, expression_type="ConditionExpression"
468
+ )
469
+ opts.update(exp)
470
+
471
+ if fail_on_exists:
472
+ sk_expression = f"attribute_not_exists({table_sk})"
473
+ pk_expression = f"attribute_not_exists({table_pk})"
474
+ fail_expression = f"({pk_expression} AND {sk_expression}) " if table_sk is not None else pk_expression
475
+ if ConditionExpression:
476
+ opts["ConditionExpression"] = f"{fail_expression} AND {opts['ConditionExpression']}"
477
+ else:
478
+ opts["ConditionExpression"] = fail_expression
479
+
480
+ return DYNAMOCLIENT.put_item(**opts)
481
+
482
+ def update(self, **opts):
483
+ diff = self._deepdiff
484
+
485
+ if not diff.hasChanged:
486
+ return None
487
+
488
+ pk_name = self._config.indexes.table.partitionkey
489
+ sk_name = self._config.indexes.table.sortkey
490
+ if (
491
+ pk_name in diff.keys
492
+ or sk_name in diff.keys
493
+ ):
494
+ raise AttributeError("Cannot update table key attributes. Use `self.save()` instead.")
495
+
496
+ attribute_names = {}
497
+ attribute_values = {}
498
+ set_statement_items = []
499
+ del_statement_items = []
500
+ set_items = {**diff.added, **diff.changed}
501
+ key = {
502
+ pk_name: TYPE_SERIALIZER.serialize(self._db_item()[pk_name])
503
+ }
504
+ if sk_name is not None:
505
+ key[sk_name] = TYPE_SERIALIZER.serialize(self._db_item()[sk_name])
506
+
507
+ for attr, val in self._db_item().items():
508
+ if attr in diff.keys:
509
+ attribute_names[f"#{attr}"] = attr
510
+ attribute_values[f":{attr}"] = val
511
+ if attr in set_items.keys():
512
+ statement = f"#{attr} = :{attr}"
513
+ set_statement_items.append(statement)
514
+ else:
515
+ del_statement_items.append(statement)
516
+
517
+ set_statement = f"SET {', '.join(set_statement_items)}" if set_statement_items else ""
518
+ del_statement = f"REMOVE {', '.join(del_statement_items)}" if del_statement_items else ""
519
+
520
+ opts["TableName"] = self._config.table
521
+ opts["Key"] = key
522
+ opts["ExpressionAttributeNames"] = attribute_names
523
+ opts["ExpressionAttributeValues"] = {
524
+ k: TYPE_SERIALIZER.serialize(v)
525
+ for k, v in attribute_values.items()
526
+ }
527
+
528
+ opts["UpdateExpression"] = f"{set_statement} {del_statement}"
529
+ DYNAMOCLIENT.update_item(**opts)
530
+ return self
531
+
532
+ @dataclass
533
+ class QueryResults(UserDict):
534
+ Items: List[DynamojoModel]
535
+ Count: int
536
+ ResponseMetadata: Dict[str, Any]
537
+ ScannedCount: int
538
+ LastEvaluatedKey: Dict[str, Dict[str, Any]] = None
539
+ ConsumedCapacity: Dict[str, Any] = None
540
+
541
+
542
+ DynamojoModel = TypeVar("DynamojoModel", bound=DynamojoBase)
543
+
dynamojo/boto.py ADDED
@@ -0,0 +1,4 @@
1
+ from boto3 import Session
2
+
3
+ Session = Session()
4
+ DYNAMOCLIENT = Session.client("dynamodb")
dynamojo/config.py ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ from typing import Union, Callable, List, Dict
3
+
4
+ from pydantic import BaseModel, PrivateAttr
5
+
6
+ from .index import IndexList, IndexMap, Mutator
7
+
8
+ class JoinedAttribute(BaseModel):
9
+ attribute: str
10
+ fields: List[str]
11
+ separator: str = "~"
12
+
13
+
14
+ class DynamojoConfig(BaseModel):
15
+ # A dictionary in the form of {"<target attribute>": ["source_att_one", "source_att_two"]} where <target_attribute> will
16
+ # automatically be overwritten by the attributes of it's corresponding list being joined with '~'. This is useful for creating
17
+ # keys that can be queried over using Key().begins_with() or Key().between() by creating a means to filter based on the compounded
18
+ # attributes.
19
+ joined_attributes: List[JoinedAttribute]
20
+ __joined_attributes__: Dict = {}
21
+
22
+ # A list of database `Index` objects from `dynamojo.indexes.get_indexes()``
23
+ indexes: IndexList
24
+
25
+ # A list of `IndexMap` objects used to map arbitrary fields into index attributes
26
+ index_maps: List[IndexMap] = []
27
+
28
+ static_attributes: List[str] = []
29
+
30
+ # A Dynamodb table name
31
+ table: str
32
+
33
+ mutators: List[Mutator] = []
34
+
35
+ # If set to False then attributes that are aliases of indexes will be stripped
36
+ # out before storing in the db
37
+ store_aliases: bool = True
38
+
39
+ underscore_attrs_are_private: bool = True
40
+
41
+ # Dict of `index key: alias name`
42
+ __index_aliases__: dict = PrivateAttr(default={})
43
+
44
+ __index_keys__: List[str] = PrivateAttr(default={})
45
+
46
+ class Config:
47
+ underscore_attrs_are_private: bool = True
48
+ arbitrary_types_allowed = True
49
+ extra = "allow"
50
+
51
+ def __init__(self, **kwargs):
52
+ super().__init__(**kwargs)
53
+
54
+ for attr in self.joined_attributes:
55
+ self.__joined_attributes__[attr.attribute] = attr
56
+
57
+ for index_map in self.index_maps:
58
+ if sk_att := index_map.sortkey:
59
+ self.__index_aliases__[index_map.index.sortkey] = sk_att
60
+ if getattr(index_map, "partitionkey", None):
61
+ self.__index_aliases__[
62
+ index_map.index.partitionkey
63
+ ] = index_map.partitionkey
64
+
65
+ self.__index_keys__ = list(set(self.__index_aliases__.keys()))
dynamojo/exceptions.py ADDED
@@ -0,0 +1,30 @@
1
+ class DynamodbException(Exception):
2
+ pass
3
+
4
+
5
+ class RequiredAttributeError(DynamodbException):
6
+ pass
7
+
8
+
9
+ class StaticAttributeError(DynamodbException):
10
+ pass
11
+
12
+
13
+ class UnknownAttributeError(DynamodbException):
14
+ pass
15
+
16
+
17
+ class ProtectedAttributeError(DynamodbException):
18
+ pass
19
+
20
+
21
+ class ItemNotFoundError(DynamodbException):
22
+ pass
23
+
24
+
25
+ class NotAuthorized(DynamodbException):
26
+ pass
27
+
28
+
29
+ class IndexNotFoundError(DynamodbException):
30
+ pass
dynamojo/index.py ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3.8
2
+ from collections import UserDict
3
+ from typing import List, Callable, Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from .boto import DYNAMOCLIENT
8
+
9
+
10
+ class Mutator(BaseModel):
11
+ source: str
12
+ callable: Callable[[str, Any, object], Any]
13
+
14
+ class Config:
15
+ frozen = True
16
+ arbitrary_types_allowed: True
17
+
18
+
19
+ class Index:
20
+ """
21
+ Used as a factory for creating objects that can generate KeyConditionExpressions and abstract the
22
+ partion/sort key names from the developer using them.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ name: str,
29
+ sortkey: str,
30
+ partitionkey: str = None,
31
+ ) -> None:
32
+
33
+ self.__partitionkey = partitionkey if partitionkey else None
34
+ self.__sortkey = sortkey if sortkey else None
35
+ self.__name = name
36
+ self.is_composite = partitionkey and sortkey
37
+
38
+ @property
39
+ def partitionkey(self) -> str:
40
+ """The partition key of the index as Key(partition key name)"""
41
+ return self.__partitionkey
42
+
43
+ @property
44
+ def sortkey(self) -> str:
45
+ """The sort key of the index as Key(sort key name)"""
46
+ return self.__sortkey
47
+
48
+ @property
49
+ def table_index(self) -> bool:
50
+ """Whether or not this index is the table index"""
51
+ return isinstance(self, TableIndex)
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ """Name of the index"""
56
+ return self.__name
57
+
58
+
59
+ class Gsi(Index):
60
+ def __init__(self, name: str, partitionkey: str, sortkey: str = None) -> None:
61
+ super().__init__(name=name, sortkey=sortkey, partitionkey=partitionkey)
62
+
63
+
64
+ class Lsi(Index):
65
+ def __init__(self, name: str, sortkey: str) -> None:
66
+ super().__init__(name=name, sortkey=sortkey)
67
+
68
+
69
+ class TableIndex(Index):
70
+ def __init__(self, name: str, partitionkey: str, sortkey: str = None) -> None:
71
+ super().__init__(name=name, partitionkey=partitionkey, sortkey=sortkey)
72
+
73
+
74
+ class IndexList(UserDict):
75
+ def __init__(self, *args: List[Index]) -> None:
76
+ super().__init__()
77
+ has_table = False
78
+ for index in args:
79
+ if not isinstance(index, Index):
80
+ raise TypeError("Invalid type for Index")
81
+ if isinstance(index, TableIndex):
82
+ if has_table:
83
+ raise ValueError("An IndexList object can only have one TableIndex")
84
+ has_table = True
85
+
86
+ super().__setattr__(index.name, index)
87
+ self.data[index.name] = index
88
+
89
+
90
+ def get_indexes(table_name: str) -> IndexList:
91
+
92
+ desc = DYNAMOCLIENT.describe_table(TableName=table_name)["Table"]
93
+ gsi_list = desc.get("GlobalSecondaryIndexes", [])
94
+ lsi_list = desc.get("LocalSecondaryIndexes", [])
95
+
96
+ table_list = [{"IndexName": "table", "KeySchema": desc["KeySchema"]}]
97
+
98
+ def build_indexes(index_type, index_list):
99
+ index_objects = []
100
+ for index in index_list:
101
+ args = {}
102
+ args["name"] = index["IndexName"]
103
+
104
+ for attr in index["KeySchema"]:
105
+ if attr["KeyType"] == "HASH" and index_type != Lsi:
106
+ args["partitionkey"] = attr["AttributeName"]
107
+ elif attr["KeyType"] == "RANGE":
108
+ args["sortkey"] = attr["AttributeName"]
109
+
110
+ index_objects.append(index_type(**args))
111
+
112
+ return index_objects
113
+
114
+ indexes = IndexList(
115
+ *[
116
+ *build_indexes(Gsi, gsi_list),
117
+ *build_indexes(Lsi, lsi_list),
118
+ *build_indexes(TableIndex, table_list),
119
+ ]
120
+ )
121
+ return indexes
122
+
123
+
124
+ class IndexMap:
125
+ index: Index
126
+ pk: str
127
+ sk: str = None
128
+
129
+ def __init__(
130
+ self, index: Index, partitionkey: str = None, sortkey: str = None
131
+ ) -> None:
132
+ if isinstance(index, Lsi) and partitionkey:
133
+ raise ValueError(
134
+ "Lsi indexes only specify a sort key and use the table's partition key"
135
+ )
136
+
137
+ elif index.partitionkey and not partitionkey:
138
+ raise ValueError(f"Partition key required for index {index.name}")
139
+
140
+ if index.sortkey and not sortkey:
141
+ raise ValueError(f"Sort key required for index {index.name}")
142
+
143
+ if sortkey and not index.sortkey:
144
+ raise ValueError(f"Index {index.name} requires a sort key")
145
+
146
+ if partitionkey:
147
+ self.partitionkey = partitionkey
148
+
149
+ if sortkey:
150
+ self.sortkey = sortkey
151
+
152
+ self.index = index
dynamojo/utils.py ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ from pydantic import BaseModel, PrivateAttr
3
+ from typing import (
4
+ TYPE_CHECKING,
5
+ Any,
6
+ Dict,
7
+ NamedTuple
8
+ )
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from .base import DynamojoBase
13
+ else:
14
+ DynamojoBase = object
15
+
16
+ from boto3.dynamodb.types import TypeSerializer, TypeDeserializer
17
+
18
+
19
+ TYPE_SERIALIZER = TypeSerializer()
20
+ TYPE_DESERIALIZER = TypeDeserializer()
21
+
22
+ Change = NamedTuple("Change", [("old", Any), ("new", Any)])
23
+ Diff = NamedTuple("Diff", (("added", Dict[str, Any]), ("removed", Dict[str, Any]), ("changed", Dict[str, Any])))
24
+
25
+
26
+ class Delta(BaseModel):
27
+ old: DynamojoBase = None
28
+ new: DynamojoBase = None
29
+
30
+ _deep: bool = PrivateAttr()
31
+ _old: Dict[str, Any] = PrivateAttr()
32
+ _new: Dict[str, Any] = PrivateAttr()
33
+
34
+ def __init__(self, deep=True, **kwargs):
35
+ self._deep = deep
36
+ super().__init__(**kwargs)
37
+
38
+ if deep:
39
+ self._old = self.old._db_item()
40
+ self._new = self.new._db_item()
41
+ else:
42
+ self._old = self.old.item()
43
+ self._new = self.new.item()
44
+
45
+
46
+ @property
47
+ def delta(self) -> Diff:
48
+ diff = Diff({}, {}, {})
49
+
50
+ for key, val in self._old.items():
51
+ if key not in self._new:
52
+ diff.removed[key] = val
53
+ if key in self._new and val != self._new[key]:
54
+ diff.changed[key] = Change(
55
+ self._old[key],
56
+ self._new[key]
57
+ )
58
+
59
+ for key, val in self._new.items():
60
+ if key not in self._old:
61
+ diff.added[key] = val
62
+
63
+ return diff
64
+
65
+ @property
66
+ def added(self) -> Dict[str, Any]:
67
+ return self.delta.added
68
+
69
+ @property
70
+ def changed(self) -> Change:
71
+ return self.delta.changed
72
+
73
+ @property
74
+ def removed(self) -> Dict[str, Any]:
75
+ return self.delta.removed
76
+
77
+ @property
78
+ def hasChanged(self):
79
+ return not self._old == self._new
80
+
81
+ @property
82
+ def keys(self):
83
+ return {
84
+ **self.added,
85
+ **self.removed,
86
+ **self.changed
87
+ }.keys()
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.1
2
+ Name: Dynamojo
3
+ Version: 0.2.0
4
+ Summary: A cli client for accessing AWS secrets
5
+ Home-page: https://github.com/mathewmoon/dynamojo
6
+ Author: Mathew Moon
7
+ Author-email: me@mathewmoon.net
8
+ Classifier: Programming Language :: Python :: 2
9
+ Classifier: Programming Language :: Python :: 2.7
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.4
12
+ Classifier: Programming Language :: Python :: 3.5
13
+ Classifier: Programming Language :: Python :: 3.6
14
+ Classifier: Programming Language :: Python :: 3.7
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Requires-Dist: boto3 (>=1.34.56,<2.0.0)
21
+ Requires-Dist: pydantic (<2.0)
22
+ Project-URL: Repository, https://github.com/mathewmoon/dynamojo
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Dynamojo
26
+ ## Because one table is better than more
27
+
28
+ Dynamojo takes the concept of Dynamodb Single Table design and creates a modeling framework for it. This library is opinionated in the following ways:
29
+ - Indexes should be generic. They could mean different things for different types of items. **An index attribute shouldn't imply that it is always a date, color, etc**.
30
+ - When using generic indexes the attributes should shadow a human readable attribute. For instance if you have a partition key named "pk" that for items that represent users stores their userid, then there should also be an attribute named userid.
31
+ - When creating models for item types that will be stored in the database the developer should only have to worry about their access patterns in terms of the human readable attributes, not be in the weeds of the index design of the table. Mapping items to indexes should happen in code, not in the table definition itself
32
+ - Table and Global Secondary indexes should always define a sortkey. There is no reason not to. It's better to have it in cases where you don't need it than to need it and not have it.
33
+
34
+ Dynamojo is built on top of Pydantic with some bells and whistles:
35
+ - put, update, delete, and query db objects
36
+ - Dynamically map attributes to the index of your choice. EG: attribute "userId" automatically populates the partition key named "pk"
37
+ - Dynamically join attributes into another using a delimiter. For instance create a field that is `<userid>~<date>~<action>` to use as a sort key for fast queries
38
+ - Mutate attributes when set
39
+ - Create models that subclass other models. A common pattern is to define a base class for your project that has a baseline of methods that you will need other than db operations. Different item types would then be created as models that are subclasses from the base class. See test.py
40
+ - Flag attributes as immutable so they can't be modified once set
41
+ - Use all of the features of `put_item()`, `query()`, `delete()`, and `update()` that you normally could with `boto3.client("dynamodb")`
42
+
43
+ ### Limitations:
44
+ - Dynamojo doesn't do scans because scans are dumb. I will die on the hill of defending that statement.
45
+ - If you have so much data that replicating indexed data into human readable columns is too expensive then this library may not be for you. But if you have that much data you should have a staff of engineers that can write your own library.
46
+
47
+
48
+
49
+ ### See test.py for examples
50
+
51
+ This library is very opinionated about how the table's indexes should be structured. Below is Terraform that shows the
52
+ correct way to set up the table. Index keys are never referenced directly when using the table. Rely on IndexMap for that.
53
+ Since LSI's can only be created at table creation time, and all indexes cost nothing if not used, we go ahead and create
54
+ all of the indexes that AWS will allow us to when the table is created.
55
+
56
+ ```hcl
57
+ resource "aws_dynamodb_table" "test_table" {
58
+ name = "test-dynamojo"
59
+ hash_key = "pk"
60
+ range_key = "sk"
61
+ billing_mode = "PAY_PER_REQUEST"
62
+
63
+ # LSI attributes
64
+ dynamic "attribute" {
65
+ for_each = range(5)
66
+
67
+ content {
68
+ name = "lsi${attribute.value}_sk"
69
+ type = "S"
70
+ }
71
+ }
72
+
73
+ # GSI pk attributes
74
+ dynamic "attribute" {
75
+ for_each = range(20)
76
+
77
+ content {
78
+ name = "gsi${attribute.value}_pk"
79
+ type = "S"
80
+ }
81
+ }
82
+
83
+ # GSI sk attributes
84
+ dynamic "attribute" {
85
+ for_each = range(20)
86
+
87
+ content {
88
+ name = "gsi${attribute.value}_sk"
89
+ type = "S"
90
+ }
91
+ }
92
+
93
+ attribute {
94
+ name = "pk"
95
+ type = "S"
96
+ }
97
+
98
+ attribute {
99
+ name = "sk"
100
+ type = "S"
101
+ }
102
+
103
+ # GSI's
104
+ dynamic "global_secondary_index" {
105
+ for_each = range(20)
106
+
107
+ content {
108
+ name = "gsi${global_secondary_index.value}"
109
+ hash_key = "gsi${global_secondary_index.value}_pk"
110
+ range_key = "gsi${global_secondary_index.value}_sk"
111
+ projection_type = "ALL"
112
+ }
113
+ }
114
+
115
+ # LSI's
116
+ dynamic "local_secondary_index" {
117
+ for_each = range(5)
118
+
119
+ content {
120
+ name = "lsi${local_secondary_index.value}"
121
+ range_key = "lsi${local_secondary_index.value}_sk"
122
+ projection_type = "ALL"
123
+ }
124
+ }
125
+ }
126
+ ```
@@ -0,0 +1,10 @@
1
+ dynamojo/__init__.py,sha256=bILBw1CxIisxMiMPhNF_m6PxGA9hYwam7MKIed2Pljg,449
2
+ dynamojo/base.py,sha256=ImPBdgcVIEojoJfvjgwnobn9rAtsNLmPa3CJ96oZnyM,19880
3
+ dynamojo/boto.py,sha256=VtrXAHyts0y4nCI7fo8BuowQE9qCoVZ9KJ1ZHXvmysQ,89
4
+ dynamojo/config.py,sha256=dFfMJpYUQ4gRPvsS_EVpgoEPmRz0UglO9wwTMlfx2aE,2212
5
+ dynamojo/exceptions.py,sha256=PL9LkviDckaBZOQUq-2tYG4hPyfHr623eK9UjnICMM0,445
6
+ dynamojo/index.py,sha256=lV8GIWyHBFQh6zMZZxVJPHOBRi_ue2XqqPwq97Wc_zo,4406
7
+ dynamojo/utils.py,sha256=QzsNIdVzfOd-Y8Zn4Zti07a-i71WNOup-v_ZCNsJRwk,1882
8
+ dynamojo-0.2.0.dist-info/METADATA,sha256=JhAXHv-lBAu8AdqJKQ08PY-amdBfSoMFXsJf2oqw700,5159
9
+ dynamojo-0.2.0.dist-info/WHEEL,sha256=IrRNNNJ-uuL1ggO5qMvT1GGhQVdQU54d6ZpYqEZfEWo,92
10
+ dynamojo-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2.py3-none-any