mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-26 17:42:24 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e524d96c |
Generated
+1
-2
@@ -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" },
|
||||
|
||||
Generated
+1
-2
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-38
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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' "
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Generated
+27
-28
@@ -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]]
|
||||
|
||||
Generated
+2
-3
@@ -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" },
|
||||
|
||||
Generated
+2
-3
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user