Skip to content

Commit 9b0f3b9

Browse files
author
Michael Brewer
authored
Merge branch 'awslabs:develop' into fix-1025-v2
2 parents 6e567e0 + eeb127c commit 9b0f3b9

File tree

11 files changed

+178
-26
lines changed

11 files changed

+178
-26
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,40 @@
1-
**Issue #, if available:**
1+
**Issue number:**
22

3-
## Description of changes:
3+
## Summary
44

5-
<!--- One or two sentences as a summary of what's being changed -->
5+
### Changes
66

7-
**Checklist**
7+
> Please provide a summary of what's being changed
88
9-
<!--- Leave unchecked if your change doesn't seem to apply -->
9+
### User experience
10+
11+
> Please share what the user experience looks like before and after this change
12+
13+
## Checklist
14+
15+
If your change doesn't seem to apply, please leave them unchecked.
1016

1117
* [ ] [Meet tenets criteria](https://awslabs.github.io/aws-lambda-powertools-python/#tenets)
12-
* [ ] Update tests
13-
* [ ] Update docs
18+
* [ ] I have performed a self-review of my this change
19+
* [ ] Changes are tested
20+
* [ ] Changes are documented
1421
* [ ] PR title follows [conventional commit semantics](https://github.com/awslabs/aws-lambda-powertools-python/blob/376ec0a2ac0d2a40e0af5717bef42ff84ca0d1b9/.github/semantic.yml#L2)
1522

16-
## Breaking change checklist
1723

18-
<!--- Ignore if it's not a breaking change -->
24+
<details>
25+
<summary>Is this a breaking change?</summary>
26+
27+
**RFC issue number**:
1928

20-
**RFC issue #**:
29+
Checklist:
2130

2231
* [ ] Migration process documented
2332
* [ ] Implement warnings (if it can live side by side)
2433

34+
</details>
35+
36+
## Acknowledgment
37+
2538
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
39+
40+
**Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful.

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
_DYNAMIC_ROUTE_PATTERN = r"(<\w+>)"
2727
_SAFE_URI = "-._~()'!*:@,;" # https://www.ietf.org/rfc/rfc3986.txt
2828
# API GW/ALB decode non-safe URI chars; we must support them too
29-
_UNSAFE_URI = "%<>\[\]{}|^" # noqa: W605
29+
_UNSAFE_URI = "%<> \[\]{}|^" # noqa: W605
3030
_NAMED_GROUP_BOUNDARY_PATTERN = fr"(?P\1[{_SAFE_URI}{_UNSAFE_URI}\\w]+)"
3131

3232

aws_lambda_powertools/logging/formatter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ def __init__(
127127

128128
super(LambdaPowertoolsFormatter, self).__init__(datefmt=self.datefmt)
129129

130-
keys_combined = {**self._build_default_keys(), **kwargs}
131-
self.log_format.update(**keys_combined)
130+
self.keys_combined = {**self._build_default_keys(), **kwargs}
131+
self.log_format.update(**self.keys_combined)
132132

133133
def serialize(self, log: Dict) -> str:
134134
"""Serialize structured log dict to JSON str"""
@@ -187,7 +187,7 @@ def remove_keys(self, keys: Iterable[str]):
187187

188188
def clear_state(self):
189189
self.log_format = dict.fromkeys(self.log_record_order)
190-
self.log_format.update(**self._build_default_keys())
190+
self.log_format.update(**self.keys_combined)
191191

192192
@staticmethod
193193
def _build_default_keys():

aws_lambda_powertools/middleware_factory/factory.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import inspect
33
import logging
44
import os
5-
from typing import Callable, Optional
5+
from typing import Any, Callable, Optional
66

77
from ..shared import constants
88
from ..shared.functions import resolve_truthy_env_var_choice
@@ -12,7 +12,8 @@
1212
logger = logging.getLogger(__name__)
1313

1414

15-
def lambda_handler_decorator(decorator: Optional[Callable] = None, trace_execution: Optional[bool] = None):
15+
# Maintenance: we can't yet provide an accurate return type without ParamSpec etc. see #1066
16+
def lambda_handler_decorator(decorator: Optional[Callable] = None, trace_execution: Optional[bool] = None) -> Callable:
1617
"""Decorator factory for decorating Lambda handlers.
1718
1819
You can use lambda_handler_decorator to create your own middlewares,
@@ -110,7 +111,7 @@ def lambda_handler(event, context):
110111
)
111112

112113
@functools.wraps(decorator)
113-
def final_decorator(func: Optional[Callable] = None, **kwargs):
114+
def final_decorator(func: Optional[Callable] = None, **kwargs: Any):
114115
# If called with kwargs return new func with kwargs
115116
if func is None:
116117
return functools.partial(final_decorator, **kwargs)

aws_lambda_powertools/utilities/idempotency/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from typing import Any, Callable, Dict, Optional, Tuple
3+
from copy import deepcopy
34

45
from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig
56
from aws_lambda_powertools.utilities.idempotency.exceptions import (
@@ -69,7 +70,7 @@ def __init__(
6970
Function keyword arguments
7071
"""
7172
self.function = function
72-
self.data = _prepare_data(function_payload)
73+
self.data = deepcopy(_prepare_data(function_payload))
7374
self.fn_args = function_args
7475
self.fn_kwargs = function_kwargs
7576

tests/functional/event_handler/test_api_gateway.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,42 @@ def get_network_account(account_id: str, network_id: str):
715715
app.resolve(event, {})
716716

717717

718+
def test_similar_dynamic_routes_with_whitespaces():
719+
# GIVEN
720+
app = ApiGatewayResolver()
721+
event = deepcopy(LOAD_GW_EVENT)
722+
723+
# WHEN
724+
# r'^/accounts/(?P<account_id>\\w+\\b)$' # noqa: E800
725+
@app.get("/accounts/<account_id>")
726+
def get_account(account_id: str):
727+
assert account_id == "single account"
728+
729+
# r'^/accounts/(?P<account_id>\\w+\\b)/source_networks$' # noqa: E800
730+
@app.get("/accounts/<account_id>/source_networks")
731+
def get_account_networks(account_id: str):
732+
assert account_id == "nested account"
733+
734+
# r'^/accounts/(?P<account_id>\\w+\\b)/source_networks/(?P<network_id>\\w+\\b)$' # noqa: E800
735+
@app.get("/accounts/<account_id>/source_networks/<network_id>")
736+
def get_network_account(account_id: str, network_id: str):
737+
assert account_id == "nested account"
738+
assert network_id == "network 123"
739+
740+
# THEN
741+
event["resource"] = "/accounts/{account_id}"
742+
event["path"] = "/accounts/single account"
743+
assert app.resolve(event, {})["statusCode"] == 200
744+
745+
event["resource"] = "/accounts/{account_id}/source_networks"
746+
event["path"] = "/accounts/nested account/source_networks"
747+
assert app.resolve(event, {})["statusCode"] == 200
748+
749+
event["resource"] = "/accounts/{account_id}/source_networks/{network_id}"
750+
event["path"] = "/accounts/nested account/source_networks/network 123"
751+
assert app.resolve(event, {})["statusCode"] == 200
752+
753+
718754
@pytest.mark.parametrize(
719755
"req",
720756
[

tests/functional/idempotency/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig
1515
from aws_lambda_powertools.utilities.jmespath_utils import extract_data_from_envelope
1616
from aws_lambda_powertools.utilities.validation import envelopes
17-
from tests.functional.utils import hash_idempotency_key, json_serialize, load_event
17+
from tests.functional.idempotency.utils import hash_idempotency_key
18+
from tests.functional.utils import json_serialize, load_event
1819

1920
TABLE_NAME = "TEST_TABLE"
2021

tests/functional/idempotency/test_idempotency.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent, idempotent_function
2323
from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer, DataRecord
2424
from aws_lambda_powertools.utilities.validation import envelopes, validator
25-
from tests.functional.utils import hash_idempotency_key, json_serialize, load_event
25+
from tests.functional.idempotency.utils import (
26+
build_idempotency_put_item_stub,
27+
build_idempotency_update_item_stub,
28+
hash_idempotency_key,
29+
)
30+
from tests.functional.utils import json_serialize, load_event
2631

2732
TABLE_NAME = "TEST_TABLE"
2833

@@ -275,6 +280,40 @@ def lambda_handler(event, context):
275280
stubber.deactivate()
276281

277282

283+
@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True, "event_key_jmespath": "body"}], indirect=True)
284+
def test_idempotent_lambda_first_execution_event_mutation(
285+
idempotency_config: IdempotencyConfig,
286+
persistence_store: DynamoDBPersistenceLayer,
287+
lambda_apigw_event,
288+
lambda_response,
289+
lambda_context,
290+
):
291+
"""
292+
Test idempotent decorator where lambda_handler mutates the event.
293+
Ensures we're passing data by value, not reference.
294+
"""
295+
event = copy.deepcopy(lambda_apigw_event)
296+
stubber = stub.Stubber(persistence_store.table.meta.client)
297+
ddb_response = {}
298+
stubber.add_response("put_item", ddb_response, build_idempotency_put_item_stub(data=event["body"]))
299+
stubber.add_response(
300+
"update_item",
301+
ddb_response,
302+
build_idempotency_update_item_stub(data=event["body"], handler_response=lambda_response),
303+
)
304+
stubber.activate()
305+
306+
@idempotent(config=idempotency_config, persistence_store=persistence_store)
307+
def lambda_handler(event, context):
308+
event.pop("body") # remove exact key we're using for idempotency
309+
return lambda_response
310+
311+
lambda_handler(event, lambda_context)
312+
313+
stubber.assert_no_pending_responses()
314+
stubber.deactivate()
315+
316+
278317
@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True)
279318
def test_idempotent_lambda_expired(
280319
idempotency_config: IdempotencyConfig,

tests/functional/idempotency/utils.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import hashlib
2+
from typing import Any, Dict
3+
4+
from botocore import stub
5+
6+
from tests.functional.utils import json_serialize
7+
8+
9+
def hash_idempotency_key(data: Any):
10+
"""Serialize data to JSON, encode, and hash it for idempotency key"""
11+
return hashlib.md5(json_serialize(data).encode()).hexdigest()
12+
13+
14+
def build_idempotency_put_item_stub(
15+
data: Dict, function_name: str = "test-func", handler_name: str = "lambda_handler"
16+
) -> Dict:
17+
idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}"
18+
return {
19+
"ConditionExpression": "attribute_not_exists(#id) OR #now < :now",
20+
"ExpressionAttributeNames": {"#id": "id", "#now": "expiration"},
21+
"ExpressionAttributeValues": {":now": stub.ANY},
22+
"Item": {"expiration": stub.ANY, "id": idempotency_key_hash, "status": "INPROGRESS"},
23+
"TableName": "TEST_TABLE",
24+
}
25+
26+
27+
def build_idempotency_update_item_stub(
28+
data: Dict,
29+
handler_response: Dict,
30+
function_name: str = "test-func",
31+
handler_name: str = "lambda_handler",
32+
) -> Dict:
33+
idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}"
34+
serialized_lambda_response = json_serialize(handler_response)
35+
return {
36+
"ExpressionAttributeNames": {"#expiry": "expiration", "#response_data": "data", "#status": "status"},
37+
"ExpressionAttributeValues": {
38+
":expiry": stub.ANY,
39+
":response_data": serialized_lambda_response,
40+
":status": "COMPLETED",
41+
},
42+
"Key": {"id": idempotency_key_hash},
43+
"TableName": "TEST_TABLE",
44+
"UpdateExpression": "SET #response_data = :response_data, " "#expiry = :expiry, #status = :status",
45+
}

tests/functional/test_logger.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,26 @@ def handler(event, context):
683683
assert key in second_log
684684

685685

686+
def test_clear_state_keeps_custom_keys(lambda_context, stdout, service_name):
687+
# GIVEN
688+
location_format = "%(module)s.%(funcName)s:clear_state"
689+
logger = Logger(service=service_name, stream=stdout, location=location_format, custom_key="foo")
690+
691+
# WHEN clear_state is set
692+
@logger.inject_lambda_context(clear_state=True)
693+
def handler(event, context):
694+
logger.info("Foo")
695+
696+
# THEN all standard keys should be available as usual
697+
handler({}, lambda_context)
698+
handler({}, lambda_context)
699+
700+
first_log, second_log = capture_multiple_logging_statements_output(stdout)
701+
for log in (first_log, second_log):
702+
assert "foo" == log["custom_key"]
703+
assert "test_logger.handler:clear_state" == log["location"]
704+
705+
686706
def test_clear_state_keeps_exception_keys(lambda_context, stdout, service_name):
687707
# GIVEN
688708
logger = Logger(service=service_name, stream=stdout)

tests/functional/utils.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import base64
2-
import hashlib
32
import json
43
from pathlib import Path
54
from typing import Any
@@ -22,8 +21,3 @@ def b64_to_str(data: str) -> str:
2221

2322
def json_serialize(data):
2423
return json.dumps(data, sort_keys=True, cls=Encoder)
25-
26-
27-
def hash_idempotency_key(data: Any):
28-
"""Serialize data to JSON, encode, and hash it for idempotency key"""
29-
return hashlib.md5(json_serialize(data).encode()).hexdigest()

0 commit comments

Comments
 (0)