From cb2b57453b331937a2eaf23f8fd10e7243c53953 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 20 Oct 2026 10:08:41 +0000 Subject: [PATCH] fix(event_handler): resolve Depends() and Request params with a None default on Python 3.10 On Python 3.10, typing.get_type_hints wraps the annotation of a parameter whose default is None in Optional[...], hiding the Annotated metadata. Depends() was silently skipped and the parameter received None; a Request parameter with a None default was not injected either. Python 3.11+ stopped wrapping, so only 3.10 was affected. Unwrap Optional/Union before inspecting the annotation. --- .../event_handler/api_gateway.py | 5 +- .../event_handler/depends.py | 20 ++++++- .../required_dependencies/test_depends.py | 53 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 70b099269db..0dd06a4cf94 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1482,8 +1482,11 @@ def _find_request_param_name(func: Callable) -> str | None: except Exception: hints = {} + from aws_lambda_powertools.event_handler.depends import _unwrap_optional + for param_name, annotation in hints.items(): - if annotation is Request: + # Python 3.10 wraps annotations of ``= None`` parameters in Optional[...] + if _unwrap_optional(annotation) is Request: return param_name return None diff --git a/aws_lambda_powertools/event_handler/depends.py b/aws_lambda_powertools/event_handler/depends.py index f05167c63d9..8f67280cf3b 100644 --- a/aws_lambda_powertools/event_handler/depends.py +++ b/aws_lambda_powertools/event_handler/depends.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Annotated, Any, get_args, get_origin, get_type_hints +from types import UnionType +from typing import TYPE_CHECKING, Annotated, Any, Union, get_args, get_origin, get_type_hints if TYPE_CHECKING: from collections.abc import Callable @@ -89,8 +90,23 @@ def __init__(self, *, param_name: str, depends: Depends, dependant: Dependant) - self.dependant = dependant +def _unwrap_optional(annotation: Any) -> Any: + """Return the inner type of ``Optional[X]`` / ``X | None``, or the annotation itself. + + On Python 3.10, ``typing.get_type_hints`` wraps the annotation of a parameter whose + default is ``None`` in ``Optional[...]``, which would hide ``Annotated`` metadata such + as ``Depends()`` or a ``Request`` annotation. Python 3.11 stopped doing that. + """ + if get_origin(annotation) in (Union, UnionType): + args = [arg for arg in get_args(annotation) if arg is not type(None)] + if len(args) == 1: + return args[0] + return annotation + + def _get_depends_from_annotation(annotation: Any) -> Depends | None: """Extract a Depends instance from an Annotated[Type, Depends(...)] annotation.""" + annotation = _unwrap_optional(annotation) if get_origin(annotation) is Annotated: for arg in get_args(annotation)[1:]: if isinstance(arg, Depends): @@ -202,7 +218,7 @@ def solve_dependencies( except Exception: # pragma: no cover - defensive for broken annotations hints = {} for param_name, annotation in hints.items(): - if annotation is RequestClass: + if _unwrap_optional(annotation) is RequestClass: sub_values[param_name] = request try: diff --git a/tests/functional/event_handler/required_dependencies/test_depends.py b/tests/functional/event_handler/required_dependencies/test_depends.py index 6f65349e34a..7781394cd87 100644 --- a/tests/functional/event_handler/required_dependencies/test_depends.py +++ b/tests/functional/event_handler/required_dependencies/test_depends.py @@ -645,3 +645,56 @@ def handler(user: Annotated[str, Depends(get_current_user)]): result = app.resolve(load_event(event_file), {}) assert result["statusCode"] == 401 assert json.loads(result["body"]) == {"statusCode": 401, "message": "Missing authentication"} + + +def test_depends_with_none_default_is_resolved(): + """A Depends() parameter that also declares ``= None`` is still resolved (Python 3.10 wraps it in Optional).""" + app = APIGatewayHttpResolver() + + def get_region() -> str: + return "eu-west-1" + + @app.post("/my/path") + def handler(region: Annotated[str, Depends(get_region)] = None): + return {"region": region} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"region": "eu-west-1"} + + +def test_depends_inside_explicit_optional_is_resolved(): + """``Optional[Annotated[str, Depends(...)]]`` and ``Annotated[...] | None`` both resolve.""" + from typing import Optional + + app = APIGatewayHttpResolver() + + def get_region() -> str: + return "eu-west-1" + + def get_zone() -> str: + return "a" + + @app.post("/my/path") + def handler( + region: Optional[Annotated[str, Depends(get_region)]] = None, # noqa: UP045 + zone: Annotated[str, Depends(get_zone)] | None = None, + ): + return {"region": region, "zone": zone} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"region": "eu-west-1", "zone": "a"} + + +def test_request_param_with_none_default_is_injected(): + """A ``request: Request = None`` parameter is injected on every Python version.""" + app = APIGatewayHttpResolver() + + @app.post("/my/path") + def handler(request: Request = None): + return {"method": request.method} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"method": "POST"}