From da0994b74144caa76cd982197cd712f918a9d77a Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 29 Apr 2025 14:31:28 -0400 Subject: [PATCH] removing langchain-core pydantic utilities --- libs/langgraph/langgraph/utils/pydantic.py | 243 ++++++++++++++++++++- libs/langgraph/poetry.lock | 6 +- libs/langgraph/pyproject.toml | 1 + 3 files changed, 233 insertions(+), 17 deletions(-) diff --git a/libs/langgraph/langgraph/utils/pydantic.py b/libs/langgraph/langgraph/utils/pydantic.py index e8600e19c..b44c942cb 100644 --- a/libs/langgraph/langgraph/utils/pydantic.py +++ b/libs/langgraph/langgraph/utils/pydantic.py @@ -1,10 +1,181 @@ import sys import typing +import warnings +from contextlib import nullcontext from dataclasses import is_dataclass -from typing import Any, Optional +from functools import lru_cache +from typing import ( + Any, + Optional, + Union, + cast, + overload, +) -import typing_extensions -from pydantic import BaseModel +from pydantic import ( + BaseModel, + ConfigDict, + Field, + RootModel, +) +from pydantic import ( + create_model as _create_model_base, +) +from pydantic.fields import FieldInfo +from pydantic.json_schema import ( + DEFAULT_REF_TEMPLATE, + GenerateJsonSchema, + JsonSchemaMode, +) +from typing_extensions import TypedDict + + +@overload +def get_fields(model: type[BaseModel]) -> dict[str, FieldInfo]: ... + + +@overload +def get_fields(model: BaseModel) -> dict[str, FieldInfo]: ... + + +def get_fields( + model: Union[type[BaseModel], BaseModel], +) -> dict[str, FieldInfo]: + """Get the field names of a Pydantic model.""" + if hasattr(model, "model_fields"): + return model.model_fields + + if hasattr(model, "__fields__"): + return model.__fields__ # type: ignore[return-value] + msg = f"Expected a Pydantic model. Got {type(model)}" + raise TypeError(msg) + + +_SchemaConfig = ConfigDict( + arbitrary_types_allowed=True, frozen=True, protected_namespaces=() +) + +NO_DEFAULT = object() + + +def _create_root_model( + name: str, + type_: Any, + module_name: Optional[str] = None, + default_: object = NO_DEFAULT, +) -> type[BaseModel]: + """Create a base class.""" + + def schema( + cls: type[BaseModel], + by_alias: bool = True, # noqa: FBT001,FBT002 + ref_template: str = DEFAULT_REF_TEMPLATE, + ) -> dict[str, Any]: + # Complains about schema not being defined in superclass + schema_ = super(cls, cls).schema( # type: ignore[misc] + by_alias=by_alias, ref_template=ref_template + ) + schema_["title"] = name + return schema_ + + def model_json_schema( + cls: type[BaseModel], + by_alias: bool = True, # noqa: FBT001,FBT002 + ref_template: str = DEFAULT_REF_TEMPLATE, + schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, + mode: JsonSchemaMode = "validation", + ) -> dict[str, Any]: + # Complains about model_json_schema not being defined in superclass + schema_ = super(cls, cls).model_json_schema( # type: ignore[misc] + by_alias=by_alias, + ref_template=ref_template, + schema_generator=schema_generator, + mode=mode, + ) + schema_["title"] = name + return schema_ + + base_class_attributes = { + "__annotations__": {"root": type_}, + "model_config": ConfigDict(arbitrary_types_allowed=True), + "schema": classmethod(schema), + "model_json_schema": classmethod(model_json_schema), + "__module__": module_name or "langchain_core.runnables.utils", + } + + if default_ is not NO_DEFAULT: + base_class_attributes["root"] = default_ + with warnings.catch_warnings(): + custom_root_type = type(name, (RootModel,), base_class_attributes) + return cast("type[BaseModel]", custom_root_type) + + +@lru_cache(maxsize=256) +def _create_root_model_cached( + model_name: str, + type_: Any, + *, + module_name: Optional[str] = None, + default_: object = NO_DEFAULT, +) -> type[BaseModel]: + return _create_root_model( + model_name, type_, default_=default_, module_name=module_name + ) + + +@lru_cache(maxsize=256) +def _create_model_cached( + model_name: str, + /, + **field_definitions: Any, +) -> type[BaseModel]: + return _create_model_base( + model_name, + __config__=_SchemaConfig, + **_remap_field_definitions(field_definitions), + ) + + +# Reserved names should capture all the `public` names / methods that are +# used by BaseModel internally. This will keep the reserved names up-to-date. +# For reference, the reserved names are: +# "construct", "copy", "dict", "from_orm", "json", "parse_file", "parse_obj", +# "parse_raw", "schema", "schema_json", "update_forward_refs", "validate", +# "model_computed_fields", "model_config", "model_construct", "model_copy", +# "model_dump", "model_dump_json", "model_extra", "model_fields", +# "model_fields_set", "model_json_schema", "model_parametrized_name", +# "model_post_init", "model_rebuild", "model_validate", "model_validate_json", +# "model_validate_strings" +_RESERVED_NAMES = {key for key in dir(BaseModel) if not key.startswith("_")} + + +def _remap_field_definitions(field_definitions: dict[str, Any]) -> dict[str, Any]: + """This remaps fields to avoid colliding with internal pydantic fields.""" + + remapped = {} + for key, value in field_definitions.items(): + if key.startswith("_") or key in _RESERVED_NAMES: + # Let's add a prefix to avoid colliding with internal pydantic fields + if isinstance(value, FieldInfo): + msg = ( + f"Remapping for fields starting with '_' or fields with a name " + f"matching a reserved name {_RESERVED_NAMES} is not supported if " + f" the field is a pydantic Field instance. Got {key}." + ) + raise NotImplementedError(msg) + type_, default_ = value + remapped[f"private_{key}"] = ( + type_, + Field( + default=default_, + alias=key, + serialization_alias=key, + title=key.lstrip("_").replace("_", " ").title(), + ), + ) + else: + remapped[key] = value + return remapped def create_model( @@ -15,19 +186,67 @@ def create_model( ) -> type[BaseModel]: """Create a pydantic model with the given field definitions. + Attention: + Please do not use outside of langchain packages. This API + is subject to change at any time. + Args: model_name: The name of the model. + module_name: The name of the module where the model is defined. + This is used by Pydantic to resolve any forward references. field_definitions: The field definitions for the model. root: Type for a root model (RootModel) - """ - # for langchain-core >= 0.3.0 - from langchain_core.utils.pydantic import create_model_v2 - return create_model_v2( - model_name, - field_definitions=field_definitions, - root=root, - ) + Returns: + Type[BaseModel]: The created model. + """ + field_definitions = field_definitions or {} + + if root: + if field_definitions: + msg = ( + "When specifying __root__ no other " + f"fields should be provided. Got {field_definitions}" + ) + raise NotImplementedError(msg) + + if isinstance(root, tuple): + kwargs = {"type_": root[0], "default_": root[1]} + else: + kwargs = {"type_": root} + + try: + named_root_model = _create_root_model_cached(model_name, **kwargs) + except TypeError: + # something in the arguments into _create_root_model_cached is not hashable + named_root_model = _create_root_model( + model_name, + **kwargs, + ) + return named_root_model + + # No root, just field definitions + names = set(field_definitions.keys()) + + capture_warnings = False + + for name in names: + # Also if any non-reserved name is used (e.g., model_id or model_name) + if name.startswith("model"): + capture_warnings = True + + with warnings.catch_warnings() if capture_warnings else nullcontext(): + if capture_warnings: + warnings.filterwarnings(action="ignore") + try: + return _create_model_cached(model_name, **field_definitions) + except TypeError: + # something in field definitions is not hashable + return _create_model_base( + model_name, + __config__=_SchemaConfig, + **_remap_field_definitions(field_definitions), + ) def is_supported_by_pydantic(type_: Any) -> bool: @@ -45,7 +264,7 @@ def is_supported_by_pydantic(type_: Any) -> bool: if hasattr(type_, "__orig_bases__"): for base in type_.__orig_bases__: - if base is typing_extensions.TypedDict: + if base is TypedDict: return True elif base is typing.TypedDict: # noqa: TID251 # ignoring TID251 since it's OK to use typing.TypedDict in this case. diff --git a/libs/langgraph/poetry.lock b/libs/langgraph/poetry.lock index a49144223..fa4507768 100644 --- a/libs/langgraph/poetry.lock +++ b/libs/langgraph/poetry.lock @@ -26,7 +26,6 @@ description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" groups = ["main", "dev"] -markers = "python_version < \"4.0\"" files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -2270,7 +2269,6 @@ description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" groups = ["main", "dev"] -markers = "python_version < \"4.0\"" files = [ {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, @@ -2295,7 +2293,6 @@ description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" groups = ["main", "dev"] -markers = "python_version < \"4.0\"" files = [ {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, @@ -3306,7 +3303,6 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {main = "python_version < \"4.0\""} [[package]] name = "tzdata" @@ -3677,4 +3673,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "0e0c3fc2d5a5348c8df102497c7221a1f052f37a788b315eb82dfc7d63423e17" +content-hash = "770dcaa5816fffb667b5e3639c0ee5add24df0a99bb9a89a6d8fb2c4a79fb185" diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index 01df27968..ca29b4821 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -14,6 +14,7 @@ langgraph-checkpoint = "^2.0.10" langgraph-sdk = { version = ">=0.1.42", python = "<4.0" } langgraph-prebuilt = { version = ">=0.1.8", python = "<4.0" } xxhash = "^3.5.0" +pydantic = { version = ">=2.7.4"} [tool.poetry.group.dev.dependencies] pytest = "^8.3.2"