daplug-ddb 1.0.0b8__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 +15 -0
- daplug_ddb/adapter.py +303 -0
- daplug_ddb/common/__init__.py +11 -0
- daplug_ddb/common/base_adapter.py +69 -0
- daplug_ddb/common/dict_merger.py +66 -0
- daplug_ddb/common/json_helper.py +19 -0
- daplug_ddb/common/logger.py +16 -0
- daplug_ddb/common/publisher.py +29 -0
- daplug_ddb/common/schema_loader.py +13 -0
- daplug_ddb/common/schema_mapper.py +51 -0
- daplug_ddb/exception.py +2 -0
- daplug_ddb/prefixer.py +183 -0
- daplug_ddb/types/__init__.py +15 -0
- daplug_ddb/types/dynamo_item.py +7 -0
- daplug_ddb/types/dynamo_items.py +9 -0
- daplug_ddb/types/message_attributes.py +7 -0
- daplug_ddb/types/prefix_config.py +10 -0
- daplug_ddb/types/schema_config.py +11 -0
- daplug_ddb-1.0.0b8.dist-info/METADATA +552 -0
- daplug_ddb-1.0.0b8.dist-info/RECORD +31 -0
- daplug_ddb-1.0.0b8.dist-info/WHEEL +5 -0
- daplug_ddb-1.0.0b8.dist-info/licenses/LICENSE +201 -0
- daplug_ddb-1.0.0b8.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/integration/__init__.py +0 -0
- tests/integration/mock_table.py +63 -0
- tests/integration/test_adapter.py +425 -0
- tests/unit/__init__.py +0 -0
- tests/unit/mocks.py +84 -0
- tests/unit/test_adapter_unit.py +293 -0
- tests/unit/test_prefixer.py +64 -0
daplug_ddb/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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 for creating a DynamoDB adapter."""
|
|
10
|
+
|
|
11
|
+
kwargs.pop("engine", None) # allow legacy callers to pass engine without effect
|
|
12
|
+
return DynamodbAdapter(**kwargs)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["adapter", "DynamodbAdapter", "BatchItemException"]
|
daplug_ddb/adapter.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""DynamoDB adapter exposing normalized CRUD operations."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Any, Dict, Iterable, Optional, Union
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from boto3.dynamodb.conditions import Attr
|
|
10
|
+
|
|
11
|
+
from daplug_ddb.prefixer import DynamodbPrefixer
|
|
12
|
+
from daplug_ddb.types import DynamoItem, DynamoItems
|
|
13
|
+
|
|
14
|
+
from .common import map_to_schema, merge
|
|
15
|
+
from .common.base_adapter import BaseAdapter
|
|
16
|
+
from .exception import BatchItemException
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DynamodbAdapter(BaseAdapter):
|
|
20
|
+
"""Implements DynamoDB CRUD operations with schema normalization."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
23
|
+
super().__init__(**kwargs)
|
|
24
|
+
self.table = self.__get_dynamo_table(kwargs["table"], kwargs.get("endpoint"))
|
|
25
|
+
self.schema_file: Optional[str] = kwargs.get("schema_file")
|
|
26
|
+
self.identifier: str = kwargs.get("identifier", "")
|
|
27
|
+
self.idempotence_key: Optional[str] = kwargs.get("idempotence_key")
|
|
28
|
+
self.raise_idempotence_error: bool = kwargs.get("raise_idempotence_error", False)
|
|
29
|
+
self.idempotence_use_latest: bool = kwargs.get("idempotence_use_latest", False)
|
|
30
|
+
self._prefix_keys = ("hash_key", "hash_prefix", "range_key", "range_prefix")
|
|
31
|
+
self._default_prefix_config = self.__extract_prefix_config(kwargs)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def prefixing_enabled(self) -> bool:
|
|
35
|
+
"""Returns True when adapter-level prefix config has been supplied."""
|
|
36
|
+
|
|
37
|
+
return bool(self._default_prefix_config)
|
|
38
|
+
|
|
39
|
+
def create(self, **kwargs: Any) -> DynamoItem:
|
|
40
|
+
if kwargs.get("operation") == "overwrite":
|
|
41
|
+
return self.overwrite(**kwargs)
|
|
42
|
+
return self.insert(**kwargs)
|
|
43
|
+
|
|
44
|
+
def read(self, **kwargs: Any) -> Union[DynamoItem, DynamoItems, Dict[str, Any]]:
|
|
45
|
+
if kwargs.get("operation") == "query":
|
|
46
|
+
return self.query(**kwargs)
|
|
47
|
+
if kwargs.get("operation") == "scan":
|
|
48
|
+
return self.scan(**kwargs)
|
|
49
|
+
return self.get(**kwargs)
|
|
50
|
+
|
|
51
|
+
def scan(self, **kwargs: Any) -> Union[DynamoItems, Dict[str, Any]]:
|
|
52
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
53
|
+
request_args = self.__prepare_request_arguments(prefixer, kwargs)
|
|
54
|
+
response = self.table.scan(**request_args)
|
|
55
|
+
if not prefixer.enabled:
|
|
56
|
+
return response if kwargs.get("raw_scan") else response.get("Items", [])
|
|
57
|
+
cleaned_response = prefixer.apply_response(response, add=False)
|
|
58
|
+
if kwargs.get("raw_scan"):
|
|
59
|
+
return cleaned_response if cleaned_response is not None else response
|
|
60
|
+
cleaned = cleaned_response or {}
|
|
61
|
+
return cleaned.get("Items", [])
|
|
62
|
+
|
|
63
|
+
def get(self, **kwargs: Any) -> DynamoItem:
|
|
64
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
65
|
+
request_args = self.__prepare_request_arguments(prefixer, kwargs)
|
|
66
|
+
result: Dict[str, Any] = self.table.get_item(**request_args)
|
|
67
|
+
item = result.get("Item", {})
|
|
68
|
+
if not prefixer.enabled:
|
|
69
|
+
return item if isinstance(item, dict) else {}
|
|
70
|
+
cleaned = prefixer.apply_item(item, add=False)
|
|
71
|
+
return cleaned if isinstance(cleaned, dict) else item
|
|
72
|
+
|
|
73
|
+
def query(self, **kwargs: Any) -> Union[DynamoItems, Dict[str, Any]]:
|
|
74
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
75
|
+
request_args = self.__prepare_request_arguments(prefixer, kwargs)
|
|
76
|
+
response = self.table.query(**request_args)
|
|
77
|
+
if not prefixer.enabled:
|
|
78
|
+
return response if kwargs.get("raw_query") else response.get("Items", [])
|
|
79
|
+
cleaned_response = prefixer.apply_response(response, add=False)
|
|
80
|
+
if kwargs.get("raw_query"):
|
|
81
|
+
return cleaned_response if cleaned_response is not None else response
|
|
82
|
+
cleaned = cleaned_response or {}
|
|
83
|
+
return cleaned.get("Items", [])
|
|
84
|
+
|
|
85
|
+
def overwrite(self, **kwargs: Any) -> DynamoItem:
|
|
86
|
+
payload = self.__map_with_schema(kwargs["data"], kwargs)
|
|
87
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
88
|
+
item_to_store = (
|
|
89
|
+
prefixer.apply_item(payload, add=True)
|
|
90
|
+
if prefixer.enabled
|
|
91
|
+
else payload
|
|
92
|
+
)
|
|
93
|
+
self.table.put_item(Item=item_to_store)
|
|
94
|
+
response_item = (
|
|
95
|
+
prefixer.apply_item(item_to_store, add=False)
|
|
96
|
+
if prefixer.enabled
|
|
97
|
+
else payload
|
|
98
|
+
)
|
|
99
|
+
result_item = response_item if isinstance(response_item, dict) else payload
|
|
100
|
+
super().publish("create", result_item, **kwargs)
|
|
101
|
+
return result_item
|
|
102
|
+
|
|
103
|
+
def insert(self, **kwargs: Any) -> DynamoItem:
|
|
104
|
+
payload = self.__map_with_schema(kwargs["data"], kwargs)
|
|
105
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
106
|
+
item_to_store = (
|
|
107
|
+
prefixer.apply_item(payload, add=True)
|
|
108
|
+
if prefixer.enabled
|
|
109
|
+
else payload
|
|
110
|
+
)
|
|
111
|
+
self.table.put_item(
|
|
112
|
+
Item=item_to_store,
|
|
113
|
+
ConditionExpression=Attr(self.identifier).not_exists(),
|
|
114
|
+
)
|
|
115
|
+
response_item = (
|
|
116
|
+
prefixer.apply_item(item_to_store, add=False)
|
|
117
|
+
if prefixer.enabled
|
|
118
|
+
else payload
|
|
119
|
+
)
|
|
120
|
+
result_item = response_item if isinstance(response_item, dict) else payload
|
|
121
|
+
super().publish("create", result_item, **kwargs)
|
|
122
|
+
return result_item
|
|
123
|
+
|
|
124
|
+
def batch_insert(self, **kwargs: Any) -> None:
|
|
125
|
+
data = kwargs["data"]
|
|
126
|
+
batch_size: int = kwargs.get("batch_size", 25)
|
|
127
|
+
|
|
128
|
+
if not isinstance(data, list):
|
|
129
|
+
raise BatchItemException("Batched data must be contained within a list")
|
|
130
|
+
mapped_items = [self.__map_with_schema(item, kwargs) for item in data]
|
|
131
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
132
|
+
batched_data: Iterable[DynamoItems] = (
|
|
133
|
+
mapped_items[pos: pos + batch_size] for pos in range(0, len(mapped_items), batch_size)
|
|
134
|
+
)
|
|
135
|
+
with self.table.batch_writer() as writer:
|
|
136
|
+
for batch in batched_data:
|
|
137
|
+
if prefixer.enabled:
|
|
138
|
+
prefixed_batch = prefixer.apply_items(batch, add=True)
|
|
139
|
+
items_to_store = prefixed_batch if prefixed_batch is not None else batch
|
|
140
|
+
else:
|
|
141
|
+
items_to_store = batch
|
|
142
|
+
for item in items_to_store:
|
|
143
|
+
writer.put_item(Item=item)
|
|
144
|
+
|
|
145
|
+
def delete(self, **kwargs: Any) -> DynamoItem:
|
|
146
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
147
|
+
request_args = self.__prepare_request_arguments(prefixer, kwargs)
|
|
148
|
+
request_args["ReturnValues"] = "ALL_OLD"
|
|
149
|
+
result = self.table.delete_item(**request_args).get("Attributes", {})
|
|
150
|
+
if prefixer.enabled:
|
|
151
|
+
cleaned = prefixer.apply_item(result, add=False)
|
|
152
|
+
cleaned_item = cleaned if isinstance(cleaned, dict) else result
|
|
153
|
+
else:
|
|
154
|
+
cleaned_item = result
|
|
155
|
+
super().publish("delete", cleaned_item, **kwargs)
|
|
156
|
+
return cleaned_item if isinstance(cleaned_item, dict) else {}
|
|
157
|
+
|
|
158
|
+
def batch_delete(self, **kwargs: Any) -> None:
|
|
159
|
+
batch_size: int = kwargs.get("batch_size", 25)
|
|
160
|
+
if not isinstance(kwargs["data"], list):
|
|
161
|
+
raise BatchItemException("Batched data must be contained within a list")
|
|
162
|
+
batched_data: Iterable[DynamoItems] = (
|
|
163
|
+
kwargs["data"][pos: pos + batch_size]
|
|
164
|
+
for pos in range(0, len(kwargs["data"]), batch_size)
|
|
165
|
+
)
|
|
166
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
167
|
+
with self.table.batch_writer() as writer:
|
|
168
|
+
for batch in batched_data:
|
|
169
|
+
if prefixer.enabled:
|
|
170
|
+
prefixed_batch = prefixer.apply_items(batch, add=True)
|
|
171
|
+
items_to_delete = prefixed_batch if prefixed_batch is not None else batch
|
|
172
|
+
else:
|
|
173
|
+
items_to_delete = batch
|
|
174
|
+
for item in items_to_delete:
|
|
175
|
+
writer.delete_item(Key=item)
|
|
176
|
+
|
|
177
|
+
def update(self, **kwargs: Any) -> DynamoItem:
|
|
178
|
+
prefixer = self.__build_prefixer(kwargs)
|
|
179
|
+
original_data = self.__get_original_data(**kwargs)
|
|
180
|
+
merged_data = merge(original_data, kwargs["data"], **kwargs)
|
|
181
|
+
payload = self.__map_with_schema(merged_data, kwargs)
|
|
182
|
+
if prefixer.enabled:
|
|
183
|
+
prefixed_item = prefixer.apply_item(payload, add=True)
|
|
184
|
+
data_to_store = prefixed_item if isinstance(prefixed_item, dict) else payload
|
|
185
|
+
response_template_raw = prefixer.apply_item(data_to_store, add=False)
|
|
186
|
+
response_template = (
|
|
187
|
+
response_template_raw if isinstance(response_template_raw, dict) else payload
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
data_to_store = payload
|
|
191
|
+
response_template = payload
|
|
192
|
+
key_name = self.idempotence_key
|
|
193
|
+
original_value = (
|
|
194
|
+
original_data.get(key_name)
|
|
195
|
+
if isinstance(original_data, dict) and key_name
|
|
196
|
+
else None
|
|
197
|
+
)
|
|
198
|
+
new_value = (
|
|
199
|
+
response_template.get(key_name)
|
|
200
|
+
if isinstance(response_template, dict) and key_name
|
|
201
|
+
else None
|
|
202
|
+
)
|
|
203
|
+
if self.__should_use_latest(original_value, new_value):
|
|
204
|
+
return self.__clean_for_response(prefixer, original_data)
|
|
205
|
+
put_kwargs = self.__build_put_kwargs(original_value, data_to_store)
|
|
206
|
+
self.table.put_item(**put_kwargs)
|
|
207
|
+
cleaned_item = self.__clean_for_response(prefixer, data_to_store)
|
|
208
|
+
super().publish("update", cleaned_item, **kwargs)
|
|
209
|
+
return cleaned_item
|
|
210
|
+
|
|
211
|
+
@lru_cache(maxsize=128)
|
|
212
|
+
def __get_dynamo_table(self, table: str, endpoint: Optional[str] = None) -> Any:
|
|
213
|
+
return boto3.resource("dynamodb", endpoint_url=endpoint).Table(table)
|
|
214
|
+
|
|
215
|
+
def __build_put_kwargs(self, original_value: Any, data_to_store: Dict[str, Any]) -> Dict[str, Any]:
|
|
216
|
+
put_kwargs: Dict[str, Any] = {"Item": data_to_store}
|
|
217
|
+
if not self.idempotence_key:
|
|
218
|
+
return put_kwargs
|
|
219
|
+
if original_value is None:
|
|
220
|
+
if self.raise_idempotence_error:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"idempotence key '{self.idempotence_key}' not found in original item"
|
|
223
|
+
)
|
|
224
|
+
return put_kwargs
|
|
225
|
+
if (
|
|
226
|
+
original_value != data_to_store.get(self.idempotence_key)
|
|
227
|
+
and self.raise_idempotence_error
|
|
228
|
+
):
|
|
229
|
+
raise ValueError("update: idempotence key value has changed")
|
|
230
|
+
put_kwargs["ConditionExpression"] = Attr(self.idempotence_key).eq(original_value)
|
|
231
|
+
return put_kwargs
|
|
232
|
+
|
|
233
|
+
def __clean_for_response(self, prefixer: DynamodbPrefixer, item: Dict[str, Any]) -> Dict[str, Any]:
|
|
234
|
+
if not prefixer.enabled:
|
|
235
|
+
return item
|
|
236
|
+
cleaned = prefixer.apply_item(item, add=False)
|
|
237
|
+
return cleaned if isinstance(cleaned, dict) else item
|
|
238
|
+
|
|
239
|
+
def __get_original_data(self, **kwargs: Any) -> DynamoItem:
|
|
240
|
+
if kwargs["operation"] == "get":
|
|
241
|
+
original_data = self.get(**kwargs)
|
|
242
|
+
else:
|
|
243
|
+
query_result = self.query(**kwargs)
|
|
244
|
+
if isinstance(query_result, list):
|
|
245
|
+
items = query_result
|
|
246
|
+
elif isinstance(query_result, dict):
|
|
247
|
+
items = query_result.get("Items", [])
|
|
248
|
+
else:
|
|
249
|
+
items = []
|
|
250
|
+
if not items:
|
|
251
|
+
raise ValueError("update: no data found to update")
|
|
252
|
+
original_data = items[0]
|
|
253
|
+
if not original_data:
|
|
254
|
+
raise ValueError("update: no data found to update")
|
|
255
|
+
return original_data
|
|
256
|
+
|
|
257
|
+
def __should_use_latest(self, original_value: Any, new_value: Any) -> bool:
|
|
258
|
+
if not self.idempotence_use_latest or not self.idempotence_key:
|
|
259
|
+
return False
|
|
260
|
+
if original_value is None or new_value is None:
|
|
261
|
+
return False
|
|
262
|
+
try:
|
|
263
|
+
original_dt = datetime.fromisoformat(str(original_value))
|
|
264
|
+
new_dt = datetime.fromisoformat(str(new_value))
|
|
265
|
+
except ValueError as exc:
|
|
266
|
+
raise ValueError("idempotence_use_latest requires ISO date-compatible values") from exc
|
|
267
|
+
return original_dt > new_dt
|
|
268
|
+
|
|
269
|
+
def __map_with_schema(self, data: Dict[str, Any], call_kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
270
|
+
schema_file = call_kwargs.get("schema_file") or self.schema_file
|
|
271
|
+
schema_name = call_kwargs.get("schema")
|
|
272
|
+
if schema_file and schema_name:
|
|
273
|
+
return map_to_schema(data, schema_file, schema_name)
|
|
274
|
+
return deepcopy(data)
|
|
275
|
+
|
|
276
|
+
def __build_prefixer(self, call_kwargs: Dict[str, Any]) -> DynamodbPrefixer:
|
|
277
|
+
config = dict(self._default_prefix_config)
|
|
278
|
+
for key in self._prefix_keys:
|
|
279
|
+
value = call_kwargs.get(key)
|
|
280
|
+
if value is not None:
|
|
281
|
+
config[key] = value
|
|
282
|
+
return DynamodbPrefixer(**config)
|
|
283
|
+
|
|
284
|
+
def __extract_prefix_config(self, source: Dict[str, Any]) -> Dict[str, Any]:
|
|
285
|
+
config: Dict[str, Any] = {}
|
|
286
|
+
for key in ("hash_key", "hash_prefix", "range_key", "range_prefix"):
|
|
287
|
+
value = source.get(key)
|
|
288
|
+
if value is not None:
|
|
289
|
+
config[key] = value
|
|
290
|
+
return config
|
|
291
|
+
|
|
292
|
+
def __prepare_request_arguments(
|
|
293
|
+
self,
|
|
294
|
+
prefixer: DynamodbPrefixer,
|
|
295
|
+
call_kwargs: Dict[str, Any],
|
|
296
|
+
) -> Dict[str, Any]:
|
|
297
|
+
raw_query = call_kwargs.get("query") or {}
|
|
298
|
+
if not isinstance(raw_query, dict):
|
|
299
|
+
return {}
|
|
300
|
+
if prefixer.enabled:
|
|
301
|
+
transformed = prefixer.apply_request(raw_query, add=True)
|
|
302
|
+
return transformed if isinstance(transformed, dict) else {}
|
|
303
|
+
return deepcopy(raw_query)
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
"identifier": kwargs.get("identifier"),
|
|
21
|
+
"idempotence_key": kwargs.get("idempotence_key"),
|
|
22
|
+
"author_identifier": kwargs.get("author_identifier"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def publish(self, db_operation: str, db_data: Dict[str, Any], **kwargs: Any) -> None:
|
|
26
|
+
attributes = self.create_format_attibutes(db_operation, kwargs.get("sns_attributes", {}))
|
|
27
|
+
self.publisher.publish(
|
|
28
|
+
endpoint=self.sns_endpoint,
|
|
29
|
+
arn=self.sns_arn,
|
|
30
|
+
attributes=attributes,
|
|
31
|
+
data=db_data,
|
|
32
|
+
fifo_group_id=kwargs.get("fifo_group_id"),
|
|
33
|
+
fifo_duplication_id=kwargs.get("fifo_duplication_id"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def create_format_attibutes(self, operation: str, call_attributes: dict) -> MessageAttributes:
|
|
37
|
+
combined = self.__combined_attributes(operation, call_attributes)
|
|
38
|
+
formatted_attributes: MessageAttributes = {}
|
|
39
|
+
for key, value in combined.items():
|
|
40
|
+
if value is not None:
|
|
41
|
+
data_type = "String" if isinstance(value, str) else "Number"
|
|
42
|
+
formatted_attributes[key] = {
|
|
43
|
+
"DataType": data_type,
|
|
44
|
+
"StringValue": value,
|
|
45
|
+
}
|
|
46
|
+
return formatted_attributes
|
|
47
|
+
|
|
48
|
+
def __combined_attributes(self, operation: str, call_attributes: dict) -> Dict[str, Any]:
|
|
49
|
+
pieces = []
|
|
50
|
+
base: Dict[str, Any] = {}
|
|
51
|
+
if self.sns_defaults:
|
|
52
|
+
base.update(self.default_attributes)
|
|
53
|
+
base["operation"] = operation
|
|
54
|
+
pieces.append(base)
|
|
55
|
+
|
|
56
|
+
if self.sns_custom:
|
|
57
|
+
pieces.append(self.sns_custom)
|
|
58
|
+
if call_attributes:
|
|
59
|
+
pieces.append(call_attributes)
|
|
60
|
+
|
|
61
|
+
return self.__merge_attributes(*pieces)
|
|
62
|
+
|
|
63
|
+
def __merge_attributes(self, *dicts: Dict[str, Any]) -> Dict[str, Any]:
|
|
64
|
+
merged: Dict[str, Any] = {}
|
|
65
|
+
for data in dicts:
|
|
66
|
+
if not data:
|
|
67
|
+
continue
|
|
68
|
+
merged.update(data)
|
|
69
|
+
return merged
|
|
@@ -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)
|
daplug_ddb/exception.py
ADDED