diff --git a/libs/core/langchain_core/_api/deprecation.py b/libs/core/langchain_core/_api/deprecation.py index 16a540b860c..ccc31d6b9a5 100644 --- a/libs/core/langchain_core/_api/deprecation.py +++ b/libs/core/langchain_core/_api/deprecation.py @@ -12,20 +12,39 @@ module. import contextlib import functools import inspect +import sys import warnings from collections.abc import Callable, Generator from typing import ( + TYPE_CHECKING, Any, ParamSpec, + TypeGuard, TypeVar, cast, ) from pydantic.fields import FieldInfo -from pydantic.v1.fields import FieldInfo as FieldInfoV1 from langchain_core._api.internal import is_caller_internal +if TYPE_CHECKING: + from pydantic.v1.fields import FieldInfo as FieldInfoV1 + + +def _is_pydantic_v1_field_info(obj: Any) -> TypeGuard["FieldInfoV1"]: + """Check if `obj` is a `pydantic.v1.fields.FieldInfo` without forcing import. + + Importing `pydantic.v1` emits a `UserWarning` on Python 3.14+. Skipping the + import entirely when no caller has constructed a v1 `FieldInfo` keeps that + warning out of `langchain_core`'s import path. If a caller did construct one, + `pydantic.v1.fields` is already in `sys.modules` and isinstance is safe. + """ + mod = sys.modules.get("pydantic.v1.fields") + if mod is None: + return False + return isinstance(obj, mod.FieldInfo) + def _build_deprecation_message( *, @@ -59,7 +78,9 @@ class LangChainPendingDeprecationWarning(PendingDeprecationWarning): # PUBLIC API -# Last Any should be FieldInfoV1 but this leads to circular imports +# Bound is `Any` (not `FieldInfoV1`) because importing `pydantic.v1` at module +# scope emits a `UserWarning` on Python 3.14+; v1 `FieldInfo` support is handled +# at runtime via `_is_pydantic_v1_field_info`. T = TypeVar("T", bound=type | Callable[..., Any] | Any) @@ -246,7 +267,7 @@ def deprecated( ) return obj - elif isinstance(obj, FieldInfoV1): + elif _is_pydantic_v1_field_info(obj): wrapped = None if not _obj_type: _obj_type = "attribute" @@ -256,6 +277,8 @@ def deprecated( old_doc = obj.description def finalize(_: Callable[..., Any], new_doc: str, /) -> T: + from pydantic.v1.fields import FieldInfo as FieldInfoV1 # noqa: PLC0415 + return cast( "T", FieldInfoV1( diff --git a/libs/core/tests/unit_tests/_api/test_deprecation.py b/libs/core/tests/unit_tests/_api/test_deprecation.py index c3aac854209..7993a823380 100644 --- a/libs/core/tests/unit_tests/_api/test_deprecation.py +++ b/libs/core/tests/unit_tests/_api/test_deprecation.py @@ -1,4 +1,6 @@ import inspect +import subprocess +import sys import warnings from typing import Any @@ -588,3 +590,43 @@ def test_deprecated_property_has_pep702_attribute() -> None: # The __deprecated__ attribute is on the underlying fget function assert hasattr(prop.fget, "__deprecated__") assert prop.fget.__deprecated__ == "Use new_property instead." + + +def test_importing_deprecation_does_not_load_pydantic_v1() -> None: + """`langchain_core._api.deprecation` must not eagerly import `pydantic.v1`. + + Pydantic emits a `UserWarning` from `pydantic/v1/__init__.py` on Python 3.14+, + and the module is on the import path for much of `langchain_core`. Run in a + fresh subprocess because sibling tests in this process may have already + imported `pydantic.v1` transitively. + """ + code = ( + "import sys\n" + "import langchain_core._api.deprecation # noqa: F401\n" + "loaded = sorted(m for m in sys.modules if m.startswith('pydantic.v1'))\n" + "assert not loaded, loaded\n" + ) + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_deprecated_pydantic_v1_field_info() -> None: + """The lazy v1 `FieldInfo` branch wraps and reconstructs the field correctly. + + Verifies behavior when `pydantic.v1` is loaded by the caller. + """ + from pydantic.v1 import BaseModel as V1Base # noqa: PLC0415 + from pydantic.v1.fields import FieldInfo as V1FieldInfo # noqa: PLC0415 + + field = V1FieldInfo(default=1, description="original") + wrapped = deprecated(since="2.0.0", name="x", removal="3.0.0")(field) + + assert isinstance(wrapped, V1FieldInfo) + assert wrapped.default == 1 + assert "deprecated" in (wrapped.description or "").lower() + assert "original" in (wrapped.description or "") + + # Sanity: still usable as a v1 model field. + class M(V1Base): + x: int = wrapped # type: ignore[assignment] + + assert M(x=2).x == 2