mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-27 01:52:25 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c850a5baa | ||
|
|
19db4d2b23 | ||
|
|
e6fe1a5d68 | ||
|
|
aceeb8e352 | ||
|
|
6c2154047a | ||
|
|
817125ae4f | ||
|
|
9cda4fa11d | ||
|
|
fc9c710bae | ||
|
|
8cb87eaf76 | ||
|
|
089cdd0ffb |
@@ -56,46 +56,3 @@ jobs:
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
|
||||
build-strict-msgpack:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/langgraph
|
||||
name: "test strict msgpack #3.12"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.12
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
enable-cache: true
|
||||
cache-suffix: "test-langgraph-strict-msgpack"
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_RO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv sync --frozen --group test --no-dev
|
||||
|
||||
- name: Run tests (strict msgpack)
|
||||
shell: bash
|
||||
env:
|
||||
LANGGRAPH_STRICT_MSGPACK: "1"
|
||||
run: make test_parallel
|
||||
|
||||
- name: Ensure the tests did not create any additional files
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
STATUS="$(git status)"
|
||||
echo "$STATUS"
|
||||
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
|
||||
@@ -79,7 +79,7 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
|
||||
|
||||
## Additional resources
|
||||
|
||||
- [Guides](https://docs.langchain.com/oss/python/langgraph/guides): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
|
||||
- [Guides](https://docs.langchain.com/oss/python/langgraph/overview): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
|
||||
- [Reference](https://reference.langchain.com/python/langgraph/): Detailed reference on core classes, methods, how to use the graph and checkpointing APIs, and higher-level prebuilt components.
|
||||
- [Examples](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Guided examples on getting started with LangGraph.
|
||||
- [LangChain Forum](https://forum.langchain.com/): Connect with the community and share all of your technical questions, ideas, and feedback.
|
||||
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
|
||||
Twitter = "https://x.com/LangChainAI"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite"
|
||||
Twitter = "https://x.com/LangChainAI"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -37,48 +37,6 @@ LC_REVIVER = Reviver()
|
||||
EMPTY_BYTES = b""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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"),
|
||||
# regex
|
||||
("re", "compile"),
|
||||
# langgraph
|
||||
("langgraph.types", "Send"),
|
||||
("langgraph.types", "Interrupt"),
|
||||
("langgraph.types", "Command"),
|
||||
("langgraph.types", "StateSnapshot"),
|
||||
("langgraph.types", "PregelTask"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class JsonPlusSerializer(SerializerProtocol):
|
||||
"""Serializer that uses ormsgpack, with optional fallbacks.
|
||||
@@ -96,29 +54,18 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
*,
|
||||
pickle_fallback: bool = False,
|
||||
allowed_json_modules: Sequence[tuple[str, ...]] | Literal[True] | None = None,
|
||||
# TODO: change default to None once users have had time to configure allowlists
|
||||
allowed_msgpack_modules: Sequence[tuple[str, ...]]
|
||||
| Literal[True]
|
||||
| None = True,
|
||||
__unpack_ext_hook__: Callable[[int, bytes], Any] | None = None,
|
||||
) -> None:
|
||||
self.pickle_fallback = pickle_fallback
|
||||
# JSON allowlist
|
||||
self._allowed_json_modules: set[tuple[str, ...]] | Literal[True] | None = (
|
||||
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)
|
||||
)
|
||||
# Msgpack allowlist
|
||||
self._allowed_msgpack_modules: set[tuple[str, ...]] | Literal[True] | None = (
|
||||
{mod_and_name for mod_and_name in allowed_msgpack_modules}
|
||||
if allowed_msgpack_modules and allowed_msgpack_modules is not True
|
||||
else (allowed_msgpack_modules if allowed_msgpack_modules is True else 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 _encode_constructor_args(
|
||||
@@ -143,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
|
||||
@@ -160,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:
|
||||
@@ -192,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):
|
||||
@@ -203,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"
|
||||
@@ -214,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(
|
||||
@@ -501,158 +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 SAFE_MSGPACK_TYPES:
|
||||
return True
|
||||
|
||||
if allowed_modules is not None and allowed_modules is not True:
|
||||
if key in allowed_modules:
|
||||
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
|
||||
else:
|
||||
# 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,
|
||||
# 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
|
||||
)
|
||||
return False
|
||||
# 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 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]):
|
||||
return None
|
||||
# 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 None
|
||||
# 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 None
|
||||
# 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(tup[0], tup[1]):
|
||||
return None
|
||||
# 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 None
|
||||
# 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 None
|
||||
# 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
|
||||
|
||||
|
||||
_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:
|
||||
|
||||
@@ -18,7 +18,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint"
|
||||
Twitter = "https://x.com/LangChainAI"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -37,10 +37,6 @@ class MyPydantic(BaseModel):
|
||||
inner: InnerPydantic
|
||||
|
||||
|
||||
class AnotherPydantic(BaseModel):
|
||||
foo: str
|
||||
|
||||
|
||||
class InnerPydanticV1(BaseModelV1):
|
||||
hello: str
|
||||
|
||||
@@ -516,240 +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.
|
||||
|
||||
TODO: We'll want to change this to block unregistered types in the future."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
TODO: This will be the default behavior in the future."""
|
||||
|
||||
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()
|
||||
assert result is None
|
||||
|
||||
|
||||
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()
|
||||
assert result is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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,77 +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()
|
||||
assert result.checkpoint["channel_values"]["foo"] is None
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Create a tarball of project source for remote builds."""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
import click
|
||||
|
||||
|
||||
_WARN_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
_MAX_SIZE = 200 * 1024 * 1024 # 200 MB
|
||||
|
||||
|
||||
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
||||
"""Strip symlinks, hardlinks, and traversal paths from archive."""
|
||||
if tarinfo.issym() or tarinfo.islnk():
|
||||
return None
|
||||
if ".." in tarinfo.name.split("/"):
|
||||
return None
|
||||
return tarinfo
|
||||
|
||||
|
||||
def _read_ignore_patterns(context_dir: pathlib.Path) -> list[str]:
|
||||
"""Read .dockerignore patterns if present."""
|
||||
dockerignore = context_dir / ".dockerignore"
|
||||
if dockerignore.is_file():
|
||||
patterns = []
|
||||
for line in dockerignore.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
patterns.append(line)
|
||||
return patterns
|
||||
return []
|
||||
|
||||
|
||||
def _should_ignore(rel_path: str, patterns: list[str]) -> bool:
|
||||
"""Check if a relative path matches any dockerignore pattern."""
|
||||
import fnmatch
|
||||
|
||||
rel_path = rel_path.replace(os.sep, "/")
|
||||
|
||||
for pattern in patterns:
|
||||
negate = pattern.startswith("!")
|
||||
if negate:
|
||||
pattern = pattern[1:]
|
||||
|
||||
if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(
|
||||
rel_path, f"**/{pattern}"
|
||||
):
|
||||
if negate:
|
||||
return False
|
||||
return True
|
||||
|
||||
parts = rel_path.split("/")
|
||||
for i in range(len(parts)):
|
||||
partial = "/".join(parts[: i + 1])
|
||||
if fnmatch.fnmatch(partial, pattern):
|
||||
if negate:
|
||||
return False
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_archive(
|
||||
config_path: pathlib.Path,
|
||||
) -> tuple[str, int]:
|
||||
"""Create a .tar.gz archive of the project source.
|
||||
|
||||
Returns (archive_path, file_size).
|
||||
The archive root is config.parent (the directory containing langgraph.json).
|
||||
"""
|
||||
context_dir = config_path.parent.resolve()
|
||||
config_filename = config_path.name
|
||||
ignore_patterns = _read_ignore_patterns(context_dir)
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix="langgraph-deploy-")
|
||||
archive_path = os.path.join(tmp_dir, "source.tar.gz")
|
||||
|
||||
with tarfile.open(archive_path, "w:gz") as tar:
|
||||
for root, dirs, files in os.walk(context_dir):
|
||||
rel_root = os.path.relpath(root, context_dir)
|
||||
if rel_root == ".":
|
||||
rel_root = ""
|
||||
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not _should_ignore(
|
||||
os.path.join(rel_root, d) if rel_root else d, ignore_patterns
|
||||
)
|
||||
]
|
||||
|
||||
for f in files:
|
||||
rel_path = os.path.join(rel_root, f) if rel_root else f
|
||||
if _should_ignore(rel_path, ignore_patterns):
|
||||
continue
|
||||
full_path = os.path.join(root, f)
|
||||
arcname = rel_path.replace(os.sep, "/")
|
||||
info = tar.gettarinfo(full_path, arcname=arcname)
|
||||
filtered = _tar_filter(info)
|
||||
if filtered is None:
|
||||
continue
|
||||
with open(full_path, "rb") as fobj:
|
||||
tar.addfile(filtered, fobj)
|
||||
|
||||
file_size = os.path.getsize(archive_path)
|
||||
|
||||
# Validate config file is at archive root
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
names = tar.getnames()
|
||||
if config_filename not in names:
|
||||
os.unlink(archive_path)
|
||||
raise click.ClickException(
|
||||
f"Archive validation failed: {config_filename} not found at archive root"
|
||||
)
|
||||
|
||||
if file_size > _MAX_SIZE:
|
||||
os.unlink(archive_path)
|
||||
raise click.ClickException(
|
||||
f"Source archive is {file_size / 1_048_576:.1f} MB, which exceeds the 200 MB limit. "
|
||||
"Check your .dockerignore for large files (model weights, data, node_modules, .venv)."
|
||||
)
|
||||
|
||||
if file_size > _WARN_SIZE:
|
||||
click.secho(
|
||||
f" Warning: source archive is {file_size / 1_048_576:.1f} MB. "
|
||||
"Consider adding large files to .dockerignore.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
return archive_path, file_size
|
||||
+811
-11
@@ -1,10 +1,18 @@
|
||||
"""CLI entrypoint for LangGraph API server."""
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import json as json_mod
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from contextlib import contextmanager
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
@@ -17,11 +25,147 @@ from langgraph_cli.config import Config
|
||||
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
|
||||
from langgraph_cli.docker import DockerCapabilities
|
||||
from langgraph_cli.exec import Runner, subp_exec
|
||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||
from langgraph_cli.progress import Progress
|
||||
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
|
||||
from langgraph_cli.util import warn_non_wolfi_distro
|
||||
from langgraph_cli.version import __version__
|
||||
|
||||
RESERVED_ENV_VARS = frozenset(
|
||||
[
|
||||
# LANGCHAIN_RESERVED_ENV_VARS from host-backend
|
||||
"LANGCHAIN_TRACING_V2",
|
||||
"LANGSMITH_TRACING_V2",
|
||||
"LANGCHAIN_ENDPOINT",
|
||||
"LANGCHAIN_PROJECT",
|
||||
"LANGSMITH_PROJECT",
|
||||
"LANGSMITH_LANGGRAPH_GIT_REPO",
|
||||
"LANGGRAPH_GIT_REPO_PATH",
|
||||
"LANGCHAIN_API_KEY",
|
||||
"LANGSMITH_CONTROL_PLANE_API_KEY",
|
||||
"POSTGRES_URI",
|
||||
"POSTGRES_PASSWORD",
|
||||
"DATABASE_URI",
|
||||
"LANGSMITH_LANGGRAPH_GIT_REF",
|
||||
"LANGSMITH_LANGGRAPH_GIT_REF_SHA",
|
||||
"LANGGRAPH_AUTH_TYPE",
|
||||
"LANGSMITH_AUTH_ENDPOINT",
|
||||
"LANGSMITH_TENANT_ID",
|
||||
"LANGSMITH_AUTH_VERIFY_TENANT_ID",
|
||||
"LANGSMITH_HOST_PROJECT_ID",
|
||||
"LANGSMITH_HOST_PROJECT_NAME",
|
||||
"LANGSMITH_HOST_REVISION_ID",
|
||||
"LOG_JSON",
|
||||
"LOG_DICT_TRACEBACKS",
|
||||
"REDIS_URI",
|
||||
"LANGCHAIN_CALLBACKS_BACKGROUND",
|
||||
"DD_TRACE_PSYCOPG_ENABLED",
|
||||
"DD_TRACE_REDIS_ENABLED",
|
||||
"LANGGRAPH_CLOUD_LICENSE_KEY",
|
||||
# ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted)
|
||||
"LANGSMITH_API_KEY",
|
||||
"LANGSMITH_ENDPOINT",
|
||||
"POSTGRES_URI_CUSTOM",
|
||||
"REDIS_URI_CUSTOM",
|
||||
"PATH",
|
||||
"PORT",
|
||||
"MOUNT_PREFIX",
|
||||
"LSD_ENV",
|
||||
"LSD_DD_API_KEY",
|
||||
"LSD_DD_ENDPOINT",
|
||||
"LSD_DEPLOYMENT_TYPE",
|
||||
]
|
||||
)
|
||||
|
||||
_API_KEY_ENV_NAMES = (
|
||||
"LANGGRAPH_HOST_API_KEY",
|
||||
"LANGSMITH_API_KEY",
|
||||
"LANGCHAIN_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
def _parse_dotenv_file(path: pathlib.Path) -> dict[str, str]:
|
||||
"""Parse a .env file into a dict, skipping comments and blank lines."""
|
||||
result: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return result
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if (
|
||||
value
|
||||
and len(value) >= 2
|
||||
and value[0] == value[-1]
|
||||
and value[0] in ("'", '"')
|
||||
):
|
||||
value = value[1:-1]
|
||||
if key:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _parse_env_from_config(
|
||||
config_json: dict, config_path: pathlib.Path
|
||||
) -> dict[str, str]:
|
||||
"""Resolve env vars from langgraph.json 'env' field or a .env fallback."""
|
||||
env_field = config_json.get("env")
|
||||
if isinstance(env_field, dict):
|
||||
return {str(k): str(v) for k, v in env_field.items()}
|
||||
if isinstance(env_field, str):
|
||||
env_path = (config_path.parent / env_field).resolve()
|
||||
return _parse_dotenv_file(env_path)
|
||||
fallback = pathlib.Path.cwd() / ".env"
|
||||
return _parse_dotenv_file(fallback)
|
||||
|
||||
|
||||
def _secrets_from_env(
|
||||
env_vars: dict[str, str],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Convert env dict to secrets list, filtering reserved vars with warnings."""
|
||||
secrets: list[dict[str, str]] = []
|
||||
for name, value in env_vars.items():
|
||||
if name in RESERVED_ENV_VARS:
|
||||
click.secho(f" Skipping reserved env var: {name}", fg="yellow")
|
||||
continue
|
||||
if not value:
|
||||
continue
|
||||
secrets.append({"name": name, "value": value})
|
||||
return secrets
|
||||
|
||||
|
||||
_TERMINAL_STATUSES = frozenset(
|
||||
[
|
||||
"DEPLOYED",
|
||||
"CREATE_FAILED",
|
||||
"BUILD_FAILED",
|
||||
"DEPLOY_FAILED",
|
||||
"SKIPPED",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _docker_config_for_token(registry_host: str, token: str):
|
||||
"""Create a temporary Docker config with only the push token.
|
||||
|
||||
Yields the path to a temporary config directory that can be passed
|
||||
to ``docker --config <path>`` so that system credential helpers
|
||||
(e.g. gcloud) don't interfere with the push token.
|
||||
"""
|
||||
auth_b64 = base64.b64encode(f"oauth2accesstoken:{token}".encode()).decode()
|
||||
config_data = {"auths": {registry_host: {"auth": auth_b64}}}
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, "config.json"), "w") as f:
|
||||
json_mod.dump(config_data, f)
|
||||
yield tmpdir
|
||||
|
||||
|
||||
OPT_DOCKER_COMPOSE = click.option(
|
||||
"--docker-compose",
|
||||
"-d",
|
||||
@@ -304,37 +448,37 @@ def _build(
|
||||
passthrough: Sequence[str] = (),
|
||||
install_command: str | None = None,
|
||||
build_command: str | None = None,
|
||||
docker_command: Sequence[str] | None = None,
|
||||
extra_flags: Sequence[str] = (),
|
||||
verbose: bool = True,
|
||||
):
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
langgraph_cli.config.docker_tag(config_json, base_image, api_version),
|
||||
verbose=True,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
set("Building...")
|
||||
# apply options
|
||||
args = [
|
||||
"-f",
|
||||
"-", # stdin
|
||||
"-t",
|
||||
tag,
|
||||
]
|
||||
# determine build context: use current directory for JS projects, config parent for Python
|
||||
is_js_project = config_json.get("node_version") and not config_json.get(
|
||||
"python_version"
|
||||
)
|
||||
# build/install commands only apply to JS projects for now
|
||||
# without install/build command, JS projects will follow the old behavior
|
||||
if is_js_project and (build_command or install_command):
|
||||
build_context = str(pathlib.Path.cwd())
|
||||
else:
|
||||
build_context = str(config.parent)
|
||||
|
||||
# apply config
|
||||
# Deep copy to avoid mutating the caller's config (config_to_docker
|
||||
# rewrites graph paths to container-internal paths in place).
|
||||
config_json = copy.deepcopy(config_json)
|
||||
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
|
||||
config_path=config,
|
||||
config=config_json,
|
||||
@@ -344,19 +488,19 @@ def _build(
|
||||
build_command=build_command,
|
||||
build_context=build_context,
|
||||
)
|
||||
# add additional_contexts
|
||||
if additional_contexts:
|
||||
for k, v in additional_contexts.items():
|
||||
args.extend(["--build-context", f"{k}={v}"])
|
||||
cmd = tuple(docker_command) if docker_command else ("docker", "build")
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"build",
|
||||
*cmd,
|
||||
*args,
|
||||
*extra_flags,
|
||||
*passthrough,
|
||||
build_context,
|
||||
input=stdin,
|
||||
verbose=True,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -429,6 +573,662 @@ def build(
|
||||
)
|
||||
|
||||
|
||||
@click.option(
|
||||
"--api-key",
|
||||
envvar="LANGGRAPH_HOST_API_KEY",
|
||||
help="API key. If omitted, resolved from .env or prompted.",
|
||||
)
|
||||
@click.option(
|
||||
"--name",
|
||||
help=(
|
||||
"Deployment name. Defaults to current directory name "
|
||||
"if --deployment-id is not provided."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--deployment-id",
|
||||
help=(
|
||||
"ID of an existing deployment to update. If omitted, "
|
||||
"--name is used to find or create the deployment."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--deployment-type",
|
||||
type=click.Choice(["dev", "prod"]),
|
||||
default="dev",
|
||||
show_default=True,
|
||||
help="Deployment type (used when creating a new deployment).",
|
||||
)
|
||||
@click.option(
|
||||
"--no-wait",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip waiting for deployment status.",
|
||||
)
|
||||
@OPT_VERBOSE
|
||||
@click.option(
|
||||
"--host-url",
|
||||
envvar="LANGGRAPH_HOST_URL",
|
||||
default="https://api.host.langchain.com",
|
||||
hidden=True,
|
||||
)
|
||||
@click.option("--image-name", hidden=True)
|
||||
@click.option("--image-tag", default="latest", hidden=True)
|
||||
@click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
default=DEFAULT_CONFIG,
|
||||
hidden=True,
|
||||
type=click.Path(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
resolve_path=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
)
|
||||
@click.option("--pull/--no-pull", default=True, hidden=True)
|
||||
@click.option("--base-image", hidden=True)
|
||||
@click.option("--install-command", hidden=True)
|
||||
@click.option("--build-command", hidden=True)
|
||||
@click.option("--api-version", type=str, hidden=True)
|
||||
@click.option(
|
||||
"--remote/--no-remote",
|
||||
default=None,
|
||||
help=(
|
||||
"Force or disable remote build. Default: auto-detect "
|
||||
"(use remote build when Docker is unavailable)."
|
||||
),
|
||||
)
|
||||
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
|
||||
@cli.command(
|
||||
help=(
|
||||
"Build and deploy a LangGraph image to LangSmith Deployments.\n\n"
|
||||
"Run from the root of your LangGraph project (where langgraph.json "
|
||||
"is located). This command also accepts build flags (--base-image, "
|
||||
"--pull, etc.). See 'langgraph build --help' for details."
|
||||
),
|
||||
context_settings=dict(ignore_unknown_options=True),
|
||||
)
|
||||
@log_command
|
||||
def deploy(
|
||||
config: pathlib.Path,
|
||||
pull: bool,
|
||||
verbose: bool,
|
||||
api_version: str | None,
|
||||
host_url: str | None,
|
||||
api_key: str | None,
|
||||
deployment_id: str | None,
|
||||
deployment_type: str,
|
||||
name: str | None,
|
||||
image_name: str | None,
|
||||
image_tag: str,
|
||||
base_image: str | None,
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
no_wait: bool,
|
||||
remote: bool | None,
|
||||
docker_build_args: Sequence[str],
|
||||
):
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
warn_non_wolfi_distro(config_json)
|
||||
|
||||
env_vars = _parse_env_from_config(config_json, config)
|
||||
|
||||
if not api_key:
|
||||
for key_name in _API_KEY_ENV_NAMES:
|
||||
if key_name in env_vars:
|
||||
api_key = env_vars[key_name]
|
||||
break
|
||||
if not api_key:
|
||||
api_key = click.prompt("Host API key", hide_input=True)
|
||||
|
||||
if not deployment_id and not name:
|
||||
default_name = _normalize_image_name(pathlib.Path.cwd().name)
|
||||
name = click.prompt("Deployment name", default=default_name)
|
||||
|
||||
secrets = _secrets_from_env(env_vars)
|
||||
|
||||
# Determine whether to use remote build
|
||||
use_remote = remote
|
||||
if use_remote is None:
|
||||
docker_available = langgraph_cli.docker.is_docker_available()
|
||||
use_remote = not docker_available
|
||||
elif use_remote is False:
|
||||
pass # --no-remote: fail if Docker is missing (existing behavior)
|
||||
|
||||
if use_remote:
|
||||
_deploy_remote(
|
||||
config=config,
|
||||
config_json=config_json,
|
||||
verbose=verbose,
|
||||
api_version=api_version,
|
||||
host_url=host_url,
|
||||
api_key=api_key,
|
||||
deployment_id=deployment_id,
|
||||
deployment_type=deployment_type,
|
||||
name=name,
|
||||
base_image=base_image,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
no_wait=no_wait,
|
||||
secrets=secrets,
|
||||
)
|
||||
else:
|
||||
_deploy_local(
|
||||
config=config,
|
||||
config_json=config_json,
|
||||
verbose=verbose,
|
||||
api_version=api_version,
|
||||
host_url=host_url,
|
||||
api_key=api_key,
|
||||
deployment_id=deployment_id,
|
||||
deployment_type=deployment_type,
|
||||
name=name,
|
||||
image_name=image_name,
|
||||
image_tag=image_tag,
|
||||
base_image=base_image,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
no_wait=no_wait,
|
||||
pull=pull,
|
||||
docker_build_args=docker_build_args,
|
||||
secrets=secrets,
|
||||
)
|
||||
|
||||
|
||||
def _deploy_local(
|
||||
*,
|
||||
config: pathlib.Path,
|
||||
config_json: dict,
|
||||
verbose: bool,
|
||||
api_version: str | None,
|
||||
host_url: str | None,
|
||||
api_key: str,
|
||||
deployment_id: str | None,
|
||||
deployment_type: str,
|
||||
name: str | None,
|
||||
image_name: str | None,
|
||||
image_tag: str,
|
||||
base_image: str | None,
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
no_wait: bool,
|
||||
pull: bool,
|
||||
docker_build_args: Sequence[str],
|
||||
secrets: list[dict[str, str]],
|
||||
):
|
||||
"""Local Docker build + push deploy path."""
|
||||
needs_buildx = platform.machine() != "x86_64"
|
||||
local_tag = f"langgraph-deploy-tmp:{int(time.time())}"
|
||||
|
||||
with Runner() as runner:
|
||||
langgraph_cli.docker.check_capabilities(
|
||||
runner,
|
||||
require_compose=False,
|
||||
require_buildx=needs_buildx,
|
||||
)
|
||||
|
||||
def log_step(message: str) -> None:
|
||||
click.secho(message, fg="cyan")
|
||||
|
||||
step = 1
|
||||
|
||||
log_step(f"{step}. Building image")
|
||||
if needs_buildx:
|
||||
build_flags: list[str] = [
|
||||
"--platform",
|
||||
"linux/amd64",
|
||||
"--load",
|
||||
]
|
||||
if not verbose:
|
||||
build_flags.append("--progress=quiet")
|
||||
with Progress(message="Building...", elapsed=not verbose):
|
||||
_build(
|
||||
runner,
|
||||
lambda _msg: None,
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
local_tag,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
docker_command=("docker", "buildx", "build"),
|
||||
extra_flags=build_flags,
|
||||
verbose=verbose,
|
||||
)
|
||||
else:
|
||||
with Progress(message="Building...", elapsed=not verbose):
|
||||
_build(
|
||||
runner,
|
||||
lambda _msg: None,
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
local_tag,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
verbose=verbose,
|
||||
)
|
||||
step += 1
|
||||
|
||||
client = HostBackendClient(host_url, api_key)
|
||||
|
||||
deployment_id = _find_or_create_deployment(
|
||||
client, deployment_id, name, deployment_type, secrets, "internal_docker",
|
||||
step, log_step,
|
||||
)
|
||||
step += 1
|
||||
|
||||
log_step(f"{step}. Requesting push token")
|
||||
push_data = client.request_push_token(deployment_id)
|
||||
deployment_token = push_data.get("token")
|
||||
registry_url = push_data.get("registry_url")
|
||||
if not deployment_token or not registry_url:
|
||||
raise click.ClickException(
|
||||
"Push token response missing token or registry_url"
|
||||
)
|
||||
step += 1
|
||||
|
||||
normalized_registry = registry_url.rstrip("/")
|
||||
if "://" in normalized_registry:
|
||||
normalized_registry = normalized_registry.split("//", 1)[1]
|
||||
repo_seed = image_name or name or config.parent.name
|
||||
repo_name = _normalize_image_name(repo_seed)
|
||||
tag_value = _normalize_image_tag(image_tag)
|
||||
remote_image = f"{normalized_registry}/{repo_name}:{tag_value}"
|
||||
|
||||
registry_host = normalized_registry.split("/")[0]
|
||||
|
||||
with _docker_config_for_token(registry_host, deployment_token) as cfg:
|
||||
log_step(f"{step}. Logging into {registry_host}")
|
||||
token_input = (
|
||||
deployment_token
|
||||
if deployment_token.endswith("\n")
|
||||
else f"{deployment_token}\n"
|
||||
)
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"--config",
|
||||
cfg,
|
||||
"login",
|
||||
"-u",
|
||||
"oauth2accesstoken",
|
||||
"--password-stdin",
|
||||
registry_host,
|
||||
input=token_input,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
step += 1
|
||||
|
||||
log_step(f"{step}. Pushing image {remote_image}")
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"tag",
|
||||
local_tag,
|
||||
remote_image,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
with Progress(message="Pushing...", elapsed=not verbose):
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"--config",
|
||||
cfg,
|
||||
"push",
|
||||
remote_image,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
step += 1
|
||||
|
||||
log_step(f"{step}. Updating deployment {deployment_id}")
|
||||
client.update_deployment(deployment_id, remote_image, secrets=secrets)
|
||||
|
||||
if no_wait:
|
||||
click.secho(" Deployment updated", fg="green")
|
||||
return
|
||||
|
||||
_poll_revision_status(client, deployment_id, verbose=verbose)
|
||||
|
||||
|
||||
def _deploy_remote(
|
||||
*,
|
||||
config: pathlib.Path,
|
||||
config_json: dict,
|
||||
verbose: bool,
|
||||
api_version: str | None,
|
||||
host_url: str | None,
|
||||
api_key: str,
|
||||
deployment_id: str | None,
|
||||
deployment_type: str,
|
||||
name: str | None,
|
||||
base_image: str | None,
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
no_wait: bool,
|
||||
secrets: list[dict[str, str]],
|
||||
):
|
||||
"""Remote build deploy path (no local Docker required)."""
|
||||
import urllib.request
|
||||
|
||||
from langgraph_cli.archive import create_archive
|
||||
|
||||
def log_step(message: str) -> None:
|
||||
click.secho(message, fg="cyan")
|
||||
|
||||
step = 1
|
||||
click.secho("Docker not available. Using remote build.", fg="yellow")
|
||||
|
||||
# -- Step: Create tarball --
|
||||
log_step(f"{step}. Creating source archive")
|
||||
try:
|
||||
archive_path, file_size = create_archive(config)
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nCancelled.")
|
||||
raise click.exceptions.Exit(1)
|
||||
click.secho(
|
||||
f" Archive created ({file_size / 1_048_576:.1f} MB)", fg="green"
|
||||
)
|
||||
step += 1
|
||||
|
||||
client = HostBackendClient(host_url, api_key)
|
||||
|
||||
# -- Step: Find or create deployment --
|
||||
deployment_id = _find_or_create_deployment(
|
||||
client, deployment_id, name, deployment_type, secrets, "internal_source",
|
||||
step, log_step,
|
||||
)
|
||||
step += 1
|
||||
|
||||
# -- Step: Request upload URL --
|
||||
log_step(f"{step}. Requesting upload URL")
|
||||
upload_data = client.request_upload_url(deployment_id)
|
||||
signed_url = upload_data.get("upload_url")
|
||||
object_path = upload_data.get("object_path")
|
||||
if not signed_url or not object_path:
|
||||
raise click.ClickException("Upload URL response missing required fields")
|
||||
step += 1
|
||||
|
||||
# -- Step: Upload tarball --
|
||||
log_step(f"{step}. Uploading source")
|
||||
try:
|
||||
_upload_to_gcs(signed_url, archive_path, file_size)
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nUpload cancelled.")
|
||||
raise click.exceptions.Exit(1)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(archive_path)
|
||||
except OSError:
|
||||
pass
|
||||
step += 1
|
||||
|
||||
# -- Step: Update deployment --
|
||||
log_step(f"{step}. Triggering remote build")
|
||||
client.update_deployment_internal_source(
|
||||
deployment_id,
|
||||
source_tarball_path=object_path,
|
||||
secrets=secrets,
|
||||
config_path=config.name,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
)
|
||||
step += 1
|
||||
|
||||
if no_wait:
|
||||
click.secho(" Build triggered", fg="green")
|
||||
return
|
||||
|
||||
# -- Poll revision status with optional log streaming --
|
||||
_poll_revision_status(client, deployment_id, verbose=verbose, is_remote_build=True)
|
||||
|
||||
|
||||
def _upload_to_gcs(signed_url: str, file_path: str, file_size: int) -> None:
|
||||
"""Upload tarball to GCS via signed PUT URL with progress display."""
|
||||
import urllib.request
|
||||
|
||||
uploaded = 0
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
original_read = f.read
|
||||
|
||||
def tracked_read(size=-1):
|
||||
nonlocal uploaded
|
||||
data = original_read(size)
|
||||
if data:
|
||||
uploaded += len(data)
|
||||
pct = int(uploaded * 100 / file_size) if file_size else 100
|
||||
click.echo(
|
||||
f"\r Uploading ({file_size / 1_048_576:.1f} MB)... {pct}%",
|
||||
nl=False,
|
||||
)
|
||||
return data
|
||||
|
||||
f.read = tracked_read
|
||||
|
||||
req = urllib.request.Request(
|
||||
signed_url,
|
||||
data=f,
|
||||
method="PUT",
|
||||
headers={
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Length": str(file_size),
|
||||
"X-Goog-Content-Length-Range": "0,209715200",
|
||||
},
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
except urllib.error.HTTPError as err:
|
||||
detail = err.read().decode("utf-8", errors="ignore")
|
||||
raise click.ClickException(
|
||||
f"Upload failed with status {err.code}: {detail}"
|
||||
) from None
|
||||
click.echo()
|
||||
|
||||
|
||||
def _find_or_create_deployment(
|
||||
client: HostBackendClient,
|
||||
deployment_id: str | None,
|
||||
name: str | None,
|
||||
deployment_type: str,
|
||||
secrets: list[dict[str, str]],
|
||||
source: str,
|
||||
step: int,
|
||||
log_step: Callable[[str], None],
|
||||
) -> str:
|
||||
"""Find an existing deployment or create a new one. Returns deployment_id."""
|
||||
if deployment_id:
|
||||
log_step(f"{step}. Using deployment {deployment_id}")
|
||||
return deployment_id
|
||||
|
||||
log_step(f"{step}. Looking up deployment '{name}'")
|
||||
existing = client.list_deployments(name_contains=name)
|
||||
found_id = None
|
||||
if isinstance(existing, dict):
|
||||
for dep in existing.get("resources", []):
|
||||
if isinstance(dep, dict) and dep.get("name") == name:
|
||||
found_id = dep.get("id")
|
||||
break
|
||||
if found_id:
|
||||
deployment_id = str(found_id)
|
||||
click.secho(
|
||||
f" Found existing deployment (ID: {deployment_id})",
|
||||
fg="green",
|
||||
)
|
||||
return deployment_id
|
||||
|
||||
log_step(f" Creating deployment '{name}'")
|
||||
payload = {
|
||||
"name": name,
|
||||
"source": source,
|
||||
"source_config": {"deployment_type": deployment_type},
|
||||
"source_revision_config": {},
|
||||
"secrets": secrets,
|
||||
}
|
||||
created = client.create_deployment(payload)
|
||||
created_id = created.get("id") if isinstance(created, dict) else None
|
||||
if not isinstance(created_id, str) or not created_id:
|
||||
raise HostBackendError(
|
||||
"POST /v2/deployments succeeded but response missing a valid 'id'"
|
||||
)
|
||||
deployment_id = created_id
|
||||
click.secho(f" Deployment ID: {deployment_id}", fg="green")
|
||||
return deployment_id
|
||||
|
||||
|
||||
def _poll_revision_status(
|
||||
client: HostBackendClient,
|
||||
deployment_id: str,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
is_remote_build: bool = False,
|
||||
) -> None:
|
||||
"""Poll revision status until terminal, optionally streaming build logs."""
|
||||
revisions_resp = client.list_revisions(deployment_id, limit=1)
|
||||
resources = (
|
||||
revisions_resp.get("resources", [])
|
||||
if isinstance(revisions_resp, dict)
|
||||
else []
|
||||
)
|
||||
if not resources:
|
||||
click.secho(" Deployment updated", fg="green")
|
||||
return
|
||||
|
||||
revision_id = str(resources[0]["id"])
|
||||
last_status = ""
|
||||
log_offset: str | None = None
|
||||
|
||||
deadline = time.time() + 900 if is_remote_build else time.time() + 300
|
||||
with Progress(message="Deploying...", elapsed=True) as set_progress:
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
rev = client.get_revision(deployment_id, revision_id)
|
||||
except KeyboardInterrupt:
|
||||
set_progress("")
|
||||
click.secho(
|
||||
f"\n Interrupted. Deployment ID: {deployment_id}, "
|
||||
f"Revision ID: {revision_id}",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(
|
||||
" The build will continue remotely.",
|
||||
fg="yellow",
|
||||
)
|
||||
raise click.exceptions.Exit(1)
|
||||
|
||||
status = (
|
||||
rev.get("status", "UNKNOWN") if isinstance(rev, dict) else "UNKNOWN"
|
||||
)
|
||||
if status != last_status:
|
||||
last_status = status
|
||||
set_progress("")
|
||||
click.secho(f" Status: {status}", fg="cyan")
|
||||
if status in _TERMINAL_STATUSES:
|
||||
break
|
||||
set_progress(f"{status}...")
|
||||
|
||||
# Stream build logs when verbose and building
|
||||
if (
|
||||
is_remote_build
|
||||
and verbose
|
||||
and status in ("AWAITING_BUILD", "BUILDING")
|
||||
):
|
||||
try:
|
||||
logs_resp = client.list_build_logs(
|
||||
deployment_id, revision_id, offset=log_offset
|
||||
)
|
||||
if isinstance(logs_resp, dict):
|
||||
for entry in logs_resp.get("logs", []):
|
||||
msg = entry.get("message", "")
|
||||
if msg:
|
||||
click.echo(f" | {msg}")
|
||||
log_offset = logs_resp.get("next_offset") or log_offset
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(3)
|
||||
else:
|
||||
set_progress("")
|
||||
|
||||
# On BUILD_FAILED, tail the last few build log lines
|
||||
if is_remote_build and last_status == "BUILD_FAILED" and not verbose:
|
||||
click.secho(" Last build log lines:", fg="red")
|
||||
try:
|
||||
logs_resp = client.list_build_logs(
|
||||
deployment_id, revision_id, order="desc", limit=30
|
||||
)
|
||||
if isinstance(logs_resp, dict):
|
||||
entries = list(reversed(logs_resp.get("logs", [])))
|
||||
for entry in entries:
|
||||
msg = entry.get("message", "")
|
||||
if msg:
|
||||
click.echo(f" | {msg}")
|
||||
except Exception:
|
||||
click.secho(" (failed to fetch build logs)", fg="red")
|
||||
click.secho(
|
||||
" Re-run with --verbose to see full build output.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
dep_info = client.get_deployment(deployment_id)
|
||||
custom_url = None
|
||||
if isinstance(dep_info, dict):
|
||||
sc = dep_info.get("source_config")
|
||||
if isinstance(sc, dict):
|
||||
custom_url = sc.get("custom_url")
|
||||
|
||||
if last_status == "DEPLOYED":
|
||||
click.secho(" Deployment successful!", fg="green")
|
||||
if custom_url:
|
||||
click.secho(f" URL: {custom_url}", fg="green")
|
||||
elif last_status in ("BUILD_FAILED", "DEPLOY_FAILED", "CREATE_FAILED"):
|
||||
click.secho(f" Deployment failed: {last_status}", fg="red")
|
||||
raise click.exceptions.Exit(1)
|
||||
else:
|
||||
click.secho(
|
||||
f" Timed out waiting for deployment (last status: {last_status}).",
|
||||
fg="yellow",
|
||||
)
|
||||
if custom_url:
|
||||
click.secho(
|
||||
f" Check status at: {custom_url}",
|
||||
fg="yellow",
|
||||
)
|
||||
else:
|
||||
click.secho(
|
||||
" Check status in the LangSmith Deployments dashboard.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_image_name(value: str | None) -> str:
|
||||
if not value:
|
||||
return "app"
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", value.lower()).strip("-.")
|
||||
return slug or "app"
|
||||
|
||||
|
||||
def _normalize_image_tag(value: str) -> str:
|
||||
if not value:
|
||||
value = "latest"
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+", value):
|
||||
raise click.UsageError(
|
||||
"Image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _get_docker_ignore_content() -> str:
|
||||
"""Return the content of a .dockerignore file.
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ DockerComposeType = Literal["plugin", "standalone"]
|
||||
|
||||
class DockerCapabilities(NamedTuple):
|
||||
version_docker: Version
|
||||
version_compose: Version
|
||||
version_compose: Version | None
|
||||
healthcheck_start_interval: bool
|
||||
compose_type: DockerComposeType = "plugin"
|
||||
compose_type: DockerComposeType | None = None
|
||||
|
||||
|
||||
def _parse_version(version: str) -> Version:
|
||||
@@ -45,7 +45,26 @@ def _parse_version(version: str) -> Version:
|
||||
)
|
||||
|
||||
|
||||
def check_capabilities(runner) -> DockerCapabilities:
|
||||
def is_docker_available() -> bool:
|
||||
"""Check if Docker is installed and running without raising."""
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_capabilities(
|
||||
runner, *, require_compose: bool = True, require_buildx: bool = False
|
||||
) -> DockerCapabilities:
|
||||
# check docker available
|
||||
if shutil.which("docker") is None:
|
||||
raise click.UsageError("Docker not installed") from None
|
||||
@@ -61,25 +80,33 @@ def check_capabilities(runner) -> DockerCapabilities:
|
||||
if not info["ServerVersion"]:
|
||||
raise click.UsageError("Docker not running") from None
|
||||
|
||||
compose_type: DockerComposeType
|
||||
try:
|
||||
compose = next(
|
||||
p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
|
||||
)
|
||||
compose_version_str = compose["Version"]
|
||||
compose_type = "plugin"
|
||||
except (KeyError, StopIteration):
|
||||
if shutil.which("docker-compose") is None:
|
||||
raise click.UsageError("Docker Compose not installed") from None
|
||||
compose_type: DockerComposeType | None = None
|
||||
compose_version: Version | None = None
|
||||
if require_compose:
|
||||
try:
|
||||
compose = next(
|
||||
p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
|
||||
)
|
||||
compose_version_str = compose["Version"]
|
||||
compose_type = "plugin"
|
||||
except (KeyError, StopIteration):
|
||||
if shutil.which("docker-compose") is None:
|
||||
raise click.UsageError("Docker Compose not installed") from None
|
||||
|
||||
compose_version_str, _ = runner.run(
|
||||
subp_exec("docker-compose", "--version", "--short", collect=True)
|
||||
)
|
||||
compose_type = "standalone"
|
||||
compose_version_str, _ = runner.run(
|
||||
subp_exec("docker-compose", "--version", "--short", collect=True)
|
||||
)
|
||||
compose_type = "standalone"
|
||||
compose_version = _parse_version(compose_version_str)
|
||||
|
||||
if require_buildx:
|
||||
try:
|
||||
runner.run(subp_exec("docker", "buildx", "version", collect=True))
|
||||
except click.exceptions.Exit:
|
||||
raise click.UsageError("Docker Buildx not installed") from None
|
||||
|
||||
# parse versions
|
||||
docker_version = _parse_version(info["ServerVersion"])
|
||||
compose_version = _parse_version(compose_version_str)
|
||||
|
||||
# check capabilities
|
||||
return DockerCapabilities(
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""HTTP client for LangGraph host backend deployments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class HostBackendError(click.ClickException):
|
||||
"""Raised when the host backend returns an error response."""
|
||||
|
||||
|
||||
class HostBackendClient:
|
||||
"""Minimal JSON HTTP client for the host backend deployment service."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
if not base_url:
|
||||
raise click.UsageError("Host backend URL is required")
|
||||
base_url = base_url.rstrip("/")
|
||||
self._base_url = base_url
|
||||
self._api_key = api_key
|
||||
|
||||
def _request(
|
||||
self, method: str, path: str, payload: dict[str, Any] | None = None
|
||||
) -> Any:
|
||||
url = f"{self._base_url}{path}"
|
||||
data: bytes | None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
else:
|
||||
data = None
|
||||
headers: dict[str, str] = {
|
||||
"X-Api-Key": self._api_key,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read()
|
||||
except urllib.error.HTTPError as err:
|
||||
detail = err.read().decode("utf-8", errors="ignore")
|
||||
message = detail or err.reason
|
||||
raise HostBackendError(
|
||||
f"{method} {path} failed with status {err.code}: {message}"
|
||||
) from None
|
||||
except urllib.error.URLError as err:
|
||||
raise HostBackendError(str(err.reason)) from None
|
||||
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as err:
|
||||
raise HostBackendError(
|
||||
f"Failed to decode response from {path}: {err.msg}"
|
||||
) from None
|
||||
|
||||
def create_deployment(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._request("POST", "/v2/deployments", payload)
|
||||
|
||||
def list_deployments(self, name_contains: str) -> dict[str, Any]:
|
||||
encoded = urllib.parse.quote(name_contains, safe="")
|
||||
return self._request("GET", f"/v2/deployments?name_contains={encoded}")
|
||||
|
||||
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/v2/deployments/{deployment_id}")
|
||||
|
||||
def request_push_token(self, deployment_id: str) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v2/deployments/{deployment_id}/push-token",
|
||||
)
|
||||
|
||||
def update_deployment(
|
||||
self,
|
||||
deployment_id: str,
|
||||
image_uri: str,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"source_revision_config": {"image_uri": image_uri},
|
||||
}
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
return self._request(
|
||||
"PATCH",
|
||||
f"/v2/deployments/{deployment_id}",
|
||||
payload,
|
||||
)
|
||||
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
|
||||
)
|
||||
|
||||
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
|
||||
)
|
||||
|
||||
def request_upload_url(self, deployment_id: str) -> dict[str, Any]:
|
||||
"""Get a signed GCS URL for uploading the source tarball."""
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v2/deployments/{deployment_id}/upload-url",
|
||||
)
|
||||
|
||||
def update_deployment_internal_source(
|
||||
self,
|
||||
deployment_id: str,
|
||||
source_tarball_path: str,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
config_path: str | None = None,
|
||||
install_command: str | None = None,
|
||||
build_command: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Trigger a remote build revision with the uploaded tarball."""
|
||||
src_config: dict[str, Any] = {
|
||||
"source_tarball_path": source_tarball_path,
|
||||
}
|
||||
if config_path is not None:
|
||||
src_config["langgraph_config_path"] = config_path
|
||||
|
||||
payload: dict[str, Any] = {"source_revision_config": src_config}
|
||||
|
||||
source_config: dict[str, Any] = {}
|
||||
if install_command is not None:
|
||||
source_config["install_command"] = install_command
|
||||
if build_command is not None:
|
||||
source_config["build_command"] = build_command
|
||||
if source_config:
|
||||
payload["source_config"] = source_config
|
||||
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
|
||||
|
||||
def list_build_logs(
|
||||
self,
|
||||
deployment_id: str,
|
||||
revision_id: str,
|
||||
order: str = "asc",
|
||||
limit: int = 50,
|
||||
offset: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch build logs for a revision."""
|
||||
payload: dict[str, Any] = {"order": order, "limit": limit}
|
||||
if offset:
|
||||
payload["offset"] = offset
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v1/projects/{deployment_id}/revisions/{revision_id}/build_logs",
|
||||
payload,
|
||||
)
|
||||
@@ -12,8 +12,11 @@ class Progress:
|
||||
while True:
|
||||
yield from "|/-\\"
|
||||
|
||||
def __init__(self, *, message=""):
|
||||
def __init__(self, *, message="", elapsed: bool = False):
|
||||
self.message = message
|
||||
self._base_message = message
|
||||
self._show_elapsed = elapsed
|
||||
self._stop = threading.Event()
|
||||
self.spinner_generator = self.spinning_cursor()
|
||||
|
||||
def spinner_iteration(self):
|
||||
@@ -21,7 +24,6 @@ class Progress:
|
||||
sys.stdout.write(next(self.spinner_generator) + " " + message)
|
||||
sys.stdout.flush()
|
||||
time.sleep(self.delay)
|
||||
# clear the spinner and message
|
||||
sys.stdout.write(
|
||||
"\b" * (len(message) + 2)
|
||||
+ " " * (len(message) + 2)
|
||||
@@ -29,13 +31,26 @@ class Progress:
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
def _format_elapsed(self, seconds: float) -> str:
|
||||
mins, secs = divmod(int(seconds), 60)
|
||||
if mins:
|
||||
return f"{self._base_message} ({mins}m {secs:02d}s)"
|
||||
return f"{self._base_message} ({secs}s)"
|
||||
|
||||
def spinner_task(self):
|
||||
while self.message:
|
||||
start = time.monotonic()
|
||||
while not self._stop.is_set():
|
||||
if not self.message:
|
||||
time.sleep(self.delay)
|
||||
continue
|
||||
if self._show_elapsed:
|
||||
self.message = self._format_elapsed(time.monotonic() - start)
|
||||
message = self.message
|
||||
if not message:
|
||||
continue
|
||||
sys.stdout.write(next(self.spinner_generator) + " " + message)
|
||||
sys.stdout.flush()
|
||||
time.sleep(self.delay)
|
||||
# clear the spinner and message
|
||||
sys.stdout.write(
|
||||
"\b" * (len(message) + 2)
|
||||
+ " " * (len(message) + 2)
|
||||
@@ -50,21 +65,22 @@ class Progress:
|
||||
|
||||
def set_message(message):
|
||||
self.message = message
|
||||
if not message:
|
||||
self.thread.join()
|
||||
self._base_message = message or self._base_message
|
||||
|
||||
return set_message
|
||||
else:
|
||||
|
||||
def set_message(message):
|
||||
sys.stderr.write(message + "\n")
|
||||
sys.stderr.flush()
|
||||
if message:
|
||||
sys.stderr.write(message + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
return set_message
|
||||
|
||||
def __exit__(self, exception, value, tb):
|
||||
if sys.stdout.isatty():
|
||||
self.message = ""
|
||||
self._stop.set()
|
||||
try:
|
||||
self.thread.join()
|
||||
finally:
|
||||
|
||||
@@ -126,7 +126,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
|
||||
@@ -146,34 +146,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +306,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.
|
||||
|
||||
@@ -26,7 +26,7 @@ inmem = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/cli"
|
||||
Twitter = "https://x.com/LangChainAI"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -591,27 +591,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",
|
||||
|
||||
@@ -591,27 +591,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",
|
||||
|
||||
@@ -38,7 +38,7 @@ Homepage = "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
Documentation = "https://reference.langchain.com/python/langgraph/"
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/langgraph"
|
||||
Changelog = "https://github.com/langchain-ai/langgraph/releases"
|
||||
Twitter = "https://x.com/LangChainAI"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt"
|
||||
Twitter = "https://x.com/LangChainAI"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ path = "langgraph_sdk/__init__.py"
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py"
|
||||
Twitter = "https://x.com/LangChainAI"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user