Skip to content

Commit fce91e5

Browse files
feat(redis) - merging from develop
2 parents 0d1e3e9 + 9517e3d commit fce91e5

File tree

103 files changed

+2300
-1025
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

103 files changed

+2300
-1025
lines changed

.github/workflows/publish_v2_layer.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
# NOTE: we need QEMU to build Layer against a different architecture (e.g., ARM)
6666
- name: Set up Docker Buildx
6767
id: builder
68-
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 # v2.0.0
68+
uses: docker/setup-buildx-action@f03ac48505955848960e80bbb68046aa35c7b9e7 # v2.4.1
6969
with:
7070
install: true
7171
driver: docker

.github/workflows/run-e2e-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
id-token: write # needed to request JWT with GitHub's OIDC Token endpoint. docs: https://bit.ly/3MNgQO9
2727
contents: read
2828
strategy:
29+
fail-fast: false # needed so if a version fails, the others will still be able to complete and cleanup
2930
matrix:
3031
version: ["3.7", "3.8", "3.9"]
3132
if: ${{ github.actor != 'dependabot[bot]' }}

CHANGELOG.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,72 @@
66

77
## Bug Fixes
88

9+
* **idempotency:** make idempotent_function decorator thread safe ([#1899](https://github.com/awslabs/aws-lambda-powertools-python/issues/1899))
10+
11+
## Documentation
12+
13+
* **homepage:** set url for end-of-support in announce block ([#1893](https://github.com/awslabs/aws-lambda-powertools-python/issues/1893))
14+
* **idempotency:** add IAM permissions section ([#1902](https://github.com/awslabs/aws-lambda-powertools-python/issues/1902))
15+
16+
## Features
17+
18+
* **metrics:** add default_dimensions to single_metric ([#1880](https://github.com/awslabs/aws-lambda-powertools-python/issues/1880))
19+
20+
## Maintenance
21+
22+
* **deps:** bump docker/setup-buildx-action from 2.4.0 to 2.4.1 ([#1903](https://github.com/awslabs/aws-lambda-powertools-python/issues/1903))
23+
* **deps-dev:** bump mypy-boto3-appconfig from 1.26.0.post1 to 1.26.63 ([#1895](https://github.com/awslabs/aws-lambda-powertools-python/issues/1895))
24+
* **deps-dev:** bump types-requests from 2.28.11.8 to 2.28.11.12 ([#1906](https://github.com/awslabs/aws-lambda-powertools-python/issues/1906))
25+
* **deps-dev:** bump pytest-xdist from 3.1.0 to 3.2.0 ([#1905](https://github.com/awslabs/aws-lambda-powertools-python/issues/1905))
26+
* **deps-dev:** bump aws-cdk-lib from 2.63.0 to 2.63.2 ([#1904](https://github.com/awslabs/aws-lambda-powertools-python/issues/1904))
27+
* **deps-dev:** bump mkdocs-material from 9.0.10 to 9.0.11 ([#1896](https://github.com/awslabs/aws-lambda-powertools-python/issues/1896))
28+
* **deps-dev:** bump mkdocs-material from 9.0.9 to 9.0.10 ([#1888](https://github.com/awslabs/aws-lambda-powertools-python/issues/1888))
29+
* **deps-dev:** bump mypy-boto3-s3 from 1.26.58 to 1.26.62 ([#1889](https://github.com/awslabs/aws-lambda-powertools-python/issues/1889))
30+
* **deps-dev:** bump black from 22.12.0 to 23.1.0 ([#1886](https://github.com/awslabs/aws-lambda-powertools-python/issues/1886))
31+
* **deps-dev:** bump aws-cdk-lib from 2.62.2 to 2.63.0 ([#1887](https://github.com/awslabs/aws-lambda-powertools-python/issues/1887))
32+
* **maintainers:** fix release workflow rename
33+
* **pypi:** add new links to Pypi package homepage ([#1912](https://github.com/awslabs/aws-lambda-powertools-python/issues/1912))
34+
35+
36+
<a name="v2.7.1"></a>
37+
## [v2.7.1] - 2023-02-01
38+
## Bug Fixes
39+
940
* parallel_run should fail when e2e tests fail
1041
* bump aws-cdk version
1142
* **ci:** scope e2e tests by python version
43+
* **ci:** add auth to API HTTP Gateway and Lambda Function Url ([#1882](https://github.com/awslabs/aws-lambda-powertools-python/issues/1882))
44+
* **license:** correction to MIT + MIT-0 (no proprietary anymore) ([#1883](https://github.com/awslabs/aws-lambda-powertools-python/issues/1883))
45+
* **license:** add MIT-0 license header ([#1871](https://github.com/awslabs/aws-lambda-powertools-python/issues/1871))
46+
* **tests:** make logs fetching more robust ([#1878](https://github.com/awslabs/aws-lambda-powertools-python/issues/1878))
47+
* **tests:** remove custom workers
48+
* **tests:** make sure multiple e2e tests run concurrently ([#1861](https://github.com/awslabs/aws-lambda-powertools-python/issues/1861))
1249

1350
## Documentation
1451

1552
* **event-source:** fix incorrect method in example CloudWatch Logs ([#1857](https://github.com/awslabs/aws-lambda-powertools-python/issues/1857))
53+
* **homepage:** add banner for end-of-support v1 ([#1879](https://github.com/awslabs/aws-lambda-powertools-python/issues/1879))
54+
* **parameters:** snippets split, improved, and lint ([#1564](https://github.com/awslabs/aws-lambda-powertools-python/issues/1564))
1655

1756
## Maintenance
1857

58+
* update v2 layer ARN on documentation
59+
* **deps:** bump docker/setup-buildx-action from 2.0.0 to 2.4.0 ([#1873](https://github.com/awslabs/aws-lambda-powertools-python/issues/1873))
1960
* **deps:** bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 ([#1855](https://github.com/awslabs/aws-lambda-powertools-python/issues/1855))
61+
* **deps-dev:** bump mypy-boto3-s3 from 1.26.0.post1 to 1.26.58 ([#1868](https://github.com/awslabs/aws-lambda-powertools-python/issues/1868))
62+
* **deps-dev:** bump isort from 5.11.4 to 5.11.5 ([#1875](https://github.com/awslabs/aws-lambda-powertools-python/issues/1875))
63+
* **deps-dev:** bump aws-cdk-lib from 2.62.1 to 2.62.2 ([#1869](https://github.com/awslabs/aws-lambda-powertools-python/issues/1869))
64+
* **deps-dev:** bump mkdocs-material from 9.0.6 to 9.0.8 ([#1874](https://github.com/awslabs/aws-lambda-powertools-python/issues/1874))
2065
* **deps-dev:** bump aws-cdk-lib from 2.62.0 to 2.62.1 ([#1866](https://github.com/awslabs/aws-lambda-powertools-python/issues/1866))
2166
* **deps-dev:** bump mypy-boto3-cloudformation from 1.26.35.post1 to 1.26.57 ([#1865](https://github.com/awslabs/aws-lambda-powertools-python/issues/1865))
2267
* **deps-dev:** bump coverage from 7.0.5 to 7.1.0 ([#1862](https://github.com/awslabs/aws-lambda-powertools-python/issues/1862))
2368
* **deps-dev:** bump aws-cdk-lib from 2.61.1 to 2.62.0 ([#1863](https://github.com/awslabs/aws-lambda-powertools-python/issues/1863))
69+
* **deps-dev:** bump flake8-bugbear from 22.12.6 to 23.1.20 ([#1854](https://github.com/awslabs/aws-lambda-powertools-python/issues/1854))
2470
* **deps-dev:** bump mypy-boto3-lambda from 1.26.49 to 1.26.55 ([#1856](https://github.com/awslabs/aws-lambda-powertools-python/issues/1856))
2571

72+
## Reverts
73+
* fix(tests): remove custom workers
74+
2675

2776
<a name="v2.7.0"></a>
2877
## [v2.7.0] - 2023-01-24
@@ -2801,7 +2850,8 @@
28012850
* Merge pull request [#5](https://github.com/awslabs/aws-lambda-powertools-python/issues/5) from jfuss/feat/python38
28022851

28032852

2804-
[Unreleased]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.7.0...HEAD
2853+
[Unreleased]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.7.1...HEAD
2854+
[v2.7.1]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.7.0...v2.7.1
28052855
[v2.7.0]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.6.0...v2.7.0
28062856
[v2.6.0]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.5.0...v2.6.0
28072857
[v2.5.0]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.4.0...v2.5.0

LICENSE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
MIT No Attribution
2+
13
Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
24

35
Permission is hereby granted, free of charge, to any person obtaining a copy of

MAINTAINERS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ These are some questions to keep in mind when drafting your first or future rele
218218

219219
Once you're happy, hit `Publish release` 🎉🎉🎉.
220220

221-
This will kick off the [Publishing workflow](https://github.com/awslabs/aws-lambda-powertools-python/actions/workflows/on_release_notes.yml) and within a few minutes you should see the latest version in PyPi, and all issues labeled as `pending-release` will be closed and notified.
221+
This will kick off the [Publishing workflow](https://github.com/awslabs/aws-lambda-powertools-python/actions/workflows/release.yml) and within a few minutes you should see the latest version in PyPi, and all issues labeled as `pending-release` will be closed and notified.
222222

223223
> TODO: Include information to verify SAR and Lambda Layers deployment; we're still finalizing Lambda Layer automated deployment in GitHub Actions - ping @am29d when in doubt.
224224

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,6 @@ def route(
836836
# Override _compile_regex to exclude trailing slashes for route resolution
837837
@staticmethod
838838
def _compile_regex(rule: str, base_regex: str = _ROUTE_REGEX):
839-
840839
return super(APIGatewayRestResolver, APIGatewayRestResolver)._compile_regex(rule, "^{}/*$")
841840

842841

aws_lambda_powertools/logging/utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ def copy_config_to_registered_loggers(
1212
exclude: Optional[Set[str]] = None,
1313
include: Optional[Set[str]] = None,
1414
) -> None:
15-
1615
"""Copies source Logger level and handler to all registered loggers for consistent formatting.
1716
1817
Parameters

aws_lambda_powertools/metrics/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,11 @@ def add_metric(self, name: str, unit: Union[MetricUnit, str], value: float) -> N
463463

464464
@contextmanager
465465
def single_metric(
466-
name: str, unit: MetricUnit, value: float, namespace: Optional[str] = None
466+
name: str,
467+
unit: MetricUnit,
468+
value: float,
469+
namespace: Optional[str] = None,
470+
default_dimensions: Optional[Dict[str, str]] = None,
467471
) -> Generator[SingleMetric, None, None]:
468472
"""Context manager to simplify creation of a single metric
469473
@@ -516,6 +520,11 @@ def single_metric(
516520
try:
517521
metric: SingleMetric = SingleMetric(namespace=namespace)
518522
metric.add_metric(name=name, unit=unit, value=value)
523+
524+
if default_dimensions:
525+
for dim_name, dim_value in default_dimensions.items():
526+
metric.add_dimension(name=dim_name, value=dim_value)
527+
519528
yield metric
520529
metric_set = metric.serialize_metric_set()
521530
finally:

aws_lambda_powertools/utilities/feature_flags/schema.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,6 @@ def __init__(self, rule: Dict[str, Any], rule_name: str, logger: Optional[Union[
272272
self.logger = logger or logging.getLogger(__name__)
273273

274274
def validate(self):
275-
276275
if not self.conditions or not isinstance(self.conditions, list):
277276
self.logger.debug(f"Condition is empty or invalid for rule={self.rule_name}")
278277
raise SchemaValidationError(f"Invalid condition, rule={self.rule_name}")

aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py

Lines changed: 46 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from typing import Any, Dict, Optional
55

66
import boto3
7+
from boto3.dynamodb.types import TypeDeserializer
78
from botocore.config import Config
9+
from botocore.exceptions import ClientError
810

911
from aws_lambda_powertools.shared import constants
1012
from aws_lambda_powertools.utilities.idempotency import BasePersistenceLayer
@@ -79,13 +81,14 @@ def __init__(
7981

8082
self._boto_config = boto_config or Config()
8183
self._boto3_session = boto3_session or boto3.session.Session()
84+
self._client = self._boto3_session.client("dynamodb", config=self._boto_config)
85+
8286
if sort_key_attr == key_attr:
8387
raise ValueError(f"key_attr [{key_attr}] and sort_key_attr [{sort_key_attr}] cannot be the same!")
8488

8589
if static_pk_value is None:
8690
static_pk_value = f"idempotency#{os.getenv(constants.LAMBDA_FUNCTION_NAME_ENV, '')}"
8791

88-
self._table = None
8992
self.table_name = table_name
9093
self.key_attr = key_attr
9194
self.static_pk_value = static_pk_value
@@ -95,31 +98,15 @@ def __init__(
9598
self.status_attr = status_attr
9699
self.data_attr = data_attr
97100
self.validation_key_attr = validation_key_attr
98-
super(DynamoDBPersistenceLayer, self).__init__()
99101

100-
@property
101-
def table(self):
102-
"""
103-
Caching property to store boto3 dynamodb Table resource
102+
self._deserializer = TypeDeserializer()
104103

105-
"""
106-
if self._table:
107-
return self._table
108-
ddb_resource = self._boto3_session.resource("dynamodb", config=self._boto_config)
109-
self._table = ddb_resource.Table(self.table_name)
110-
return self._table
111-
112-
@table.setter
113-
def table(self, table):
114-
"""
115-
Allow table instance variable to be set directly, primarily for use in tests
116-
"""
117-
self._table = table
104+
super(DynamoDBPersistenceLayer, self).__init__()
118105

119106
def _get_key(self, idempotency_key: str) -> dict:
120107
if self.sort_key_attr:
121-
return {self.key_attr: self.static_pk_value, self.sort_key_attr: idempotency_key}
122-
return {self.key_attr: idempotency_key}
108+
return {self.key_attr: {"S": self.static_pk_value}, self.sort_key_attr: {"S": idempotency_key}}
109+
return {self.key_attr: {"S": idempotency_key}}
123110

124111
def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord:
125112
"""
@@ -136,36 +123,39 @@ def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord:
136123
representation of item
137124
138125
"""
126+
data = self._deserializer.deserialize({"M": item})
139127
return DataRecord(
140-
idempotency_key=item[self.key_attr],
141-
status=item[self.status_attr],
142-
expiry_timestamp=item[self.expiry_attr],
143-
in_progress_expiry_timestamp=item.get(self.in_progress_expiry_attr),
144-
response_data=item.get(self.data_attr),
145-
payload_hash=item.get(self.validation_key_attr),
128+
idempotency_key=data[self.key_attr],
129+
status=data[self.status_attr],
130+
expiry_timestamp=data[self.expiry_attr],
131+
in_progress_expiry_timestamp=data.get(self.in_progress_expiry_attr),
132+
response_data=data.get(self.data_attr),
133+
payload_hash=data.get(self.validation_key_attr),
146134
)
147135

148136
def _get_record(self, idempotency_key) -> DataRecord:
149-
response = self.table.get_item(Key=self._get_key(idempotency_key), ConsistentRead=True)
150-
137+
response = self._client.get_item(
138+
TableName=self.table_name, Key=self._get_key(idempotency_key), ConsistentRead=True
139+
)
151140
try:
152141
item = response["Item"]
153-
except KeyError:
154-
raise IdempotencyItemNotFoundError
142+
except KeyError as exc:
143+
raise IdempotencyItemNotFoundError from exc
155144
return self._item_to_data_record(item)
156145

157146
def _put_record(self, data_record: DataRecord) -> None:
158147
item = {
159148
**self._get_key(data_record.idempotency_key),
160-
self.expiry_attr: data_record.expiry_timestamp,
161-
self.status_attr: data_record.status,
149+
self.key_attr: {"S": data_record.idempotency_key},
150+
self.expiry_attr: {"N": str(data_record.expiry_timestamp)},
151+
self.status_attr: {"S": data_record.status},
162152
}
163153

164154
if data_record.in_progress_expiry_timestamp is not None:
165-
item[self.in_progress_expiry_attr] = data_record.in_progress_expiry_timestamp
155+
item[self.in_progress_expiry_attr] = {"N": str(data_record.in_progress_expiry_timestamp)}
166156

167-
if self.payload_validation_enabled:
168-
item[self.validation_key_attr] = data_record.payload_hash
157+
if self.payload_validation_enabled and data_record.payload_hash:
158+
item[self.validation_key_attr] = {"S": data_record.payload_hash}
169159

170160
now = datetime.datetime.now()
171161
try:
@@ -199,8 +189,8 @@ def _put_record(self, data_record: DataRecord) -> None:
199189
condition_expression = (
200190
f"{idempotency_key_not_exist} OR {idempotency_expiry_expired} OR ({inprogress_expiry_expired})"
201191
)
202-
203-
self.table.put_item(
192+
self._client.put_item(
193+
TableName=self.table_name,
204194
Item=item,
205195
ConditionExpression=condition_expression,
206196
ExpressionAttributeNames={
@@ -210,22 +200,28 @@ def _put_record(self, data_record: DataRecord) -> None:
210200
"#status": self.status_attr,
211201
},
212202
ExpressionAttributeValues={
213-
":now": int(now.timestamp()),
214-
":now_in_millis": int(now.timestamp() * 1000),
215-
":inprogress": STATUS_CONSTANTS["INPROGRESS"],
203+
":now": {"N": str(int(now.timestamp()))},
204+
":now_in_millis": {"N": str(int(now.timestamp() * 1000))},
205+
":inprogress": {"S": STATUS_CONSTANTS["INPROGRESS"]},
216206
},
217207
)
218-
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
219-
logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}")
220-
raise IdempotencyItemAlreadyExistsError
208+
except ClientError as exc:
209+
error_code = exc.response.get("Error", {}).get("Code")
210+
if error_code == "ConditionalCheckFailedException":
211+
logger.debug(
212+
f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}"
213+
)
214+
raise IdempotencyItemAlreadyExistsError from exc
215+
else:
216+
raise
221217

222218
def _update_record(self, data_record: DataRecord):
223219
logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}")
224220
update_expression = "SET #response_data = :response_data, #expiry = :expiry, " "#status = :status"
225221
expression_attr_values = {
226-
":expiry": data_record.expiry_timestamp,
227-
":response_data": data_record.response_data,
228-
":status": data_record.status,
222+
":expiry": {"N": str(data_record.expiry_timestamp)},
223+
":response_data": {"S": data_record.response_data},
224+
":status": {"S": data_record.status},
229225
}
230226
expression_attr_names = {
231227
"#expiry": self.expiry_attr,
@@ -235,7 +231,7 @@ def _update_record(self, data_record: DataRecord):
235231

236232
if self.payload_validation_enabled:
237233
update_expression += ", #validation_key = :validation_key"
238-
expression_attr_values[":validation_key"] = data_record.payload_hash
234+
expression_attr_values[":validation_key"] = {"S": data_record.payload_hash}
239235
expression_attr_names["#validation_key"] = self.validation_key_attr
240236

241237
kwargs = {
@@ -245,8 +241,8 @@ def _update_record(self, data_record: DataRecord):
245241
"ExpressionAttributeNames": expression_attr_names,
246242
}
247243

248-
self.table.update_item(**kwargs)
244+
self._client.update_item(TableName=self.table_name, **kwargs)
249245

250246
def _delete_record(self, data_record: DataRecord) -> None:
251247
logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}")
252-
self.table.delete_item(Key=self._get_key(data_record.idempotency_key))
248+
self._client.delete_item(TableName=self.table_name, Key={**self._get_key(data_record.idempotency_key)})

docs/core/metrics.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,20 @@ CloudWatch EMF uses the same dimensions across all your metrics. Use `single_met
216216
--8<-- "examples/metrics/src/single_metric_output.json"
217217
```
218218

219+
By default it will skip all previously defined dimensions including default dimensions. Use `default_dimensions` keyword argument if you want to reuse default dimensions or specify custom dimensions from a dictionary.
220+
221+
=== "single_metric_default_dimensions_inherit.json"
222+
223+
```json hl_lines="10 15"
224+
--8<-- "examples/metrics/src/single_metric_default_dimensions_inherit.py"
225+
```
226+
227+
=== "single_metric_default_dimensions.py"
228+
229+
```python hl_lines="12"
230+
--8<-- "examples/metrics/src/single_metric_default_dimensions.py"
231+
```
232+
219233
### Flushing metrics manually
220234

221235
If you prefer not to use `log_metrics` because you might want to encapsulate additional logic when doing so, you can manually flush and clear metrics as follows:

0 commit comments

Comments
 (0)