Skip to content

Commit e1eefe6

Browse files
committed
fix(core): resolve string annotations in _create_subset_model_v2
Subset models created by `_create_subset_model_v2` copied the source model's raw `__annotations__` verbatim. When the source model lives in a module using `from __future__ import annotations`, the copied values are unresolved strings that cannot be evaluated in the subset model's namespace, which breaks consumers that evaluate annotations, such as model subclassing on pydantic 2.14 prereleases. Resolve the annotations with `typing.get_type_hints(include_extras=True)` instead, keeping `Annotated` extras intact, and fall back to the raw copy for models whose annotations `get_type_hints` cannot resolve, for example models defined inside function bodies that reference local names. The pydantic 2.14.0a1 crash in #37835 itself is a pydantic regression with a pure-pydantic reduction and is being coordinated upstream; this change removes the related latent hazard on the langchain side and adds regression tests.
1 parent 6f7c8f5 commit e1eefe6

3 files changed

Lines changed: 98 additions & 12 deletions

File tree

libs/core/langchain_core/utils/pydantic.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Any,
1414
TypeVar,
1515
cast,
16+
get_type_hints,
1617
overload,
1718
)
1819

@@ -259,17 +260,24 @@ def _create_subset_model_v2(
259260
),
260261
)
261262

262-
# TODO(0.3): Determine if there is a more "pydantic" way to preserve annotations.
263-
# This is done to preserve __annotations__ when working with pydantic 2.x
264-
# and using the Annotated type with TypedDict.
265-
# Comment out the following line, to trigger the relevant test case.
266-
selected_annotations = [
267-
(name, annotation)
268-
for name, annotation in model.__annotations__.items()
269-
if name in field_names
270-
]
271-
272-
rtn.__annotations__ = dict(selected_annotations)
263+
# Resolve the source model's annotations (including inherited ones) so the
264+
# subset model preserves `Annotated` extras without carrying raw string
265+
# annotations. Strings copied from a module using
266+
# `from __future__ import annotations` cannot be resolved in the subset
267+
# model's namespace and break consumers that evaluate annotations, such as
268+
# model subclassing on pydantic 2.14 prereleases.
269+
try:
270+
type_hints = get_type_hints(model, include_extras=True)
271+
except Exception:
272+
# `get_type_hints` re-evaluates strings against the module namespace
273+
# and can fail where pydantic's frame-aware resolution at class
274+
# creation succeeded (e.g. function-local models). Fall back to the
275+
# raw annotations, as before.
276+
type_hints = model.__annotations__
277+
278+
rtn.__annotations__ = {
279+
name: type_hints[name] for name in field_names if name in type_hints
280+
}
273281
rtn.__doc__ = textwrap.dedent(fn_description or model.__doc__ or "")
274282
return rtn
275283

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Models with lazy string annotations for `_create_subset_model_v2` tests.
2+
3+
The `from __future__ import annotations` import is load-bearing: it makes every
4+
raw value in this module's `__annotations__` an unresolved string, which is the
5+
scenario the subset-model annotation tests exercise.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Annotated, Any
11+
12+
from pydantic import BaseModel
13+
14+
15+
class FutureModel(BaseModel):
16+
"""Model whose raw `__annotations__` values are unresolved strings."""
17+
18+
metadata: dict[str, Any] | None = None
19+
tagged: Annotated[dict, "extra"] | None = None

libs/core/tests/unit_tests/utils/test_pydantic.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import sys
44
import warnings
5-
from typing import Any
5+
from typing import Annotated, Any, get_type_hints
66

77
import pytest
88
from pydantic import BaseModel, ConfigDict, Field
@@ -16,6 +16,7 @@
1616
is_basemodel_subclass,
1717
pre_init,
1818
)
19+
from tests.unit_tests.utils.pydantic_future_annotations import FutureModel
1920

2021

2122
def test_pre_init_decorator() -> None:
@@ -133,6 +134,64 @@ class Foo(BaseModel):
133134
}
134135

135136

137+
def test_create_subset_model_v2_resolves_string_annotations() -> None:
138+
"""Test that lazy string annotations are resolved on the subset model."""
139+
subset_model = _create_subset_model_v2("Sub", FutureModel, ["metadata"])
140+
assert subset_model.__annotations__ == {"metadata": dict[str, Any] | None}
141+
142+
143+
def test_create_subset_model_v2_preserves_annotated_extras() -> None:
144+
"""Test that `Annotated` metadata survives string annotation resolution."""
145+
subset_model = _create_subset_model_v2("Sub", FutureModel, ["tagged"])
146+
assert subset_model.__annotations__ == {"tagged": Annotated[dict, "extra"] | None}
147+
148+
149+
class LocalRegistry:
150+
"""Module-level shadow without `Inner` for the fallback test below."""
151+
152+
153+
def test_create_subset_model_v2_unresolvable_annotations_fall_back() -> None:
154+
"""Test the fallback for annotations `get_type_hints` cannot resolve.
155+
156+
Models defined inside function bodies can hold forward references to other
157+
function-local names. Pydantic resolves those from the enclosing frame,
158+
but `typing.get_type_hints` only sees the module namespace and raises,
159+
for example `NameError` for an unknown name or `AttributeError` for an
160+
attribute of a shadowed name. Subset model creation must not raise for
161+
such models; the unresolvable annotation is copied as the raw string
162+
instead.
163+
"""
164+
165+
class LocalModel(BaseModel):
166+
x: "LocalDep | None" = None
167+
168+
class LocalDep(BaseModel):
169+
y: int = 0
170+
171+
LocalModel.model_rebuild()
172+
173+
with pytest.raises(NameError):
174+
get_type_hints(LocalModel)
175+
176+
subset_model = _create_subset_model_v2("Sub", LocalModel, ["x"])
177+
assert subset_model.__annotations__ == {"x": "LocalDep | None"}
178+
179+
class LocalRegistry:
180+
class Inner(BaseModel):
181+
y: int = 0
182+
183+
class ShadowModel(BaseModel):
184+
x: "LocalRegistry.Inner | None" = None
185+
186+
# `get_type_hints` resolves `LocalRegistry` to the module-level class of
187+
# the same name above, which has no `Inner`.
188+
with pytest.raises(AttributeError):
189+
get_type_hints(ShadowModel)
190+
191+
subset_model = _create_subset_model_v2("Sub", ShadowModel, ["x"])
192+
assert subset_model.__annotations__ == {"x": "LocalRegistry.Inner | None"}
193+
194+
136195
def test_fields_pydantic_v2_proper() -> None:
137196
class Foo(BaseModel):
138197
x: int

0 commit comments

Comments
 (0)