Compare commits

..
Author SHA1 Message Date
William Fu-Hinthorn 46e524d96c bazel ugh 2026-02-25 20:24:25 -08:00
38 changed files with 569 additions and 3638 deletions
+1 -2
View File
@@ -259,7 +259,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1rc2"
version = "4.0.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -280,7 +280,6 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
+1 -2
View File
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1rc2"
version = "4.0.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -289,7 +289,6 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
+1 -1
View File
@@ -37,4 +37,4 @@ type:
format format_diff:
uv run ruff format $(PYTHON_FILES)
uv run ruff check --fix $(PYTHON_FILES)
uv run ruff check --select I --fix $(PYTHON_FILES)
@@ -1,8 +1,6 @@
from __future__ import annotations
import copy
import logging
from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from typing import ( # noqa: UP035
Any,
Generic,
@@ -16,7 +14,6 @@ from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.serde.types import (
ERROR,
@@ -28,7 +25,6 @@ from langgraph.checkpoint.serde.types import (
V = TypeVar("V", int, float, str)
PendingWrite = tuple[str, str, Any]
logger = logging.getLogger(__name__)
# Marked as total=False to allow for future expansion.
@@ -478,37 +474,6 @@ class BaseCheckpointSaver(Generic[V]):
else:
return current + 1
def with_allowlist(
self, extra_allowlist: Collection[tuple[str, ...]]
) -> BaseCheckpointSaver[V]:
"""Return a shallow clone with a derived msgpack allowlist."""
serde = _with_msgpack_allowlist(self.serde, extra_allowlist)
if serde is self.serde:
return self
clone = copy.copy(self)
clone.serde = maybe_add_typed_methods(serde)
return clone
def _with_msgpack_allowlist(
serde: SerializerProtocol, extra_allowlist: Collection[tuple[str, ...]]
) -> SerializerProtocol:
if isinstance(serde, JsonPlusSerializer):
return serde.with_msgpack_allowlist(extra_allowlist)
if isinstance(serde, EncryptedSerializer):
inner = serde.serde
if isinstance(inner, JsonPlusSerializer):
updated_inner = inner.with_msgpack_allowlist(extra_allowlist)
if updated_inner is inner:
return serde
return EncryptedSerializer(serde.cipher, updated_inner)
logger.warning(
"Serializer %s does not support msgpack allowlist. "
"Strict msgpack deserialization will not be enforced.",
type(serde).__name__,
)
return serde
class EmptyChannelError(Exception):
"""Raised when attempting to get the value of a channel that hasn't been updated
@@ -1,71 +0,0 @@
import os
from collections.abc import Iterable
from typing import cast
STRICT_MSGPACK_ENABLED = os.getenv("LANGGRAPH_STRICT_MSGPACK", "false").lower() in (
"1",
"true",
"yes",
)
_SENTINEL = cast(None, object())
SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset(
{
# datetime types
("datetime", "datetime"),
("datetime", "date"),
("datetime", "time"),
("datetime", "timedelta"),
("datetime", "timezone"),
# uuid
("uuid", "UUID"),
# numeric
("decimal", "Decimal"),
# collections
("builtins", "set"),
("builtins", "frozenset"),
("collections", "deque"),
# ip addresses
("ipaddress", "IPv4Address"),
("ipaddress", "IPv4Interface"),
("ipaddress", "IPv4Network"),
("ipaddress", "IPv6Address"),
("ipaddress", "IPv6Interface"),
("ipaddress", "IPv6Network"),
# pathlib
("pathlib", "Path"),
("pathlib", "PosixPath"),
("pathlib", "WindowsPath"),
# pathlib in Python 3.13+
("pathlib._local", "Path"),
("pathlib._local", "PosixPath"),
("pathlib._local", "WindowsPath"),
# zoneinfo
("zoneinfo", "ZoneInfo"),
# regex
("re", "compile"),
# langgraph
("langgraph.types", "Send"),
("langgraph.types", "Interrupt"),
("langgraph.types", "Command"),
("langgraph.types", "StateSnapshot"),
("langgraph.types", "PregelTask"),
("langgraph.types", "Overwrite"),
("langgraph.store.base", "Item"),
("langgraph.store.base", "GetOp"),
}
)
# Allowed (module, name, method) triples for EXT_METHOD_SINGLE_ARG.
# Only these specific method invocations are permitted during deserialization.
# This is separate from SAFE_MSGPACK_TYPES which only governs construction.
SAFE_MSGPACK_METHODS: frozenset[tuple[str, str, str]] = frozenset(
{
("datetime", "datetime", "fromisoformat"),
}
)
AllowedMsgpackModules = Iterable[tuple[str, ...] | type]
@@ -41,7 +41,7 @@ class EncryptedSerializer(SerializerProtocol):
) -> "EncryptedSerializer":
"""Create an `EncryptedSerializer` using AES encryption."""
try:
from Crypto.Cipher import AES
from Crypto.Cipher import AES # type: ignore
except ImportError:
raise ImportError(
"Pycryptodome is not installed. Please install it with `pip install pycryptodome`."
@@ -1,6 +1,5 @@
from __future__ import annotations
import copy
import dataclasses
import decimal
import importlib
@@ -11,7 +10,7 @@ import pickle
import re
import sys
from collections import deque
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Callable, Sequence
from datetime import date, datetime, time, timedelta, timezone
from enum import Enum
from inspect import isclass
@@ -23,24 +22,17 @@ from ipaddress import (
IPv6Interface,
IPv6Network,
)
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import Any, Literal
from uuid import UUID
from zoneinfo import ZoneInfo
import ormsgpack
from langchain_core.load.load import Reviver
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
from langgraph.checkpoint.serde.base import SerializerProtocol
from langgraph.checkpoint.serde.types import SendProtocol
from langgraph.store.base import Item
if TYPE_CHECKING:
from langgraph.checkpoint.serde._msgpack import (
AllowedMsgpackModules,
)
from langgraph.checkpoint.serde.types import SendProtocol
LC_REVIVER = Reviver()
EMPTY_BYTES = b""
logger = logging.getLogger(__name__)
@@ -61,62 +53,21 @@ class JsonPlusSerializer(SerializerProtocol):
self,
*,
pickle_fallback: bool = False,
allowed_json_modules: Iterable[tuple[str, ...]] | Literal[True] | None = None,
allowed_msgpack_modules: (
AllowedMsgpackModules | Literal[True] | None
) = _lg_msgpack._SENTINEL,
allowed_json_modules: Sequence[tuple[str, ...]] | Literal[True] | None = None,
__unpack_ext_hook__: Callable[[int, bytes], Any] | None = None,
) -> None:
if allowed_msgpack_modules is _lg_msgpack._SENTINEL:
if _lg_msgpack.STRICT_MSGPACK_ENABLED:
allowed_msgpack_modules = None
else:
allowed_msgpack_modules = True
self.pickle_fallback = pickle_fallback
self._allowed_json_modules: set[tuple[str, ...]] | Literal[True] | None = (
_normalize_allowlist(allowed_json_modules)
self._allowed_modules = (
{mod_and_name for mod_and_name in allowed_json_modules}
if allowed_json_modules and allowed_json_modules is not True
else (allowed_json_modules if allowed_json_modules is True else None)
)
self._allowed_msgpack_modules = _normalize_allowlist(allowed_msgpack_modules)
self._custom_unpack_ext_hook = __unpack_ext_hook__ is not None
self._unpack_ext_hook = (
__unpack_ext_hook__
if __unpack_ext_hook__ is not None
else _create_msgpack_ext_hook(self._allowed_msgpack_modules)
else _msgpack_ext_hook
)
def with_msgpack_allowlist(
self, extra_allowlist: Iterable[tuple[str, ...] | type]
) -> JsonPlusSerializer:
"""Return a new serializer with a merged msgpack allowlist."""
base_allowlist = self._allowed_msgpack_modules
if base_allowlist is True or base_allowlist is False:
return self
elif base_allowlist:
base_allowlist = set(base_allowlist)
else:
base_allowlist = set()
extra = _normalize_module_keys(tuple(extra_allowlist))
merged = base_allowlist | extra
if merged == base_allowlist:
return self
allowed_msgpack_modules: AllowedMsgpackModules | Literal[True] | None
if merged:
allowed_msgpack_modules = tuple(merged)
elif isinstance(self._allowed_msgpack_modules, set):
allowed_msgpack_modules = tuple(self._allowed_msgpack_modules)
else:
allowed_msgpack_modules = self._allowed_msgpack_modules
clone = copy.copy(self)
clone._allowed_json_modules = _normalize_allowlist(self._allowed_json_modules)
clone._allowed_msgpack_modules = _normalize_allowlist(allowed_msgpack_modules)
if not clone._custom_unpack_ext_hook:
clone._unpack_ext_hook = _create_msgpack_ext_hook(
clone._allowed_msgpack_modules
)
return clone
def _encode_constructor_args(
self,
constructor: Callable | type[Any],
@@ -139,7 +90,7 @@ class JsonPlusSerializer(SerializerProtocol):
return out
def _reviver(self, value: dict[str, Any]) -> Any:
if self._allowed_json_modules and (
if self._allowed_modules and (
value.get("lc", None) == 2
and value.get("type", None) == "constructor"
and value.get("id", None) is not None
@@ -156,7 +107,7 @@ class JsonPlusSerializer(SerializerProtocol):
return LC_REVIVER(value)
def _revive_lc2(self, value: dict[str, Any]) -> Any:
self._check_allowed_json_modules(value)
self._check_allowed_modules(value)
[*module, name] = value["id"]
try:
@@ -188,7 +139,7 @@ class JsonPlusSerializer(SerializerProtocol):
except Exception:
return None
def _check_allowed_json_modules(self, value: dict[str, Any]) -> None:
def _check_allowed_modules(self, value: dict[str, Any]) -> None:
needed = tuple(value["id"])
method = value.get("method")
if isinstance(method, list):
@@ -199,7 +150,7 @@ class JsonPlusSerializer(SerializerProtocol):
method_display = "<init>"
dotted = ".".join(needed)
if not self._allowed_json_modules:
if not self._allowed_modules:
raise InvalidModuleError(
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
"No allowed_json_modules configured.\n\n"
@@ -210,9 +161,9 @@ class JsonPlusSerializer(SerializerProtocol):
"or plain-JSON representations revived without import-time side effects."
)
if self._allowed_json_modules is True:
if self._allowed_modules is True:
return
if needed in self._allowed_json_modules:
if needed in self._allowed_modules:
return
raise InvalidModuleError(
@@ -497,174 +448,92 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
raise TypeError(f"Object of type {obj.__class__.__name__} is not serializable")
def _create_msgpack_ext_hook(
allowed_modules: set[tuple[str, ...]] | Literal[True] | None,
) -> Callable[[int, bytes], Any]:
"""Create msgpack ext hook with allowlist.
Args:
allowed_modules: Set of (module, name) tuples that are allowed to be
deserialized, or True to allow all with warnings for unregistered types, or None to only allow safe types.
Returns:
An ext_hook function for use with ormsgpack.unpackb.
"""
def _check_allowed(module: str, name: str) -> bool:
"""Check if type is allowed. Returns True if allowed, False if blocked."""
key = (module, name)
if key in _lg_msgpack.SAFE_MSGPACK_TYPES:
return True
if allowed_modules is True:
# default is to warn but allow unregistered types
logger.warning(
"Deserializing unregistered type %s.%s from checkpoint. "
"This will be blocked in a future version. "
"Add to allowed_msgpack_modules to silence: [(%r, %r)]",
module,
name,
module,
name,
def _msgpack_ext_hook(code: int, data: bytes) -> Any:
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
try:
tup = ormsgpack.unpackb(
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
return True
if allowed_modules is not None:
if key in allowed_modules:
return True
# strict mode blocks unregistered types
logger.warning(
"Blocked deserialization of %s.%s - not in allowed_msgpack_modules. "
"Add to allowed_msgpack_modules to allow: [(%r, %r)]",
module,
name,
module,
name,
)
return False
# module, name, arg
return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
except Exception:
return
elif code == EXT_CONSTRUCTOR_POS_ARGS:
try:
tup = ormsgpack.unpackb(
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
# module, name, args
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
except Exception:
return
elif code == EXT_CONSTRUCTOR_KW_ARGS:
try:
tup = ormsgpack.unpackb(
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
# module, name, args
return getattr(importlib.import_module(tup[0]), tup[1])(**tup[2])
except Exception:
return
elif code == EXT_METHOD_SINGLE_ARG:
try:
tup = ormsgpack.unpackb(
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
# module, name, arg, method
return getattr(getattr(importlib.import_module(tup[0]), tup[1]), tup[3])(
tup[2]
)
except Exception:
return
elif code == EXT_PYDANTIC_V1:
try:
tup = ormsgpack.unpackb(
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
# module, name, kwargs
cls = getattr(importlib.import_module(tup[0]), tup[1])
try:
return cls(**tup[2])
except Exception:
return cls.construct(**tup[2])
except Exception:
# for pydantic objects we can't find/reconstruct
# let's return the kwargs dict instead
try:
return tup[2]
except NameError:
return
elif code == EXT_PYDANTIC_V2:
try:
tup = ormsgpack.unpackb(
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
# module, name, kwargs, method
cls = getattr(importlib.import_module(tup[0]), tup[1])
try:
return cls(**tup[2])
except Exception:
return cls.model_construct(**tup[2])
except Exception:
# for pydantic objects we can't find/reconstruct
# let's return the kwargs dict instead
try:
return tup[2]
except NameError:
return
elif code == EXT_NUMPY_ARRAY:
try:
import numpy as _np
def _check_allowed_method(module: str, name: str, method: str) -> bool:
"""Check if a method invocation is allowed."""
key = (module, name, method)
if key in _lg_msgpack.SAFE_MSGPACK_METHODS:
return True
logger.warning(
"Blocked deserialization of method call %s.%s.%s - "
"not in allowed methods set.",
module,
name,
method,
)
return False
def ext_hook(code: int, data: bytes) -> Any:
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
try:
tup = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
if not _check_allowed(tup[0], tup[1]):
# We default to returning the raw data. If the user
# is using this in the context of a pydantic state, etc., then
# it would be validated upon construction.
return tup[2]
# module, name, arg
return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
except Exception:
return None
elif code == EXT_CONSTRUCTOR_POS_ARGS:
try:
tup = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
if not _check_allowed(tup[0], tup[1]):
return tup[2]
# module, name, args
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
except Exception:
return None
elif code == EXT_CONSTRUCTOR_KW_ARGS:
try:
tup = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
if not _check_allowed(tup[0], tup[1]):
return tup[2]
# module, name, kwargs
return getattr(importlib.import_module(tup[0]), tup[1])(**tup[2])
except Exception:
return None
elif code == EXT_METHOD_SINGLE_ARG:
try:
tup = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
if not _check_allowed_method(tup[0], tup[1], tup[3]):
return tup[2]
# module, name, arg, method
return getattr(
getattr(importlib.import_module(tup[0]), tup[1]), tup[3]
)(tup[2])
except Exception:
return None
elif code == EXT_PYDANTIC_V1:
try:
tup = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
if not _check_allowed(tup[0], tup[1]):
return tup[2]
# module, name, kwargs
cls = getattr(importlib.import_module(tup[0]), tup[1])
try:
return cls(**tup[2])
except Exception:
return cls.construct(**tup[2])
except Exception:
# for pydantic objects we can't find/reconstruct
# let's return the kwargs dict instead
try:
return tup[2]
except NameError:
return None
elif code == EXT_PYDANTIC_V2:
try:
tup = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
if not _check_allowed(tup[0], tup[1]):
return tup[2]
# module, name, kwargs, method
cls = getattr(importlib.import_module(tup[0]), tup[1])
try:
return cls(**tup[2])
except Exception:
return cls.model_construct(**tup[2])
except Exception:
# for pydantic objects we can't find/reconstruct
# let's return the kwargs dict instead
try:
return tup[2]
except NameError:
return None
elif code == EXT_NUMPY_ARRAY:
try:
import numpy as _np
dtype_str, shape, order, buf = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str))
return arr.reshape(shape, order=order)
except Exception:
return None
return None
return ext_hook
# Aliasing in case anyone imported it directly
_msgpack_ext_hook = _create_msgpack_ext_hook(allowed_modules=None)
dtype_str, shape, order, buf = ormsgpack.unpackb(
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str))
return arr.reshape(shape, order=order)
except Exception:
return
def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
@@ -779,26 +648,3 @@ _option = (
def _msgpack_enc(data: Any) -> bytes:
return ormsgpack.packb(data, default=_msgpack_default, option=_option)
def _normalize_allowlist(
allowlist: AllowedMsgpackModules | Literal[True] | None,
) -> set[tuple[str, ...]] | Literal[True] | None:
if allowlist is True:
return allowlist
elif allowlist:
return _normalize_module_keys(allowlist)
else:
return None
def _normalize_module_keys(
modules: AllowedMsgpackModules,
) -> set[tuple[str, ...]]:
normalized: set[tuple[str, ...]] = set()
for module in modules:
if isclass(module):
normalized.add((module.__module__, module.__name__))
else:
normalized.add(cast(tuple[str, ...], module))
return normalized
+1 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "4.0.1rc2"
version = "4.0.0"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
@@ -42,7 +42,6 @@ lint = [
dev = [
{include-group = "test"},
{include-group = "lint"},
"pycryptodome>=3.23.0",
]
[tool.hatch.build.targets.wheel]
-437
View File
@@ -1,437 +0,0 @@
"""Tests for EncryptedSerializer with msgpack allowlist functionality.
These tests mirror the msgpack allowlist tests in test_jsonplus.py but run them
through the EncryptedSerializer to ensure the allowlist behavior is preserved
when encryption is enabled.
"""
from __future__ import annotations
import logging
import pathlib
import re
import uuid
from collections import deque
from datetime import date, datetime, time, timezone
from decimal import Decimal
from ipaddress import IPv4Address
from typing import Literal, cast
import ormsgpack
import pytest
from pydantic import BaseModel
from langgraph.checkpoint.base import BaseCheckpointSaver, _with_msgpack_allowlist
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
from langgraph.checkpoint.serde.base import CipherProtocol
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.serde.jsonplus import (
EXT_METHOD_SINGLE_ARG,
JsonPlusSerializer,
_msgpack_enc,
)
class InnerPydantic(BaseModel):
hello: str
class MyPydantic(BaseModel):
foo: str
bar: int
inner: InnerPydantic
class AnotherPydantic(BaseModel):
foo: str
class _PassthroughCipher(CipherProtocol):
def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
return "passthrough", plaintext
def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
assert ciphername == "passthrough"
return ciphertext
def _make_encrypted_serde(
allowed_msgpack_modules: (
_lg_msgpack.AllowedMsgpackModules | Literal[True] | None | object
) = _lg_msgpack._SENTINEL,
) -> EncryptedSerializer:
"""Create an EncryptedSerializer with AES encryption for testing."""
inner = JsonPlusSerializer(
allowed_msgpack_modules=cast(
_lg_msgpack.AllowedMsgpackModules | Literal[True] | None,
allowed_msgpack_modules,
)
)
return EncryptedSerializer.from_pycryptodome_aes(
serde=inner, key=b"1234567890123456"
)
def test_msgpack_method_pathlib_blocked_encrypted_strict(
tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
) -> None:
target = tmp_path / "secret.txt"
target.write_text("secret")
payload = ormsgpack.packb(
ormsgpack.Ext(
EXT_METHOD_SINGLE_ARG,
_msgpack_enc(("pathlib", "Path", target, "read_text")),
),
option=ormsgpack.OPT_NON_STR_KEYS,
)
serde = EncryptedSerializer(
_PassthroughCipher(),
JsonPlusSerializer(allowed_msgpack_modules=None),
)
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
caplog.clear()
result = serde.loads_typed(("msgpack+passthrough", payload))
assert result == target
assert "blocked deserialization of method call pathlib.path.read_text" in (
caplog.text.lower()
)
class TestEncryptedSerializerMsgpackAllowlist:
"""Test msgpack allowlist behavior through EncryptedSerializer."""
def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None:
"""Test safe types deserialize without warnings through encryption."""
serde = _make_encrypted_serde()
safe_objects = [
datetime.now(),
date.today(),
time(12, 30),
timezone.utc,
uuid.uuid4(),
Decimal("123.45"),
{1, 2, 3},
frozenset([1, 2, 3]),
deque([1, 2, 3]),
IPv4Address("192.168.1.1"),
pathlib.Path("/tmp/test"),
]
for obj in safe_objects:
caplog.clear()
dumped = serde.dumps_typed(obj)
# Verify encryption is happening
assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}"
result = serde.loads_typed(dumped)
assert "unregistered type" not in caplog.text.lower(), (
f"Unexpected warning for {type(obj)}"
)
assert result is not None
def test_pydantic_warns_by_default(self, caplog: pytest.LogCaptureFixture) -> None:
"""Pydantic models not in allowlist should log warning but still deserialize."""
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
_lg_msgpack.STRICT_MSGPACK_ENABLED = False
serde = _make_encrypted_serde()
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
assert "+aes" in dumped[0]
result = serde.loads_typed(dumped)
assert "unregistered type" in caplog.text.lower()
assert "allowed_msgpack_modules" in caplog.text
assert result == obj
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
def test_strict_mode_blocks_unregistered(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Strict mode should block unregistered types through encryption."""
serde = _make_encrypted_serde(allowed_msgpack_modules=None)
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
assert "+aes" in dumped[0]
result = serde.loads_typed(dumped)
assert "blocked" in caplog.text.lower()
expected = obj.model_dump()
assert result == expected
def test_allowlist_silences_warning(self, caplog: pytest.LogCaptureFixture) -> None:
"""Types in allowed_msgpack_modules should deserialize without warnings."""
serde = _make_encrypted_serde(
allowed_msgpack_modules=[
("tests.test_encrypted", "MyPydantic"),
("tests.test_encrypted", "InnerPydantic"),
]
)
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
assert "+aes" in dumped[0]
result = serde.loads_typed(dumped)
assert "unregistered type" not in caplog.text.lower()
assert "blocked" not in caplog.text.lower()
assert result == obj
def test_allowlist_blocks_non_listed(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Allowlists should block unregistered types even through encryption."""
serde = _make_encrypted_serde(
allowed_msgpack_modules=[("tests.test_encrypted", "MyPydantic")]
)
obj = AnotherPydantic(foo="nope")
caplog.clear()
dumped = serde.dumps_typed(obj)
assert "+aes" in dumped[0]
result = serde.loads_typed(dumped)
assert "blocked" in caplog.text.lower()
expected = obj.model_dump()
assert result == expected
def test_safe_types_value_equality(self, caplog: pytest.LogCaptureFixture) -> None:
"""Verify safe types are correctly restored with proper values through encryption."""
serde = _make_encrypted_serde(allowed_msgpack_modules=None)
test_cases = [
datetime(2024, 1, 15, 12, 30, 45, 123456),
date(2024, 6, 15),
time(14, 30, 0),
uuid.UUID("12345678-1234-5678-1234-567812345678"),
Decimal("123.456789"),
{1, 2, 3, 4, 5},
frozenset(["a", "b", "c"]),
deque([1, 2, 3]),
IPv4Address("10.0.0.1"),
pathlib.Path("/some/test/path"),
re.compile(r"\d+", re.MULTILINE),
]
for obj in test_cases:
caplog.clear()
dumped = serde.dumps_typed(obj)
assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}"
result = serde.loads_typed(dumped)
assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}"
if isinstance(obj, re.Pattern):
assert result.pattern == obj.pattern
assert result.flags == obj.flags
else:
assert result == obj, (
f"Value mismatch for {type(obj)}: {result} != {obj}"
)
def test_regex_safe_type(self, caplog: pytest.LogCaptureFixture) -> None:
"""re.compile patterns should deserialize without warnings as a safe type."""
serde = _make_encrypted_serde(allowed_msgpack_modules=None)
pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL)
caplog.clear()
dumped = serde.dumps_typed(pattern)
assert "+aes" in dumped[0]
result = serde.loads_typed(dumped)
assert "blocked" not in caplog.text.lower()
assert "unregistered" not in caplog.text.lower()
assert result.pattern == pattern.pattern
assert result.flags == pattern.flags
class TestWithMsgpackAllowlistEncrypted:
"""Test _with_msgpack_allowlist function with EncryptedSerializer."""
def test_propagates_allowlist_to_inner_serde(self) -> None:
"""_with_msgpack_allowlist should propagate allowlist to inner JsonPlusSerializer."""
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
encrypted = EncryptedSerializer.from_pycryptodome_aes(
serde=inner, key=b"1234567890123456"
)
extra = [("my.module", "MyClass")]
result = _with_msgpack_allowlist(encrypted, extra)
# Should return a new EncryptedSerializer
assert isinstance(result, EncryptedSerializer)
assert result is not encrypted
# Inner serde should have the allowlist
assert isinstance(result.serde, JsonPlusSerializer)
assert isinstance(result.serde._allowed_msgpack_modules, set)
assert ("my.module", "MyClass") in result.serde._allowed_msgpack_modules
def test_preserves_cipher(self) -> None:
"""_with_msgpack_allowlist should preserve the cipher from the original."""
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
encrypted = EncryptedSerializer.from_pycryptodome_aes(
serde=inner, key=b"1234567890123456"
)
result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")])
assert isinstance(result, EncryptedSerializer)
# Should use the same cipher
assert result.cipher is encrypted.cipher
def test_returns_same_if_not_jsonplus_inner(self) -> None:
"""_with_msgpack_allowlist should return same serde if inner is not JsonPlusSerializer."""
class DummyInnerSerde:
def dumps_typed(self, obj: object) -> tuple[str, bytes]:
return ("dummy", b"")
def loads_typed(self, data: tuple[str, bytes]) -> None:
return None
from langgraph.checkpoint.serde.base import CipherProtocol
class DummyCipher(CipherProtocol):
def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
return "dummy", plaintext
def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
return ciphertext
encrypted = EncryptedSerializer(DummyCipher(), DummyInnerSerde())
result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")])
assert result is encrypted
def test_warns_if_allowlist_unsupported(
self, caplog: pytest.LogCaptureFixture
) -> None:
class DummySerde:
def dumps_typed(self, obj: object) -> tuple[str, bytes]:
return ("dummy", b"")
def loads_typed(self, data: tuple[str, bytes]) -> object:
return data
serde = DummySerde()
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.base")
caplog.clear()
result = _with_msgpack_allowlist(serde, [("my.module", "MyClass")])
assert result is serde
assert "does not support msgpack allowlist" in caplog.text.lower()
def test_noop_allowlist_returns_same_encrypted_instance(self) -> None:
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
encrypted = EncryptedSerializer.from_pycryptodome_aes(
serde=inner, key=b"1234567890123456"
)
result = _with_msgpack_allowlist(encrypted, ())
assert result is encrypted
def test_functional_roundtrip_with_allowlist(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""End-to-end test: allowlist applied via _with_msgpack_allowlist works."""
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
encrypted = EncryptedSerializer.from_pycryptodome_aes(
serde=inner, key=b"1234567890123456"
)
# Apply allowlist for MyPydantic
updated = _with_msgpack_allowlist(
encrypted,
[
("tests.test_encrypted", "MyPydantic"),
("tests.test_encrypted", "InnerPydantic"),
],
)
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = updated.dumps_typed(obj)
assert "+aes" in dumped[0]
result = updated.loads_typed(dumped)
# Should deserialize without blocking
assert "blocked" not in caplog.text.lower()
assert result == obj
def test_original_still_blocks_after_with_allowlist(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Original serde should still block after _with_msgpack_allowlist creates a new one."""
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
encrypted = EncryptedSerializer.from_pycryptodome_aes(
serde=inner, key=b"1234567890123456"
)
# Apply allowlist - this should create a NEW serde
_with_msgpack_allowlist(
encrypted,
[("tests.test_encrypted", "MyPydantic")],
)
# Original should still block
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = encrypted.dumps_typed(obj)
result = encrypted.loads_typed(dumped)
assert "blocked" in caplog.text.lower()
assert result == obj.model_dump()
class TestEncryptedSerializerUnencryptedFallback:
"""Test that EncryptedSerializer handles unencrypted data correctly."""
def test_loads_unencrypted_data(self) -> None:
"""EncryptedSerializer should handle unencrypted data for backwards compat."""
plain = JsonPlusSerializer(allowed_msgpack_modules=None)
encrypted = _make_encrypted_serde(allowed_msgpack_modules=None)
obj = {"key": "value", "number": 42}
# Serialize with plain serde
dumped = plain.dumps_typed(obj)
assert "+aes" not in dumped[0]
# Should still deserialize with encrypted serde
result = encrypted.loads_typed(dumped)
assert result == obj
def test_with_allowlist_uses_copy_protocol() -> None:
class CopyAwareSaver(BaseCheckpointSaver[str]):
def __init__(self) -> None:
super().__init__(serde=JsonPlusSerializer(allowed_msgpack_modules=None))
self.copy_was_used = False
def __copy__(self) -> object:
clone = object.__new__(self.__class__)
clone.__dict__ = self.__dict__.copy()
clone.copy_was_used = True
return clone
saver = CopyAwareSaver()
updated = saver.with_allowlist([("tests.test_encrypted", "MyPydantic")])
assert isinstance(updated, CopyAwareSaver)
assert updated is not saver
assert updated.copy_was_used is True
assert saver.copy_was_used is False
+2 -401
View File
@@ -1,6 +1,5 @@
import dataclasses
import json
import logging
import pathlib
import re
import sys
@@ -14,20 +13,15 @@ from zoneinfo import ZoneInfo
import dataclasses_json
import numpy as np
import ormsgpack
import pandas as pd
import pytest
from pydantic import BaseModel, SecretStr
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import SecretStr as SecretStrV1
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
from langgraph.checkpoint.serde._msgpack import AllowedMsgpackModules
from langgraph.checkpoint.serde.jsonplus import (
EXT_METHOD_SINGLE_ARG,
InvalidModuleError,
JsonPlusSerializer,
_msgpack_enc,
_msgpack_ext_hook_to_json,
)
from langgraph.store.base import Item
@@ -43,10 +37,6 @@ class MyPydantic(BaseModel):
inner: InnerPydantic
class AnotherPydantic(BaseModel):
foo: str
class InnerPydanticV1(BaseModelV1):
hello: str
@@ -148,27 +138,7 @@ def test_serde_jsonplus() -> None:
)
to_serialize["my_secret_str_v1"] = SecretStrV1("meow")
allowed_msgpack_modules: AllowedMsgpackModules = [
InnerDataclass,
MyDataclass,
MyDataclassWSlots,
MyEnum,
InnerPydantic,
MyPydantic,
# Testing that it supports both.
(Person.__module__, Person.__name__),
(SecretStr.__module__, SecretStr.__name__),
]
if sys.version_info < (3, 14):
allowed_msgpack_modules.extend( # type: ignore
[
(InnerPydanticV1.__module__, InnerPydanticV1.__name__),
(MyPydanticV1.__module__, MyPydanticV1.__name__),
(SecretStrV1.__module__, SecretStrV1.__name__),
]
)
serde = JsonPlusSerializer(allowed_msgpack_modules=allowed_msgpack_modules)
serde = JsonPlusSerializer()
dumped = serde.dumps_typed(to_serialize)
@@ -542,374 +512,5 @@ def test_serde_jsonplus_pandas_series(series: pd.Series) -> None:
assert dumped[0] == "pickle"
result = serde.loads_typed(dumped)
assert result.equals(series)
def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None:
"""Test safe types deserialize without warnings."""
serde = JsonPlusSerializer()
safe_objects = [
datetime.now(),
date.today(),
time(12, 30),
timezone.utc,
uuid.uuid4(),
Decimal("123.45"),
{1, 2, 3},
frozenset([1, 2, 3]),
deque([1, 2, 3]),
IPv4Address("192.168.1.1"),
pathlib.Path("/tmp/test"),
]
for obj in safe_objects:
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "unregistered type" not in caplog.text.lower(), (
f"Unexpected warning for {type(obj)}"
)
assert result is not None
def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None:
"""Pydantic models not in allowlist should log warning but still deserialize."""
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
_lg_msgpack.STRICT_MSGPACK_ENABLED = False
serde = JsonPlusSerializer()
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "unregistered type" in caplog.text.lower()
assert "allowed_msgpack_modules" in caplog.text
assert result == obj
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
def test_msgpack_env_strict_default(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Strict msgpack env should default to blocking unregistered types."""
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
_lg_msgpack.STRICT_MSGPACK_ENABLED = True
serde = JsonPlusSerializer()
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "blocked" in caplog.text.lower()
assert result == obj.model_dump()
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) -> None:
"""Types in allowed_msgpack_modules should deserialize without warnings."""
serde = JsonPlusSerializer(
allowed_msgpack_modules=[
("tests.test_jsonplus", "MyPydantic"),
("tests.test_jsonplus", "InnerPydantic"),
]
)
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "unregistered type" not in caplog.text.lower()
assert result == obj
def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None:
"""allowed_msgpack_modules=None should block unregistered types."""
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "blocked" in caplog.text.lower()
expected = obj.model_dump()
assert result == expected
def test_msgpack_allowlist_blocks_non_listed(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Allowlists should block unregistered types even if msgpack is enabled."""
serde = JsonPlusSerializer(
allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
)
obj = AnotherPydantic(foo="nope")
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "blocked" in caplog.text.lower()
expected = obj.model_dump()
# It's not allowed, so we just leave it as a dict
assert result == expected
def test_msgpack_strict_allows_safe_types(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Safe types should still deserialize in strict mode without warnings."""
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
safe = uuid.uuid4()
caplog.clear()
dumped = serde.dumps_typed(safe)
result = serde.loads_typed(dumped)
assert "blocked" not in caplog.text.lower()
assert result == safe
def test_msgpack_regex_safe_type(caplog: pytest.LogCaptureFixture) -> None:
"""re.compile patterns should deserialize without warnings as a safe type."""
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL)
caplog.clear()
dumped = serde.dumps_typed(pattern)
result = serde.loads_typed(dumped)
assert "blocked" not in caplog.text.lower()
assert "unregistered" not in caplog.text.lower()
assert result.pattern == pattern.pattern
assert result.flags == pattern.flags
def test_msgpack_method_pathlib_blocked_in_strict(
tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
) -> None:
target = tmp_path / "secret.txt"
target.write_text("secret")
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
payload = ormsgpack.packb(
ormsgpack.Ext(
EXT_METHOD_SINGLE_ARG,
_msgpack_enc(("pathlib", "Path", target, "read_text")),
),
option=ormsgpack.OPT_NON_STR_KEYS,
)
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
caplog.clear()
result = serde.loads_typed(("msgpack", payload))
assert result == target
assert "blocked deserialization of method call pathlib.path.read_text" in (
caplog.text.lower()
)
def test_msgpack_method_pathlib_blocked_default_mode(
tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
) -> None:
target = tmp_path / "secret.txt"
target.write_text("secret")
serde = JsonPlusSerializer(allowed_msgpack_modules=True)
payload = ormsgpack.packb(
ormsgpack.Ext(
EXT_METHOD_SINGLE_ARG,
_msgpack_enc(("pathlib", "Path", target, "read_text")),
),
option=ormsgpack.OPT_NON_STR_KEYS,
)
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
caplog.clear()
result = serde.loads_typed(("msgpack", payload))
assert result == target
assert "blocked deserialization of method call pathlib.path.read_text" in (
caplog.text.lower()
)
def test_msgpack_regex_still_works_strict(caplog: pytest.LogCaptureFixture) -> None:
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
pattern = re.compile(r"pattern", re.IGNORECASE | re.MULTILINE)
caplog.clear()
result = serde.loads_typed(serde.dumps_typed(pattern))
assert "blocked" not in caplog.text.lower()
assert result.pattern == pattern.pattern
assert result.flags == pattern.flags
def test_msgpack_path_constructor_still_works() -> None:
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
path_obj = pathlib.Path("/tmp/foo")
result = serde.loads_typed(serde.dumps_typed(path_obj))
assert result == path_obj
def test_with_msgpack_allowlist_noop_returns_same_instance() -> None:
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
result = serde.with_msgpack_allowlist(())
assert result is serde
def test_with_msgpack_allowlist_supports_subclass_without_init_kwargs() -> None:
class CustomSerializer(JsonPlusSerializer):
def __init__(self) -> None:
super().__init__(allowed_msgpack_modules=None)
serde = CustomSerializer()
result = serde.with_msgpack_allowlist([MyDataclass])
assert isinstance(result, CustomSerializer)
assert result is not serde
assert serde._allowed_msgpack_modules is None
assert result._allowed_msgpack_modules == {
(MyDataclass.__module__, MyDataclass.__name__)
}
def test_with_msgpack_allowlist_rebuilds_default_unpack_hook() -> None:
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
original_hook = serde._unpack_ext_hook
result = serde.with_msgpack_allowlist([MyDataclass])
assert result._unpack_ext_hook is not original_hook
def test_with_msgpack_allowlist_preserves_custom_unpack_hook() -> None:
def custom_hook(code: int, data: bytes) -> None:
return None
serde = JsonPlusSerializer(
allowed_msgpack_modules=None, __unpack_ext_hook__=custom_hook
)
result = serde.with_msgpack_allowlist([MyDataclass])
assert result._unpack_ext_hook is custom_hook
@pytest.mark.skipif(sys.version_info >= (3, 14), reason="pydantic v1 not on 3.14+")
def test_msgpack_pydantic_v1_allowlist(caplog: pytest.LogCaptureFixture) -> None:
"""Pydantic v1 models in allowlist should deserialize without warnings."""
serde = JsonPlusSerializer(
allowed_msgpack_modules=[
("tests.test_jsonplus", "MyPydanticV1"),
("tests.test_jsonplus", "InnerPydanticV1"),
]
)
obj = MyPydanticV1(foo="test", bar=42, inner=InnerPydanticV1(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "unregistered type" not in caplog.text.lower()
assert "blocked" not in caplog.text.lower()
assert result == obj
def test_msgpack_dataclass_allowlist(caplog: pytest.LogCaptureFixture) -> None:
"""Dataclasses in allowlist should deserialize without warnings."""
serde = JsonPlusSerializer(
allowed_msgpack_modules=[
("tests.test_jsonplus", "MyDataclass"),
("tests.test_jsonplus", "InnerDataclass"),
]
)
obj = MyDataclass(foo="test", bar=42, inner=InnerDataclass(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "unregistered type" not in caplog.text.lower()
assert "blocked" not in caplog.text.lower()
assert result == obj
def test_msgpack_safe_types_value_equality(caplog: pytest.LogCaptureFixture) -> None:
"""Verify safe types are correctly restored with proper values."""
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
test_cases = [
datetime(2024, 1, 15, 12, 30, 45, 123456),
date(2024, 6, 15),
time(14, 30, 0),
uuid.UUID("12345678-1234-5678-1234-567812345678"),
Decimal("123.456789"),
{1, 2, 3, 4, 5},
frozenset(["a", "b", "c"]),
deque([1, 2, 3]),
IPv4Address("10.0.0.1"),
pathlib.Path("/some/test/path"),
re.compile(r"\d+", re.MULTILINE),
]
for obj in test_cases:
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}"
# For regex patterns, compare pattern and flags
if isinstance(obj, re.Pattern):
assert result.pattern == obj.pattern
assert result.flags == obj.flags
else:
assert result == obj, f"Value mismatch for {type(obj)}: {result} != {obj}"
def test_msgpack_nested_pydantic_serializes_as_dict(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Nested Pydantic models are serialized via model_dump() as dicts.
This means nested models don't go through the ext hook and don't need
to be in the allowlist - only the outer type does.
"""
# Only allow outer type - inner is serialized as dict via model_dump()
serde = JsonPlusSerializer(
allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
)
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
caplog.clear()
dumped = serde.dumps_typed(obj)
result = serde.loads_typed(dumped)
# No blocking should occur - inner is serialized as dict, not ext
assert "blocked" not in caplog.text.lower()
assert result == obj
-109
View File
@@ -1,9 +1,7 @@
import logging
from typing import Any
import pytest
from langchain_core.runnables import RunnableConfig
from pydantic import BaseModel
from langgraph.checkpoint.base import (
Checkpoint,
@@ -12,11 +10,6 @@ from langgraph.checkpoint.base import (
empty_checkpoint,
)
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
class MemoryPydantic(BaseModel):
foo: str
class TestMemorySaver:
@@ -206,105 +199,3 @@ async def test_memory_saver() -> None:
with memory_saver as sync_memory_saver:
assert sync_memory_saver is memory_saver
def test_memory_saver_warns_on_unregistered_msgpack(
caplog: pytest.LogCaptureFixture,
) -> None:
serde = JsonPlusSerializer()
memory_saver = InMemorySaver(serde=serde)
obj = MemoryPydantic(foo="bar")
checkpoint = empty_checkpoint()
checkpoint["channel_values"] = {"foo": obj}
checkpoint["channel_versions"] = {"foo": 1}
config: RunnableConfig = {
"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
}
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
result = memory_saver.get_tuple(new_config)
assert result is not None
assert "unregistered type" in caplog.text.lower()
assert result.checkpoint["channel_values"]["foo"] == obj
def test_memory_saver_allowlist_silences_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
serde = JsonPlusSerializer(
allowed_msgpack_modules=[("tests.test_memory", "MemoryPydantic")]
)
memory_saver = InMemorySaver(serde=serde)
obj = MemoryPydantic(foo="bar")
checkpoint = empty_checkpoint()
checkpoint["channel_values"] = {"foo": obj}
checkpoint["channel_versions"] = {"foo": 1}
config: RunnableConfig = {
"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
}
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
result = memory_saver.get_tuple(new_config)
assert result is not None
assert "unregistered type" not in caplog.text.lower()
assert result.checkpoint["channel_values"]["foo"] == obj
def test_memory_saver_strict_blocks_unregistered(
caplog: pytest.LogCaptureFixture,
) -> None:
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
memory_saver = InMemorySaver(serde=serde)
obj = MemoryPydantic(foo="bar")
checkpoint = empty_checkpoint()
checkpoint["channel_values"] = {"foo": obj}
checkpoint["channel_versions"] = {"foo": 1}
config: RunnableConfig = {
"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
}
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
result = memory_saver.get_tuple(new_config)
assert result is not None
assert "blocked" in caplog.text.lower()
expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
assert result.checkpoint["channel_values"]["foo"] == expected
def test_memory_saver_with_allowlist_proxy_isolated() -> None:
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
memory_saver = InMemorySaver(serde=serde)
proxy = memory_saver.with_allowlist([("tests.test_memory", "MemoryPydantic")])
obj = MemoryPydantic(foo="bar")
checkpoint = empty_checkpoint()
checkpoint["channel_values"] = {"foo": obj}
checkpoint["channel_versions"] = {"foo": 1}
config: RunnableConfig = {
"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
}
new_config = proxy.put(config, checkpoint, {}, {"foo": 1})
proxied = proxy.get_tuple(new_config)
assert proxied is not None
assert proxied.checkpoint["channel_values"]["foo"] == obj
direct = memory_saver.get_tuple(new_config)
assert direct is not None
expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
assert direct.checkpoint["channel_values"]["foo"] == expected
+1 -38
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1rc2"
version = "4.0.0"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -302,7 +302,6 @@ dev = [
{ name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pandas" },
{ name = "pandas-stubs" },
{ name = "pycryptodome" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -342,7 +341,6 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -914,41 +912,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
+160
View File
@@ -1,5 +1,7 @@
"""CLI entrypoint for LangGraph API server."""
import difflib
import json
import os
import pathlib
import shutil
@@ -165,6 +167,10 @@ def cli():
pass
def _format_json(value: object) -> str:
return json.dumps(value, indent=2, sort_keys=True) + os.linesep
@OPT_RECREATE
@OPT_PULL
@OPT_PORT
@@ -606,6 +612,160 @@ def dockerfile(
)
@cli.group(
"build-spec",
help="🧾 Export or verify a machine-readable build specification for hermetic builds.",
)
def build_spec() -> None:
pass
@OPT_CONFIG
@click.option(
"--base-image",
help="Base image to use for the LangGraph API server. Defaults to langchain/langgraph-api or langchain/langgraphjs-api.",
)
@OPT_API_VERSION
@click.option(
"--install-command",
help="Custom install command (Node projects only). If omitted, auto-detects based on package manager files.",
)
@click.option(
"--build-command",
help="Custom build command to run from the langgraph.json directory (Node projects only).",
)
@click.option(
"--output",
"-o",
type=click.Path(
exists=False,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
help="Path to write the build spec JSON. If omitted, prints to stdout.",
)
@build_spec.command("export")
@log_command
def build_spec_export(
config: pathlib.Path,
base_image: str | None,
api_version: str | None,
install_command: str | None,
build_command: str | None,
output: pathlib.Path | None,
) -> None:
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
is_js_project = config_json.get("node_version") and not config_json.get(
"python_version"
)
if is_js_project and (build_command or install_command):
build_context = str(pathlib.Path.cwd())
else:
build_context = str(config.parent)
spec = langgraph_cli.config.config_to_build_spec(
config_path=config,
config=config_json,
base_image=base_image,
api_version=api_version,
install_command=install_command,
build_command=build_command,
build_context=build_context,
)
rendered = _format_json(spec)
if output is None:
click.echo(rendered, nl=False)
return
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="utf-8") as f:
f.write(rendered)
secho(f"✅ Created build spec: {output}", fg="green")
@OPT_CONFIG
@click.argument(
"spec_path",
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
)
@click.option(
"--base-image",
help="Base image to use for the LangGraph API server. Defaults to langchain/langgraph-api or langchain/langgraphjs-api.",
)
@OPT_API_VERSION
@click.option(
"--install-command",
help="Custom install command (Node projects only). If omitted, auto-detects based on package manager files.",
)
@click.option(
"--build-command",
help="Custom build command to run from the langgraph.json directory (Node projects only).",
)
@build_spec.command("verify")
@log_command
def build_spec_verify(
config: pathlib.Path,
spec_path: pathlib.Path,
base_image: str | None,
api_version: str | None,
install_command: str | None,
build_command: str | None,
) -> None:
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
is_js_project = config_json.get("node_version") and not config_json.get(
"python_version"
)
if is_js_project and (build_command or install_command):
build_context = str(pathlib.Path.cwd())
else:
build_context = str(config.parent)
expected = langgraph_cli.config.config_to_build_spec(
config_path=config,
config=config_json,
base_image=base_image,
api_version=api_version,
install_command=install_command,
build_command=build_command,
build_context=build_context,
)
expected_str = _format_json(expected)
with open(spec_path, encoding="utf-8") as f:
actual = json.load(f)
actual_str = _format_json(actual)
if actual_str == expected_str:
secho(f"✅ Build spec is in sync: {spec_path}", fg="green")
return
diff = "\n".join(
difflib.unified_diff(
actual_str.splitlines(),
expected_str.splitlines(),
fromfile=str(spec_path),
tofile="generated",
lineterm="",
)
)
raise click.ClickException(
f"Build spec is out of sync: {spec_path}\n\n{diff}"
) from None
@click.option(
"--host",
default="127.0.0.1",
+98 -61
View File
@@ -4,7 +4,7 @@ import pathlib
import re
import textwrap
from collections import Counter
from typing import Literal, NamedTuple
from typing import Any, Literal, NamedTuple
import click
@@ -874,6 +874,55 @@ def get_build_tools_to_uninstall(config: Config) -> tuple[str]:
)
def _build_langgraph_env(
config: Config, *, runtime: Literal["python", "node"]
) -> dict[str, str]:
"""Build runtime environment variables that are serialized from config."""
env_map: dict[str, str] = {}
if (store_config := config.get("store")) is not None:
env_map["LANGGRAPH_STORE"] = json.dumps(store_config)
if (auth_config := config.get("auth")) is not None:
env_map["LANGGRAPH_AUTH"] = json.dumps(auth_config)
if (encryption_config := config.get("encryption")) is not None:
env_map["LANGGRAPH_ENCRYPTION"] = json.dumps(encryption_config)
if (http_config := config.get("http")) is not None:
env_map["LANGGRAPH_HTTP"] = json.dumps(http_config)
if (webhooks_config := config.get("webhooks")) is not None:
env_map["LANGGRAPH_WEBHOOKS"] = json.dumps(webhooks_config)
if (checkpointer_config := config.get("checkpointer")) is not None:
env_map["LANGGRAPH_CHECKPOINTER"] = json.dumps(checkpointer_config)
# Keep Python/Node behavior consistent with current Dockerfile generation:
# Python emits UI vars when explicitly set (including empty dict), while
# Node currently emits them only when truthy.
if runtime == "python":
if (ui := config.get("ui")) is not None:
env_map["LANGGRAPH_UI"] = json.dumps(ui)
if (ui_config := config.get("ui_config")) is not None:
env_map["LANGGRAPH_UI_CONFIG"] = json.dumps(ui_config)
else:
if ui := config.get("ui"):
env_map["LANGGRAPH_UI"] = json.dumps(ui)
if ui_config := config.get("ui_config"):
env_map["LANGGRAPH_UI_CONFIG"] = json.dumps(ui_config)
env_map["LANGSERVE_GRAPHS"] = json.dumps(config["graphs"])
return env_map
def _extract_workdir(dockerfile: str) -> str | None:
for line in dockerfile.splitlines():
if line.startswith("WORKDIR "):
return line.removeprefix("WORKDIR ").strip() or None
return None
def python_config_to_docker(
config_path: pathlib.Path,
config: Config,
@@ -1006,36 +1055,8 @@ ADD {relpath} /deps/{name}
)
)
env_vars = []
if (store_config := config.get("store")) is not None:
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
if (auth_config := config.get("auth")) is not None:
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
if (encryption_config := config.get("encryption")) is not None:
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
# Inject webhooks configuration if provided
if (webhooks_config := config.get("webhooks")) is not None:
env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
if (checkpointer_config := config.get("checkpointer")) is not None:
env_vars.append(
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
)
if (ui := config.get("ui")) is not None:
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
if (ui_config := config.get("ui_config")) is not None:
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
env_map = _build_langgraph_env(config, runtime="python")
env_vars = [f"ENV {key}='{value}'" for key, value in env_map.items()]
js_inst_str: str = ""
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir:
@@ -1137,36 +1158,8 @@ def node_config_to_docker(
image_str = docker_tag(config, base_image, api_version)
env_vars: list[str] = []
if (store_config := config.get("store")) is not None:
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
if (auth_config := config.get("auth")) is not None:
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
if (encryption_config := config.get("encryption")) is not None:
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
# Inject webhooks configuration if provided
if (webhooks_config := config.get("webhooks")) is not None:
env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
if (checkpointer_config := config.get("checkpointer")) is not None:
env_vars.append(
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
)
if ui := config.get("ui"):
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
if ui_config := config.get("ui_config"):
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
env_map = _build_langgraph_env(config, runtime="node")
env_vars = [f"ENV {key}='{value}'" for key, value in env_map.items()]
# For monorepo support, we need to handle install and build commands differently
if build_context:
@@ -1292,6 +1285,50 @@ def config_to_docker(
)
def config_to_build_spec(
config_path: pathlib.Path,
config: Config,
*,
base_image: str | None = None,
api_version: str | None = None,
install_command: str | None = None,
build_command: str | None = None,
build_context: str | None = None,
) -> dict[str, Any]:
"""Generate a machine-readable, versioned build specification."""
# Normalize via JSON round-trip to avoid mutating the caller's dictionary.
normalized: Config = json.loads(json.dumps(config))
is_node_runtime = bool(
normalized.get("node_version") and not normalized.get("python_version")
)
runtime: Literal["python", "node"] = "node" if is_node_runtime else "python"
resolved_base_image = docker_tag(normalized, base_image, api_version)
dockerfile, additional_contexts = config_to_docker(
config_path=config_path,
config=normalized,
base_image=base_image,
api_version=api_version,
install_command=install_command,
build_command=build_command,
build_context=build_context,
)
env_map = _build_langgraph_env(normalized, runtime=runtime)
return {
"schema_version": 1,
"kind": "langgraph.build_spec",
"runtime": runtime,
"resolved_base_image": resolved_base_image,
"build_context": build_context,
"additional_contexts": additional_contexts,
"env": env_map,
"graphs": normalized["graphs"],
"working_dir": _extract_workdir(dockerfile),
"dockerfile": dockerfile,
}
def config_to_compose(
config_path: pathlib.Path,
config: Config,
+4 -30
View File
@@ -128,7 +128,7 @@ class SerdeConfig(TypedDict, total=False):
If omitted, no serde is set up (the object store will still be present, however)."""
allowed_json_modules: list[list[str]] | bool | None
"""Optional. List of allowed python modules to de-serialize custom objects from JSON.
"""Optional. List of allowed python modules to de-serialize custom objects from.
If provided, only the specified modules will be allowed to be deserialized.
If omitted, no modules are allowed, and the object returned will simply be a json object OR
@@ -148,34 +148,7 @@ class SerdeConfig(TypedDict, total=False):
Example:
{...
"serde": {
"allowed_json_modules": True
}
}
"""
allowed_msgpack_modules: list[list[str]] | bool | None
"""Optional. List of allowed python modules to de-serialize custom objects from msgpack.
Known safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always
allowed regardless of this setting. Use this to allowlist your custom Pydantic models,
dataclasses, and other user-defined types.
If True (default), unregistered types will log a warning but still be deserialized.
If None, only known safe types will be deserialized; unregistered types will be blocked.
Example - allowlist specific types (no warnings for these):
{...
"serde": {
"allowed_msgpack_modules": [
["my_agent.models", "MyState"],
]
}
}
Example - strict mode (only safe types allowed):
{...
"serde": {
"allowed_msgpack_modules": null
"allowed_json_modules": true
}
}
@@ -355,7 +328,8 @@ class EncryptionConfig(TypedDict, total=False):
"""Configuration for custom at-rest encryption logic.
Allows you to implement custom encryption for sensitive data stored in the database,
including metadata fields and checkpoint blobs."""
including metadata fields and checkpoint blobs.
"""
path: str
"""Required. Path to an instance of the Encryption() class that implements custom encryption handlers.
+1 -21
View File
@@ -608,27 +608,7 @@
"type": "null"
}
],
"description": "Optional. List of allowed python modules to de-serialize custom objects from JSON.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
},
"allowed_msgpack_modules": {
"anyOf": [
{
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"type": "boolean"
},
{
"type": "null"
}
],
"description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n"
"description": "Optional. List of allowed python modules to de-serialize custom objects from.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
},
"pickle_fallback": {
"type": "boolean",
+1 -21
View File
@@ -608,27 +608,7 @@
"type": "null"
}
],
"description": "Optional. List of allowed python modules to de-serialize custom objects from JSON.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
},
"allowed_msgpack_modules": {
"anyOf": [
{
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"type": "boolean"
},
{
"type": "null"
}
],
"description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n"
"description": "Optional. List of allowed python modules to de-serialize custom objects from.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
},
"pickle_fallback": {
"type": "boolean",
+142
View File
@@ -287,6 +287,148 @@ def test_version_option() -> None:
)
def test_build_spec_export_command_python_to_stdout() -> None:
runner = CliRunner()
config_content = {
"python_version": "3.11",
"image_distro": "wolfi",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
(temp_dir / "agent.py").touch()
result = runner.invoke(
cli,
["build-spec", "export", "--config", str(temp_dir / "config.json")],
)
assert result.exit_code == 0, result.output
spec = json.loads(result.output)
assert spec["schema_version"] == 1
assert spec["kind"] == "langgraph.build_spec"
assert spec["runtime"] == "python"
assert spec["resolved_base_image"] == "langchain/langgraph-api:3.11-wolfi"
assert spec["env"]["LANGSERVE_GRAPHS"] == '{"agent": "agent.py:graph"}'
assert spec["working_dir"] is not None
assert spec["working_dir"].startswith("/deps/")
def test_build_spec_export_command_node_to_file() -> None:
runner = CliRunner()
config_content = {
"node_version": "20",
"image_distro": "wolfi",
"graphs": {"agent": "src/agent.ts:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
spec_path = temp_dir / "buildspec.json"
(temp_dir / "src").mkdir(parents=True, exist_ok=True)
(temp_dir / "src" / "agent.ts").touch()
(temp_dir / "package.json").write_text("{}", encoding="utf-8")
result = runner.invoke(
cli,
[
"build-spec",
"export",
"--config",
str(temp_dir / "config.json"),
"--output",
str(spec_path),
],
)
assert result.exit_code == 0, result.output
assert spec_path.exists()
with open(spec_path, encoding="utf-8") as f:
spec = json.load(f)
assert spec["runtime"] == "node"
assert spec["resolved_base_image"] == "langchain/langgraphjs-api:20-wolfi"
assert spec["working_dir"] is not None
assert spec["working_dir"].startswith("/deps/")
def test_build_spec_verify_command_in_sync() -> None:
runner = CliRunner()
config_content = {
"python_version": "3.11",
"image_distro": "wolfi",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
spec_path = temp_dir / "buildspec.json"
(temp_dir / "agent.py").touch()
export_result = runner.invoke(
cli,
[
"build-spec",
"export",
"--config",
str(temp_dir / "config.json"),
"--output",
str(spec_path),
],
)
assert export_result.exit_code == 0, export_result.output
verify_result = runner.invoke(
cli,
[
"build-spec",
"verify",
str(spec_path),
"--config",
str(temp_dir / "config.json"),
],
)
assert verify_result.exit_code == 0, verify_result.output
assert "Build spec is in sync" in verify_result.output
def test_build_spec_verify_command_out_of_sync() -> None:
runner = CliRunner()
config_content = {
"python_version": "3.11",
"image_distro": "wolfi",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
spec_path = temp_dir / "buildspec.json"
(temp_dir / "agent.py").touch()
export_result = runner.invoke(
cli,
[
"build-spec",
"export",
"--config",
str(temp_dir / "config.json"),
"--output",
str(spec_path),
],
)
assert export_result.exit_code == 0, export_result.output
with open(spec_path, encoding="utf-8") as f:
spec = json.load(f)
spec["runtime"] = "node"
with open(spec_path, "w", encoding="utf-8") as f:
json.dump(spec, f, indent=2, sort_keys=True)
verify_result = runner.invoke(
cli,
[
"build-spec",
"verify",
str(spec_path),
"--config",
str(temp_dir / "config.json"),
],
)
assert verify_result.exit_code != 0
assert "Build spec is out of sync" in verify_result.output
def test_dockerfile_command_basic() -> None:
"""Test the 'dockerfile' command with basic configuration."""
runner = CliRunner()
+4 -4
View File
@@ -87,15 +87,15 @@ integration_tests:
WORKERS ?= auto
XDIST_ARGS := $(if $(WORKERS),-n $(WORKERS) --dist worksteal,)
MAXFAIL ?= 1
MAXFAIL_ARGS = $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
MAXFAIL ?=
MAXFAIL_ARGS := $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
# Add an '-x' if xdist is enabled
XDIST_ARGS := $(if $(WORKERS),-x $(XDIST_ARGS),)
test_watch:
make start-services &&\
make start-dev-server &&\
uv run ptw -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) $(TEST); \
uv run ptw . -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) $(TEST); \
EXIT_CODE=$$?; \
make stop-services; \
make stop-dev-server; \
@@ -130,7 +130,7 @@ type:
format format_diff:
uv run ruff format $(PYTHON_FILES)
uv run ruff check --fix $(PYTHON_FILES)
uv run ruff check --select I --fix $(PYTHON_FILES)
spell_check:
uv run codespell --toml pyproject.toml
-5
View File
@@ -10,7 +10,6 @@ from bench.fanout_to_subgraph import fanout_to_subgraph, fanout_to_subgraph_sync
from bench.pydantic_state import pydantic_state
from bench.react_agent import react_agent
from bench.sequential import create_sequential
from bench.serde_allowlist import collect_allowlist_large, collect_allowlist_small
from bench.wide_dict import wide_dict
from bench.wide_state import wide_state
from langgraph.graph import StateGraph
@@ -514,7 +513,3 @@ compilation_benchmarks = (
for name, graph in compilation_benchmarks:
r.bench_func(name + "_compilation", compile_graph, graph)
# Serde allowlist collection
r.bench_func("serde_allowlist_small", collect_allowlist_small)
r.bench_func("serde_allowlist_large", collect_allowlist_large)
-81
View File
@@ -1,81 +0,0 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from enum import Enum
from typing import Annotated
from pydantic import BaseModel
from typing_extensions import NotRequired, TypedDict
from langgraph._internal._serde import collect_allowlist_from_schemas
class Color(Enum):
RED = "red"
BLUE = "blue"
@dataclass
class InnerDataclass:
value: int
class InnerModel(BaseModel):
name: str
class InnerTyped(TypedDict):
payload: InnerDataclass
optional: NotRequired[InnerModel]
@dataclass
class Node:
value: int
child: Node | None = None
@dataclass
class NestedDataclass:
inner: InnerDataclass
items: list[InnerModel]
mapping: dict[str, InnerDataclass]
optional: InnerModel | None
union: InnerDataclass | InnerModel
queue: deque[InnerDataclass]
frozen: frozenset[InnerModel]
AnnotatedList = Annotated[list[InnerDataclass], "meta"]
class DummyChannel:
@property
def ValueType(self) -> type[InnerDataclass]:
return InnerDataclass
@property
def UpdateType(self) -> type[InnerModel]:
return InnerModel
SCHEMAS_SMALL = [InnerDataclass, InnerModel, Color]
SCHEMAS_LARGE = [
InnerDataclass,
InnerModel,
Color,
InnerTyped,
Node,
NestedDataclass,
AnnotatedList,
]
CHANNELS = {"a": DummyChannel(), "b": DummyChannel()}
def collect_allowlist_small() -> None:
collect_allowlist_from_schemas(schemas=SCHEMAS_SMALL, channels=CHANNELS)
def collect_allowlist_large() -> None:
collect_allowlist_from_schemas(schemas=SCHEMAS_LARGE, channels=CHANNELS)
@@ -1,253 +0,0 @@
from __future__ import annotations
import dataclasses
import logging
import sys
import types
from collections import deque
from enum import Enum
from typing import (
Annotated,
Any,
Literal,
Union,
get_args,
get_origin,
get_type_hints,
)
from langchain_core import messages as lc_messages
from langgraph.checkpoint.base import BaseCheckpointSaver
from pydantic import BaseModel
from typing_extensions import NotRequired, Required, is_typeddict
try:
from langgraph.checkpoint.serde._msgpack import ( # noqa: F401
STRICT_MSGPACK_ENABLED,
)
except ImportError:
STRICT_MSGPACK_ENABLED = False
_warned_allowlist_unsupported = False
logger = logging.getLogger(__name__)
def _supports_checkpointer_allowlist() -> bool:
return hasattr(BaseCheckpointSaver, "with_allowlist")
_SUPPORTS_ALLOWLIST = _supports_checkpointer_allowlist()
def apply_checkpointer_allowlist(
checkpointer: Any, allowlist: set[tuple[str, ...]] | None
) -> Any:
if not checkpointer or allowlist is None or checkpointer in (True, False):
return checkpointer
if not _SUPPORTS_ALLOWLIST:
global _warned_allowlist_unsupported
if not _warned_allowlist_unsupported:
logger.warning(
"Checkpointer does not support with_allowlist; strict msgpack "
"allowlist will be skipped."
)
_warned_allowlist_unsupported = True
return checkpointer
return checkpointer.with_allowlist(allowlist)
def curated_core_allowlist() -> set[tuple[str, ...]]:
allowlist: set[tuple[str, ...]] = set()
for name in (
"BaseMessage",
"BaseMessageChunk",
"HumanMessage",
"HumanMessageChunk",
"AIMessage",
"AIMessageChunk",
"SystemMessage",
"SystemMessageChunk",
"ChatMessage",
"ChatMessageChunk",
"ToolMessage",
"ToolMessageChunk",
"FunctionMessage",
"FunctionMessageChunk",
"RemoveMessage",
):
cls = getattr(lc_messages, name, None)
if cls is None:
continue
allowlist.add((cls.__module__, cls.__name__))
return allowlist
def build_serde_allowlist(
*,
schemas: list[type[Any]] | None = None,
channels: dict[str, Any] | None = None,
) -> set[tuple[str, ...]]:
allowlist = curated_core_allowlist()
if schemas:
schemas = [schema for schema in schemas if schema is not None]
return allowlist | collect_allowlist_from_schemas(
schemas=schemas,
channels=channels,
)
def collect_allowlist_from_schemas(
*,
schemas: list[type[Any]] | None = None,
channels: dict[str, Any] | None = None,
) -> set[tuple[str, ...]]:
allowlist: set[tuple[str, ...]] = set()
seen: set[Any] = set()
seen_ids: set[int] = set()
if schemas:
for schema in schemas:
_collect_from_type(schema, allowlist, seen, seen_ids)
if channels:
for channel in channels.values():
value_type = getattr(channel, "ValueType", None)
if value_type is not None:
_collect_from_type(value_type, allowlist, seen, seen_ids)
update_type = getattr(channel, "UpdateType", None)
if update_type is not None:
_collect_from_type(update_type, allowlist, seen, seen_ids)
return allowlist
def _collect_from_type(
typ: Any,
allowlist: set[tuple[str, ...]],
seen: set[Any],
seen_ids: set[int],
) -> None:
if _already_seen(typ, seen, seen_ids):
return
if typ is Any or typ is None:
return
if typ is Literal:
return
if isinstance(typ, types.UnionType):
for arg in typ.__args__:
_collect_from_type(arg, allowlist, seen, seen_ids)
return
origin = get_origin(typ)
if origin is Union:
for arg in get_args(typ):
_collect_from_type(arg, allowlist, seen, seen_ids)
return
if origin is Annotated or origin in (Required, NotRequired):
args = get_args(typ)
if args:
_collect_from_type(args[0], allowlist, seen, seen_ids)
return
if origin is Literal:
return
if origin in (list, set, tuple, dict, deque, frozenset):
for arg in get_args(typ):
_collect_from_type(arg, allowlist, seen, seen_ids)
return
if hasattr(typ, "__supertype__"):
_collect_from_type(typ.__supertype__, allowlist, seen, seen_ids)
return
if is_typeddict(typ):
for field_type in _safe_get_type_hints(typ).values():
_collect_from_type(field_type, allowlist, seen, seen_ids)
return
if _is_pydantic_model(typ):
allowlist.add((typ.__module__, typ.__name__))
field_types = _safe_get_type_hints(typ)
if field_types:
for field_type in field_types.values():
_collect_from_type(field_type, allowlist, seen, seen_ids)
else:
for field_type in _pydantic_field_types(typ):
_collect_from_type(field_type, allowlist, seen, seen_ids)
return
if dataclasses.is_dataclass(typ):
if typ_name := getattr(typ, "__name__", None):
allowlist.add((typ.__module__, typ_name))
field_types = _safe_get_type_hints(typ)
if field_types:
for field_type in field_types.values():
_collect_from_type(field_type, allowlist, seen, seen_ids)
else:
for field in dataclasses.fields(typ):
_collect_from_type(field.type, allowlist, seen, seen_ids)
return
if isinstance(typ, type) and issubclass(typ, Enum):
allowlist.add((typ.__module__, typ.__name__))
return
def _already_seen(typ: Any, seen: set[Any], seen_ids: set[int]) -> bool:
try:
if typ in seen:
return True
seen.add(typ)
return False
except TypeError:
typ_id = id(typ)
if typ_id in seen_ids:
return True
seen_ids.add(typ_id)
return False
def _safe_get_type_hints(typ: Any) -> dict[str, Any]:
try:
module = sys.modules.get(getattr(typ, "__module__", ""))
globalns = module.__dict__ if module else None
localns = dict(vars(typ)) if hasattr(typ, "__dict__") else None
return get_type_hints(
typ, globalns=globalns, localns=localns, include_extras=True
)
except Exception:
return {}
def _is_pydantic_model(typ: Any) -> bool:
if not isinstance(typ, type):
return False
if issubclass(typ, BaseModel):
return True
try:
from pydantic.v1 import BaseModel as BaseModelV1
except Exception:
return False
return issubclass(typ, BaseModelV1)
def _pydantic_field_types(typ: type[Any]) -> list[Any]:
if hasattr(typ, "model_fields"):
return [
field.annotation
for field in typ.model_fields.values()
if getattr(field, "annotation", None) is not None
]
if hasattr(typ, "__fields__"):
return [
field.outer_type_
for field in typ.__fields__.values()
if getattr(field, "outer_type_", None) is not None
]
return []
+2 -14
View File
@@ -20,7 +20,6 @@ from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.store.base import BaseStore
from typing_extensions import Unpack
from langgraph._internal import _serde
from langgraph._internal._constants import CACHE_NS_WRITES, PREVIOUS
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.channels.ephemeral_value import EphemeralValue
@@ -529,7 +528,7 @@ class entrypoint(Generic[ContextT]):
else:
output_type = save_type = sig.return_annotation
graph: Pregel[Any, ContextT, Any, Any] = Pregel(
return Pregel(
nodes={
func.__name__: PregelNode(
bound=bound,
@@ -560,16 +559,5 @@ class entrypoint(Generic[ContextT]):
cache=self.cache,
cache_policy=self.cache_policy,
retry_policy=self.retry_policy or (),
context_schema=self.context_schema,
context_schema=self.context_schema, # type: ignore[arg-type]
)
if _serde.STRICT_MSGPACK_ENABLED:
serde_allowlist = _serde.build_serde_allowlist(
schemas=[input_type, output_type, save_type]
+ ([self.context_schema] if self.context_schema is not None else []),
channels=graph.channels,
)
graph._serde_allowlist = serde_allowlist
graph.checkpointer = _serde.apply_checkpointer_allowlist(
graph.checkpointer, serde_allowlist
)
return graph
-24
View File
@@ -29,7 +29,6 @@ from langgraph.store.base import BaseStore
from pydantic import BaseModel, TypeAdapter
from typing_extensions import NotRequired, Required, Self, Unpack, is_typeddict
from langgraph._internal import _serde
from langgraph._internal._constants import (
INTERRUPT,
NS_END,
@@ -1080,28 +1079,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
CompiledStateGraph: The compiled `StateGraph`.
"""
checkpointer = ensure_valid_checkpointer(checkpointer)
serde_allowlist: set[tuple[str, ...]] | None = None
if _serde.STRICT_MSGPACK_ENABLED:
schema_types: list[type[Any]] = [
self.state_schema,
self.input_schema,
self.output_schema,
]
if self.context_schema is not None:
schema_types.append(self.context_schema)
for node in self.nodes.values():
schema_types.append(node.input_schema)
for branches in self.branches.values():
for branch in branches.values():
if branch.input_schema is not None:
schema_types.append(branch.input_schema)
serde_allowlist = _serde.build_serde_allowlist(
schemas=schema_types,
channels=self.channels,
)
checkpointer = _serde.apply_checkpointer_allowlist(
checkpointer, serde_allowlist
)
# assign default values
interrupt_before = interrupt_before or []
@@ -1158,7 +1135,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
cache=cache,
name=name or "LangGraph",
)
compiled._serde_allowlist = serde_allowlist
compiled.attach_node(START, None)
for key, node in self.nodes.items():
+12 -33
View File
@@ -23,35 +23,6 @@ logger = logging.getLogger(__name__)
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
def _checkpoint_ns_for_parent_command(ns: str) -> str:
"""Return the checkpoint namespace for the parent graph.
The checkpoint namespace is a `|`-separated path. Each segment is usually
of the form `name:task_id` (e.g. `parent_first:<uuid>|node:<uuid>`), but the
runtime may also insert a purely-numeric segment (e.g. `|1`) to disambiguate
concurrent tasks (e.g. `parent_first:<uuid>|1|node:<uuid>`).
Numeric segments are not real path levels, so we drop them before computing
the parent namespace.
"""
parts = ns.split(NS_SEP)
# Drop any trailing numeric selectors for the current frame (e.g. `...|node:<id>|1`).
while parts and parts[-1].isdigit():
parts.pop()
# Drop the current frame segment itself (e.g. the `node:<id>`).
if parts:
parts.pop()
# Drop any trailing numeric selectors for the parent frame (e.g. `...|1|node:<id>`).
while parts and parts[-1].isdigit():
parts.pop()
return NS_SEP.join(parts)
def run_with_retry(
task: PregelExecutableTask,
retry_policy: Sequence[RetryPolicy] | None,
@@ -79,8 +50,12 @@ def run_with_retry(
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
# this command is for the parent graph, assign it to the parent.
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
# this command is for the parent graph, assign it to the parent
parts = ns.split(NS_SEP)
if parts[-1].isdigit():
parts.pop()
parent_ns = NS_SEP.join(parts[:-1])
exc.args = (replace(cmd, graph=parent_ns),)
# bubble up
raise
except GraphBubbleUp:
@@ -171,8 +146,12 @@ async def arun_with_retry(
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
# this command is for the parent graph, assign it to the parent.
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
# this command is for the parent graph, assign it to the parent
parts = ns.split(NS_SEP)
if parts[-1].isdigit():
parts.pop()
parent_ns = NS_SEP.join(parts[:-1])
exc.args = (replace(cmd, graph=parent_ns),)
# bubble up
raise
except GraphBubbleUp:
-23
View File
@@ -48,7 +48,6 @@ from langgraph.store.base import BaseStore
from pydantic import BaseModel, TypeAdapter
from typing_extensions import Self, Unpack, deprecated, is_typeddict
from langgraph._internal import _serde
from langgraph._internal._config import (
ensure_config,
merge_configs,
@@ -699,17 +698,9 @@ class Pregel(
self.config = config
self.trigger_to_nodes = trigger_to_nodes or {}
self.name = name
self._serde_allowlist: set[tuple[str, ...]] | None = None
if auto_validate:
self.validate()
def _apply_checkpointer_allowlist(
self, checkpointer: BaseCheckpointSaver | None
) -> BaseCheckpointSaver | None:
if not _serde.STRICT_MSGPACK_ENABLED:
return checkpointer
return _serde.apply_checkpointer_allowlist(checkpointer, self._serde_allowlist)
def get_graph(
self, config: RunnableConfig | None = None, *, xray: int | bool = False
) -> Graph:
@@ -1248,8 +1239,6 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if isinstance(checkpointer, BaseCheckpointSaver):
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1292,8 +1281,6 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if isinstance(checkpointer, BaseCheckpointSaver):
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1342,8 +1329,6 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = config[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if isinstance(checkpointer, BaseCheckpointSaver):
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1395,8 +1380,6 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if isinstance(checkpointer, BaseCheckpointSaver):
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1463,8 +1446,6 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if isinstance(checkpointer, BaseCheckpointSaver):
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -1909,8 +1890,6 @@ class Pregel(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if isinstance(checkpointer, BaseCheckpointSaver):
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if not checkpointer:
raise ValueError("No checkpointer set")
@@ -2399,8 +2378,6 @@ class Pregel(
raise RuntimeError("checkpointer=True cannot be used for root graphs.")
else:
checkpointer = self.checkpointer
if isinstance(checkpointer, BaseCheckpointSaver):
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
if checkpointer and not config.get(CONF):
raise ValueError(
"Checkpointer requires one or more of the following 'configurable' "
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.0.10rc1"
version = "1.0.9"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
+5 -44
View File
@@ -1,4 +1,3 @@
import os
from contextlib import asynccontextmanager, contextmanager
from uuid import uuid4
@@ -6,7 +5,6 @@ import pytest
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from psycopg import AsyncConnection, Connection
@@ -20,60 +18,30 @@ from tests.memory_assert import ( # noqa: E402
)
DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/"
STRICT_MSGPACK = os.getenv("LANGGRAPH_STRICT_MSGPACK", "false").lower() in (
"1",
"true",
"yes",
)
def _strict_msgpack_serde() -> JsonPlusSerializer:
return JsonPlusSerializer(allowed_msgpack_modules=None)
def _apply_strict_msgpack(checkpointer) -> None:
if not STRICT_MSGPACK:
return
serde = _strict_msgpack_serde()
if hasattr(checkpointer, "serde"):
checkpointer.serde = serde
if hasattr(checkpointer, "saver") and hasattr(checkpointer.saver, "serde"):
checkpointer.saver.serde = serde
@contextmanager
def _checkpointer_memory():
if STRICT_MSGPACK:
yield MemorySaverAssertImmutable(serde=_strict_msgpack_serde())
else:
yield MemorySaverAssertImmutable()
yield MemorySaverAssertImmutable()
@contextmanager
def _checkpointer_memory_migrate_sends():
checkpointer = MemorySaverNeedsPendingSendsMigration()
_apply_strict_msgpack(checkpointer)
yield checkpointer
yield MemorySaverNeedsPendingSendsMigration()
@contextmanager
def _checkpointer_sqlite():
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
_apply_strict_msgpack(checkpointer)
yield checkpointer
@contextmanager
def _checkpointer_sqlite_aes():
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
if STRICT_MSGPACK:
checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
serde=_strict_msgpack_serde(), key=b"1234567890123456"
)
else:
checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
key=b"1234567890123456"
)
checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
key=b"1234567890123456"
)
yield checkpointer
@@ -89,7 +57,6 @@ def _checkpointer_postgres():
DEFAULT_POSTGRES_URI + database
) as checkpointer:
checkpointer.setup()
_apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -112,7 +79,6 @@ def _checkpointer_postgres_pipe():
# setup can't run inside pipeline because of implicit transaction
with checkpointer.conn.pipeline() as pipe:
checkpointer.pipe = pipe
_apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -133,7 +99,6 @@ def _checkpointer_postgres_pool():
) as pool:
checkpointer = PostgresSaver(pool)
checkpointer.setup()
_apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -144,7 +109,6 @@ def _checkpointer_postgres_pool():
@asynccontextmanager
async def _checkpointer_sqlite_aio():
async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
_apply_strict_msgpack(checkpointer)
yield checkpointer
@@ -162,7 +126,6 @@ async def _checkpointer_postgres_aio():
DEFAULT_POSTGRES_URI + database
) as checkpointer:
await checkpointer.setup()
_apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -189,7 +152,6 @@ async def _checkpointer_postgres_aio_pipe():
# setup can't run inside pipeline because of implicit transaction
async with checkpointer.conn.pipeline() as pipe:
checkpointer.pipe = pipe
_apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -214,7 +176,6 @@ async def _checkpointer_postgres_aio_pool():
) as pool:
checkpointer = AsyncPostgresSaver(pool)
await checkpointer.setup()
_apply_strict_msgpack(checkpointer)
yield checkpointer
finally:
# drop unique db
@@ -1,53 +0,0 @@
from __future__ import annotations
from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
def test_parent_command_from_nested_subgraph() -> None:
class ParentState(TypedDict):
jump_from_idx: int
class ChildState(TypedDict):
jump: bool
child_builder: StateGraph[ChildState] = StateGraph(ChildState)
def child_node(state: ChildState) -> Command | ChildState:
if state["jump"]:
return Command(graph=Command.PARENT, goto="parent_second")
return state
child_builder.add_node("node", child_node)
child_builder.add_edge(START, "node")
child_0 = child_builder.compile()
child_1 = child_builder.compile()
parent_builder: StateGraph[ParentState] = StateGraph(ParentState)
def parent_first(state: ParentState) -> ParentState:
child_0.invoke({"jump": state["jump_from_idx"] == 1})
if state["jump_from_idx"] == 1:
raise AssertionError("Shouldn't be here")
child_1.invoke({"jump": state["jump_from_idx"] == 2})
if state["jump_from_idx"] == 2:
raise AssertionError("Shouldn't be here")
return state
def parent_second(state: ParentState) -> ParentState:
return state
parent_builder.add_node("parent_first", parent_first)
parent_builder.add_node("parent_second", parent_second)
parent_builder.add_edge(START, "parent_first")
parent_builder.add_edge("parent_second", END)
graph = parent_builder.compile()
assert graph.invoke({"jump_from_idx": 1}) == {"jump_from_idx": 1}
assert graph.invoke({"jump_from_idx": 2}) == {"jump_from_idx": 2}
@@ -1,57 +0,0 @@
from __future__ import annotations
import pytest
from langchain_core.runnables import RunnableConfig
from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
pytestmark = pytest.mark.anyio
async def test_parent_command_from_nested_subgraph() -> None:
class ParentState(TypedDict):
jump_from_idx: int
class ChildState(TypedDict):
jump: bool
child_builder: StateGraph[ChildState] = StateGraph(ChildState)
async def child_node(state: ChildState) -> Command | ChildState:
if state["jump"]:
return Command(graph=Command.PARENT, goto="parent_second")
return state
child_builder.add_node("node", child_node)
child_builder.add_edge(START, "node")
child_0 = child_builder.compile()
child_1 = child_builder.compile()
parent_builder: StateGraph[ParentState] = StateGraph(ParentState)
async def parent_first(state: ParentState, config: RunnableConfig) -> ParentState:
await child_0.ainvoke({"jump": state["jump_from_idx"] == 1}, config)
if state["jump_from_idx"] == 1:
raise AssertionError("Shouldn't be here")
await child_1.ainvoke({"jump": state["jump_from_idx"] == 2}, config)
if state["jump_from_idx"] == 2:
raise AssertionError("Shouldn't be here")
return state
async def parent_second(state: ParentState) -> ParentState:
return state
parent_builder.add_node("parent_first", parent_first)
parent_builder.add_node("parent_second", parent_second)
parent_builder.add_edge(START, "parent_first")
parent_builder.add_edge("parent_second", END)
graph = parent_builder.compile().with_config(recursion_limit=10)
assert await graph.ainvoke({"jump_from_idx": 1}) == {"jump_from_idx": 1}
assert await graph.ainvoke({"jump_from_idx": 2}) == {"jump_from_idx": 2}
-48
View File
@@ -8,7 +8,6 @@ import uuid
from enum import Enum
from typing import Annotated, Literal, Optional
from langgraph.checkpoint.base import BaseCheckpointSaver
from pydantic import (
BaseModel,
ByteSize,
@@ -24,10 +23,7 @@ from pydantic import (
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:
@@ -316,47 +312,3 @@ def test_pydantic_state_field_validator():
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
+1 -17
View File
@@ -4,7 +4,7 @@ import pytest
from typing_extensions import TypedDict
from langgraph.graph import START, StateGraph
from langgraph.pregel._retry import _checkpoint_ns_for_parent_command, _should_retry_on
from langgraph.pregel._retry import _should_retry_on
from langgraph.types import RetryPolicy
@@ -78,22 +78,6 @@ def test_should_retry_on_empty_sequence():
assert _should_retry_on(policy, ValueError("test error")) is False
def test_checkpoint_ns_for_parent_command() -> None:
assert _checkpoint_ns_for_parent_command("") == ""
assert _checkpoint_ns_for_parent_command("node:1") == ""
assert _checkpoint_ns_for_parent_command("node:1|child:2") == "node:1"
assert _checkpoint_ns_for_parent_command("node:1|1|child:2") == "node:1"
assert _checkpoint_ns_for_parent_command("node:1|1|child:2|1") == "node:1"
assert (
_checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1|1")
== "parent:1|1|child:1"
)
assert (
_checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1")
== "parent:1|1|child:1"
)
def test_should_retry_default_retry_on():
"""Test the default retry_on function."""
import httpx
@@ -1,159 +0,0 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType, Optional, Union
import pytest
from pydantic import BaseModel
from typing_extensions import NotRequired, Required, TypedDict
from langgraph._internal._serde import (
collect_allowlist_from_schemas,
curated_core_allowlist,
)
class Color(Enum):
RED = "red"
BLUE = "blue"
@dataclass
class InnerDataclass:
value: int
class InnerModel(BaseModel):
name: str
@dataclass
class Node:
value: int
child: Node | None = None
if TYPE_CHECKING:
class MissingType:
pass
@dataclass
class MissingRefDataclass:
payload: MissingType
class Payload(TypedDict):
item: InnerDataclass
maybe: NotRequired[InnerModel]
required: Required[str]
@dataclass
class NestedDataclass:
inner: InnerDataclass
items: list[InnerModel]
mapping: dict[str, InnerDataclass]
optional: InnerModel | None
union: InnerDataclass | InnerModel
queue: deque[InnerDataclass]
frozen: frozenset[InnerModel]
AnnotatedList = Annotated[list[InnerDataclass], "meta"]
UserId = NewType("UserId", int)
class DummyChannel:
@property
def ValueType(self) -> type[InnerDataclass]:
return InnerDataclass
@property
def UpdateType(self) -> type[InnerModel]:
return InnerModel
def test_curated_core_allowlist_includes_messages() -> None:
try:
from langchain_core.messages import BaseMessage
except Exception:
pytest.skip("langchain_core not available")
allowlist = curated_core_allowlist()
assert (BaseMessage.__module__, BaseMessage.__name__) in allowlist
def test_collect_allowlist_basic_models() -> None:
allowlist = collect_allowlist_from_schemas(
schemas=[InnerDataclass, InnerModel, Color]
)
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
assert (Color.__module__, Color.__name__) in allowlist
def test_collect_allowlist_nested_containers() -> None:
allowlist = collect_allowlist_from_schemas(schemas=[NestedDataclass])
assert (NestedDataclass.__module__, NestedDataclass.__name__) in allowlist
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
def test_collect_allowlist_annotated_and_union() -> None:
allowlist = collect_allowlist_from_schemas(
schemas=[AnnotatedList, InnerModel | None, InnerDataclass | None]
)
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
def test_collect_allowlist_literal_and_any() -> None:
allowlist = collect_allowlist_from_schemas(schemas=[Any, Literal["a"]])
assert allowlist == set()
def test_collect_allowlist_typeddict_fields_only() -> None:
allowlist = collect_allowlist_from_schemas(schemas=[Payload])
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
assert (Payload.__module__, Payload.__name__) not in allowlist
def test_collect_allowlist_forward_refs() -> None:
allowlist = collect_allowlist_from_schemas(schemas=[Node])
assert (Node.__module__, Node.__name__) in allowlist
def test_collect_allowlist_missing_forward_ref() -> None:
allowlist = collect_allowlist_from_schemas(schemas=[MissingRefDataclass])
assert allowlist == {(MissingRefDataclass.__module__, MissingRefDataclass.__name__)}
def test_collect_allowlist_newtype_supertype() -> None:
allowlist = collect_allowlist_from_schemas(schemas=[UserId])
assert allowlist == set()
def test_collect_allowlist_channels() -> None:
channels = {"a": DummyChannel(), "b": DummyChannel()}
allowlist = collect_allowlist_from_schemas(channels=channels)
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
def test_collect_allowlist_pep604_union() -> None:
schema = InnerDataclass | InnerModel
allowlist = collect_allowlist_from_schemas(schemas=[schema])
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
def test_collect_allowlist_typing_union_optional() -> None:
typing_optional = Optional[InnerDataclass] # noqa: UP045
typing_union = Union[InnerDataclass, InnerModel] # noqa: UP007
allowlist = collect_allowlist_from_schemas(schemas=[typing_optional, typing_union])
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
@@ -1,641 +0,0 @@
"""Tests for subgraph persistence behavior (sync).
Covers three checkpointer settings for subgraph state:
- checkpointer=False: no persistence, even when parent has a checkpointer
- checkpointer=None (default): "stateless" inherits parent checkpointer for
interrupt support, but state resets each invocation. This is the common case
when an agent is invoked from inside a tool used by another agent.
- checkpointer=True: "stateful" state accumulates across invocations on the same thread id
"""
from uuid import uuid4
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.base import BaseCheckpointSaver
from typing_extensions import TypedDict
from langgraph.graph import START, StateGraph
from langgraph.graph.message import MessagesState
from langgraph.types import Command, Interrupt, interrupt
from tests.any_str import AnyStr
class ParentState(TypedDict):
result: str
# -- checkpointer=None (stateless) --
def test_stateless_interrupt_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=None (the default) can
still support interrupt/resume when invoked from inside a parent graph that
has a checkpointer. This is the "stateless" pattern the subgraph inherits
the parent's checkpointer just enough to pause and resume, but does not
retain any state across separate parent invocations. This pattern commonly
appears when an agent is invoked from inside a tool used by another agent.
"""
# Build a subgraph that interrupts before echoing.
# Two nodes: "process" interrupts then echoes, "respond" returns "Done".
def process(state: MessagesState) -> dict:
interrupt("continue?")
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage(content="Done")]}
inner = (
StateGraph(MessagesState)
.add_node("process", process)
.add_node("respond", respond)
.add_edge(START, "process")
.add_edge("process", "respond")
.compile()
)
def call_inner(state: ParentState) -> dict:
resp = inner.invoke({"messages": [HumanMessage(content="apples")]})
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
# First invoke hits the interrupt
result = parent.invoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
# Resume completes the subgraph
result = parent.invoke(Command(resume=True), config)
assert result == {"result": "Done"}
def test_stateless_state_resets(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=None (the default) does
not retain any message history between separate parent invocations. Each time
the parent graph invokes the subgraph, it starts with a clean slate. This
confirms the "stateless" behavior: even though the parent has a checkpointer,
the subgraph state is not persisted across calls.
"""
# Build a simple echo subgraph: echoes "Processing: <input>"
def echo(state: MessagesState) -> dict:
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
inner = (
StateGraph(MessagesState)
.add_node("echo", echo)
.add_edge(START, "echo")
.compile()
)
subgraph_messages: list[list[str]] = []
call_count = 0
def call_inner(state: ParentState) -> dict:
nonlocal call_count
call_count += 1
topic = "apples" if call_count == 1 else "bananas"
resp = inner.invoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
result1 = parent.invoke({"result": ""}, config)
assert result1 == {"result": "Processing: tell me about apples"}
result2 = parent.invoke({"result": ""}, config)
assert result2 == {"result": "Processing: tell me about bananas"}
# Both invocations produce fresh history — no memory of prior call
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
]
assert subgraph_messages[1] == [
"tell me about bananas",
"Processing: tell me about bananas",
]
def test_stateless_state_resets_with_interrupt(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=None resets its state
between parent invocations even when interrupt/resume is used. The subgraph
is invoked twice from the parent, each time with an interrupt that must be
resumed. After both invoke+resume cycles, each subgraph run should only
contain its own messages no bleed-over from the previous run.
"""
# Build a subgraph that interrupts before echoing, then responds "Done"
def process(state: MessagesState) -> dict:
interrupt("continue?")
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage(content="Done")]}
inner = (
StateGraph(MessagesState)
.add_node("process", process)
.add_node("respond", respond)
.add_edge(START, "process")
.add_edge("process", "respond")
.compile()
)
subgraph_messages: list[list[str]] = []
call_count = 0
def call_inner(state: ParentState) -> dict:
nonlocal call_count
call_count += 1
topic = "apples" if call_count == 1 else "bananas"
resp = inner.invoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
# First invoke+resume cycle
result = parent.invoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
result = parent.invoke(Command(resume=True), config)
assert result == {"result": "Done"}
# Second invoke+resume cycle
result = parent.invoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
result = parent.invoke(Command(resume=True), config)
assert result == {"result": "Done"}
# Both invocations produce fresh history — no memory of prior call
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
]
assert subgraph_messages[1] == [
"tell me about bananas",
"Processing: tell me about bananas",
"Done",
]
# -- checkpointer=False --
def test_checkpointer_false_no_persistence(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=False gets no
persistence at all, even when the parent graph has a checkpointer. Unlike
the default (checkpointer=None) which inherits just enough from the parent
to support interrupt/resume, checkpointer=False explicitly opts out of all
checkpoint behavior. Each invocation starts completely fresh.
"""
# Build a simple echo subgraph with checkpointer=False
def echo(state: MessagesState) -> dict:
return {
"messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")]
}
inner = (
StateGraph(MessagesState)
.add_node("echo", echo)
.add_edge(START, "echo")
.compile(checkpointer=False)
)
subgraph_messages: list[list[str]] = []
call_count = 0
def call_inner(state: ParentState) -> dict:
nonlocal call_count
call_count += 1
topic = "apples" if call_count == 1 else "bananas"
resp = inner.invoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
result1 = parent.invoke({"result": ""}, config)
assert result1 == {"result": "Processed: tell me about apples"}
result2 = parent.invoke({"result": ""}, config)
assert result2 == {"result": "Processed: tell me about bananas"}
# Both start fresh — no history from first call
assert subgraph_messages[0] == [
"tell me about apples",
"Processed: tell me about apples",
]
assert subgraph_messages[1] == [
"tell me about bananas",
"Processed: tell me about bananas",
]
# -- checkpointer=True (stateful) --
def test_stateful_state_accumulates(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=True ("stateful")
retains its message history across separate parent invocations. To enable
this, the subgraph is wrapped in an outer graph compiled with
checkpointer=True this wrapper gives the inner subgraph its own persistent
checkpoint namespace. After two parent calls, the second subgraph invocation
should see messages from both the first and second calls.
"""
# Build a simple echo subgraph
def echo(state: MessagesState) -> dict:
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
inner = (
StateGraph(MessagesState)
.add_node("echo", echo)
.add_edge(START, "echo")
.compile()
)
# Wrap the inner subgraph with checkpointer=True to enable stateful.
# The wrapper graph gives the subgraph its own persistent checkpoint
# namespace, keyed by the node name ("agent").
wrapper = (
StateGraph(MessagesState)
.add_node("agent", inner)
.add_edge(START, "agent")
.compile(checkpointer=True)
)
subgraph_messages: list[list[str]] = []
topics = ["apples", "bananas"]
def call_inner(state: ParentState) -> dict:
topic = topics[len(subgraph_messages)]
resp = wrapper.invoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
result1 = parent.invoke({"result": ""}, config)
assert result1 == {"result": "Processing: tell me about apples"}
result2 = parent.invoke({"result": ""}, config)
assert result2 == {"result": "Processing: tell me about bananas"}
# First call: fresh history
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
]
# Second call: retains messages from first call
assert subgraph_messages[1] == [
"tell me about apples",
"Processing: tell me about apples",
"tell me about bananas",
"Processing: tell me about bananas",
]
def test_stateful_state_accumulates_with_interrupt(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a stateful subgraph (checkpointer=True) retains its
message history across parent invocations even when interrupt/resume is
involved. The subgraph interrupts before echoing, then responds "Done".
After two invoke+resume cycles, the second run should contain the full
accumulated history from both calls.
"""
# Build a subgraph that interrupts before echoing, then responds "Done"
def process(state: MessagesState) -> dict:
interrupt("continue?")
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage(content="Done")]}
inner = (
StateGraph(MessagesState)
.add_node("process", process)
.add_node("respond", respond)
.add_edge(START, "process")
.add_edge("process", "respond")
.compile()
)
# Wrap with checkpointer=True for stateful
wrapper = (
StateGraph(MessagesState)
.add_node("agent", inner)
.add_edge(START, "agent")
.compile(checkpointer=True)
)
subgraph_messages: list[list[str]] = []
topics = ["apples", "bananas"]
def call_inner(state: ParentState) -> dict:
topic = topics[len(subgraph_messages)]
resp = wrapper.invoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
# First invoke+resume cycle
result = parent.invoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
result = parent.invoke(Command(resume=True), config)
assert result == {"result": "Done"}
# Second invoke+resume cycle
result = parent.invoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
result = parent.invoke(Command(resume=True), config)
assert result == {"result": "Done"}
# First call: fresh history
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
]
# Second call: retains messages from first call
assert subgraph_messages[1] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
"tell me about bananas",
"Processing: tell me about bananas",
"Done",
]
def test_stateful_interrupt_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a stateful subgraph (checkpointer=True) correctly
supports interrupt/resume while also accumulating state. Each invoke+resume
pair triggers the subgraph, and after the second pair completes we verify
both the per-step invoke outputs and the accumulated message history. This
exercises the full lifecycle: interrupt, resume, state accumulation.
"""
# Build a subgraph that interrupts before echoing, then responds "Done"
def process(state: MessagesState) -> dict:
interrupt("continue?")
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage(content="Done")]}
inner = (
StateGraph(MessagesState)
.add_node("process", process)
.add_node("respond", respond)
.add_edge(START, "process")
.add_edge("process", "respond")
.compile()
)
# Wrap with checkpointer=True for stateful
wrapper = (
StateGraph(MessagesState)
.add_node("agent", inner)
.add_edge(START, "agent")
.compile(checkpointer=True)
)
subgraph_messages: list[list[str]] = []
topics = ["apples", "bananas"]
def call_inner(state: ParentState) -> dict:
topic = topics[len(subgraph_messages)]
resp = wrapper.invoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
# First invocation: hits interrupt
result = parent.invoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
# Resume: completes first call
result = parent.invoke(Command(resume=True), config)
assert result == {"result": "Done"}
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
]
# Second invocation: hits interrupt, state accumulated from first call
result = parent.invoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
# Resume: completes second call with accumulated state
result = parent.invoke(Command(resume=True), config)
assert result == {"result": "Done"}
assert subgraph_messages[1] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
"tell me about bananas",
"Processing: tell me about bananas",
"Done",
]
def test_stateful_namespace_isolation(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that two different stateful subgraphs (checkpointer=True)
maintain completely independent state when they use different wrapper node
names. A "fruit_agent" and "veggie_agent" are each wrapped in their own
stateful graph. After two parent invocations, each agent should only
see its own accumulated history with no cross-contamination between them.
"""
# Build two simple echo subgraphs with different prefixes
def fruit_echo(state: MessagesState) -> dict:
return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]}
def veggie_echo(state: MessagesState) -> dict:
return {
"messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")]
}
fruit_inner = (
StateGraph(MessagesState)
.add_node("echo", fruit_echo)
.add_edge(START, "echo")
.compile()
)
veggie_inner = (
StateGraph(MessagesState)
.add_node("echo", veggie_echo)
.add_edge(START, "echo")
.compile()
)
# Wrap each with checkpointer=True, using different node names to get
# independent checkpoint namespaces
fruit = (
StateGraph(MessagesState)
.add_node("fruit_agent", fruit_inner)
.add_edge(START, "fruit_agent")
.compile(checkpointer=True)
)
veggie = (
StateGraph(MessagesState)
.add_node("veggie_agent", veggie_inner)
.add_edge(START, "veggie_agent")
.compile(checkpointer=True)
)
fruit_msgs: list[list[str]] = []
veggie_msgs: list[list[str]] = []
call_count = 0
def call_both(state: ParentState) -> dict:
nonlocal call_count
call_count += 1
suffix = "round 1" if call_count == 1 else "round 2"
f = fruit.invoke({"messages": [HumanMessage(content=f"cherries {suffix}")]})
v = veggie.invoke({"messages": [HumanMessage(content=f"broccoli {suffix}")]})
fruit_msgs.append([m.text for m in f["messages"]])
veggie_msgs.append([m.text for m in v["messages"]])
return {"result": f["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_both", call_both)
.add_edge(START, "call_both")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
result1 = parent.invoke({"result": ""}, config)
assert result1 == {"result": "Fruit: cherries round 1"}
result2 = parent.invoke({"result": ""}, config)
assert result2 == {"result": "Fruit: cherries round 2"}
# First call: each agent sees only its own history
assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"]
assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"]
# Second call: each accumulated independently — no cross-contamination
assert fruit_msgs[1] == [
"cherries round 1",
"Fruit: cherries round 1",
"cherries round 2",
"Fruit: cherries round 2",
]
assert veggie_msgs[1] == [
"broccoli round 1",
"Veggie: broccoli round 1",
"broccoli round 2",
"Veggie: broccoli round 2",
]
@@ -1,662 +0,0 @@
"""Tests for subgraph persistence behavior (async).
Covers three checkpointer settings for subgraph state:
- checkpointer=False: no persistence, even when parent has a checkpointer
- checkpointer=None (default): "stateless" inherits parent checkpointer for
interrupt support, but state resets each invocation. This is the common case
when an agent is invoked from inside a tool used by another agent.
- checkpointer=True: "stateful" state accumulates across invocations on the same thread id
"""
import sys
from uuid import uuid4
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.base import BaseCheckpointSaver
from typing_extensions import TypedDict
from langgraph.graph import START, StateGraph
from langgraph.graph.message import MessagesState
from langgraph.types import Command, Interrupt, interrupt
from tests.any_str import AnyStr
pytestmark = pytest.mark.anyio
NEEDS_CONTEXTVARS = pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
class ParentState(TypedDict):
result: str
# -- checkpointer=None (stateless) --
@NEEDS_CONTEXTVARS
async def test_stateless_interrupt_resume_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=None (the default) can
still support interrupt/resume when invoked from inside a parent graph that
has a checkpointer. This is the "stateless" pattern the subgraph inherits
the parent's checkpointer just enough to pause and resume, but does not
retain any state across separate parent invocations. This pattern commonly
appears when an agent is invoked from inside a tool used by another agent.
"""
# Build a subgraph that interrupts before echoing.
# Two nodes: "process" interrupts then echoes, "respond" returns "Done".
def process(state: MessagesState) -> dict:
interrupt("continue?")
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage(content="Done")]}
inner = (
StateGraph(MessagesState)
.add_node("process", process)
.add_node("respond", respond)
.add_edge(START, "process")
.add_edge("process", "respond")
.compile()
)
async def call_inner(state: ParentState) -> dict:
resp = await inner.ainvoke({"messages": [HumanMessage(content="apples")]})
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
# First invoke hits the interrupt
result = await parent.ainvoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
# Resume completes the subgraph
result = await parent.ainvoke(Command(resume=True), config)
assert result == {"result": "Done"}
@NEEDS_CONTEXTVARS
async def test_stateless_state_resets_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=None (the default) does
not retain any message history between separate parent invocations. Each time
the parent graph invokes the subgraph, it starts with a clean slate. This
confirms the "stateless" behavior: even though the parent has a checkpointer,
the subgraph state is not persisted across calls.
"""
# Build a simple echo subgraph: echoes "Processing: <input>"
def echo(state: MessagesState) -> dict:
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
inner = (
StateGraph(MessagesState)
.add_node("echo", echo)
.add_edge(START, "echo")
.compile()
)
subgraph_messages: list[list[str]] = []
call_count = 0
async def call_inner(state: ParentState) -> dict:
nonlocal call_count
call_count += 1
topic = "apples" if call_count == 1 else "bananas"
resp = await inner.ainvoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
result1 = await parent.ainvoke({"result": ""}, config)
assert result1 == {"result": "Processing: tell me about apples"}
result2 = await parent.ainvoke({"result": ""}, config)
assert result2 == {"result": "Processing: tell me about bananas"}
# Both invocations produce fresh history — no memory of prior call
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
]
assert subgraph_messages[1] == [
"tell me about bananas",
"Processing: tell me about bananas",
]
@NEEDS_CONTEXTVARS
async def test_stateless_state_resets_with_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=None resets its state
between parent invocations even when interrupt/resume is used. The subgraph
is invoked twice from the parent, each time with an interrupt that must be
resumed. After both invoke+resume cycles, each subgraph run should only
contain its own messages no bleed-over from the previous run.
"""
# Build a subgraph that interrupts before echoing, then responds "Done"
def process(state: MessagesState) -> dict:
interrupt("continue?")
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage(content="Done")]}
inner = (
StateGraph(MessagesState)
.add_node("process", process)
.add_node("respond", respond)
.add_edge(START, "process")
.add_edge("process", "respond")
.compile()
)
subgraph_messages: list[list[str]] = []
call_count = 0
async def call_inner(state: ParentState) -> dict:
nonlocal call_count
call_count += 1
topic = "apples" if call_count == 1 else "bananas"
resp = await inner.ainvoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
# First invoke+resume cycle
result = await parent.ainvoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
result = await parent.ainvoke(Command(resume=True), config)
assert result == {"result": "Done"}
# Second invoke+resume cycle
result = await parent.ainvoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
result = await parent.ainvoke(Command(resume=True), config)
assert result == {"result": "Done"}
# Both invocations produce fresh history — no memory of prior call
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
]
assert subgraph_messages[1] == [
"tell me about bananas",
"Processing: tell me about bananas",
"Done",
]
# -- checkpointer=False --
@NEEDS_CONTEXTVARS
async def test_checkpointer_false_no_persistence_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=False gets no
persistence at all, even when the parent graph has a checkpointer. Unlike
the default (checkpointer=None) which inherits just enough from the parent
to support interrupt/resume, checkpointer=False explicitly opts out of all
checkpoint behavior. Each invocation starts completely fresh.
"""
# Build a simple echo subgraph with checkpointer=False
def echo(state: MessagesState) -> dict:
return {
"messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")]
}
inner = (
StateGraph(MessagesState)
.add_node("echo", echo)
.add_edge(START, "echo")
.compile(checkpointer=False)
)
subgraph_messages: list[list[str]] = []
call_count = 0
async def call_inner(state: ParentState) -> dict:
nonlocal call_count
call_count += 1
topic = "apples" if call_count == 1 else "bananas"
resp = await inner.ainvoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
result1 = await parent.ainvoke({"result": ""}, config)
assert result1 == {"result": "Processed: tell me about apples"}
result2 = await parent.ainvoke({"result": ""}, config)
assert result2 == {"result": "Processed: tell me about bananas"}
# Both start fresh — no history from first call
assert subgraph_messages[0] == [
"tell me about apples",
"Processed: tell me about apples",
]
assert subgraph_messages[1] == [
"tell me about bananas",
"Processed: tell me about bananas",
]
# -- checkpointer=True (stateful) --
@NEEDS_CONTEXTVARS
async def test_stateful_state_accumulates_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a subgraph compiled with checkpointer=True ("stateful")
retains its message history across separate parent invocations. To enable
this, the subgraph is wrapped in an outer graph compiled with
checkpointer=True this wrapper gives the inner subgraph its own persistent
checkpoint namespace. After two parent calls, the second subgraph invocation
should see messages from both the first and second calls.
"""
# Build a simple echo subgraph
def echo(state: MessagesState) -> dict:
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
inner = (
StateGraph(MessagesState)
.add_node("echo", echo)
.add_edge(START, "echo")
.compile()
)
# Wrap the inner subgraph with checkpointer=True to enable stateful.
# The wrapper graph gives the subgraph its own persistent checkpoint
# namespace, keyed by the node name ("agent").
wrapper = (
StateGraph(MessagesState)
.add_node("agent", inner)
.add_edge(START, "agent")
.compile(checkpointer=True)
)
subgraph_messages: list[list[str]] = []
topics = ["apples", "bananas"]
async def call_inner(state: ParentState) -> dict:
topic = topics[len(subgraph_messages)]
resp = await wrapper.ainvoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
result1 = await parent.ainvoke({"result": ""}, config)
assert result1 == {"result": "Processing: tell me about apples"}
result2 = await parent.ainvoke({"result": ""}, config)
assert result2 == {"result": "Processing: tell me about bananas"}
# First call: fresh history
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
]
# Second call: retains messages from first call
assert subgraph_messages[1] == [
"tell me about apples",
"Processing: tell me about apples",
"tell me about bananas",
"Processing: tell me about bananas",
]
@NEEDS_CONTEXTVARS
async def test_stateful_state_accumulates_with_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a stateful subgraph (checkpointer=True) retains its
message history across parent invocations even when interrupt/resume is
involved. The subgraph interrupts before echoing, then responds "Done".
After two invoke+resume cycles, the second run should contain the full
accumulated history from both calls.
"""
# Build a subgraph that interrupts before echoing, then responds "Done"
def process(state: MessagesState) -> dict:
interrupt("continue?")
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage(content="Done")]}
inner = (
StateGraph(MessagesState)
.add_node("process", process)
.add_node("respond", respond)
.add_edge(START, "process")
.add_edge("process", "respond")
.compile()
)
# Wrap with checkpointer=True for stateful
wrapper = (
StateGraph(MessagesState)
.add_node("agent", inner)
.add_edge(START, "agent")
.compile(checkpointer=True)
)
subgraph_messages: list[list[str]] = []
topics = ["apples", "bananas"]
async def call_inner(state: ParentState) -> dict:
topic = topics[len(subgraph_messages)]
resp = await wrapper.ainvoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
# First invoke+resume cycle
result = await parent.ainvoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
result = await parent.ainvoke(Command(resume=True), config)
assert result == {"result": "Done"}
# Second invoke+resume cycle
result = await parent.ainvoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
result = await parent.ainvoke(Command(resume=True), config)
assert result == {"result": "Done"}
# First call: fresh history
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
]
# Second call: retains messages from first call
assert subgraph_messages[1] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
"tell me about bananas",
"Processing: tell me about bananas",
"Done",
]
@NEEDS_CONTEXTVARS
async def test_stateful_interrupt_resume_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that a stateful subgraph (checkpointer=True) correctly
supports interrupt/resume while also accumulating state. Each invoke+resume
pair triggers the subgraph, and after the second pair completes we verify
both the per-step invoke outputs and the accumulated message history. This
exercises the full lifecycle: interrupt, resume, state accumulation.
"""
# Build a subgraph that interrupts before echoing, then responds "Done"
def process(state: MessagesState) -> dict:
interrupt("continue?")
return {
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
}
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage(content="Done")]}
inner = (
StateGraph(MessagesState)
.add_node("process", process)
.add_node("respond", respond)
.add_edge(START, "process")
.add_edge("process", "respond")
.compile()
)
# Wrap with checkpointer=True for stateful
wrapper = (
StateGraph(MessagesState)
.add_node("agent", inner)
.add_edge(START, "agent")
.compile(checkpointer=True)
)
subgraph_messages: list[list[str]] = []
topics = ["apples", "bananas"]
async def call_inner(state: ParentState) -> dict:
topic = topics[len(subgraph_messages)]
resp = await wrapper.ainvoke(
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
)
subgraph_messages.append([m.text for m in resp["messages"]])
return {"result": resp["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_inner", call_inner)
.add_edge(START, "call_inner")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
# First invocation: hits interrupt
result = await parent.ainvoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
# Resume: completes first call
result = await parent.ainvoke(Command(resume=True), config)
assert result == {"result": "Done"}
assert subgraph_messages[0] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
]
# Second invocation: hits interrupt, state accumulated from first call
result = await parent.ainvoke({"result": ""}, config)
assert result == {
"result": "",
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
}
# Resume: completes second call with accumulated state
result = await parent.ainvoke(Command(resume=True), config)
assert result == {"result": "Done"}
assert subgraph_messages[1] == [
"tell me about apples",
"Processing: tell me about apples",
"Done",
"tell me about bananas",
"Processing: tell me about bananas",
"Done",
]
@NEEDS_CONTEXTVARS
async def test_stateful_namespace_isolation_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Tests that two different stateful subgraphs (checkpointer=True)
maintain completely independent state when they use different wrapper node
names. A "fruit_agent" and "veggie_agent" are each wrapped in their own
stateful graph. After two parent invocations, each agent should only
see its own accumulated history with no cross-contamination between them.
"""
# Build two simple echo subgraphs with different prefixes
def fruit_echo(state: MessagesState) -> dict:
return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]}
def veggie_echo(state: MessagesState) -> dict:
return {
"messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")]
}
fruit_inner = (
StateGraph(MessagesState)
.add_node("echo", fruit_echo)
.add_edge(START, "echo")
.compile()
)
veggie_inner = (
StateGraph(MessagesState)
.add_node("echo", veggie_echo)
.add_edge(START, "echo")
.compile()
)
# Wrap each with checkpointer=True, using different node names to get
# independent checkpoint namespaces
fruit = (
StateGraph(MessagesState)
.add_node("fruit_agent", fruit_inner)
.add_edge(START, "fruit_agent")
.compile(checkpointer=True)
)
veggie = (
StateGraph(MessagesState)
.add_node("veggie_agent", veggie_inner)
.add_edge(START, "veggie_agent")
.compile(checkpointer=True)
)
fruit_msgs: list[list[str]] = []
veggie_msgs: list[list[str]] = []
call_count = 0
async def call_both(state: ParentState) -> dict:
nonlocal call_count
call_count += 1
suffix = "round 1" if call_count == 1 else "round 2"
f = await fruit.ainvoke(
{"messages": [HumanMessage(content=f"cherries {suffix}")]}
)
v = await veggie.ainvoke(
{"messages": [HumanMessage(content=f"broccoli {suffix}")]}
)
fruit_msgs.append([m.text for m in f["messages"]])
veggie_msgs.append([m.text for m in v["messages"]])
return {"result": f["messages"][-1].text}
parent = (
StateGraph(ParentState)
.add_node("call_both", call_both)
.add_edge(START, "call_both")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": str(uuid4())}}
result1 = await parent.ainvoke({"result": ""}, config)
assert result1 == {"result": "Fruit: cherries round 1"}
result2 = await parent.ainvoke({"result": ""}, config)
assert result2 == {"result": "Fruit: cherries round 2"}
# First call: each agent sees only its own history
assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"]
assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"]
# Second call: each accumulated independently — no cross-contamination
assert fruit_msgs[1] == [
"cherries round 1",
"Fruit: cherries round 1",
"cherries round 2",
"Fruit: cherries round 2",
]
assert veggie_msgs[1] == [
"broccoli round 1",
"Veggie: broccoli round 1",
"broccoli round 2",
"Veggie: broccoli round 2",
]
+27 -28
View File
@@ -1348,7 +1348,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.16"
version = "1.2.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -1360,14 +1360,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/a7/4c992456dae89a8704afec03e3c2a0149ccc5f29c1cbdd5f4aa77628e921/langchain_core-1.2.16.tar.gz", hash = "sha256:055a4bfe7d62f4ac45ed49fd759ee2e6bdd15abf998fbeea695fda5da2de6413", size = 835286, upload-time = "2026-02-25T16:27:30.551Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/bb/c501ca60556c11ac80d1454bdcac63cb33583ce4e64fc4535ad5a7d5c6ba/langchain_core-1.2.13.tar.gz", hash = "sha256:d2773d0d0130a356378db9a858cfeef64c3d64bc03722f1d4d6c40eb46fdf01b", size = 831612, upload-time = "2026-02-15T07:45:57.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/a1/57d5feaa11dc2ebb40f3bc3d7bf4294b6703e152e56edea9d4c622475a6a/langchain_core-1.2.16-py3-none-any.whl", hash = "sha256:2768add9aa97232a7712580f678e0ba045ee1036c71fe471355be0434fcb6e30", size = 502219, upload-time = "2026-02-25T16:27:29.379Z" },
{ url = "https://files.pythonhosted.org/packages/12/ab/60fd69e5d55f67d422baefddaaca523c42cd7510ab6aeb17db6ae57fb107/langchain_core-1.2.13-py3-none-any.whl", hash = "sha256:b31823e28d3eff1e237096d0bd3bf80c6f9624eb471a9496dbfbd427779f8d82", size = 500485, upload-time = "2026-02-15T07:45:55.422Z" },
]
[[package]]
name = "langgraph"
version = "1.0.10rc1"
version = "1.0.9"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1548,7 +1548,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1rc2"
version = "4.0.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -1569,7 +1569,6 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
@@ -3181,14 +3180,14 @@ wheels = [
[[package]]
name = "redis"
version = "7.2.1"
version = "7.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" },
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
]
[[package]]
@@ -3389,27 +3388,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.4"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
]
[[package]]
+2 -3
View File
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.10rc1"
version = "1.0.9"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -352,7 +352,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1rc2"
version = "4.0.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -373,7 +373,6 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
+2 -3
View File
@@ -265,7 +265,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.10rc1"
version = "1.0.9"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -349,7 +349,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1rc2"
version = "4.0.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -370,7 +370,6 @@ dev = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },