Skip to content

Commit bf0ae43

Browse files
shanabheitorlessa
andauthored
feat(data-classes): replace AttributeValue in DynamoDBStreamEvent with deserialized Python values (#1619)
Co-authored-by: heitorlessa <lessa@amazon.co.uk>
1 parent 2864b46 commit bf0ae43

File tree

7 files changed

+225
-309
lines changed

7 files changed

+225
-309
lines changed

aws_lambda_powertools/utilities/batch/base.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,10 @@ def lambda_handler(event, context: LambdaContext):
323323
@tracer.capture_method
324324
def record_handler(record: DynamoDBRecord):
325325
logger.info(record.dynamodb.new_image)
326-
payload: dict = json.loads(record.dynamodb.new_image.get("item").s_value)
326+
payload: dict = json.loads(record.dynamodb.new_image.get("item"))
327327
# alternatively:
328-
# changes: Dict[str, dynamo_db_stream_event.AttributeValue] = record.dynamodb.new_image # noqa: E800
329-
# payload = change.get("Message").raw_event -> {"S": "<payload>"}
328+
# changes: Dict[str, Any] = record.dynamodb.new_image # noqa: E800
329+
# payload = change.get("Message") -> "<payload>"
330330
...
331331
332332
@logger.inject_lambda_context

aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py

+107-155
Original file line numberDiff line numberDiff line change
@@ -1,169 +1,100 @@
1+
from decimal import Clamped, Context, Decimal, Inexact, Overflow, Rounded, Underflow
12
from enum import Enum
2-
from typing import Any, Dict, Iterator, List, Optional, Union
3+
from typing import Any, Callable, Dict, Iterator, Optional, Sequence, Set
34

45
from aws_lambda_powertools.utilities.data_classes.common import DictWrapper
56

7+
# NOTE: DynamoDB supports up to 38 digits precision
8+
# Therefore, this ensures our Decimal follows what's stored in the table
9+
DYNAMODB_CONTEXT = Context(
10+
Emin=-128,
11+
Emax=126,
12+
prec=38,
13+
traps=[Clamped, Overflow, Inexact, Rounded, Underflow],
14+
)
615

7-
class AttributeValueType(Enum):
8-
Binary = "B"
9-
BinarySet = "BS"
10-
Boolean = "BOOL"
11-
List = "L"
12-
Map = "M"
13-
Number = "N"
14-
NumberSet = "NS"
15-
Null = "NULL"
16-
String = "S"
17-
StringSet = "SS"
1816

17+
class TypeDeserializer:
18+
"""
19+
Deserializes DynamoDB types to Python types.
1920
20-
class AttributeValue(DictWrapper):
21-
"""Represents the data for an attribute
21+
It's based on boto3's [DynamoDB TypeDeserializer](https://boto3.amazonaws.com/v1/documentation/api/latest/_modules/boto3/dynamodb/types.html). # noqa: E501
2222
23-
Documentation:
24-
--------------
25-
- https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_streams_AttributeValue.html
26-
- https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html
23+
The only notable difference is that for Binary (`B`, `BS`) values we return Python Bytes directly,
24+
since we don't support Python 2.
2725
"""
2826

29-
def __init__(self, data: Dict[str, Any]):
30-
"""AttributeValue constructor
27+
def deserialize(self, value: Dict) -> Any:
28+
"""Deserialize DynamoDB data types into Python types.
3129
3230
Parameters
3331
----------
34-
data: Dict[str, Any]
35-
Raw lambda event dict
36-
"""
37-
super().__init__(data)
38-
self.dynamodb_type = list(data.keys())[0]
32+
value: Any
33+
DynamoDB value to be deserialized to a python type
3934
40-
@property
41-
def b_value(self) -> Optional[str]:
42-
"""An attribute of type Base64-encoded binary data object
4335
44-
Example:
45-
>>> {"B": "dGhpcyB0ZXh0IGlzIGJhc2U2NC1lbmNvZGVk"}
46-
"""
47-
return self.get("B")
36+
Here are the various conversions:
4837
49-
@property
50-
def bs_value(self) -> Optional[List[str]]:
51-
"""An attribute of type Array of Base64-encoded binary data objects
52-
53-
Example:
54-
>>> {"BS": ["U3Vubnk=", "UmFpbnk=", "U25vd3k="]}
55-
"""
56-
return self.get("BS")
57-
58-
@property
59-
def bool_value(self) -> Optional[bool]:
60-
"""An attribute of type Boolean
61-
62-
Example:
63-
>>> {"BOOL": True}
64-
"""
65-
item = self.get("BOOL")
66-
return None if item is None else bool(item)
38+
DynamoDB Python
39+
-------- ------
40+
{'NULL': True} None
41+
{'BOOL': True/False} True/False
42+
{'N': str(value)} str(value)
43+
{'S': string} string
44+
{'B': bytes} bytes
45+
{'NS': [str(value)]} set([str(value)])
46+
{'SS': [string]} set([string])
47+
{'BS': [bytes]} set([bytes])
48+
{'L': list} list
49+
{'M': dict} dict
6750
68-
@property
69-
def list_value(self) -> Optional[List["AttributeValue"]]:
70-
"""An attribute of type Array of AttributeValue objects
71-
72-
Example:
73-
>>> {"L": [ {"S": "Cookies"} , {"S": "Coffee"}, {"N": "3.14159"}]}
74-
"""
75-
item = self.get("L")
76-
return None if item is None else [AttributeValue(v) for v in item]
77-
78-
@property
79-
def map_value(self) -> Optional[Dict[str, "AttributeValue"]]:
80-
"""An attribute of type String to AttributeValue object map
81-
82-
Example:
83-
>>> {"M": {"Name": {"S": "Joe"}, "Age": {"N": "35"}}}
84-
"""
85-
return _attribute_value_dict(self._data, "M")
86-
87-
@property
88-
def n_value(self) -> Optional[str]:
89-
"""An attribute of type Number
90-
91-
Numbers are sent across the network to DynamoDB as strings, to maximize compatibility across languages
92-
and libraries. However, DynamoDB treats them as number type attributes for mathematical operations.
51+
Parameters
52+
----------
53+
value: Any
54+
DynamoDB value to be deserialized to a python type
9355
94-
Example:
95-
>>> {"N": "123.45"}
56+
Returns
57+
--------
58+
any
59+
Python native type converted from DynamoDB type
9660
"""
97-
return self.get("N")
98-
99-
@property
100-
def ns_value(self) -> Optional[List[str]]:
101-
"""An attribute of type Number Set
10261

103-
Example:
104-
>>> {"NS": ["42.2", "-19", "7.5", "3.14"]}
105-
"""
106-
return self.get("NS")
62+
dynamodb_type = list(value.keys())[0]
63+
deserializer: Optional[Callable] = getattr(self, f"_deserialize_{dynamodb_type}".lower(), None)
64+
if deserializer is None:
65+
raise TypeError(f"Dynamodb type {dynamodb_type} is not supported")
10766

108-
@property
109-
def null_value(self) -> None:
110-
"""An attribute of type Null.
67+
return deserializer(value[dynamodb_type])
11168

112-
Example:
113-
>>> {"NULL": True}
114-
"""
69+
def _deserialize_null(self, value: bool) -> None:
11570
return None
11671

117-
@property
118-
def s_value(self) -> Optional[str]:
119-
"""An attribute of type String
72+
def _deserialize_bool(self, value: bool) -> bool:
73+
return value
12074

121-
Example:
122-
>>> {"S": "Hello"}
123-
"""
124-
return self.get("S")
75+
def _deserialize_n(self, value: str) -> Decimal:
76+
return DYNAMODB_CONTEXT.create_decimal(value)
12577

126-
@property
127-
def ss_value(self) -> Optional[List[str]]:
128-
"""An attribute of type Array of strings
78+
def _deserialize_s(self, value: str) -> str:
79+
return value
12980

130-
Example:
131-
>>> {"SS": ["Giraffe", "Hippo" ,"Zebra"]}
132-
"""
133-
return self.get("SS")
81+
def _deserialize_b(self, value: bytes) -> bytes:
82+
return value
13483

135-
@property
136-
def get_type(self) -> AttributeValueType:
137-
"""Get the attribute value type based on the contained data"""
138-
return AttributeValueType(self.dynamodb_type)
84+
def _deserialize_ns(self, value: Sequence[str]) -> Set[Decimal]:
85+
return set(map(self._deserialize_n, value))
13986

140-
@property
141-
def l_value(self) -> Optional[List["AttributeValue"]]:
142-
"""Alias of list_value"""
143-
return self.list_value
87+
def _deserialize_ss(self, value: Sequence[str]) -> Set[str]:
88+
return set(map(self._deserialize_s, value))
14489

145-
@property
146-
def m_value(self) -> Optional[Dict[str, "AttributeValue"]]:
147-
"""Alias of map_value"""
148-
return self.map_value
90+
def _deserialize_bs(self, value: Sequence[bytes]) -> Set[bytes]:
91+
return set(map(self._deserialize_b, value))
14992

150-
@property
151-
def get_value(self) -> Union[Optional[bool], Optional[str], Optional[List], Optional[Dict]]:
152-
"""Get the attribute value"""
153-
try:
154-
return getattr(self, f"{self.dynamodb_type.lower()}_value")
155-
except AttributeError:
156-
raise TypeError(f"Dynamodb type {self.dynamodb_type} is not supported")
93+
def _deserialize_l(self, value: Sequence[Dict]) -> Sequence[Any]:
94+
return [self.deserialize(v) for v in value]
15795

158-
159-
def _attribute_value_dict(attr_values: Dict[str, dict], key: str) -> Optional[Dict[str, AttributeValue]]:
160-
"""A dict of type String to AttributeValue object map
161-
162-
Example:
163-
>>> {"NewImage": {"Id": {"S": "xxx-xxx"}, "Value": {"N": "35"}}}
164-
"""
165-
attr_values_dict = attr_values.get(key)
166-
return None if attr_values_dict is None else {k: AttributeValue(v) for k, v in attr_values_dict.items()}
96+
def _deserialize_m(self, value: Dict) -> Dict:
97+
return {k: self.deserialize(v) for k, v in value.items()}
16798

16899

169100
class StreamViewType(Enum):
@@ -176,28 +107,57 @@ class StreamViewType(Enum):
176107

177108

178109
class StreamRecord(DictWrapper):
110+
_deserializer = TypeDeserializer()
111+
112+
def __init__(self, data: Dict[str, Any]):
113+
"""StreamRecord constructor
114+
Parameters
115+
----------
116+
data: Dict[str, Any]
117+
Represents the dynamodb dict inside DynamoDBStreamEvent's records
118+
"""
119+
super().__init__(data)
120+
self._deserializer = TypeDeserializer()
121+
122+
def _deserialize_dynamodb_dict(self, key: str) -> Optional[Dict[str, Any]]:
123+
"""Deserialize DynamoDB records available in `Keys`, `NewImage`, and `OldImage`
124+
125+
Parameters
126+
----------
127+
key : str
128+
DynamoDB key (e.g., Keys, NewImage, or OldImage)
129+
130+
Returns
131+
-------
132+
Optional[Dict[str, Any]]
133+
Deserialized records in Python native types
134+
"""
135+
dynamodb_dict = self._data.get(key)
136+
if dynamodb_dict is None:
137+
return None
138+
139+
return {k: self._deserializer.deserialize(v) for k, v in dynamodb_dict.items()}
140+
179141
@property
180142
def approximate_creation_date_time(self) -> Optional[int]:
181143
"""The approximate date and time when the stream record was created, in UNIX epoch time format."""
182144
item = self.get("ApproximateCreationDateTime")
183145
return None if item is None else int(item)
184146

185-
# NOTE: This override breaks the Mapping protocol of DictWrapper, it's left here for backwards compatibility with
186-
# a 'type: ignore' comment. See #1516 for discussion
187147
@property
188-
def keys(self) -> Optional[Dict[str, AttributeValue]]: # type: ignore[override]
148+
def keys(self) -> Optional[Dict[str, Any]]: # type: ignore[override]
189149
"""The primary key attribute(s) for the DynamoDB item that was modified."""
190-
return _attribute_value_dict(self._data, "Keys")
150+
return self._deserialize_dynamodb_dict("Keys")
191151

192152
@property
193-
def new_image(self) -> Optional[Dict[str, AttributeValue]]:
153+
def new_image(self) -> Optional[Dict[str, Any]]:
194154
"""The item in the DynamoDB table as it appeared after it was modified."""
195-
return _attribute_value_dict(self._data, "NewImage")
155+
return self._deserialize_dynamodb_dict("NewImage")
196156

197157
@property
198-
def old_image(self) -> Optional[Dict[str, AttributeValue]]:
158+
def old_image(self) -> Optional[Dict[str, Any]]:
199159
"""The item in the DynamoDB table as it appeared before it was modified."""
200-
return _attribute_value_dict(self._data, "OldImage")
160+
return self._deserialize_dynamodb_dict("OldImage")
201161

202162
@property
203163
def sequence_number(self) -> Optional[str]:
@@ -233,7 +193,7 @@ def aws_region(self) -> Optional[str]:
233193

234194
@property
235195
def dynamodb(self) -> Optional[StreamRecord]:
236-
"""The main body of the stream record, containing all the DynamoDB-specific fields."""
196+
"""The main body of the stream record, containing all the DynamoDB-specific dicts."""
237197
stream_record = self.get("dynamodb")
238198
return None if stream_record is None else StreamRecord(stream_record)
239199

@@ -278,26 +238,18 @@ class DynamoDBStreamEvent(DictWrapper):
278238
279239
Example
280240
-------
281-
**Process dynamodb stream events and use get_type and get_value for handling conversions**
241+
**Process dynamodb stream events. DynamoDB types are automatically converted to their equivalent Python values.**
282242
283243
from aws_lambda_powertools.utilities.data_classes import event_source, DynamoDBStreamEvent
284-
from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import (
285-
AttributeValueType,
286-
AttributeValue,
287-
)
288244
from aws_lambda_powertools.utilities.typing import LambdaContext
289245
290246
291247
@event_source(data_class=DynamoDBStreamEvent)
292248
def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext):
293249
for record in event.records:
294-
key: AttributeValue = record.dynamodb.keys["id"]
295-
if key == AttributeValueType.Number:
296-
assert key.get_value == key.n_value
297-
print(key.get_value)
298-
elif key == AttributeValueType.Map:
299-
assert key.get_value == key.map_value
300-
print(key.get_value)
250+
# {"N": "123.45"} => Decimal("123.45")
251+
key: str = record.dynamodb.keys["id"]
252+
print(key)
301253
"""
302254

303255
@property

0 commit comments

Comments
 (0)