From b766712a8e6544fb60361ec4d33979bec27c63bc Mon Sep 17 00:00:00 2001 From: Vishwa Shah Date: Fri, 1 May 2026 15:37:47 +0000 Subject: [PATCH] Unwrap Annotated[T, ...] inside find_graphene_type Pydantic v2 strips top-level Annotated metadata from FieldInfo.annotation, but does not strip Annotated inside Union arms or generic containers, so Optional[PositiveFloat] reaches find_graphene_type as Union[Annotated[float, Gt(gt=0)], None]. The inner arm was dispatched to convert_generic_python_type and raised ConversionError. This affected every constrained pydantic scalar (PositiveFloat, PositiveInt, NonNegativeInt, conint(...), confloat(...)) inside Optional or any container, plus user-defined Annotated[T, ...] aliases in the same positions. Unwrap Annotated[T, ...] -> T at the entry point alongside the existing PEP 604 UnionType normalization. Constraints (Gt, Le, etc.) are already enforced by pydantic at validation time and have no GraphQL representation, so dropping them at conversion is correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- graphene_pydantic/converters.py | 7 +++++++ tests/test_converters.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/graphene_pydantic/converters.py b/graphene_pydantic/converters.py index 1e3297c..0654d8e 100644 --- a/graphene_pydantic/converters.py +++ b/graphene_pydantic/converters.py @@ -209,6 +209,13 @@ def find_graphene_type( if isinstance(type_, UnionType): type_ = T.Union[type_.__args__] + # Unwrap Annotated[T, ...] -> T. Pydantic v2 strips top-level Annotated + # from FieldInfo.annotation, but does NOT strip inside Union arms or + # generic containers, so e.g. Optional[PositiveFloat] reaches us with + # the inner arm still wrapped as Annotated[float, Gt(gt=0)]. + if T.get_origin(type_) is T.Annotated: + type_ = T.get_args(type_)[0] + if type_ == uuid.UUID: return UUID elif type_ in (str, bytes): diff --git a/tests/test_converters.py b/tests/test_converters.py index 6c18450..a209c96 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -9,11 +9,15 @@ import graphene import graphene.types import pytest -from pydantic import BaseModel +from pydantic import BaseModel, PositiveFloat, PositiveInt from pydantic import create_model import graphene_pydantic.converters as converters -from graphene_pydantic.converters import ConversionError, convert_pydantic_field +from graphene_pydantic.converters import ( + ConversionError, + convert_pydantic_field, + find_graphene_type, +) from graphene_pydantic.objecttype import PydanticObjectType from graphene_pydantic.registry import Placeholder, get_global_registry @@ -212,6 +216,30 @@ def test_unresolved_placeholders(): ) +def test_annotated_unwrapped_at_top_level(): + # Pydantic strips top-level Annotated from FieldInfo.annotation, but + # find_graphene_type may still receive a raw Annotated[T, ...] from + # callers; verify it resolves to the underlying type. + field = _get_field_from_spec("attr", (PositiveFloat, 1.0)) + assert find_graphene_type(PositiveFloat, field, None) is graphene.Float + + +def test_annotated_inside_optional(): + # Regression: Optional[PositiveFloat] reaches find_graphene_type as + # Union[Annotated[float, Gt(gt=0)], None]; the inner arm is still + # Annotated and previously raised ConversionError. + field = _get_field_from_spec("attr", (T.Optional[PositiveFloat], 1.0)) + # Optional[T] decomposes to T in graphene_pydantic's union handling. + assert find_graphene_type(field.annotation, field, None) is graphene.Float + + +def test_annotated_inside_list(): + field = _get_field_from_spec("attr", (T.List[PositiveInt], [1])) + result = find_graphene_type(field.annotation, field, None) + assert isinstance(result, graphene.List) + assert result.of_type is graphene.Int + + def test_self_referencing(): class NodeModel(BaseModel): id: int