From e99028cfc6d53519de38570b7f1f55c327c42732 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Mon, 28 Apr 2025 21:44:17 -0400 Subject: [PATCH] what would it look like to remove pydantic v1 support? --- .../langgraph/langgraph/graph/schema_utils.py | 36 ++----- libs/langgraph/langgraph/graph/state.py | 7 +- libs/langgraph/langgraph/utils/fields.py | 8 +- libs/langgraph/langgraph/utils/pydantic.py | 31 ++---- libs/langgraph/poetry.lock | 2 +- libs/langgraph/tests/agents.py | 27 ++---- libs/langgraph/tests/test_messages_state.py | 16 +-- libs/langgraph/tests/test_pregel.py | 97 +++++-------------- libs/langgraph/tests/test_pregel_async.py | 17 +--- libs/langgraph/tests/test_pydantic.py | 45 +-------- 10 files changed, 59 insertions(+), 227 deletions(-) diff --git a/libs/langgraph/langgraph/graph/schema_utils.py b/libs/langgraph/langgraph/graph/schema_utils.py index 83b5a1b58..de769a660 100644 --- a/libs/langgraph/langgraph/graph/schema_utils.py +++ b/libs/langgraph/langgraph/graph/schema_utils.py @@ -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 diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index e88b6db99..6e6107eaa 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -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()) diff --git a/libs/langgraph/langgraph/utils/fields.py b/libs/langgraph/langgraph/utils/fields.py index d94c58a64..e13c697d8 100644 --- a/libs/langgraph/langgraph/utils/fields.py +++ b/libs/langgraph/langgraph/utils/fields.py @@ -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: diff --git a/libs/langgraph/langgraph/utils/pydantic.py b/libs/langgraph/langgraph/utils/pydantic.py index 66f434e5b..e8600e19c 100644 --- a/libs/langgraph/langgraph/utils/pydantic.py +++ b/libs/langgraph/langgraph/utils/pydantic.py @@ -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 diff --git a/libs/langgraph/poetry.lock b/libs/langgraph/poetry.lock index e02f14739..a49144223 100644 --- a/libs/langgraph/poetry.lock +++ b/libs/langgraph/poetry.lock @@ -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" diff --git a/libs/langgraph/tests/agents.py b/libs/langgraph/tests/agents.py index 6e5ce595a..d639724ab 100644 --- a/libs/langgraph/tests/agents.py +++ b/libs/langgraph/tests/agents.py @@ -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.""" - ) - } - } diff --git a/libs/langgraph/tests/test_messages_state.py b/libs/langgraph/tests/test_messages_state.py index d88e6226b..882b0cfed 100644 --- a/libs/langgraph/tests/test_messages_state.py +++ b/libs/langgraph/tests/test_messages_state.py @@ -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) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 75d408f29..a365d02f0 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -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 diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index e5ca5248d..8dc410eda 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -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]: diff --git a/libs/langgraph/tests/test_pydantic.py b/libs/langgraph/tests/test_pydantic.py index 53e6fe4e7..61da471b6 100644 --- a/libs/langgraph/tests/test_pydantic.py +++ b/libs/langgraph/tests/test_pydantic.py @@ -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!"