what would it look like to remove pydantic v1 support?

This commit is contained in:
Sydney Runkle
2025-04-28 21:44:17 -04:00
parent 062253fe48
commit e99028cfc6
10 changed files with 59 additions and 227 deletions
+7 -29
View File
@@ -14,7 +14,6 @@ from typing import (
)
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
__all__ = ["SchemaCoercionMapper"]
@@ -45,7 +44,7 @@ class SchemaCoercionMapper:
def __init__(
self,
schema: type[Any],
schema: type[BaseModel],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
@@ -63,30 +62,12 @@ class SchemaCoercionMapper:
else get_type_hints(schema, localns={schema.__name__: schema})
)
if issubclass(schema, BaseModelV1):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.__fields__.items()
}
self._construct = schema.construct
unhandled_attrs = (
"__pre_root_validators__",
"__post_root_validators__",
"__validators__",
)
if any(getattr(schema, c, None) for c in unhandled_attrs):
self.coerce: Callable[[Any, Any], Union[BaseModelV1, BaseModel]] = (
lambda v, _: schema(**v)
)
else:
self.coerce = self._coerce
elif issubclass(schema, BaseModel):
if issubclass(schema, BaseModel):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.model_fields.items()
}
self._construct: Callable[..., Any] = schema.model_construct # type: ignore
self._construct: Callable[..., Any] = schema.model_construct
unhandled_attrs = ("validators", "field_validators", "root_validators")
if (decorators := getattr(schema, "__pydantic_decorators__", None)) and any(
getattr(decorators, attr, None) for attr in unhandled_attrs
@@ -94,9 +75,8 @@ class SchemaCoercionMapper:
self.coerce = lambda v, _: schema.model_validate(v)
else:
self.coerce = self._coerce
else:
raise TypeError("Schema is neither a Pydantic v1 nor v2 model.")
raise TypeError("Schema must be a Pydantic V2 model.")
self._field_coercers: Optional[dict[str, Callable[[Any, int], Any]]] = None
@@ -138,14 +118,12 @@ class SchemaCoercionMapper:
if isclass(field_type):
# This is needed bcs. of issubclass issues on older versions of python
is_class_ = True
try:
is_bm_v2 = issubclass(field_type, BaseModel)
is_bm_subclass = issubclass(field_type, BaseModel)
except TypeError:
# python < 3.11 issue.
is_class_ = False
is_bm_v2 = False
if is_bm_v2 or (is_class_ and issubclass(field_type, BaseModelV1)):
is_bm_subclass = False
if is_bm_subclass:
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
+3 -4
View File
@@ -23,7 +23,6 @@ from typing import (
from langchain_core.runnables import Runnable, RunnableConfig
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Self
from langgraph._api.deprecation import LangGraphDeprecationWarning
@@ -642,7 +641,7 @@ class StateGraph(Graph):
self.input
if len(self.channels) > 1
and isclass(self.input)
and issubclass(self.input, (BaseModel, BaseModelV1))
and issubclass(self.input, BaseModel)
else None
),
nodes={},
@@ -1026,7 +1025,7 @@ def _pick_mapper(
if isclass(schema):
if issubclass(schema, dict):
return None
if issubclass(schema, (BaseModel, BaseModelV1)):
if issubclass(schema, BaseModel):
return SchemaCoercionMapper(schema, type_hints=type_hints)
return partial(_coerce_state, schema)
@@ -1204,7 +1203,7 @@ def _get_schema(
channels: dict,
name: str,
) -> type[BaseModel]:
if isclass(typ) and issubclass(typ, (BaseModel, BaseModelV1)):
if isclass(typ) and issubclass(typ, BaseModel):
return typ
else:
keys = list(schemas[typ].keys())
+1 -7
View File
@@ -3,7 +3,6 @@ from collections.abc import Generator, Sequence
from typing import Annotated, Any, Optional, Union, get_type_hints
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import NotRequired, ReadOnly, Required, get_origin
# NOTE: this is redefined here separately from langgraph.constants
@@ -158,12 +157,7 @@ def get_enhanced_type_hints(
def get_update_as_tuples(input: Any, keys: Sequence[str]) -> list[tuple[str, Any]]:
"""Get Pydantic state update as a list of (key, value) tuples."""
# Pydantic v1
if isinstance(input, BaseModelV1):
keep: Optional[set[str]] = input.__fields_set__
defaults = {k: v.default for k, v in input.__fields__.items()}
# Pydantic v2
elif isinstance(input, BaseModel):
if isinstance(input, BaseModel):
keep = input.model_fields_set
defaults = {k: v.default for k, v in input.model_fields.items()}
else:
+9 -22
View File
@@ -1,11 +1,10 @@
import sys
import typing
from dataclasses import is_dataclass
from typing import Any, Optional, Union
from typing import Any, Optional
import typing_extensions
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
def create_model(
@@ -13,7 +12,7 @@ def create_model(
*,
field_definitions: Optional[dict[str, Any]] = None,
root: Optional[Any] = None,
) -> Union[BaseModel, BaseModelV1]:
) -> type[BaseModel]:
"""Create a pydantic model with the given field definitions.
Args:
@@ -21,24 +20,14 @@ def create_model(
field_definitions: The field definitions for the model.
root: Type for a root model (RootModel)
"""
try:
# for langchain-core >= 0.3.0
from langchain_core.utils.pydantic import create_model_v2
# 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,
)
except ImportError:
# for langchain-core < 0.3.0
from langchain_core.runnables.utils import create_model
v1_kwargs = {}
if root is not None:
v1_kwargs["__root__"] = root
return create_model(model_name, **v1_kwargs, **(field_definitions or {}))
return create_model_v2(
model_name,
field_definitions=field_definitions,
root=root,
)
def is_supported_by_pydantic(type_: Any) -> bool:
@@ -51,8 +40,6 @@ def is_supported_by_pydantic(type_: Any) -> bool:
if is_dataclass(type_):
return True
# Pydantic does not support mixing .v1 and root namespaces, so
# we only check for BaseModel (not pydantic.v1.BaseModel).
if isinstance(type_, type) and issubclass(type_, BaseModel):
return True
+1 -1
View File
@@ -1367,7 +1367,7 @@ typing-extensions = ">=4.7"
[[package]]
name = "langgraph-checkpoint"
version = "2.0.24"
version = "2.0.25"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = false
python-versions = ">=3.9"
+7 -20
View File
@@ -6,22 +6,18 @@ from pydantic import BaseModel
# define these objects to avoid importing langchain_core.agents
# and therefore avoid relying on core Pydantic version
class AgentAction(BaseModel):
"""
Represents a request to execute an action by an agent.
The action consists of the name of the tool to execute and the input to pass
to the tool. The log is used to pass along extra information about the action.
"""
tool: str
tool_input: Union[str, dict]
log: str
type: Literal["AgentAction"] = "AgentAction"
model_config = {
"json_schema_extra": {
"description": (
"""Represents a request to execute an action by an agent.
The action consists of the name of the tool to execute and the input to pass
to the tool. The log is used to pass along extra information about the action."""
)
}
}
class AgentFinish(BaseModel):
"""Final return value of an ActionAgent.
@@ -32,12 +28,3 @@ class AgentFinish(BaseModel):
return_values: dict
log: str
type: Literal["AgentFinish"] = "AgentFinish"
model_config = {
"json_schema_extra": {
"description": (
"""Final return value of an ActionAgent.
Agents return an AgentFinish when they have reached a stopping condition."""
)
}
}
+3 -13
View File
@@ -12,13 +12,11 @@ from langchain_core.messages import (
ToolMessage,
)
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import TypedDict
from langgraph.graph import add_messages
from langgraph.graph.message import REMOVE_ALL_MESSAGES, MessagesState
from langgraph.graph.state import END, START, StateGraph
from tests.conftest import IS_LANGCHAIN_CORE_030_OR_GREATER
from tests.messages import _AnyIdHumanMessage
_, CORE_MINOR, CORE_PATCH = (int(v) for v in langchain_core.__version__.split("."))
@@ -175,19 +173,11 @@ def test_delete_all():
assert result == expected_result
MESSAGES_STATE_SCHEMAS = [MessagesState]
if IS_LANGCHAIN_CORE_030_OR_GREATER:
class MessagesStatePydantic(BaseModel):
messages: Annotated[list[AnyMessage], add_messages]
class MessagesStatePydantic(BaseModel):
messages: Annotated[list[AnyMessage], add_messages]
MESSAGES_STATE_SCHEMAS.append(MessagesStatePydantic)
else:
class MessagesStatePydanticV1(BaseModelV1):
messages: Annotated[list[AnyMessage], add_messages]
MESSAGES_STATE_SCHEMAS.append(MessagesStatePydanticV1)
MESSAGES_STATE_SCHEMAS = [MessagesState, MessagesStatePydantic]
@pytest.mark.parametrize("state_schema", MESSAGES_STATE_SCHEMAS)
+24 -73
View File
@@ -33,6 +33,7 @@ from langchain_core.runnables import (
)
from langchain_core.runnables.graph import Edge
from langsmith import traceable
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pytest_mock import MockerFixture
from syrupy import SnapshotAssertion
from typing_extensions import TypedDict
@@ -2602,8 +2603,6 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1(
request: pytest.FixtureRequest,
checkpointer_name: str,
) -> None:
from pydantic.v1 import BaseModel, ValidationError
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
setup = mocker.Mock()
teardown = mocker.Mock()
@@ -2642,8 +2641,7 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1(
yo: int
class State(BaseModel):
class Config:
arbitrary_types_allowed = True
model_config = ConfigDict(arbitrary_types_allowed=True)
query: str
inner: Annotated[InnerObject, lambda x, y: y]
@@ -2777,11 +2775,6 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
request: pytest.FixtureRequest,
checkpointer_name: str,
) -> None:
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic.v1 import BaseModel as BaseModelV1
IS_V1 = BaseModel is BaseModelV1
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
setup = mocker.Mock()
teardown = mocker.Mock()
@@ -2819,28 +2812,14 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
class InnerObject(BaseModel):
yo: int
if IS_V1:
class State(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
class State(BaseModel):
class Config:
arbitrary_types_allowed = True
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
else:
class State(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
class StateUpdate(BaseModel):
query: Optional[str] = None
@@ -2966,8 +2945,6 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_inp
request: pytest.FixtureRequest,
checkpointer_name: str,
) -> None:
from pydantic import BaseModel
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
def sorted_add(
@@ -4415,7 +4392,6 @@ def test_remove_message_from_node():
def test_xray_lance(snapshot: SnapshotAssertion):
from langchain_core.messages import AnyMessage, HumanMessage
from pydantic import BaseModel, Field
class Analyst(BaseModel):
affiliation: str = Field(
@@ -5533,8 +5509,6 @@ def test_dict_mixed_return() -> None:
def test_command_pydantic_dataclass() -> None:
from pydantic import BaseModel
class PydanticState(BaseModel):
foo: str
@@ -6351,9 +6325,7 @@ def test_double_interrupt_subgraph(
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_multi_resume(
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
def test_multi_resume(request: pytest.FixtureRequest, checkpointer_name: str) -> None:
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
class ChildState(TypedDict):
@@ -6362,11 +6334,11 @@ def test_multi_resume(
human_inputs: list[str]
def get_human_input(state: ChildState):
human_input = interrupt(state['prompt'])
human_input = interrupt(state["prompt"])
return {
'human_input': human_input,
'human_inputs': [human_input],
"human_input": human_input,
"human_inputs": [human_input],
}
child_graph = (
@@ -6385,13 +6357,13 @@ def test_multi_resume(
return [
Send(
"child_graph",
{'prompt': prompt},
{"prompt": prompt},
)
for prompt in state['prompts']
for prompt in state["prompts"]
]
def cleanup(state: ParentState):
assert len(state['human_inputs']) == len(state["prompts"])
assert len(state["human_inputs"]) == len(state["prompts"])
parent_graph = (
StateGraph(ParentState)
@@ -6404,21 +6376,19 @@ def test_multi_resume(
)
thread_config: RunnableConfig = {
'configurable': {
'thread_id': uuid.uuid4(),
"configurable": {
"thread_id": uuid.uuid4(),
},
}
prompts = ['a', 'b', 'c', 'd', 'e']
prompts = ["a", "b", "c", "d", "e"]
events = parent_graph.invoke(
{'prompts': prompts},
thread_config,
stream_mode='values'
{"prompts": prompts}, thread_config, stream_mode="values"
)
assert len(events['__interrupt__']) == len(prompts)
interrupt_values = {i.value for i in events['__interrupt__']}
assert len(events["__interrupt__"]) == len(prompts)
interrupt_values = {i.value for i in events["__interrupt__"]}
assert interrupt_values == set(prompts)
resume_map: dict[str, str] = {
@@ -6428,11 +6398,8 @@ def test_multi_resume(
result = parent_graph.invoke(Command(resume=resume_map), thread_config)
assert result == {
'prompts': prompts,
'human_inputs': [
f"human input for prompt {prompt}"
for prompt in prompts
],
"prompts": prompts,
"human_inputs": [f"human input for prompt {prompt}" for prompt in prompts],
}
@@ -7169,8 +7136,6 @@ def test_node_destinations() -> None:
def test_pydantic_none_state_update() -> None:
from pydantic import BaseModel
class State(BaseModel):
foo: Optional[str]
@@ -7182,8 +7147,6 @@ def test_pydantic_none_state_update() -> None:
def test_pydantic_state_update_command() -> None:
from pydantic import BaseModel
class State(BaseModel):
foo: Optional[str]
@@ -7215,8 +7178,6 @@ def test_pydantic_state_update_command() -> None:
def test_pydantic_state_mutation() -> None:
from pydantic import BaseModel, Field
class Inner(BaseModel):
a: int = 0
@@ -7249,8 +7210,6 @@ def test_pydantic_state_mutation() -> None:
def test_pydantic_state_mutation_command() -> None:
from pydantic import BaseModel, Field
class Inner(BaseModel):
a: int = 0
@@ -7529,8 +7488,6 @@ def test_interrupt_subgraph_reenter_checkpointer_true(
def test_empty_invoke() -> None:
from pydantic import BaseModel
def reducer_merge_dicts(
dict1: dict[Any, Any], dict2: dict[Any, Any]
) -> dict[Any, Any]:
@@ -7582,8 +7539,6 @@ def test_empty_invoke() -> None:
def test_parallel_interrupts(
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
from pydantic import BaseModel, Field
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
# --- CHILD GRAPH ---
@@ -7759,8 +7714,6 @@ def test_parallel_interrupts(
def test_parallel_interrupts_double(
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
from pydantic import BaseModel, Field
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
# --- CHILD GRAPH ---
@@ -8214,8 +8167,6 @@ def test_batch_update_as_input(
def test_migration_graph(snapshot: SnapshotAssertion) -> None:
from pydantic import BaseModel
class DummyState(BaseModel):
pass_count: int = 0
+3 -14
View File
@@ -26,6 +26,7 @@ import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough
from langchain_core.utils.aiter import aclosing
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pytest_mock import MockerFixture
from syrupy import SnapshotAssertion
from typing_extensions import TypedDict
@@ -4663,16 +4664,9 @@ async def test_in_one_fan_out_state_graph_waiting_edge_via_branch(
]
@pytest.mark.parametrize("version", ["v1", "v2"])
async def test_nested_pydantic_models(version: str) -> None:
async def test_nested_pydantic_models() -> None:
"""Test that nested Pydantic models are properly constructed from leaf nodes up."""
# Define nested Pydantic models
if version == "v1":
from pydantic.v1 import BaseModel, Field
else:
from pydantic import BaseModel, Field
class NestedModel(BaseModel):
value: int
name: str
@@ -4799,8 +4793,6 @@ async def test_nested_pydantic_models(version: str) -> None:
async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class(
snapshot: SnapshotAssertion, mocker: MockerFixture, checkpointer_name: str
) -> None:
from pydantic.v1 import BaseModel, ValidationError
setup = mocker.Mock()
teardown = mocker.Mock()
@@ -4835,8 +4827,7 @@ async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class(
return sorted(operator.add(x, y))
class State(BaseModel):
class Config:
arbitrary_types_allowed = True
model_config = ConfigDict(arbitrary_types_allowed=True)
query: str
answer: Optional[str] = None
@@ -4992,8 +4983,6 @@ async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class(
async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
snapshot: SnapshotAssertion, checkpointer_name: str
) -> None:
from pydantic import BaseModel, ValidationError
def sorted_add(
x: list[str], y: Union[list[str], list[tuple[str, str]]]
) -> list[str]:
+1 -44
View File
@@ -9,6 +9,7 @@ from enum import Enum
from typing import Annotated, Literal, Optional, Union
import pytest
from pydantic import BaseModel, field_validator, model_validator
from langgraph.constants import END, START
from langgraph.graph.state import StateGraph
@@ -45,15 +46,6 @@ def test_is_supported_by_pydantic() -> None:
assert is_supported_by_pydantic(PydanticModel) is True
if hasattr(pydantic, "v1"):
class PydanticModelV1(pydantic.v1.BaseModel):
x: int
assert is_supported_by_pydantic(PydanticModelV1) is False
assert is_supported_by_pydantic(int) is False
@pytest.mark.parametrize("version", ["v1", "v2"])
def test_nested_pydantic_models(version: str) -> None:
@@ -84,10 +76,6 @@ def test_nested_pydantic_models(version: str) -> None:
conlist,
constr,
)
from pydantic.v1 import BaseModel as BaseModelV1
if BaseModel is BaseModelV1:
pytest.skip("Cannot test pydantic v2 using installed version < 2")
class NestedModel(BaseModel):
value: int
@@ -314,8 +302,6 @@ def test_nested_pydantic_models(version: str) -> None:
def test_pydantic_state_field_validator():
from pydantic import BaseModel, field_validator, model_validator
class State(BaseModel):
name: str
text: str = ""
@@ -346,32 +332,3 @@ def test_pydantic_state_field_validator():
g = builder.compile()
res = g.invoke(input_state)
assert res["text"] == "Hello, Validated John!"
def test_pydantic_v1_state_root_validator():
from pydantic.v1 import BaseModel, root_validator
class State(BaseModel):
name: str
text: str = ""
only_root: int = 13
@root_validator(pre=True)
@classmethod
def validate(cls, values: dict):
values["name"] = "Validated " + values["name"]
return values | {"only_root": 396}
input_state = {"name": "John"}
def process_node(state: State):
assert State(**input_state) == state
return {"text": "Hello, " + state.name + "!"}
builder = StateGraph(state_schema=State)
builder.add_node("process", process_node)
builder.add_edge(START, "process")
builder.add_edge("process", END)
g = builder.compile()
res = g.invoke(input_state)
assert res["text"] == "Hello, Validated John!"