Skip to content

Commit 4663745

Browse files
neilramsayleandrodamascenaheitorlessa
authored
feat(event_sources): Add __str__ to Data Classes base DictWrapper (#2129)
Co-authored-by: Neil Ramsay <[email protected]> Co-authored-by: Leandro Damascena <[email protected]> Co-authored-by: Heitor Lessa <[email protected]>
1 parent 11b6a37 commit 4663745

File tree

7 files changed

+324
-1
lines changed

7 files changed

+324
-1
lines changed

Diff for: aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py

+2
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ def location(self) -> CodePipelineLocation:
8080

8181

8282
class CodePipelineArtifactCredentials(DictWrapper):
83+
_sensitive_properties = ["secret_access_key", "session_token"]
84+
8385
@property
8486
def access_key_id(self) -> str:
8587
return self["accessKeyId"]

Diff for: aws_lambda_powertools/utilities/data_classes/common.py

+44-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import base64
22
import json
33
from collections.abc import Mapping
4-
from typing import Any, Dict, Iterator, Optional
4+
from typing import Any, Dict, Iterator, List, Optional
55

66
from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer
77

@@ -28,6 +28,49 @@ def __iter__(self) -> Iterator:
2828
def __len__(self) -> int:
2929
return len(self._data)
3030

31+
def __str__(self) -> str:
32+
return str(self._str_helper())
33+
34+
def _str_helper(self) -> Dict[str, Any]:
35+
"""
36+
Recursively get a Dictionary of DictWrapper properties primarily
37+
for use by __str__ for debugging purposes.
38+
39+
Will remove "raw_event" properties, and any defined by the Data Class
40+
`_sensitive_properties` list field.
41+
This should be used in case where secrets, such as access keys, are
42+
stored in the Data Class but should not be logged out.
43+
"""
44+
properties = self._properties()
45+
sensitive_properties = ["raw_event"]
46+
if hasattr(self, "_sensitive_properties"):
47+
sensitive_properties.extend(self._sensitive_properties) # pyright: ignore
48+
49+
result: Dict[str, Any] = {}
50+
for property_key in properties:
51+
if property_key in sensitive_properties:
52+
result[property_key] = "[SENSITIVE]"
53+
else:
54+
try:
55+
property_value = getattr(self, property_key)
56+
result[property_key] = property_value
57+
58+
# Checks whether the class is a subclass of the parent class to perform a recursive operation.
59+
if issubclass(property_value.__class__, DictWrapper):
60+
result[property_key] = property_value._str_helper()
61+
# Checks if the key is a list and if it is a subclass of the parent class
62+
elif isinstance(property_value, list):
63+
for seq, item in enumerate(property_value):
64+
if issubclass(item.__class__, DictWrapper):
65+
result[property_key][seq] = item._str_helper()
66+
except Exception:
67+
result[property_key] = "[Cannot be deserialized]"
68+
69+
return result
70+
71+
def _properties(self) -> List[str]:
72+
return [p for p in dir(self.__class__) if isinstance(getattr(self.__class__, p), property)]
73+
3174
def get(self, key: str, default: Optional[Any] = None) -> Optional[Any]:
3275
return self._data.get(key, default)
3376

Diff for: docs/utilities/data_classes.md

+41
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,22 @@ Same example as above, but using the `event_source` decorator
5252
if 'helloworld' in event.path and event.http_method == 'GET':
5353
do_something_with(event.body, user)
5454
```
55+
56+
Log Data Event for Troubleshooting
57+
58+
=== "app.py"
59+
60+
```python hl_lines="4 8"
61+
from aws_lambda_powertools.utilities.data_classes import event_source, APIGatewayProxyEvent
62+
from aws_lambda_powertools.logging.logger import Logger
63+
64+
logger = Logger(service="hello_logs", level="DEBUG")
65+
66+
@event_source(data_class=APIGatewayProxyEvent)
67+
def lambda_handler(event: APIGatewayProxyEvent, context):
68+
logger.debug(event)
69+
```
70+
5571
**Autocomplete with self-documented properties and methods**
5672

5773
![Utilities Data Classes](../media/utilities_data_classes.png)
@@ -1104,3 +1120,28 @@ This example is based on the AWS Blog post [Introducing Amazon S3 Object Lambda
11041120
for record in event.records:
11051121
do_something_with(record.body)
11061122
```
1123+
1124+
## Advanced
1125+
1126+
### Debugging
1127+
1128+
Alternatively, you can print out the fields to obtain more information. All classes come with a `__str__` method that generates a dictionary string which can be quite useful for debugging.
1129+
1130+
However, certain events may contain sensitive fields such as `secret_access_key` and `session_token`, which are labeled as `[SENSITIVE]` to prevent any accidental disclosure of confidential information.
1131+
1132+
!!! warning "If we fail to deserialize a field value (e.g., JSON), they will appear as `[Cannot be deserialized]`"
1133+
1134+
=== "debugging.py"
1135+
```python hl_lines="9"
1136+
--8<-- "examples/event_sources/src/debugging.py"
1137+
```
1138+
1139+
=== "debugging_event.json"
1140+
```json hl_lines="28 29"
1141+
--8<-- "examples/event_sources/src/debugging_event.json"
1142+
```
1143+
=== "debugging_output.json"
1144+
```json hl_lines="16 17 18"
1145+
--8<-- "examples/event_sources/src/debugging_output.json"
1146+
```
1147+
```

Diff for: examples/event_sources/src/debugging.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from aws_lambda_powertools.utilities.data_classes import (
2+
CodePipelineJobEvent,
3+
event_source,
4+
)
5+
6+
7+
@event_source(data_class=CodePipelineJobEvent)
8+
def lambda_handler(event, context):
9+
print(event)

Diff for: examples/event_sources/src/debugging_event.json

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"CodePipeline.job": {
3+
"id": "11111111-abcd-1111-abcd-111111abcdef",
4+
"accountId": "111111111111",
5+
"data": {
6+
"actionConfiguration": {
7+
"configuration": {
8+
"FunctionName": "MyLambdaFunctionForAWSCodePipeline",
9+
"UserParameters": "some-input-such-as-a-URL"
10+
}
11+
},
12+
"inputArtifacts": [
13+
{
14+
"name": "ArtifactName",
15+
"revision": null,
16+
"location": {
17+
"type": "S3",
18+
"s3Location": {
19+
"bucketName": "the name of the bucket configured as the pipeline artifact store in Amazon S3, for example codepipeline-us-east-2-1234567890",
20+
"objectKey": "the name of the application, for example CodePipelineDemoApplication.zip"
21+
}
22+
}
23+
}
24+
],
25+
"outputArtifacts": [],
26+
"artifactCredentials": {
27+
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
28+
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
29+
"sessionToken": "MIICiTCCAfICCQD6m7oRw0uXOjANBgkqhkiG9w0BAQUFADCBiDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAldBMRAwDgYDVQQHEwdTZWF0dGxlMQ8wDQYDVQQKEwZBbWF6b24xFDASBgNVBAsTC0lBTSBDb25zb2xlMRIwEAYDVQQDEwlUZXN0Q2lsYWMxHzAdBgkqhkiG9w0BCQEWEG5vb25lQGFtYXpvbi5jb20wHhcNMTEwNDI1MjA0NTIxWhcNMTIwNDI0MjA0NTIxWjCBiDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAldBMRAwDgYDVQQHEwdTZWF0dGxlMQ8wDQYDVQQKEwZBbWF6b24xFDASBgNVBAsTC0lBTSBDb25zb2xlMRIwEAYDVQQDEwlUZXN0Q2lsYWMxHzAdBgkqhkiG9w0BCQEWEG5vb25lQGFtYXpvbi5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMaK0dn+a4GmWIWJ21uUSfwfEvySWtC2XADZ4nB+BLYgVIk60CpiwsZ3G93vUEIO3IyNoH/f0wYK8m9TrDHudUZg3qX4waLG5M43q7Wgc/MbQITxOUSQv7c7ugFFDzQGBzZswY6786m86gpEIbb3OhjZnzcvQAaRHhdlQWIMm2nrAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAtCu4nUhVVxYUntneD9+h8Mg9q6q+auNKyExzyLwaxlAoo7TJHidbtS4J5iNmZgXL0FkbFFBjvSfpJIlJ00zbhNYS5f6GuoEDmFJl0ZxBHjJnyp378OD8uTs7fLvjx79LjSTbNYiytVbZPQUQ5Yaxu2jXnimvw3rrszlaEXAMPLE="
30+
},
31+
"continuationToken": "A continuation token if continuing job"
32+
}
33+
}
34+
}

Diff for: examples/event_sources/src/debugging_output.json

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"account_id":"111111111111",
3+
"data":{
4+
"action_configuration":{
5+
"configuration":{
6+
"decoded_user_parameters":"[Cannot be deserialized]",
7+
"function_name":"MyLambdaFunctionForAWSCodePipeline",
8+
"raw_event":"[SENSITIVE]",
9+
"user_parameters":"some-input-such-as-a-URL"
10+
},
11+
"raw_event":"[SENSITIVE]"
12+
},
13+
"artifact_credentials":{
14+
"access_key_id":"AKIAIOSFODNN7EXAMPLE",
15+
"expiration_time":"None",
16+
"raw_event":"[SENSITIVE]",
17+
"secret_access_key":"[SENSITIVE]",
18+
"session_token":"[SENSITIVE]"
19+
},
20+
"continuation_token":"A continuation token if continuing job",
21+
"encryption_key":"None",
22+
"input_artifacts":[
23+
{
24+
"location":{
25+
"get_type":"S3",
26+
"raw_event":"[SENSITIVE]",
27+
"s3_location":{
28+
"bucket_name":"the name of the bucket configured as the pipeline artifact store in Amazon S3, for example codepipeline-us-east-2-1234567890",
29+
"key":"the name of the application, for example CodePipelineDemoApplication.zip",
30+
"object_key":"the name of the application, for example CodePipelineDemoApplication.zip",
31+
"raw_event":"[SENSITIVE]"
32+
}
33+
},
34+
"name":"ArtifactName",
35+
"raw_event":"[SENSITIVE]",
36+
"revision":"None"
37+
}
38+
],
39+
"output_artifacts":[
40+
41+
],
42+
"raw_event":"[SENSITIVE]"
43+
},
44+
"decoded_user_parameters":"[Cannot be deserialized]",
45+
"get_id":"11111111-abcd-1111-abcd-111111abcdef",
46+
"input_bucket_name":"the name of the bucket configured as the pipeline artifact store in Amazon S3, for example codepipeline-us-east-2-1234567890",
47+
"input_object_key":"the name of the application, for example CodePipelineDemoApplication.zip",
48+
"raw_event":"[SENSITIVE]",
49+
"user_parameters":"some-input-such-as-a-URL"
50+
}

Diff for: tests/functional/test_data_classes.py

+144
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,150 @@ class DataClassSample(DictWrapper):
126126
assert event_source.items() == data.items()
127127

128128

129+
def test_dict_wrapper_str_no_property():
130+
"""
131+
Checks that the _properties function returns
132+
only the "raw_event", and the resulting string
133+
notes it as sensitive.
134+
"""
135+
136+
class DataClassSample(DictWrapper):
137+
attribute = None
138+
139+
def function(self) -> None:
140+
pass
141+
142+
event_source = DataClassSample({})
143+
assert str(event_source) == "{'raw_event': '[SENSITIVE]'}"
144+
145+
146+
def test_dict_wrapper_str_single_property():
147+
"""
148+
Checks that the _properties function returns
149+
the defined property "data_property", and
150+
resulting string includes the property value.
151+
"""
152+
153+
class DataClassSample(DictWrapper):
154+
attribute = None
155+
156+
def function(self) -> None:
157+
pass
158+
159+
@property
160+
def data_property(self) -> str:
161+
return "value"
162+
163+
event_source = DataClassSample({})
164+
assert str(event_source) == "{'data_property': 'value', 'raw_event': '[SENSITIVE]'}"
165+
166+
167+
def test_dict_wrapper_str_property_exception():
168+
"""
169+
Check the recursive _str_helper function handles
170+
exceptions that may occur when accessing properties
171+
"""
172+
173+
class DataClassSample(DictWrapper):
174+
attribute = None
175+
176+
def function(self) -> None:
177+
pass
178+
179+
@property
180+
def data_property(self):
181+
raise Exception()
182+
183+
event_source = DataClassSample({})
184+
assert str(event_source) == "{'data_property': '[Cannot be deserialized]', 'raw_event': '[SENSITIVE]'}"
185+
186+
187+
def test_dict_wrapper_str_property_list_exception():
188+
"""
189+
Check that _str_helper properly handles exceptions
190+
that occur when recursively working through items
191+
in a list property.
192+
"""
193+
194+
class BrokenDataClass(DictWrapper):
195+
@property
196+
def broken_data_property(self):
197+
raise Exception()
198+
199+
class DataClassSample(DictWrapper):
200+
attribute = None
201+
202+
def function(self) -> None:
203+
pass
204+
205+
@property
206+
def data_property(self) -> list:
207+
return ["string", 0, 0.0, BrokenDataClass({})]
208+
209+
event_source = DataClassSample({})
210+
event_str = (
211+
"{'data_property': ['string', 0, 0.0, {'broken_data_property': "
212+
+ "'[Cannot be deserialized]', 'raw_event': '[SENSITIVE]'}], 'raw_event': '[SENSITIVE]'}"
213+
)
214+
assert str(event_source) == event_str
215+
216+
217+
def test_dict_wrapper_str_recursive_property():
218+
"""
219+
Check that the _str_helper function recursively
220+
handles Data Classes within Data Classes
221+
"""
222+
223+
class DataClassTerminal(DictWrapper):
224+
attribute = None
225+
226+
def function(self) -> None:
227+
pass
228+
229+
@property
230+
def terminal_property(self) -> str:
231+
return "end-recursion"
232+
233+
class DataClassRecursive(DictWrapper):
234+
attribute = None
235+
236+
def function(self) -> None:
237+
pass
238+
239+
@property
240+
def data_property(self) -> DataClassTerminal:
241+
return DataClassTerminal({})
242+
243+
event_source = DataClassRecursive({})
244+
assert (
245+
str(event_source)
246+
== "{'data_property': {'raw_event': '[SENSITIVE]', 'terminal_property': 'end-recursion'},"
247+
+ " 'raw_event': '[SENSITIVE]'}"
248+
)
249+
250+
251+
def test_dict_wrapper_sensitive_properties_property():
252+
"""
253+
Checks that the _str_helper function correctly
254+
handles _sensitive_properties
255+
"""
256+
257+
class DataClassSample(DictWrapper):
258+
attribute = None
259+
260+
def function(self) -> None:
261+
pass
262+
263+
_sensitive_properties = ["data_property"]
264+
265+
@property
266+
def data_property(self) -> str:
267+
return "value"
268+
269+
event_source = DataClassSample({})
270+
assert str(event_source) == "{'data_property': '[SENSITIVE]', 'raw_event': '[SENSITIVE]'}"
271+
272+
129273
def test_cloud_watch_dashboard_event():
130274
event = CloudWatchDashboardCustomWidgetEvent(load_event("cloudWatchDashboardEvent.json"))
131275
assert event.describe is False

0 commit comments

Comments
 (0)