a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
363 lines
11 KiB
Python
363 lines
11 KiB
Python
import datetime
|
|
import decimal
|
|
import ipaddress
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
import uuid
|
|
from enum import Enum
|
|
from typing import Annotated, Literal, Optional
|
|
|
|
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
from pydantic import (
|
|
BaseModel,
|
|
ByteSize,
|
|
Field,
|
|
SecretStr,
|
|
confloat,
|
|
conint,
|
|
conlist,
|
|
constr,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
from langgraph._internal._pydantic import is_supported_by_pydantic
|
|
from langgraph.constants import END, START
|
|
from langgraph.func import entrypoint, task
|
|
from langgraph.graph.state import StateGraph
|
|
from langgraph.types import Command, Interrupt, interrupt
|
|
from tests.any_str import AnyStr
|
|
|
|
|
|
def test_is_supported_by_pydantic() -> None:
|
|
"""Test if types are supported by pydantic."""
|
|
import typing
|
|
|
|
import pydantic
|
|
import typing_extensions
|
|
|
|
class TypedDictExtensions(typing_extensions.TypedDict):
|
|
x: int
|
|
|
|
assert is_supported_by_pydantic(TypedDictExtensions) is True
|
|
|
|
class VanillaClass:
|
|
x: int
|
|
|
|
assert is_supported_by_pydantic(VanillaClass) is False
|
|
|
|
class BuiltinTypedDict(typing.TypedDict): # noqa: TID251
|
|
x: int
|
|
|
|
if sys.version_info >= (3, 12):
|
|
assert is_supported_by_pydantic(BuiltinTypedDict) is True
|
|
else:
|
|
assert is_supported_by_pydantic(BuiltinTypedDict) is False
|
|
|
|
class PydanticModel(pydantic.BaseModel):
|
|
x: int
|
|
|
|
assert is_supported_by_pydantic(PydanticModel) is True
|
|
|
|
|
|
def test_nested_pydantic_models() -> None:
|
|
"""Test that nested Pydantic models are properly constructed from leaf nodes up."""
|
|
|
|
class NestedModel(BaseModel):
|
|
value: int
|
|
name: str
|
|
|
|
# For constrained types
|
|
PositiveInt = Annotated[int, Field(gt=0)]
|
|
NonNegativeFloat = Annotated[float, Field(ge=0)]
|
|
|
|
# Enum type
|
|
class UserRole(Enum):
|
|
ADMIN = "admin"
|
|
USER = "user"
|
|
GUEST = "guest"
|
|
|
|
# Forward reference model
|
|
class RecursiveModel(BaseModel):
|
|
value: str
|
|
child: Optional["RecursiveModel"] = None
|
|
|
|
# Discriminated union models
|
|
class Cat(BaseModel):
|
|
pet_type: Literal["cat"]
|
|
meow: str
|
|
|
|
class Dog(BaseModel):
|
|
pet_type: Literal["dog"]
|
|
bark: str
|
|
|
|
# Cyclic reference model
|
|
class Person(BaseModel):
|
|
id: str
|
|
name: str
|
|
friends: list[str] = Field(default_factory=list) # IDs of friends
|
|
|
|
conlist_type = conlist(item_type=int, min_length=2, max_length=5)
|
|
|
|
class State(BaseModel):
|
|
# Basic nested model tests
|
|
top_level: str
|
|
auuid: uuid.UUID
|
|
nested: NestedModel
|
|
optional_nested: Annotated[NestedModel | None, lambda x, y: y, "Foo"]
|
|
dict_nested: dict[str, NestedModel]
|
|
simple_str_list: list[str]
|
|
list_nested: Annotated[
|
|
dict | list[dict[str, NestedModel]], lambda x, y: (x or []) + [y]
|
|
]
|
|
tuple_nested: tuple[str, NestedModel]
|
|
tuple_list_nested: list[tuple[int, NestedModel]]
|
|
complex_tuple: tuple[str, dict[str, tuple[int, NestedModel]]]
|
|
|
|
# Forward reference test
|
|
recursive: RecursiveModel
|
|
|
|
# Discriminated union test
|
|
pet: Cat | Dog
|
|
|
|
# Cyclic reference test
|
|
people: dict[str, Person] # Map of ID -> Person
|
|
|
|
# Rich type adapters
|
|
ip_address: ipaddress.IPv4Address
|
|
ip_address_v6: ipaddress.IPv6Address
|
|
amount: decimal.Decimal
|
|
file_path: pathlib.Path
|
|
timestamp: datetime.datetime
|
|
date_only: datetime.date
|
|
time_only: datetime.time
|
|
duration: datetime.timedelta
|
|
immutable_set: frozenset[int]
|
|
binary_data: bytes
|
|
pattern: re.Pattern
|
|
secret: SecretStr
|
|
file_size: ByteSize
|
|
|
|
# Constrained types
|
|
positive_value: PositiveInt
|
|
non_negative: NonNegativeFloat
|
|
limited_string: constr(min_length=3, max_length=10)
|
|
bounded_int: conint(ge=10, le=100)
|
|
restricted_float: confloat(gt=0, lt=1)
|
|
required_list: conlist_type
|
|
|
|
# Enum & Literal
|
|
role: UserRole
|
|
status: Literal["active", "inactive", "pending"]
|
|
|
|
# Annotated & NewType
|
|
validated_age: Annotated[int, Field(gt=0, lt=120)]
|
|
|
|
# Generic containers with validators
|
|
decimal_list: list[decimal.Decimal]
|
|
id_tuple: tuple[uuid.UUID, uuid.UUID]
|
|
|
|
inputs = {
|
|
# Basic nested models
|
|
"top_level": "initial",
|
|
"auuid": str(uuid.uuid4()),
|
|
"nested": {"value": 42, "name": "test"},
|
|
"optional_nested": {"value": 10, "name": "optional"},
|
|
"dict_nested": {"a": {"value": 5, "name": "a"}},
|
|
"list_nested": [{"a": {"value": 6, "name": "b"}}],
|
|
"tuple_nested": ["tuple-key", {"value": 7, "name": "tuple-value"}],
|
|
"tuple_list_nested": [[1, {"value": 8, "name": "tuple-in-list"}]],
|
|
"simple_str_list": ["siss", "boom", "bah"],
|
|
"complex_tuple": [
|
|
"complex",
|
|
{"nested": [9, {"value": 10, "name": "deep"}]},
|
|
],
|
|
# Forward reference
|
|
"recursive": {"value": "parent", "child": {"value": "child", "child": None}},
|
|
# Discriminated union (using a cat in this case)
|
|
"pet": {"pet_type": "cat", "meow": "meow!"},
|
|
# Cyclic references
|
|
"people": {
|
|
"1": {
|
|
"id": "1",
|
|
"name": "Alice",
|
|
"friends": ["2", "3"], # Alice is friends with Bob and Charlie
|
|
},
|
|
"2": {
|
|
"id": "2",
|
|
"name": "Bob",
|
|
"friends": ["1"], # Bob is friends with Alice
|
|
},
|
|
"3": {
|
|
"id": "3",
|
|
"name": "Charlie",
|
|
"friends": ["1", "2"], # Charlie is friends with Alice and Bob
|
|
},
|
|
},
|
|
# Rich type adapters
|
|
"ip_address": "192.168.1.1",
|
|
"ip_address_v6": "2001:db8::1",
|
|
"amount": "123.45",
|
|
"file_path": "/tmp/test.txt",
|
|
"timestamp": "2025-04-07T10:58:04",
|
|
"date_only": "2025-04-07",
|
|
"time_only": "10:58:04",
|
|
"duration": 3600, # seconds
|
|
"immutable_set": [1, 2, 3, 4],
|
|
"binary_data": b"hello world",
|
|
"pattern": "^test$",
|
|
"secret": "password123",
|
|
"file_size": 1024,
|
|
# Constrained types
|
|
"positive_value": 42,
|
|
"non_negative": 0.0,
|
|
"limited_string": "test",
|
|
"bounded_int": 50,
|
|
"restricted_float": 0.5,
|
|
"required_list": [10, 20, 30],
|
|
# Enum & Literal
|
|
"role": "admin",
|
|
"status": "active",
|
|
# Annotated & NewType
|
|
"validated_age": 30,
|
|
# Generic containers with validators
|
|
"decimal_list": ["10.5", "20.75", "30.25"],
|
|
"id_tuple": [str(uuid.uuid4()), str(uuid.uuid4())],
|
|
}
|
|
|
|
update = {"top_level": "updated", "nested": {"value": 100, "name": "updated"}}
|
|
|
|
expected = State(**inputs)
|
|
|
|
def node_fn(state: State) -> dict:
|
|
# Basic assertions
|
|
assert isinstance(state.auuid, uuid.UUID)
|
|
assert state == expected
|
|
|
|
# Rich type assertions
|
|
assert isinstance(state.ip_address, ipaddress.IPv4Address)
|
|
assert isinstance(state.ip_address_v6, ipaddress.IPv6Address)
|
|
assert isinstance(state.amount, decimal.Decimal)
|
|
assert isinstance(state.file_path, pathlib.Path)
|
|
assert isinstance(state.timestamp, datetime.datetime)
|
|
assert isinstance(state.date_only, datetime.date)
|
|
assert isinstance(state.time_only, datetime.time)
|
|
assert isinstance(state.duration, datetime.timedelta)
|
|
assert isinstance(state.immutable_set, frozenset)
|
|
assert isinstance(state.binary_data, bytes)
|
|
assert isinstance(state.pattern, re.Pattern)
|
|
|
|
# Constrained types
|
|
assert state.positive_value > 0
|
|
assert state.non_negative >= 0
|
|
assert 3 <= len(state.limited_string) <= 10
|
|
assert 10 <= state.bounded_int <= 100
|
|
assert 0 < state.restricted_float < 1
|
|
assert 2 <= len(state.required_list) <= 5
|
|
|
|
# Enum & Literal
|
|
assert state.role == UserRole.ADMIN
|
|
assert state.status == "active"
|
|
|
|
# Annotated
|
|
assert 0 < state.validated_age < 120
|
|
|
|
# Generic containers
|
|
assert len(state.decimal_list) == 3
|
|
assert len(state.id_tuple) == 2
|
|
|
|
return update
|
|
|
|
builder = StateGraph(State)
|
|
builder.add_node("process", node_fn)
|
|
builder.set_entry_point("process")
|
|
builder.set_finish_point("process")
|
|
graph = builder.compile()
|
|
|
|
result = graph.invoke(inputs.copy())
|
|
|
|
assert result == {**inputs, **update}
|
|
|
|
new_inputs = inputs.copy()
|
|
new_inputs["list_nested"] = {"foo": "bar"}
|
|
expected = State(**new_inputs)
|
|
assert {**new_inputs, **update} == graph.invoke(new_inputs.copy())
|
|
|
|
|
|
def test_pydantic_state_field_validator():
|
|
class State(BaseModel):
|
|
name: str
|
|
text: str = ""
|
|
only_root: int = 13
|
|
|
|
@field_validator("name", mode="after")
|
|
@classmethod
|
|
def validate_name(cls, value):
|
|
if value[0].islower():
|
|
raise ValueError("Name must start with a capital letter")
|
|
return "Validated " + value
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def validate_amodel(cls, values: "State"):
|
|
return values | {"only_root": 392}
|
|
|
|
input_state = {"name": "John"}
|
|
|
|
def process_node(state: State):
|
|
assert State.model_validate(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!"
|
|
|
|
|
|
class FunctionalState(BaseModel):
|
|
a: str
|
|
b: str | None = None
|
|
|
|
|
|
def test_interrupt_functional_pydantic(sync_checkpointer: BaseCheckpointSaver) -> None:
|
|
called_count = 0
|
|
|
|
@task
|
|
def foo(state: FunctionalState) -> FunctionalState:
|
|
nonlocal called_count
|
|
called_count += 1
|
|
return FunctionalState(**{"a": state.a + "foo"})
|
|
|
|
@task
|
|
def bar(state: FunctionalState) -> dict:
|
|
return {"a": state.a + "bar", "b": state.b}
|
|
|
|
@entrypoint(checkpointer=sync_checkpointer)
|
|
def graph(inputs: FunctionalState) -> FunctionalState:
|
|
fut_foo = foo(inputs)
|
|
value = interrupt("Provide value for bar:")
|
|
foo_res = fut_foo.result()
|
|
assert isinstance(foo_res, FunctionalState)
|
|
bar_input = FunctionalState(a=foo_res.a, b=value)
|
|
fut_bar = bar(bar_input)
|
|
return fut_bar.result()
|
|
|
|
config = {"configurable": {"thread_id": "1"}}
|
|
# First run, interrupted at bar
|
|
assert graph.invoke(FunctionalState(a=""), config) == {
|
|
"__interrupt__": [
|
|
Interrupt(
|
|
value="Provide value for bar:",
|
|
id=AnyStr(),
|
|
)
|
|
]
|
|
}
|
|
# Resume with an answer
|
|
res = graph.invoke(Command(resume="bar"), config)
|
|
assert res == {"a": "foobar", "b": "bar"}
|
|
assert called_count == 1
|