Skip to content

Commit 11b68ff

Browse files
fix(event_handler): sync alias and validation_alias for Pydantic 2.12+ compatibility (aws-powertools#7901)
* fix: add support for Pydantic 2.12+ * fix: add support for Pydantic 2.12+ * fix: add support for Pydantic 2.12+ * fix: add support for Pydantic 2.12+
1 parent 26f44a8 commit 11b68ff

File tree

3 files changed

+291
-12
lines changed

3 files changed

+291
-12
lines changed

aws_lambda_powertools/event_handler/openapi/params.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def __init__(
103103
alias_priority: int | None = _Unset,
104104
# MAINTENANCE: update when deprecating Pydantic v1, import these types
105105
# MAINTENANCE: validation_alias: str | AliasPath | AliasChoices | None
106-
validation_alias: str | None = None,
106+
validation_alias: str | None = _Unset,
107107
serialization_alias: str | None = None,
108108
title: str | None = None,
109109
description: str | None = None,
@@ -217,6 +217,14 @@ def __init__(
217217

218218
self.openapi_examples = openapi_examples
219219

220+
# Pydantic 2.12+ no longer copies alias to validation_alias automatically
221+
# Ensure alias and validation_alias are in sync when only one is provided
222+
if validation_alias is _Unset and alias is not None:
223+
validation_alias = alias
224+
elif alias is None and validation_alias is not _Unset and validation_alias is not None:
225+
alias = validation_alias
226+
kwargs["alias"] = alias
227+
220228
kwargs.update(
221229
{
222230
"annotation": annotation,
@@ -254,7 +262,7 @@ def __init__(
254262
alias_priority: int | None = _Unset,
255263
# MAINTENANCE: update when deprecating Pydantic v1, import these types
256264
# MAINTENANCE: validation_alias: str | AliasPath | AliasChoices | None
257-
validation_alias: str | None = None,
265+
validation_alias: str | None = _Unset,
258266
serialization_alias: str | None = None,
259267
title: str | None = None,
260268
description: str | None = None,
@@ -386,7 +394,7 @@ def __init__(
386394
annotation: Any | None = None,
387395
alias: str | None = None,
388396
alias_priority: int | None = _Unset,
389-
validation_alias: str | None = None,
397+
validation_alias: str | None = _Unset,
390398
serialization_alias: str | None = None,
391399
title: str | None = None,
392400
description: str | None = None,
@@ -517,7 +525,7 @@ def __init__(
517525
alias_priority: int | None = _Unset,
518526
# MAINTENANCE: update when deprecating Pydantic v1, import these types
519527
# str | AliasPath | AliasChoices | None
520-
validation_alias: str | None = None,
528+
validation_alias: str | None = _Unset,
521529
serialization_alias: str | None = None,
522530
convert_underscores: bool = True,
523531
title: str | None = None,
@@ -667,7 +675,7 @@ def __init__(
667675
alias_priority: int | None = _Unset,
668676
# MAINTENANCE: update when deprecating Pydantic v1, import these types
669677
# str | AliasPath | AliasChoices | None
670-
validation_alias: str | None = None,
678+
validation_alias: str | None = _Unset,
671679
serialization_alias: str | None = None,
672680
title: str | None = None,
673681
description: str | None = None,
@@ -720,6 +728,13 @@ def __init__(
720728
kwargs["openapi_examples"] = openapi_examples
721729
current_json_schema_extra = json_schema_extra or extra
722730

731+
# Pydantic 2.12+ no longer copies alias to validation_alias automatically
732+
# Ensure alias and validation_alias are in sync when only one is provided
733+
if validation_alias is _Unset and alias is not None:
734+
validation_alias = alias
735+
elif alias is None and validation_alias is not _Unset and validation_alias is not None:
736+
alias = validation_alias
737+
kwargs["alias"] = alias
723738
self.openapi_examples = openapi_examples
724739

725740
kwargs.update(
@@ -758,7 +773,7 @@ def __init__(
758773
alias_priority: int | None = _Unset,
759774
# MAINTENANCE: update when deprecating Pydantic v1, import these types
760775
# str | AliasPath | AliasChoices | None
761-
validation_alias: str | None = None,
776+
validation_alias: str | None = _Unset,
762777
serialization_alias: str | None = None,
763778
title: str | None = None,
764779
description: str | None = None,

docs/core/logger.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,18 +196,18 @@ You can append your own keys to your existing Logger via `append_keys(**addition
196196
The append_context_keys method allows temporary modification of a Logger instance's context without creating a new logger. It's useful for adding context keys to specific workflows while maintaining the logger's overall state and simplicity.
197197

198198
???+ danger "Important: Keys are removed on context exit, even if they existed before"
199-
All keys added within the context are removed when exiting, **including keys that already existed with the same name**.
200-
199+
All keys added within the context are removed when exiting, **including keys that already existed with the same name**.
200+
201201
If you need to temporarily override a key's value while preserving the original, use `append_keys()` for persistent keys and avoid key name collisions with `append_context_keys()`.
202-
202+
203203
**Example of key collision:**
204204
```python
205205
logger.append_keys(order_id="ORD-123") # Persistent key
206206
logger.info("Order received") # Has order_id="ORD-123"
207-
207+
208208
with logger.append_context_keys(order_id="ORD-CHILD"): # Overwrites
209209
logger.info("Processing") # Has order_id="ORD-CHILD"
210-
210+
211211
logger.info("Order completed") # order_id key is now MISSING!
212212
```
213213

@@ -1014,7 +1014,7 @@ You can change the order of [standard Logger keys](#standard-structured-keys) or
10141014
By default, this Logger and the standard logging library emit records with the default AWS Lambda timestamp in **UTC**.
10151015

10161016
<!-- markdownlint-disable MD013 -->
1017-
If you prefer to log in a specific timezone, you can configure it by setting the `TZ` environment variable. You can do this either as an AWS Lambda environment variable or directly within your Lambda function settings. [Click here](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime){target="_blank"} for a comprehensive list of available Lambda environment variables.
1017+
If you prefer to log in a specific timezone, you can configure it by setting the `TZ` environment variable. You can do this either as an AWS Lambda environment variable or directly within your Lambda function settings. See the [Lambda environment variables documentation](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime){target="_blank"} for a comprehensive list of available options.
10181018
<!-- markdownlint-enable MD013 -->
10191019

10201020
???+ tip

tests/functional/event_handler/_pydantic/test_openapi_params.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,270 @@ def list_items(limit: Annotated[constrained_int, Query()] = 10):
12691269
assert limit_param.required is False
12701270

12711271

1272+
def test_query_alias_sets_validation_alias_automatically():
1273+
"""
1274+
When alias is set but validation_alias is not,
1275+
validation_alias should be automatically set to alias value.
1276+
This ensures compatibility with Pydantic 2.12+.
1277+
"""
1278+
from annotated_types import Ge, Le
1279+
from pydantic import StringConstraints
1280+
1281+
# GIVEN an APIGatewayRestResolver with validation enabled
1282+
app = APIGatewayRestResolver(enable_validation=True)
1283+
1284+
# AND constrained types using annotated_types
1285+
IntQuery = Annotated[int, Ge(1), Le(100)]
1286+
StrQuery = Annotated[str, StringConstraints(min_length=4, max_length=128)]
1287+
1288+
@app.get("/foo")
1289+
def get_foo(
1290+
str_query: Annotated[StrQuery, Query(alias="strQuery")],
1291+
int_query: Annotated[IntQuery, Query(alias="intQuery")],
1292+
):
1293+
return {"int_query": int_query, "str_query": str_query}
1294+
1295+
# WHEN sending a request with aliased query parameters
1296+
event = {
1297+
"httpMethod": "GET",
1298+
"path": "/foo",
1299+
"queryStringParameters": {
1300+
"intQuery": "20",
1301+
"strQuery": "fooBarFizzBuzz",
1302+
},
1303+
}
1304+
1305+
# THEN the request should succeed with correct values
1306+
result = app(event, {})
1307+
assert result["statusCode"] == 200
1308+
body = json.loads(result["body"])
1309+
assert body["int_query"] == 20
1310+
assert body["str_query"] == "fooBarFizzBuzz"
1311+
1312+
1313+
def test_query_alias_with_multivalue_query_string_parameters():
1314+
"""
1315+
Ensure alias works with multiValueQueryStringParameters.
1316+
"""
1317+
from annotated_types import Ge, Le
1318+
from pydantic import StringConstraints
1319+
1320+
# GIVEN an APIGatewayRestResolver with validation enabled
1321+
app = APIGatewayRestResolver(enable_validation=True)
1322+
1323+
IntQuery = Annotated[int, Ge(1), Le(100)]
1324+
StrQuery = Annotated[str, StringConstraints(min_length=4, max_length=128)]
1325+
1326+
@app.get("/foo")
1327+
def get_foo(
1328+
str_query: Annotated[StrQuery, Query(alias="strQuery")],
1329+
int_query: Annotated[IntQuery, Query(alias="intQuery")],
1330+
):
1331+
return {"int_query": int_query, "str_query": str_query}
1332+
1333+
# WHEN sending a request with multiValueQueryStringParameters
1334+
event = {
1335+
"httpMethod": "GET",
1336+
"path": "/foo",
1337+
"multiValueQueryStringParameters": {
1338+
"intQuery": ["20"],
1339+
"strQuery": ["fooBarFizzBuzz"],
1340+
},
1341+
}
1342+
1343+
# THEN the request should succeed
1344+
result = app(event, {})
1345+
assert result["statusCode"] == 200
1346+
body = json.loads(result["body"])
1347+
assert body["int_query"] == 20
1348+
assert body["str_query"] == "fooBarFizzBuzz"
1349+
1350+
1351+
def test_query_explicit_validation_alias_takes_precedence():
1352+
"""
1353+
Explicitly set validation_alias is preserved and not overwritten by alias.
1354+
The alias is used by Powertools to extract the value from the request,
1355+
while validation_alias is used by Pydantic for internal validation.
1356+
"""
1357+
# GIVEN an APIGatewayRestResolver with validation enabled
1358+
app = APIGatewayRestResolver(enable_validation=True)
1359+
1360+
@app.get("/foo")
1361+
def get_foo(
1362+
my_param: Annotated[str, Query(alias="aliasName", validation_alias="validationAliasName")],
1363+
):
1364+
return {"my_param": my_param}
1365+
1366+
# WHEN sending a request with the alias name (used by Powertools to extract value)
1367+
event = {
1368+
"httpMethod": "GET",
1369+
"path": "/foo",
1370+
"queryStringParameters": {
1371+
"aliasName": "test_value",
1372+
},
1373+
}
1374+
1375+
# THEN the request should succeed using alias for extraction
1376+
result = app(event, {})
1377+
assert result["statusCode"] == 200
1378+
body = json.loads(result["body"])
1379+
assert body["my_param"] == "test_value"
1380+
1381+
1382+
def test_header_alias_sets_validation_alias_automatically():
1383+
"""
1384+
Header alias should also set validation_alias automatically.
1385+
"""
1386+
# GIVEN an APIGatewayRestResolver with validation enabled
1387+
app = APIGatewayRestResolver(enable_validation=True)
1388+
1389+
@app.get("/foo")
1390+
def get_foo(
1391+
custom_header: Annotated[str, Header(alias="X-Custom-Header")],
1392+
):
1393+
return {"custom_header": custom_header}
1394+
1395+
# WHEN sending a request with the aliased header
1396+
event = {
1397+
"httpMethod": "GET",
1398+
"path": "/foo",
1399+
"headers": {
1400+
"X-Custom-Header": "header_value",
1401+
},
1402+
}
1403+
1404+
# THEN the request should succeed
1405+
result = app(event, {})
1406+
assert result["statusCode"] == 200
1407+
body = json.loads(result["body"])
1408+
assert body["custom_header"] == "header_value"
1409+
1410+
1411+
def test_query_without_alias_works_normally():
1412+
"""
1413+
Query without alias continues to work normally.
1414+
"""
1415+
# GIVEN an APIGatewayRestResolver with validation enabled
1416+
app = APIGatewayRestResolver(enable_validation=True)
1417+
1418+
@app.get("/foo")
1419+
def get_foo(
1420+
my_param: Annotated[str, Query()],
1421+
):
1422+
return {"my_param": my_param}
1423+
1424+
# WHEN sending a request with the parameter name
1425+
event = {
1426+
"httpMethod": "GET",
1427+
"path": "/foo",
1428+
"queryStringParameters": {
1429+
"my_param": "test_value",
1430+
},
1431+
}
1432+
1433+
# THEN the request should succeed
1434+
result = app(event, {})
1435+
assert result["statusCode"] == 200
1436+
body = json.loads(result["body"])
1437+
assert body["my_param"] == "test_value"
1438+
1439+
1440+
def test_query_validation_alias_only_sets_alias_automatically():
1441+
"""
1442+
When only validation_alias is set (without alias),
1443+
alias should be automatically set to validation_alias value.
1444+
This ensures the middleware can find the parameter in the request.
1445+
"""
1446+
from annotated_types import Ge, Le
1447+
from pydantic import StringConstraints
1448+
1449+
# GIVEN an APIGatewayRestResolver with validation enabled
1450+
app = APIGatewayRestResolver(enable_validation=True)
1451+
1452+
IntQuery = Annotated[int, Ge(1), Le(100)]
1453+
StrQuery = Annotated[str, StringConstraints(min_length=4, max_length=128)]
1454+
1455+
@app.get("/foo")
1456+
def get_foo(
1457+
str_query: Annotated[StrQuery, Query(validation_alias="strQuery")],
1458+
int_query: Annotated[IntQuery, Query(validation_alias="intQuery")],
1459+
):
1460+
return {"int_query": int_query, "str_query": str_query}
1461+
1462+
# WHEN sending a request with validation_alias names
1463+
event = {
1464+
"httpMethod": "GET",
1465+
"path": "/foo",
1466+
"queryStringParameters": {
1467+
"intQuery": "20",
1468+
"strQuery": "fooBarFizzBuzz",
1469+
},
1470+
}
1471+
1472+
# THEN the request should succeed
1473+
result = app(event, {})
1474+
assert result["statusCode"] == 200
1475+
body = json.loads(result["body"])
1476+
assert body["int_query"] == 20
1477+
assert body["str_query"] == "fooBarFizzBuzz"
1478+
1479+
1480+
def test_body_alias_sets_validation_alias_automatically():
1481+
"""
1482+
When alias is set but validation_alias is not in Body,
1483+
validation_alias should be automatically set to alias value.
1484+
"""
1485+
# GIVEN an APIGatewayRestResolver with validation enabled
1486+
app = APIGatewayRestResolver(enable_validation=True)
1487+
1488+
@app.post("/foo")
1489+
def post_foo(
1490+
my_body: Annotated[str, Body(alias="myBody")],
1491+
):
1492+
return {"my_body": my_body}
1493+
1494+
# WHEN sending a request with body
1495+
event = {
1496+
"httpMethod": "POST",
1497+
"path": "/foo",
1498+
"body": '"test_value"',
1499+
}
1500+
1501+
# THEN the request should succeed
1502+
result = app(event, {})
1503+
assert result["statusCode"] == 200
1504+
body = json.loads(result["body"])
1505+
assert body["my_body"] == "test_value"
1506+
1507+
1508+
def test_body_validation_alias_only_sets_alias_automatically():
1509+
"""
1510+
When only validation_alias is set (without alias) in Body,
1511+
alias should be automatically set to validation_alias value.
1512+
"""
1513+
# GIVEN an APIGatewayRestResolver with validation enabled
1514+
app = APIGatewayRestResolver(enable_validation=True)
1515+
1516+
@app.post("/foo")
1517+
def post_foo(
1518+
my_body: Annotated[str, Body(validation_alias="myBody")],
1519+
):
1520+
return {"my_body": my_body}
1521+
1522+
# WHEN sending a request with body
1523+
event = {
1524+
"httpMethod": "POST",
1525+
"path": "/foo",
1526+
"body": '"test_value"',
1527+
}
1528+
1529+
# THEN the request should succeed
1530+
result = app(event, {})
1531+
assert result["statusCode"] == 200
1532+
body = json.loads(result["body"])
1533+
assert body["my_body"] == "test_value"
1534+
1535+
12721536
def test_body_class_annotation_without_parentheses():
12731537
"""
12741538
GIVEN an endpoint using Body class (not instance) in Annotated

0 commit comments

Comments
 (0)