Skip to content

Commit 1b307f8

Browse files
Merge branch 'develop' into feature/file-parameter-clean
2 parents e24bdf6 + 4a0269e commit 1b307f8

File tree

19 files changed

+619
-75
lines changed

19 files changed

+619
-75
lines changed

CHANGELOG.md

Lines changed: 156 additions & 0 deletions
Large diffs are not rendered by default.

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -661,30 +661,36 @@ def _get_openapi_path( # noqa PLR0912
661661
else:
662662
# Need to iterate to transform any 'model' into a 'schema'
663663
for content_type, payload in response["content"].items():
664-
new_payload: OpenAPIResponseContentSchema
665-
666664
# Case 2.1: the 'content' has a model
667665
if "model" in payload:
668666
# Find the model in the dependant's extra models
667+
model_payload_typed = cast(OpenAPIResponseContentModel, payload)
669668
return_field = next(
670669
filter(
671-
lambda model: model.type_ is cast(OpenAPIResponseContentModel, payload)["model"],
670+
lambda model: model.type_ is model_payload_typed["model"],
672671
self.dependant.response_extra_models,
673672
),
674673
)
675674
if not return_field:
676675
raise AssertionError("Model declared in custom responses was not found")
677676

678-
new_payload = self._openapi_operation_return(
677+
model_payload = self._openapi_operation_return(
679678
param=return_field,
680679
model_name_map=model_name_map,
681680
field_mapping=field_mapping,
682681
)
683682

683+
# Preserve existing fields like examples, encoding, etc.
684+
new_payload: OpenAPIResponseContentSchema = {}
685+
for key, value in payload.items():
686+
if key != "model":
687+
new_payload[key] = value # type: ignore[literal-required]
688+
new_payload.update(model_payload) # Add/override with model schema
689+
684690
# Case 2.2: the 'content' has a schema
685691
else:
686692
# Do nothing! We already have what we need!
687-
new_payload = payload
693+
new_payload = cast(OpenAPIResponseContentSchema, payload)
688694

689695
response["content"][content_type] = new_payload
690696

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
@@ -1173,35 +1173,101 @@ def get_field_info_response_type(annotation, value) -> tuple[FieldInfo | None, A
11731173
return get_field_info_and_type_annotation(inner_type, value, False, True)
11741174

11751175

1176+
def _has_discriminator(field_info: FieldInfo) -> bool:
1177+
"""Check if a FieldInfo has a discriminator."""
1178+
return hasattr(field_info, "discriminator") and field_info.discriminator is not None
1179+
1180+
1181+
def _handle_discriminator_with_param(
1182+
annotations: list[FieldInfo],
1183+
annotation: Any,
1184+
) -> tuple[FieldInfo | None, Any, bool]:
1185+
"""
1186+
Handle the special case of Field(discriminator) + Body() combination.
1187+
1188+
Returns:
1189+
tuple of (powertools_annotation, type_annotation, has_discriminator_with_body)
1190+
"""
1191+
field_obj = None
1192+
body_obj = None
1193+
1194+
for ann in annotations:
1195+
if isinstance(ann, Body):
1196+
body_obj = ann
1197+
elif _has_discriminator(ann):
1198+
field_obj = ann
1199+
1200+
if field_obj and body_obj:
1201+
# Use Body as the primary annotation, preserve full annotation for validation
1202+
return body_obj, annotation, True
1203+
1204+
raise AssertionError("Only one FieldInfo can be used per parameter")
1205+
1206+
1207+
def _create_field_info(
1208+
powertools_annotation: FieldInfo,
1209+
type_annotation: Any,
1210+
has_discriminator_with_body: bool,
1211+
) -> FieldInfo:
1212+
"""Create or copy FieldInfo based on the annotation type."""
1213+
field_info: FieldInfo
1214+
if has_discriminator_with_body:
1215+
# For discriminator + Body case, create a new Body instance directly
1216+
field_info = Body()
1217+
field_info.annotation = type_annotation
1218+
else:
1219+
# Copy field_info because we mutate field_info.default later
1220+
field_info = copy_field_info(
1221+
field_info=powertools_annotation,
1222+
annotation=type_annotation,
1223+
)
1224+
return field_info
1225+
1226+
1227+
def _set_field_default(field_info: FieldInfo, value: Any, is_path_param: bool) -> None:
1228+
"""Set the default value for a field."""
1229+
if field_info.default not in [Undefined, Required]:
1230+
raise AssertionError("FieldInfo needs to have a default value of Undefined or Required")
1231+
1232+
if value is not inspect.Signature.empty:
1233+
if is_path_param:
1234+
raise AssertionError("Cannot use a FieldInfo as a path parameter and pass a value")
1235+
field_info.default = value
1236+
else:
1237+
field_info.default = Required
1238+
1239+
11761240
def get_field_info_annotated_type(annotation, value, is_path_param: bool) -> tuple[FieldInfo | None, Any]:
11771241
"""
11781242
Get the FieldInfo and type annotation from an Annotated type.
11791243
"""
1180-
field_info: FieldInfo | None = None
11811244
annotated_args = get_args(annotation)
11821245
type_annotation = annotated_args[0]
11831246
powertools_annotations = [arg for arg in annotated_args[1:] if isinstance(arg, FieldInfo)]
11841247

1185-
if len(powertools_annotations) > 1:
1186-
raise AssertionError("Only one FieldInfo can be used per parameter")
1187-
1188-
powertools_annotation = next(iter(powertools_annotations), None)
1248+
# Determine which annotation to use
1249+
powertools_annotation: FieldInfo | None = None
1250+
has_discriminator_with_param = False
11891251

1190-
if isinstance(powertools_annotation, FieldInfo):
1191-
# Copy `field_info` because we mutate `field_info.default` later
1192-
field_info = copy_field_info(
1193-
field_info=powertools_annotation,
1194-
annotation=annotation,
1252+
if len(powertools_annotations) == 2:
1253+
powertools_annotation, type_annotation, has_discriminator_with_param = _handle_discriminator_with_param(
1254+
powertools_annotations,
1255+
annotation,
11951256
)
1196-
if field_info.default not in [Undefined, Required]:
1197-
raise AssertionError("FieldInfo needs to have a default value of Undefined or Required")
1257+
elif len(powertools_annotations) > 1:
1258+
raise AssertionError("Only one FieldInfo can be used per parameter")
1259+
else:
1260+
powertools_annotation = next(iter(powertools_annotations), None)
11981261

1199-
if value is not inspect.Signature.empty:
1200-
if is_path_param:
1201-
raise AssertionError("Cannot use a FieldInfo as a path parameter and pass a value")
1202-
field_info.default = value
1203-
else:
1204-
field_info.default = Required
1262+
# Process the annotation if it exists
1263+
field_info: FieldInfo | None = None
1264+
if isinstance(powertools_annotation, FieldInfo): # pragma: no cover
1265+
field_info = _create_field_info(powertools_annotation, type_annotation, has_discriminator_with_param)
1266+
_set_field_default(field_info, value, is_path_param)
1267+
1268+
# Preserve full annotated type for discriminated unions
1269+
if _has_discriminator(powertools_annotation): # pragma: no cover
1270+
type_annotation = annotation # pragma: no cover
12051271

12061272
return field_info, type_annotation
12071273

aws_lambda_powertools/event_handler/openapi/types.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,32 @@
6363
}
6464

6565

66+
class OpenAPIResponseHeader(TypedDict, total=False):
67+
"""OpenAPI Response Header Object"""
68+
69+
description: NotRequired[str]
70+
schema: NotRequired[dict[str, Any]]
71+
examples: NotRequired[dict[str, Any]]
72+
style: NotRequired[str]
73+
explode: NotRequired[bool]
74+
allowReserved: NotRequired[bool]
75+
deprecated: NotRequired[bool]
76+
77+
6678
class OpenAPIResponseContentSchema(TypedDict, total=False):
6779
schema: dict
80+
examples: NotRequired[dict[str, Any]]
81+
encoding: NotRequired[dict[str, Any]]
6882

6983

70-
class OpenAPIResponseContentModel(TypedDict):
84+
class OpenAPIResponseContentModel(TypedDict, total=False):
7185
model: Any
86+
examples: NotRequired[dict[str, Any]]
87+
encoding: NotRequired[dict[str, Any]]
7288

7389

74-
class OpenAPIResponse(TypedDict):
75-
description: str
90+
class OpenAPIResponse(TypedDict, total=False):
91+
description: str # Still required
92+
headers: NotRequired[dict[str, OpenAPIResponseHeader]]
7693
content: NotRequired[dict[str, OpenAPIResponseContentSchema | OpenAPIResponseContentModel]]
94+
links: NotRequired[dict[str, Any]]
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.0"
3+
VERSION = "3.20.1a0"

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"

0 commit comments

Comments
 (0)