daplug-ddb 1.0.0b1__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.
daplug_ddb/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Public interface for the daplug_ddb package."""
2
+
3
+ from typing import Any
4
+
5
+ from .adapter import BatchItemException, DynamodbAdapter
6
+
7
+
8
+ def adapter(**kwargs: Any) -> DynamodbAdapter:
9
+ """Factory helper mirroring legacy entry point for DynamoDB adapters."""
10
+
11
+ engine = kwargs.pop("engine", "dynamodb")
12
+ if engine and engine != "dynamodb": # preserve deterministic erroring for unsupported engines
13
+ raise ValueError(f"engine {engine} not supported; only 'dynamodb' is available")
14
+ return DynamodbAdapter(**kwargs)
15
+
16
+
17
+ __all__ = ["adapter", "DynamodbAdapter", "BatchItemException"]
daplug_ddb/adapter.py ADDED
@@ -0,0 +1,135 @@
1
+ """DynamoDB adapter exposing normalized CRUD operations."""
2
+
3
+ from functools import lru_cache
4
+ from typing import Any, Dict, Iterable, Optional, Union, cast
5
+
6
+ import boto3
7
+ from boto3.dynamodb.conditions import Attr
8
+
9
+ from daplug_ddb.types import DynamoItem, DynamoItems
10
+
11
+ from .common import map_to_schema, merge
12
+ from .common.base_adapter import BaseAdapter
13
+ from .exception import BatchItemException
14
+
15
+
16
+ class DynamodbAdapter(BaseAdapter):
17
+ """Implements DynamoDB CRUD operations with schema normalization."""
18
+
19
+ def __init__(self, **kwargs: Any) -> None:
20
+ super().__init__(**kwargs)
21
+ self.table = self._get_dynamo_table(kwargs["table"], kwargs.get("endpoint"))
22
+ self.schema_file: str = kwargs["schema_file"]
23
+ self.schema: str = kwargs["schema"]
24
+ self.identifier: str = kwargs["identifier"]
25
+ self.idempotence_key: Optional[str] = kwargs.get("idempotence_key")
26
+
27
+ @lru_cache(maxsize=128)
28
+ def _get_dynamo_table(self, table: str, endpoint: Optional[str] = None) -> Any:
29
+ return boto3.resource("dynamodb", endpoint_url=endpoint).Table(table)
30
+
31
+ def create(self, **kwargs: Any) -> DynamoItem:
32
+ if kwargs.get("operation") == "overwrite":
33
+ return self.overwrite(**kwargs)
34
+ return self.insert(**kwargs)
35
+
36
+ def read(self, **kwargs: Any) -> Union[DynamoItem, DynamoItems, Dict[str, Any]]:
37
+ if kwargs.get("operation") == "query":
38
+ return self.query(**kwargs)
39
+ if kwargs.get("operation") == "scan":
40
+ return self.scan(**kwargs)
41
+ return self.get(**kwargs)
42
+
43
+ def scan(self, **kwargs: Any) -> Union[DynamoItems, Dict[str, Any]]:
44
+ if kwargs.get("raw_scan"):
45
+ return self.table.scan(**kwargs.get("query", {}))
46
+ return self.table.scan(**kwargs.get("query", {})).get("Items", [])
47
+
48
+ def get(self, **kwargs: Any) -> DynamoItem:
49
+ result: Dict[str, Any] = self.table.get_item(**kwargs.get("query", {}))
50
+ return result.get("Item", {})
51
+
52
+ def query(self, **kwargs: Any) -> Union[DynamoItems, Dict[str, Any]]:
53
+ if kwargs.get("raw_query"):
54
+ return self.table.query(**kwargs.get("query", {}))
55
+ return self.table.query(**kwargs.get("query", {})).get("Items", [])
56
+
57
+ def overwrite(self, **kwargs: Any) -> DynamoItem:
58
+ overwrite_item = map_to_schema(kwargs["data"], self.schema_file, self.schema)
59
+ self.table.put_item(Item=overwrite_item)
60
+ super().publish("create", overwrite_item, **kwargs)
61
+ return overwrite_item
62
+
63
+ def insert(self, **kwargs: Any) -> DynamoItem:
64
+ new_item = map_to_schema(kwargs["data"], self.schema_file, self.schema)
65
+ self.table.put_item(
66
+ Item=new_item, ConditionExpression=Attr(self.identifier).not_exists()
67
+ )
68
+ super().publish("create", new_item, **kwargs)
69
+ return new_item
70
+
71
+ def batch_insert(self, **kwargs: Any) -> None:
72
+ data = kwargs["data"]
73
+ batch_size: int = kwargs.get("batch_size", 25)
74
+
75
+ if not isinstance(data, list):
76
+ raise BatchItemException("Batched data must be contained within a list")
77
+
78
+ batched_data: Iterable[DynamoItems] = (
79
+ data[pos : pos + batch_size] for pos in range(0, len(data), batch_size)
80
+ )
81
+ with self.table.batch_writer() as writer:
82
+ for batch in batched_data:
83
+ for item in batch:
84
+ writer.put_item(Item=item)
85
+
86
+ def delete(self, **kwargs: Any) -> DynamoItem:
87
+ kwargs["query"]["ReturnValues"] = "ALL_OLD"
88
+ result = self.table.delete_item(**kwargs["query"]).get("Attributes", {})
89
+ super().publish("delete", result, **kwargs)
90
+ return result
91
+
92
+ def batch_delete(self, **kwargs: Any) -> None:
93
+ batch_size: int = kwargs.get("batch_size", 25)
94
+ if not isinstance(kwargs["data"], list):
95
+ raise BatchItemException("Batched data must be contained within a list")
96
+ batched_data: Iterable[DynamoItems] = (
97
+ kwargs["data"][pos : pos + batch_size]
98
+ for pos in range(0, len(kwargs["data"]), batch_size)
99
+ )
100
+ with self.table.batch_writer() as writer:
101
+ for batch in batched_data:
102
+ for item in batch:
103
+ writer.delete_item(Key=item)
104
+
105
+ def update(self, **kwargs: Any) -> DynamoItem:
106
+ original_data = self._get_original_data(**kwargs)
107
+ merged_data = merge(original_data, kwargs["data"], **kwargs)
108
+ updated_data = map_to_schema(merged_data, self.schema_file, self.schema)
109
+ put_kwargs = {"Item": updated_data}
110
+ if self.idempotence_key:
111
+ original_value = original_data.get(self.idempotence_key)
112
+ if original_value is None:
113
+ raise ValueError(
114
+ f"idempotence key '{self.idempotence_key}' not found in original item"
115
+ )
116
+ put_kwargs["ConditionExpression"] = cast(Any, Attr(self.idempotence_key).eq(original_value))
117
+ self.table.put_item(**put_kwargs)
118
+ super().publish("update", updated_data, **kwargs)
119
+ return updated_data
120
+
121
+ def _get_original_data(self, **kwargs: Any) -> DynamoItem:
122
+ if kwargs["operation"] == "get":
123
+ original_data = self.get(**kwargs)
124
+ else:
125
+ query_result = self.query(**kwargs)
126
+ if isinstance(query_result, list):
127
+ items = query_result
128
+ else:
129
+ items = cast(DynamoItems, query_result.get("Items", []))
130
+ if not items:
131
+ raise ValueError("update: no data found to update")
132
+ original_data = items[0]
133
+ if not original_data:
134
+ raise ValueError("update: no data found to update")
135
+ return original_data
@@ -0,0 +1,11 @@
1
+ """Shared utilities used by the DynamoDB adapter."""
2
+
3
+ from .base_adapter import BaseAdapter
4
+ from .dict_merger import merge
5
+ from .schema_mapper import map_to_schema
6
+
7
+ __all__ = [
8
+ "BaseAdapter",
9
+ "map_to_schema",
10
+ "merge",
11
+ ]
@@ -0,0 +1,57 @@
1
+ """Shared base adapter that wraps SNS publishing."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from daplug_ddb.types import MessageAttributes
6
+
7
+ from . import publisher
8
+
9
+
10
+ class BaseAdapter:
11
+ """Provides shared publish helper logic for adapters."""
12
+
13
+ def __init__(self, **kwargs: Any) -> None:
14
+ self.sns_arn: Optional[str] = kwargs.get("sns_arn")
15
+ self.sns_custom: Dict[str, Any] = kwargs.get("sns_attributes", {})
16
+ self.sns_defaults: bool = kwargs.get("sns_default_attributes", True)
17
+ self.sns_endpoint: Optional[str] = kwargs.get("sns_endpoint")
18
+ self.publisher = publisher
19
+ self.default_attributes: Dict[str, Any] = {
20
+ "schema": kwargs.get("schema"),
21
+ "identifier": kwargs.get("identifier"),
22
+ "idempotence_key": kwargs.get("idempotence_key"),
23
+ "author_identifier": kwargs.get("author_identifier"),
24
+ }
25
+
26
+ def publish(self, db_operation: str, db_data: Dict[str, Any], **kwargs: Any) -> None:
27
+ attributes = self.create_format_attibutes(db_operation)
28
+ self.publisher.publish(
29
+ endpoint=self.sns_endpoint,
30
+ arn=self.sns_arn,
31
+ attributes=attributes,
32
+ data=db_data,
33
+ fifo_group_id=kwargs.get("fifo_group_id"),
34
+ fifo_duplication_id=kwargs.get("fifo_duplication_id"),
35
+ )
36
+
37
+ def create_format_attibutes(self, operation: str) -> MessageAttributes:
38
+ self.default_attributes["operation"] = operation
39
+ custom_attributes = self.get_attributes()
40
+ formatted_attributes: MessageAttributes = {}
41
+ for key, value in custom_attributes.items():
42
+ if value is not None:
43
+ data_type = "String" if isinstance(value, str) else "Number"
44
+ formatted_attributes[key] = {
45
+ "DataType": data_type,
46
+ "StringValue": value,
47
+ }
48
+ return formatted_attributes
49
+
50
+ def get_attributes(self) -> Dict[str, Any]:
51
+ if self.sns_defaults and self.sns_custom:
52
+ return {**self.default_attributes, **self.sns_custom}
53
+ if not self.sns_defaults and self.sns_custom:
54
+ return self.sns_custom
55
+ if self.sns_defaults and not self.sns_custom:
56
+ return self.default_attributes
57
+ return {}
@@ -0,0 +1,66 @@
1
+ """Utilities to merge dictionaries and lists with optional strategies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from typing import Any, List
7
+
8
+ import simplejson as json
9
+
10
+ from daplug_ddb.types import DynamoItem
11
+
12
+
13
+ def merge(original_data: DynamoItem, new_data: DynamoItem, **kwargs: Any) -> DynamoItem:
14
+ updated_data = copy.deepcopy(original_data)
15
+ _walk_dict(updated_data, new_data, **kwargs)
16
+ return updated_data
17
+
18
+
19
+ def _walk_dict(old_data: DynamoItem, new_data: DynamoItem, **kwargs: Any) -> None:
20
+ for new_key in new_data.keys():
21
+ if old_data.get(new_key) and isinstance(new_data[new_key], dict):
22
+ _walk_dict(old_data[new_key], new_data[new_key], **kwargs)
23
+ elif isinstance(old_data.get(new_key), list) and isinstance(new_data[new_key], list):
24
+ old_data[new_key] = _merge_lists(
25
+ old_data[new_key], new_data[new_key], kwargs.get("update_list_operation", "add")
26
+ )
27
+ else:
28
+ _merge_dicts(new_key, old_data, new_data, kwargs.get("update_dict_operation", "upsert"))
29
+
30
+
31
+ def _merge_dicts(
32
+ dict_key: str,
33
+ old_dict: DynamoItem,
34
+ new_dict: DynamoItem,
35
+ update_dict_operation: str = "upsert",
36
+ ) -> None:
37
+ if update_dict_operation == "remove":
38
+ old_dict.pop(dict_key, None)
39
+ else:
40
+ old_dict[dict_key] = new_dict[dict_key]
41
+
42
+
43
+ def _merge_lists(
44
+ old_list: List[Any], new_list: List[Any], update_list_operation: str = "add"
45
+ ) -> List[Any]:
46
+ if update_list_operation == "remove":
47
+ _remove_item_in_list(old_list, new_list)
48
+ elif update_list_operation == "add":
49
+ _add_unique_item_in_list(old_list, new_list)
50
+ elif update_list_operation == "replace":
51
+ old_list = new_list
52
+ return old_list
53
+
54
+
55
+ def _remove_item_in_list(old_list: List[Any], new_list: List[Any]) -> None:
56
+ for old_item in old_list:
57
+ old = sorted(old_item.items()) if isinstance(old_item, dict) else old_item
58
+ new = sorted(new_list[0].items()) if isinstance(new_list[0], dict) else new_list[0]
59
+ if json.dumps(old) == json.dumps(new):
60
+ old_list.remove(old_item)
61
+
62
+
63
+ def _add_unique_item_in_list(old_list: List[Any], new_list: List[Any]) -> None:
64
+ for item in new_list:
65
+ if item not in old_list:
66
+ old_list.append(item)
@@ -0,0 +1,19 @@
1
+ """Safe JSON encoding/decoding helpers."""
2
+
3
+ from typing import Any
4
+
5
+ import json
6
+
7
+
8
+ def try_decode_json(possible_json: Any) -> Any:
9
+ try:
10
+ return json.loads(possible_json)
11
+ except Exception: # pylint: disable=broad-except
12
+ return possible_json
13
+
14
+
15
+ def try_encode_json(possible_json: Any) -> Any:
16
+ try:
17
+ return json.dumps(possible_json)
18
+ except Exception: # pylint: disable=broad-except
19
+ return possible_json
@@ -0,0 +1,16 @@
1
+ """Lightweight stdout logger honoring RUN_MODE."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ import os
6
+
7
+ from . import json_helper
8
+
9
+
10
+ def log(**kwargs: Any) -> None:
11
+ if os.getenv("RUN_MODE") != "unittest":
12
+ payload: Dict[str, Any] = {
13
+ "level": kwargs.get("level", "INFO"),
14
+ "log": kwargs.get("log", {}),
15
+ }
16
+ print(json_helper.try_encode_json(payload))
@@ -0,0 +1,29 @@
1
+ """SNS publisher helper used by adapters for event fan-out."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ import boto3
6
+ import simplejson as json
7
+
8
+ from . import logger
9
+
10
+
11
+ def publish(**kwargs: Any) -> None:
12
+ if not kwargs.get("arn") or not kwargs.get("data"):
13
+ return
14
+ try:
15
+ sns_client = boto3.client(
16
+ "sns", region_name=kwargs.get("region"), endpoint_url=kwargs.get("endpoint")
17
+ )
18
+ publish_kwargs: Dict[str, Any] = {
19
+ "TopicArn": kwargs["arn"],
20
+ "Message": json.dumps(kwargs["data"]),
21
+ "MessageAttributes": kwargs.get("attributes", {}),
22
+ }
23
+ if kwargs.get("fifo_group_id"):
24
+ publish_kwargs["MessageGroupId"] = kwargs["fifo_group_id"]
25
+ if kwargs.get("fifo_duplication_id"):
26
+ publish_kwargs["MessageDeduplicationId"] = kwargs["fifo_duplication_id"]
27
+ sns_client.publish(**publish_kwargs)
28
+ except Exception as exc: # pylint: disable=broad-except
29
+ logger.log(level="WARN", log={"error": f"publish_sns_error: {exc}"})
@@ -0,0 +1,13 @@
1
+ """Loads schema definitions with JSON reference resolution."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ import jsonref
6
+ import simplejson as json
7
+ import yaml
8
+
9
+
10
+ def load_schema(schema_file: str, schema_key: str) -> Dict[str, Any]:
11
+ with open(schema_file, encoding="UTF-8") as openapi:
12
+ api_doc = yaml.load(openapi, Loader=yaml.FullLoader)
13
+ return jsonref.loads(json.dumps(api_doc))["components"]["schemas"][schema_key]
@@ -0,0 +1,51 @@
1
+ """Helpers that map arbitrary data to JSON schema-defined structures."""
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ from daplug_ddb.types import DynamoItem
6
+
7
+ from . import schema_loader
8
+
9
+
10
+ def map_to_schema(data: DynamoItem, schema_file: str, schema_key: str) -> DynamoItem:
11
+ model_data: DynamoItem = {}
12
+ model_schema = schema_loader.load_schema(schema_file, schema_key)
13
+ schemas = model_schema["allOf"] if model_schema.get("allOf") else [model_schema]
14
+ for model in schemas:
15
+ if model.get("type") == "object":
16
+ _populate_model_data(model.get("properties", {}), data, model_data)
17
+ return model_data
18
+
19
+
20
+ def _populate_model_data(
21
+ properties: Dict[str, Any], data: Any, model_data: DynamoItem
22
+ ) -> DynamoItem:
23
+ if data and isinstance(data, dict):
24
+ _populate_model_dict(properties, data, model_data)
25
+ return model_data
26
+
27
+
28
+ def _populate_model_dict(properties: Dict[str, Any], data: Dict[str, Any], model_data: DynamoItem) -> None:
29
+ for property_key, property_value in properties.items():
30
+ model_data[property_key] = {}
31
+ if property_value.get("properties"):
32
+ _populate_model_data(
33
+ property_value["properties"], data.get(property_key), model_data[property_key]
34
+ )
35
+ elif property_value.get("items", {}).get("properties"):
36
+ _populate_model_list(model_data, property_key, property_value, data)
37
+ else:
38
+ model_data[property_key] = data.get(property_key)
39
+
40
+
41
+ def _populate_model_list(
42
+ model_data: DynamoItem, property_key: str, property_value: Dict[str, Any], data: Dict[str, Any]
43
+ ) -> None:
44
+ model_data[property_key] = []
45
+ items: List[Any] = data.get(property_key, [])
46
+ for index in range(len(items)): # pylint: disable=consider-using-enumerate
47
+ if data.get(property_key) and isinstance(items, list) and index < len(items):
48
+ pop = _populate_model_data(
49
+ property_value["items"]["properties"], items[index], {}
50
+ )
51
+ model_data[property_key].append(pop)
@@ -0,0 +1,2 @@
1
+ class BatchItemException(Exception):
2
+ """Raised when batched operations receive invalid input."""
@@ -0,0 +1,7 @@
1
+ """Type exports for daplug_ddb."""
2
+
3
+ from .dynamo_item import DynamoItem
4
+ from .dynamo_items import DynamoItems
5
+ from .message_attributes import MessageAttributes
6
+
7
+ __all__ = ["DynamoItem", "DynamoItems", "MessageAttributes"]
@@ -0,0 +1,7 @@
1
+ """Type alias for a DynamoDB item."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ DynamoItem = Dict[str, Any]
6
+
7
+ __all__ = ["DynamoItem"]
@@ -0,0 +1,9 @@
1
+ """Type alias for a list of DynamoDB items."""
2
+
3
+ from typing import List
4
+
5
+ from .dynamo_item import DynamoItem
6
+
7
+ DynamoItems = List[DynamoItem]
8
+
9
+ __all__ = ["DynamoItems"]
@@ -0,0 +1,7 @@
1
+ """Type alias for SNS message attributes."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ MessageAttributes = Dict[str, Dict[str, Any]]
6
+
7
+ __all__ = ["MessageAttributes"]