Skip to content

Commit 3537456

Browse files
committed
Merge branch 'develop' into feat/support-pydantic-in-query-form-header
2 parents c425348 + e3f7647 commit 3537456

File tree

19 files changed

+358
-107
lines changed

19 files changed

+358
-107
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,39 @@
44
<a name="unreleased"></a>
55
# Unreleased
66

7+
## Bug Fixes
8+
9+
* **docs:** correct build optimization script and docs ([#7367](https://github.com/aws-powertools/powertools-lambda-python/issues/7367))
10+
711
## Code Refactoring
812

913
* **parser:** Improve AppSync models with examples and descriptions ([#7330](https://github.com/aws-powertools/powertools-lambda-python/issues/7330))
1014

15+
## Documentation
16+
17+
* **event_handler:** update test section ([#7374](https://github.com/aws-powertools/powertools-lambda-python/issues/7374))
18+
* **event_handler:** add info section about types ([#7368](https://github.com/aws-powertools/powertools-lambda-python/issues/7368))
19+
1120
## Features
1221

22+
* **event-handler:** add support for Pydantic Field discriminator in validation ([#7227](https://github.com/aws-powertools/powertools-lambda-python/issues/7227))
23+
* **event_handler:** enhance OpenAPI response with headers, links, examples and encoding ([#7312](https://github.com/aws-powertools/powertools-lambda-python/issues/7312))
1324
* **parser:** add field metadata and examples for CloudWatch models ([#7343](https://github.com/aws-powertools/powertools-lambda-python/issues/7343))
1425

1526
## Maintenance
1627

28+
* **ci:** new pre-release 3.20.1a1 ([#7372](https://github.com/aws-powertools/powertools-lambda-python/issues/7372))
1729
* **ci:** new pre-release 3.20.1a0 ([#7362](https://github.com/aws-powertools/powertools-lambda-python/issues/7362))
1830
* **deps:** bump mkdocs-material from 9.6.18 to 9.6.19 ([#7359](https://github.com/aws-powertools/powertools-lambda-python/issues/7359))
31+
* **deps:** bump protobuf from 6.32.0 to 6.32.1 ([#7376](https://github.com/aws-powertools/powertools-lambda-python/issues/7376))
32+
* **deps-dev:** bump boto3-stubs from 1.40.27 to 1.40.29 ([#7371](https://github.com/aws-powertools/powertools-lambda-python/issues/7371))
33+
* **deps-dev:** bump mypy from 1.17.1 to 1.18.1 ([#7375](https://github.com/aws-powertools/powertools-lambda-python/issues/7375))
34+
* **deps-dev:** bump pytest-asyncio from 1.1.0 to 1.2.0 ([#7377](https://github.com/aws-powertools/powertools-lambda-python/issues/7377))
1935
* **deps-dev:** bump pytest-cov from 6.3.0 to 7.0.0 ([#7357](https://github.com/aws-powertools/powertools-lambda-python/issues/7357))
36+
* **deps-dev:** bump aws-cdk from 2.1029.0 to 2.1029.1 ([#7370](https://github.com/aws-powertools/powertools-lambda-python/issues/7370))
2037
* **deps-dev:** bump testcontainers from 4.12.0 to 4.13.0 ([#7360](https://github.com/aws-powertools/powertools-lambda-python/issues/7360))
2138
* **deps-dev:** bump sentry-sdk from 2.37.0 to 2.37.1 ([#7356](https://github.com/aws-powertools/powertools-lambda-python/issues/7356))
39+
* **deps-dev:** bump boto3-stubs from 1.40.29 to 1.40.30 ([#7378](https://github.com/aws-powertools/powertools-lambda-python/issues/7378))
2240
* **deps-dev:** bump boto3-stubs from 1.40.26 to 1.40.27 ([#7358](https://github.com/aws-powertools/powertools-lambda-python/issues/7358))
2341

2442

aws_lambda_powertools/event_handler/openapi/compat.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33

44
from collections import deque
55
from collections.abc import Mapping, Sequence
6-
7-
# MAINTENANCE: remove when deprecating Pydantic v1. Mypy doesn't handle two different code paths that import different
8-
# versions of a module, so we need to ignore errors here.
6+
from copy import copy
97
from dataclasses import dataclass, is_dataclass
108
from typing import TYPE_CHECKING, Any, Deque, FrozenSet, List, Set, Tuple, Union
119

@@ -80,9 +78,19 @@ def type_(self) -> Any:
8078
return self.field_info.annotation
8179

8280
def __post_init__(self) -> None:
83-
self._type_adapter: TypeAdapter[Any] = TypeAdapter(
84-
Annotated[self.field_info.annotation, self.field_info],
85-
)
81+
# If the field_info.annotation is already an Annotated type with discriminator metadata,
82+
# use it directly instead of wrapping it again
83+
annotation = self.field_info.annotation
84+
if (
85+
get_origin(annotation) is Annotated
86+
and hasattr(self.field_info, "discriminator")
87+
and self.field_info.discriminator is not None
88+
):
89+
self._type_adapter: TypeAdapter[Any] = TypeAdapter(annotation)
90+
else:
91+
self._type_adapter: TypeAdapter[Any] = TypeAdapter(
92+
Annotated[annotation, self.field_info],
93+
)
8694

8795
def get_default(self) -> Any:
8896
if self.field_info.is_required():
@@ -176,7 +184,11 @@ def model_rebuild(model: type[BaseModel]) -> None:
176184

177185

178186
def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo:
179-
return type(field_info).from_annotation(annotation)
187+
# Create a shallow copy of the field_info to preserve its type and all attributes
188+
new_field = copy(field_info)
189+
# Update only the annotation to the new one
190+
new_field.annotation = annotation
191+
return new_field
180192

181193

182194
def get_missing_field_error(loc: tuple[str, ...]) -> dict[str, Any]:

aws_lambda_powertools/event_handler/openapi/params.py

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,35 +1038,101 @@ def get_field_info_response_type(annotation, value) -> tuple[FieldInfo | None, A
10381038
return get_field_info_and_type_annotation(inner_type, value, False, True)
10391039

10401040

1041+
def _has_discriminator(field_info: FieldInfo) -> bool:
1042+
"""Check if a FieldInfo has a discriminator."""
1043+
return hasattr(field_info, "discriminator") and field_info.discriminator is not None
1044+
1045+
1046+
def _handle_discriminator_with_param(
1047+
annotations: list[FieldInfo],
1048+
annotation: Any,
1049+
) -> tuple[FieldInfo | None, Any, bool]:
1050+
"""
1051+
Handle the special case of Field(discriminator) + Body() combination.
1052+
1053+
Returns:
1054+
tuple of (powertools_annotation, type_annotation, has_discriminator_with_body)
1055+
"""
1056+
field_obj = None
1057+
body_obj = None
1058+
1059+
for ann in annotations:
1060+
if isinstance(ann, Body):
1061+
body_obj = ann
1062+
elif _has_discriminator(ann):
1063+
field_obj = ann
1064+
1065+
if field_obj and body_obj:
1066+
# Use Body as the primary annotation, preserve full annotation for validation
1067+
return body_obj, annotation, True
1068+
1069+
raise AssertionError("Only one FieldInfo can be used per parameter")
1070+
1071+
1072+
def _create_field_info(
1073+
powertools_annotation: FieldInfo,
1074+
type_annotation: Any,
1075+
has_discriminator_with_body: bool,
1076+
) -> FieldInfo:
1077+
"""Create or copy FieldInfo based on the annotation type."""
1078+
field_info: FieldInfo
1079+
if has_discriminator_with_body:
1080+
# For discriminator + Body case, create a new Body instance directly
1081+
field_info = Body()
1082+
field_info.annotation = type_annotation
1083+
else:
1084+
# Copy field_info because we mutate field_info.default later
1085+
field_info = copy_field_info(
1086+
field_info=powertools_annotation,
1087+
annotation=type_annotation,
1088+
)
1089+
return field_info
1090+
1091+
1092+
def _set_field_default(field_info: FieldInfo, value: Any, is_path_param: bool) -> None:
1093+
"""Set the default value for a field."""
1094+
if field_info.default not in [Undefined, Required]:
1095+
raise AssertionError("FieldInfo needs to have a default value of Undefined or Required")
1096+
1097+
if value is not inspect.Signature.empty:
1098+
if is_path_param:
1099+
raise AssertionError("Cannot use a FieldInfo as a path parameter and pass a value")
1100+
field_info.default = value
1101+
else:
1102+
field_info.default = Required
1103+
1104+
10411105
def get_field_info_annotated_type(annotation, value, is_path_param: bool) -> tuple[FieldInfo | None, Any]:
10421106
"""
10431107
Get the FieldInfo and type annotation from an Annotated type.
10441108
"""
1045-
field_info: FieldInfo | None = None
10461109
annotated_args = get_args(annotation)
10471110
type_annotation = annotated_args[0]
10481111
powertools_annotations = [arg for arg in annotated_args[1:] if isinstance(arg, FieldInfo)]
10491112

1050-
if len(powertools_annotations) > 1:
1051-
raise AssertionError("Only one FieldInfo can be used per parameter")
1052-
1053-
powertools_annotation = next(iter(powertools_annotations), None)
1113+
# Determine which annotation to use
1114+
powertools_annotation: FieldInfo | None = None
1115+
has_discriminator_with_param = False
10541116

1055-
if isinstance(powertools_annotation, FieldInfo):
1056-
# Copy `field_info` because we mutate `field_info.default` later
1057-
field_info = copy_field_info(
1058-
field_info=powertools_annotation,
1059-
annotation=annotation,
1117+
if len(powertools_annotations) == 2:
1118+
powertools_annotation, type_annotation, has_discriminator_with_param = _handle_discriminator_with_param(
1119+
powertools_annotations,
1120+
annotation,
10601121
)
1061-
if field_info.default not in [Undefined, Required]:
1062-
raise AssertionError("FieldInfo needs to have a default value of Undefined or Required")
1122+
elif len(powertools_annotations) > 1:
1123+
raise AssertionError("Only one FieldInfo can be used per parameter")
1124+
else:
1125+
powertools_annotation = next(iter(powertools_annotations), None)
10631126

1064-
if value is not inspect.Signature.empty:
1065-
if is_path_param:
1066-
raise AssertionError("Cannot use a FieldInfo as a path parameter and pass a value")
1067-
field_info.default = value
1068-
else:
1069-
field_info.default = Required
1127+
# Process the annotation if it exists
1128+
field_info: FieldInfo | None = None
1129+
if isinstance(powertools_annotation, FieldInfo): # pragma: no cover
1130+
field_info = _create_field_info(powertools_annotation, type_annotation, has_discriminator_with_param)
1131+
_set_field_default(field_info, value, is_path_param)
1132+
1133+
# Preserve full annotated type for discriminated unions
1134+
if _has_discriminator(powertools_annotation): # pragma: no cover
1135+
type_annotation = annotation # pragma: no cover
10701136

10711137
return field_info, type_annotation
10721138

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Exposes version constant to avoid circular dependencies."""
22

3-
VERSION = "3.20.1a0"
3+
VERSION = "3.20.1a1"

docs/build_recipes/performance-optimization.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ description: Optimize Lambda functions for better performance and reduced costs
77

88
Optimize your Lambda functions for better performance, reduced cold start times, and lower costs. These techniques help minimize package size, improve startup speed, and reduce memory usage.
99

10+
Always validate your function's behavior after applying optimizations to ensure an optimization hasn't introduced any issues with your packages. For example, removal of directories that appear to be unnecessary, such as `docs`, can break some libraries.
11+
1012
## Reduce cold start times
1113

1214
1. **Minimize package size** by excluding unnecessary files

docs/core/event_handler/api_gateway.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,17 @@ We use the `Annotated` and OpenAPI `Body` type to instruct Event Handler that ou
428428
--8<-- "examples/event_handler_rest/src/validating_payload_subset_output.json"
429429
```
430430

431+
##### Discriminated unions
432+
433+
You can use Pydantic's `Field(discriminator="...")` with union types to create discriminated unions (also known as tagged unions). This allows the Event Handler to automatically determine which model to use based on a discriminator field in the request body.
434+
435+
```python hl_lines="3 4 8 31 36" title="discriminated_unions.py"
436+
--8<-- "examples/event_handler_rest/src/discriminated_unions.py"
437+
```
438+
439+
1. `Field(discriminator="action")` tells Pydantic to use the `action` field to determine which model to instantiate
440+
2. `Body()` annotation tells the Event Handler to parse the request body using the discriminated union
441+
431442
#### Validating responses
432443

433444
You can use `response_validation_error_http_code` to set a custom HTTP code for failed response validation. When this field is set, we will raise a `ResponseValidationError` instead of a `RequestValidationError`.
@@ -1482,6 +1493,9 @@ Each endpoint will be it's own Lambda function that is configured as a [Lambda i
14821493

14831494
You can test your routes by passing a proxy event request with required params.
14841495

1496+
???+ info
1497+
Fields such as headers and query strings are always delivered as strings when events reach Lambda. When testing your Lambda function with local events, we recommend using the sample events available in our [repository](https://github.com/aws-powertools/powertools-lambda-python/tree/develop/tests/events).
1498+
14851499
=== "API Gateway REST API"
14861500

14871501
=== "assert_rest_api_resolver_response.py"
@@ -1545,14 +1559,3 @@ You can test your routes by passing a proxy event request with required params.
15451559
Chalice is a full featured microframework that manages application and infrastructure. This utility, however, is largely focused on routing to reduce boilerplate and expects you to setup and manage infrastructure with your framework of choice.
15461560

15471561
That said, [Chalice has native integration with Lambda Powertools](https://aws.github.io/chalice/topics/middleware.html){target="_blank" rel="nofollow"} if you're looking for a more opinionated and web framework feature set.
1548-
1549-
**What happened to `ApiGatewayResolver`?**
1550-
1551-
It's been superseded by more explicit resolvers like `APIGatewayRestResolver`, `APIGatewayHttpResolver`, and `ALBResolver`.
1552-
1553-
`ApiGatewayResolver` handled multiple types of event resolvers for convenience via `proxy_type` param. However,
1554-
it made it impossible for static checkers like Mypy and IDEs IntelliSense to know what properties a `current_event` would have due to late bound resolution.
1555-
1556-
This provided a suboptimal experience for customers not being able to find all properties available besides common ones between API Gateway REST, HTTP, and ALB - while manually annotating `app.current_event` would work it is not the experience we want to provide to customers.
1557-
1558-
`ApiGatewayResolver` will be deprecated in v2 and have appropriate warnings as soon as we have a v2 draft.

examples/build_recipes/build_optimization/optimize-advanced.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ find build/ -name "*.so.*" -exec strip --strip-debug {} \; 2>/dev/null || true
1515
rm -rf build/*/site-packages/*/tests/
1616
rm -rf build/*/site-packages/*/test/
1717
rm -rf build/*/site-packages/*/.git/
18-
rm -rf build/*/site-packages/*/docs/
1918
rm -rf build/*/site-packages/*/examples/
2019
rm -rf build/*/site-packages/*/*.md
2120
rm -rf build/*/site-packages/*/*.rst

examples/build_recipes/build_optimization/optimize-package.sh

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ find build/ -name "*.dist-info" -type d -exec rm -rf {} +
77
find build/ -name "tests" -type d -exec rm -rf {} +
88
find build/ -name "test_*" -delete
99

10-
# Remove documentation and examples
11-
find build/ -name "docs" -type d -exec rm -rf {} +
10+
# Remove examples
1211
find build/ -name "examples" -type d -exec rm -rf {} +
1312

1413
echo "✅ Package optimized"

examples/event_handler_rest/src/assert_rest_api_resolver_response.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ def test_lambda_handler(lambda_context):
2222
"path": "/todos",
2323
"httpMethod": "GET",
2424
"requestContext": {"requestId": "227b78aa-779d-47d4-a48e-ce62120393b8"}, # correlation ID
25+
# Always use strings when using query parameters. API Gateway automatically converts them to strings
26+
"queryStringParameters": {"page": "5", "foo": "bar"},
2527
}
2628
# Example of API Gateway REST API request event:
2729
# https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html#apigateway-example-event
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import Literal, Union
2+
3+
from pydantic import BaseModel, Field
4+
from typing_extensions import Annotated
5+
6+
from aws_lambda_powertools import Logger, Tracer
7+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
8+
from aws_lambda_powertools.event_handler.openapi.params import Body
9+
from aws_lambda_powertools.logging import correlation_paths
10+
from aws_lambda_powertools.utilities.typing import LambdaContext
11+
12+
tracer = Tracer()
13+
logger = Logger()
14+
app = APIGatewayRestResolver(enable_validation=True)
15+
16+
17+
class FooAction(BaseModel):
18+
"""Action type for foo operations."""
19+
20+
action: Literal["foo"] = "foo"
21+
foo_data: str
22+
23+
24+
class BarAction(BaseModel):
25+
"""Action type for bar operations."""
26+
27+
action: Literal["bar"] = "bar"
28+
bar_data: int
29+
30+
31+
ActionType = Annotated[Union[FooAction, BarAction], Field(discriminator="action")] # (1)!
32+
33+
34+
@app.post("/actions")
35+
@tracer.capture_method
36+
def handle_action(action: Annotated[ActionType, Body(description="Action to perform")]): # (2)!
37+
"""Handle different action types using discriminated unions."""
38+
if isinstance(action, FooAction):
39+
return {"message": f"Handling foo action with data: {action.foo_data}"}
40+
elif isinstance(action, BarAction):
41+
return {"message": f"Handling bar action with data: {action.bar_data}"}
42+
43+
44+
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP)
45+
@tracer.capture_lambda_handler
46+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
47+
return app.resolve(event, context)

0 commit comments

Comments
 (0)