Compare commits

..
Author SHA1 Message Date
William Fu-Hinthorn 701907adf4 Full validatoin 2025-04-08 14:26:13 -07:00
47 changed files with 449 additions and 2089 deletions
+2 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
build:
@@ -71,3 +71,4 @@ jobs:
working-directory: libs/cli/js-examples
run: |
langgraph build -t langgraph-test-e
+7 -1
View File
@@ -9,7 +9,7 @@ on:
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
# This env var allows us to get inline annotations when ruff has complaints.
RUFF_OUTPUT_FORMAT: github
@@ -50,6 +50,12 @@ jobs:
working-directory: ${{ inputs.working-directory }}
run: poetry check
- name: Check lock file
if: steps.changed-files.outputs.all
shell: bash
working-directory: ${{ inputs.working-directory }}
run: poetry check --lock
- name: Install dependencies
if: steps.changed-files.outputs.all
# Also installs dev/lint/test/typing dependencies, to ensure we have
+7 -1
View File
@@ -9,7 +9,7 @@ on:
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
build:
@@ -39,6 +39,12 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_RO_TOKEN }}
- name: Check Lock
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
poetry check --lock
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.working-directory }}
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
build:
+1 -1
View File
@@ -9,7 +9,7 @@ on:
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
PYTHON_VERSION: "3.10"
jobs:
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
build:
+1 -1
View File
@@ -8,7 +8,7 @@ on:
- "libs/**"
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
benchmark:
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- "libs/**"
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
benchmark:
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
cancel-in-progress: true
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
changes:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
permissions:
contents: read
+6 -6
View File
@@ -12,7 +12,7 @@ on:
workflow_dispatch:
env:
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
markdown-link-check:
@@ -42,8 +42,8 @@ jobs:
- name: Check README.md is in sync
run: |
if ! diff -q README.md libs/langgraph/README.md >/dev/null; then
echo "README.md is out of sync with libs/langgraph/README.md"
diff -C 3 README.md libs/langgraph/README.md
exit 1
fi
if ! diff -q README.md libs/langgraph/README.md >/dev/null; then
echo "README.md is out of sync with libs/langgraph/README.md"
diff -C 3 README.md libs/langgraph/README.md
exit 1
fi
+1 -1
View File
@@ -10,7 +10,7 @@ on:
env:
PYTHON_VERSION: "3.11"
POETRY_VERSION: "2.1.2"
POETRY_VERSION: "1.7.1"
jobs:
build:
+3 -3
View File
@@ -9,7 +9,7 @@ on:
type: string
description: "JSON string of changed files"
schedule:
- cron: "0 13 * * *"
- cron: '0 13 * * *'
defaults:
run:
@@ -30,12 +30,12 @@ jobs:
uses: "./.github/actions/poetry_setup"
with:
python-version: 3.11
poetry-version: 2.1.2
poetry-version: 1.7.1
cache-key: test-langgraph-notebooks
- name: Install dependencies
run: |
poetry install --with test --no-root
poetry install --with test
poetry run pip install jupyter
- name: Start services
+1 -4
View File
@@ -1,9 +1,6 @@
# How to integrate LangGraph into your React application
!!! info "Prerequisites"
- [LangGraph Platform](../../concepts/langgraph_platform.md)
- [LangGraph Server](../../concepts/langgraph_server.md)
!!! info "Prerequisites" - [LangGraph Platform](../../concepts/langgraph_platform.md) - [LangGraph Server](../../concepts/langgraph_server.md)
The `useStream()` React hook provides a seamless way to integrate LangGraph into your React applications. It handles all the complexities of streaming, state management, and branching logic, letting you focus on building great chat experiences.
+6 -3
View File
@@ -2,6 +2,10 @@
LangGraph Platform provides a flexible authentication and authorization system that can integrate with most authentication schemes.
!!! note "Python only"
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
## Core Concepts
### Authentication vs Authorization
@@ -142,7 +146,7 @@ The returned user information is available:
After authentication, LangGraph calls your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers to control access to specific resources (e.g., threads, assistants, crons). These handlers can:
1. Add metadata to be saved during resource creation by mutating the `value["metadata"]` dictionary directly. See the [supported actions table](#supported-actions) for the list of types the value can take for each action.
1. Add metadata to be saved during resource creation by mutating the `value["metadata"]` dictionary directly. See the [supported actions table](##supported-actions) for the list of types the value can take for each action.
2. Filter resources by metadata during search/list or read operations by returning a [filter dictionary](#filter-operations).
3. Raise an HTTP exception if access is denied.
@@ -285,7 +289,7 @@ async def on_assistant_create(
)
```
Notice that we are mixing global and resource-specific handlers in the above example. Since each request is handled by the most specific handler, a request to create a `thread` would match the `on_thread_create` handler but NOT the `reject_unhandled_requests` handler. A request to `update` a thread, however would be handled by the global handler, since we don't have a more specific handler for that resource and action.
Notice that we are mixing global and resource-specific handlers in the above example. Since each request is handled by the most specific handler, a request to create a `thread` would match the `on_thread_create` handler but NOT the `reject_unhandled_requests` handler. A request to `update` a thread, however would be handled by the global handler, since we don't have a more specific handler for that resource and action. Requests to create, update,
### Filter Operations {#filter-operations}
@@ -419,7 +423,6 @@ Here are all the supported action handlers:
| | `@auth.on.crons.search` | Listing cron jobs | [`CronsSearch`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.CronsSearch) |
???+ note "About Runs"
Runs are scoped to their parent thread for access control. This means permissions are typically inherited from the thread, reflecting the conversational nature of the data model. All run operations (reading, listing) except creation are controlled by the thread's handlers.
There is a specific `create_run` handler for creating new runs because it had more arguments that you can view in the handler.
+4
View File
@@ -9,6 +9,10 @@
For a more guided walkthrough, see [**setting up custom authentication**](../../tutorials/auth/getting_started.md) tutorial.
???+ note "Python only"
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
???+ note "Support by deployment type"
Custom auth is supported for all deployments in the **managed LangGraph Cloud**, as well as **Enterprise** self-hosted plans. It is not supported for **Lite** self-hosted plans.
+1 -1
View File
@@ -2,7 +2,7 @@
!!! tip "Prerequisites"
This guide assumes familiarity with the [LangGraph Platform](../../concepts/index.md#langgraph-platform), [Persistence](../../concepts/persistence.md), and [Cross-thread persistence](../../concepts/persistence.md#memory-store) concepts.
This guide assumes familiarity with the [LangGraph Platform](../../concepts/index.md#langgraph-platform), [Persistence](../../concepts/persistence.md), and [Cross-thread persistence](../../concepts/store.md) concepts.
???+ note "LangGraph platform only"
+1 -4
View File
@@ -1,6 +1,6 @@
---
hide_comments: true
title: LangGraph
title: Home
---
<script>
@@ -23,9 +23,6 @@ title: LangGraph
.md-content h1 {
display: none;
}
.md-header__topic {
display: none;
}
</style>
{!../README.md!}
@@ -1,6 +1,5 @@
import asyncio
import threading
import warnings
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Optional
@@ -151,7 +150,7 @@ def _dump_blobs(
checkpoint_ns: str,
values: dict[str, Any],
versions: ChannelVersions,
) -> list[tuple[str, str, str, str, Optional[bytes]]]:
) -> list[tuple[str, str, str, str, str, Optional[bytes]]]:
if not versions:
return []
@@ -189,12 +188,6 @@ class ShallowPostgresSaver(BasePostgresSaver):
pipe: Optional[Pipeline] = None,
serde: Optional[SerializerProtocol] = None,
) -> None:
warnings.warn(
"ShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
"Use PostgresSaver instead, and invoke the graph with `graph.invoke(..., checkpoint_during=False)`.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(serde=serde)
if isinstance(conn, ConnectionPool) and pipe is not None:
raise ValueError(
@@ -535,12 +528,6 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
pipe: Optional[AsyncPipeline] = None,
serde: Optional[SerializerProtocol] = None,
) -> None:
warnings.warn(
"AsyncShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
"Use AsyncPostgresSaver instead, and invoke the graph with `await graph.ainvoke(..., checkpoint_during=False)`.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(serde=serde)
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
raise ValueError(
+26 -4
View File
@@ -274,13 +274,23 @@ def _build(
tag: str,
passthrough: Sequence[str] = (),
):
base_image = base_image or (
"langchain/langgraphjs-api"
if config_json.get("node_version")
else "langchain/langgraph-api"
)
# pull latest images
if pull:
runner.run(
subp_exec(
"docker",
"pull",
langgraph_cli.config.docker_tag(config_json, base_image),
(
f"{base_image}:{config_json['node_version']}"
if config_json.get("node_version")
else f"{base_image}:{config_json['python_version']}"
),
verbose=True,
)
)
@@ -440,7 +450,11 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
dockerfile, additional_contexts = langgraph_cli.config.config_to_docker(
config,
config_json,
None,
(
"langchain/langgraphjs-api"
if config_json.get("node_version")
else "langchain/langgraph-api"
),
)
with open(str(save_path), "w", encoding="utf-8") as f:
f.write(dockerfile)
@@ -705,7 +719,11 @@ def prepare_args_and_stdin(
config_path,
config,
watch=watch,
base_image=langgraph_cli.config.default_base_image(config),
base_image=(
"langchain/langgraphjs-api"
if config.get("node_version")
else "langchain/langgraph-api"
),
)
return args, stdin
@@ -732,7 +750,11 @@ def prepare(
subp_exec(
"docker",
"pull",
langgraph_cli.config.docker_tag(config_json),
(
f"langchain/langgraphjs-api:{config_json['node_version']}"
if config_json.get("node_version")
else f"langchain/langgraph-api:{config_json['python_version']}"
),
verbose=verbose,
)
)
+85 -134
View File
@@ -8,10 +8,7 @@ from typing import Any, Literal, NamedTuple, Optional, TypedDict, Union
import click
MIN_NODE_VERSION = "20"
DEFAULT_NODE_VERSION = "20"
MIN_PYTHON_VERSION = "3.11"
DEFAULT_PYTHON_VERSION = "3.11"
class TTLConfig(TypedDict, total=False):
@@ -409,18 +406,6 @@ class Config(TypedDict, total=False):
"""
PIP_CLEANUP_LINES = """# -- Ensure user deps didn't inadvertently overwrite langgraph-api
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \
touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir --no-deps -e /api
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
# -- Removing pip from the final image ~<:===~~~ --
RUN pip uninstall -y pip setuptools wheel && \
rm -rf /usr/local/lib/python*/site-packages/pip* /usr/local/lib/python*/site-packages/setuptools* /usr/local/lib/python*/site-packages/wheel* && \
find /usr/local/bin -name "pip*" -delete
# -- End of pip removal --"""
def _parse_version(version_str: str) -> tuple[int, int]:
"""Parse a version string into a tuple of (major, minor)."""
try:
@@ -443,48 +428,38 @@ def _parse_node_version(version_str: str) -> int:
) from None
def _is_python_graph(spec: Union[str, dict]) -> bool:
"""Check if a graph is a Python graph based on the file extension."""
# handle new style config
if isinstance(spec, dict):
spec = spec.get("path")
file_path = spec.split(":")[0]
file_ext = os.path.splitext(file_path)[1]
return file_ext in [".py", ".pyx", ".pyd", ".pyi"]
def validate_config(config: Config) -> Config:
"""Validate a configuration dictionary."""
graphs = config.get("graphs", {})
some_python = any(_is_python_graph(spec) for spec in graphs.values())
some_node = any(not _is_python_graph(spec) for spec in graphs.values())
node_version = config.get(
"node_version", DEFAULT_NODE_VERSION if some_node else None
config = (
{
"node_version": config.get("node_version"),
"dockerfile_lines": config.get("dockerfile_lines", []),
"dependencies": config.get("dependencies", []),
"graphs": config.get("graphs", {}),
"env": config.get("env", {}),
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
"checkpointer": config.get("checkpointer"),
"ui": config.get("ui"),
"ui_config": config.get("ui_config"),
}
if config.get("node_version")
else {
"python_version": config.get("python_version", "3.11"),
"pip_config_file": config.get("pip_config_file"),
"dockerfile_lines": config.get("dockerfile_lines", []),
"dependencies": config.get("dependencies", []),
"graphs": config.get("graphs", {}),
"env": config.get("env", {}),
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
"checkpointer": config.get("checkpointer"),
"ui": config.get("ui"),
"ui_config": config.get("ui_config"),
}
)
python_version = config.get(
"python_version", DEFAULT_PYTHON_VERSION if some_python else None
)
config = {
"node_version": node_version,
"python_version": python_version,
"pip_config_file": config.get("pip_config_file"),
"dependencies": config.get("dependencies", []),
"dockerfile_lines": config.get("dockerfile_lines", []),
"graphs": config.get("graphs", {}),
"env": config.get("env", {}),
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
"checkpointer": config.get("checkpointer"),
"ui": config.get("ui"),
"ui_config": config.get("ui_config"),
}
if config.get("node_version"):
node_version = config["node_version"]
@@ -1098,11 +1073,26 @@ ADD {relpath} /deps/{name}
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
)
install_node_str: str = (
"RUN /storage/install-node.sh"
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir
else ""
)
ui_inst_str: str = ""
install_node_str: str = ""
if config.get("ui") and local_deps.working_dir:
install_node_str = "RUN /storage/install-node.sh"
ui_inst: list[str] = []
ui_inst.append(f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'")
if config.get("ui_config"):
ui_inst.append(
f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'"
)
ui_inst.append(
f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts",
)
ui_inst_str = f"""# -- Installing UI dependencies --
{os.linesep.join(ui_inst)}
# -- End of UI dependencies install --"""
installs = f"{os.linesep}{os.linesep}".join(
filter(
@@ -1134,24 +1124,8 @@ ADD {relpath} /deps/{name}
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'])}'")
js_inst_str: str = ""
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir:
js_inst_str = os.linesep.join(
[
"# -- Installing JS dependencies --",
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}",
f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts",
"# -- End of JS dependencies install --",
]
)
graphs = config["graphs"]
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'")
docker_file_contents = [
f"FROM {base_image}:{config['python_version']}",
@@ -1165,9 +1139,7 @@ ADD {relpath} /deps/{name}
"# -- End of local dependencies install --",
os.linesep.join(env_vars),
"",
js_inst_str,
"",
PIP_CLEANUP_LINES, # Add pip cleanup after all installations are complete
ui_inst_str,
"",
f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
]
@@ -1190,70 +1162,51 @@ def node_config_to_docker(
) -> tuple[str, dict[str, str]]:
faux_path = f"/deps/{config_path.parent.name}"
install_cmd = _get_node_pm_install_cmd(config_path, config)
env_vars: list[str] = []
if (store_config := config.get("store")) is not None:
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
store_config = config.get("store")
env_additional_config = (
""
if not store_config
else 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)}'")
env_additional_config += f"""
ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'
"""
if (http_config := config.get("http")) is not None:
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
env_additional_config += f"""
ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'
"""
if (checkpointer_config := config.get("checkpointer")) is not None:
env_vars.append(
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
)
env_additional_config += f"""
ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'
"""
if ui := config.get("ui"):
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
return (
f"""FROM {base_image}:{config['node_version']}
if ui_config := config.get("ui_config"):
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
{os.linesep.join(config["dockerfile_lines"])}
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
ADD . {faux_path}
docker_file_contents = [
f"FROM {base_image}:{config['node_version']}",
"",
os.linesep.join(config["dockerfile_lines"]),
"",
f"ADD . {faux_path}",
"",
f"RUN cd {faux_path} && {install_cmd}",
"",
os.linesep.join(env_vars),
"",
f"WORKDIR {faux_path}",
"",
'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts',
]
RUN cd {faux_path} && {install_cmd}
{env_additional_config}
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
{f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'" if config.get("ui") else ""}
{f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'" if config.get("ui_config") else ""}
return os.linesep.join(docker_file_contents), {}
WORKDIR {faux_path}
def default_base_image(config: Config) -> str:
if config.get("node_version") and not config.get("python_version"):
return "langchain/langgraphjs-api"
return "langchain/langgraph-api"
def docker_tag(config: Config, base_image: Optional[str] = None) -> str:
base_image = base_image or default_base_image(config)
if config.get("node_version") and not config.get("python_version"):
return f"{base_image}:{config['node_version']}"
return f"{base_image}:{config['python_version']}"
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts""",
{},
)
def config_to_docker(
config_path: pathlib.Path, config: Config, base_image: Optional[str] = None
config_path: pathlib.Path, config: Config, base_image: str
) -> tuple[str, dict[str, str]]:
base_image = base_image or default_base_image(config)
if config.get("node_version") and not config.get("python_version"):
if config.get("node_version"):
return node_config_to_docker(config_path, config, base_image)
return python_config_to_docker(config_path, config, base_image)
@@ -1262,11 +1215,9 @@ def config_to_docker(
def config_to_compose(
config_path: pathlib.Path,
config: Config,
base_image: Optional[str] = None,
base_image: str,
watch: bool = False,
) -> str:
base_image = base_image or default_base_image(config)
env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
env_vars_str = "\n".join(f' {k}: "{v}"' for k, v in env_vars)
env_file_str = (
+20 -33
View File
@@ -20,7 +20,7 @@ description = "High level compatibility layer for multiple asynchronous event lo
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"},
{file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"},
@@ -59,7 +59,7 @@ description = "Python package for providing Mozilla's CA Bundle."
optional = true
python-versions = ">=3.6"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"},
{file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"},
@@ -405,7 +405,7 @@ description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = true
python-versions = ">=3.7"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
@@ -418,7 +418,7 @@ description = "A minimal low-level HTTP client."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"},
{file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"},
@@ -441,7 +441,7 @@ description = "The next generation HTTP client."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -467,7 +467,7 @@ description = "Internationalized Domain Names in Applications (IDNA)"
optional = true
python-versions = ">=3.6"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@@ -614,27 +614,27 @@ langgraph-sdk = ">=0.1.42,<0.2.0"
[[package]]
name = "langgraph-api"
version = "0.1.0"
version = "0.0.42"
description = ""
optional = true
python-versions = "<4.0,>=3.11.0"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langgraph_api-0.1.0-py3-none-any.whl", hash = "sha256:93eb369849d5ea0dd6076f0a36fe0a9669415c46e95fcb28ccf30c73e13e5e01"},
{file = "langgraph_api-0.1.0.tar.gz", hash = "sha256:d02201d34172f3020af8f21c4560142b302474cee4a27f4335fbd950ab22f22f"},
{file = "langgraph_api-0.0.42-py3-none-any.whl", hash = "sha256:19f69d9d39efde60a9bd3eeae6dc7dbe8d04b1b6fccf4ddf51d7e6b7187cc6ea"},
{file = "langgraph_api-0.0.42.tar.gz", hash = "sha256:a0a18545c73f9703d5d5907fc030e4a0acb79d1e6b79d4e38b3cac2bfb470e97"},
]
[package.dependencies]
blockbuster = ">=1.5.24,<2.0.0"
cloudpickle = ">=3.0.0,<4.0.0"
cryptography = ">=42.0.0,<45.0"
cryptography = ">=43.0.3,<44.0.0"
httpx = ">=0.25.0"
jsonschema-rs = ">=0.20.0,<0.30"
langchain-core = ">=0.2.38,<0.4.0"
langgraph = ">=0.2.56,<0.4.0"
langgraph-checkpoint = ">=2.0.23,<3.0"
langgraph-sdk = ">=0.1.61,<0.2.0"
langgraph-sdk = ">=0.1.59,<0.2.0"
langsmith = ">=0.1.63,<0.4.0"
orjson = ">=3.9.7"
pyjwt = ">=2.9.0,<3.0.0"
@@ -679,30 +679,17 @@ files = [
langchain-core = ">=0.2.43,<0.3.0 || >0.3.0,<0.3.1 || >0.3.1,<0.3.2 || >0.3.2,<0.3.3 || >0.3.3,<0.3.4 || >0.3.4,<0.3.5 || >0.3.5,<0.3.6 || >0.3.6,<0.3.7 || >0.3.7,<0.3.8 || >0.3.8,<0.3.9 || >0.3.9,<0.3.10 || >0.3.10,<0.3.11 || >0.3.11,<0.3.12 || >0.3.12,<0.3.13 || >0.3.13,<0.3.14 || >0.3.14,<0.3.15 || >0.3.15,<0.3.16 || >0.3.16,<0.3.17 || >0.3.17,<0.3.18 || >0.3.18,<0.3.19 || >0.3.19,<0.3.20 || >0.3.20,<0.3.21 || >0.3.21,<0.3.22 || >0.3.22,<0.4.0"
langgraph-checkpoint = ">=2.0.10,<3.0.0"
[[package]]
name = "langgraph-runtime-inmem"
version = "0.0.1"
description = "Inmem implementation for the LangGraph API server."
optional = true
python-versions = ">=3.11"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langgraph_runtime_inmem-0.0.1-py3-none-any.whl", hash = "sha256:a25ec8e3219f2fd60450de38412d24c83fbf0b2521c13871cc26ce9a68ead496"},
{file = "langgraph_runtime_inmem-0.0.1.tar.gz", hash = "sha256:144bf5217efec4969f7f9c5e8279d6914cc5133d4b1a19e466b4966fa05f00c5"},
]
[[package]]
name = "langgraph-sdk"
version = "0.1.61"
version = "0.1.60"
description = "SDK for interacting with LangGraph API"
optional = true
python-versions = "<4.0.0,>=3.9.0"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langgraph_sdk-0.1.61-py3-none-any.whl", hash = "sha256:f2d774b12497c428862993090622d51e0dbc3f53e0cee3d74a13c7495d835cc6"},
{file = "langgraph_sdk-0.1.61.tar.gz", hash = "sha256:87dd1f07ab82da8875ac343268ece8bf5414632017ebc9d1cef4b523962fd601"},
{file = "langgraph_sdk-0.1.60-py3-none-any.whl", hash = "sha256:953df85b0a6cc3a106f0496ce8f950a65d88b3ba8198c3b4bb58a54469b256a9"},
{file = "langgraph_sdk-0.1.60.tar.gz", hash = "sha256:7857a4a2a20a6a4c9934d1e7b5145eda92e3bc7286121813de2464d071050f88"},
]
[package.dependencies]
@@ -864,7 +851,7 @@ description = "Fast, correct Python JSON library supporting dataclasses, datetim
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "orjson-3.10.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:552c883d03ad185f720d0c09583ebde257e41b9521b74ff40e08b7dec4559c04"},
{file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e3e8d438d02e4854f70bfdc03a6bcdb697358dbaa6bcd19cbe24d24ece1f8"},
@@ -1427,7 +1414,7 @@ description = "Sniff out which async library your code is running under"
optional = true
python-versions = ">=3.7"
groups = ["main"]
markers = "python_version >= \"3.11\""
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -1563,7 +1550,7 @@ files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
markers = {main = "python_version >= \"3.11\" and (python_version < \"3.13\" or extra == \"inmem\")"}
markers = {main = "python_version >= \"3.11\" and extra == \"inmem\""}
[[package]]
name = "urllib3"
@@ -1847,9 +1834,9 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\
cffi = ["cffi (>=1.11)"]
[extras]
inmem = ["langgraph-api", "langgraph-runtime-inmem", "python-dotenv"]
inmem = ["langgraph-api", "python-dotenv"]
[metadata]
lock-version = "2.1"
python-versions = "^3.9.0,<4.0"
content-hash = "afc2f8776b4b6144bd1197df49ba34089889e2a1110b8470d8f1b212e0b08380"
content-hash = "4a45d739795019ae00e18ba8b0d366209deca9c5a5e65e9f387e5cf1d5aef187"
+3 -5
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-cli"
version = "0.2.2"
version = "0.1.89"
description = "CLI for interacting with LangGraph API"
authors = []
license = "MIT"
@@ -14,9 +14,7 @@ langgraph = "langgraph_cli.cli:cli"
[tool.poetry.dependencies]
python = "^3.9.0,<4.0"
click = "^8.1.7"
langgraph-api = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-runtime-inmem = { version = ">=0.0.1,<0.1.0", optional = true, python = ">=3.11,<4.0" }
langgraph-sdk = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-api = { version = ">=0.0.42,<0.1.0", optional = true, python = ">=3.11,<4.0" }
python-dotenv = { version = ">=0.8.0", optional = true }
[tool.poetry.group.dev.dependencies]
@@ -30,7 +28,7 @@ mypy = "^1.10.0"
msgspec = "^0.19.0"
[tool.poetry.extras]
inmem = ["langgraph-api", "langgraph-runtime-inmem", "python-dotenv"]
inmem = ["langgraph-api", "python-dotenv"]
[tool.pytest.ini_options]
# --strict-markers will raise errors on unknown marks.
+2 -5
View File
@@ -2,14 +2,13 @@ import json
import pathlib
import shutil
import tempfile
import textwrap
from contextlib import contextmanager
from pathlib import Path
from click.testing import CliRunner
from langgraph_cli.cli import cli, prepare_args_and_stdin
from langgraph_cli.config import PIP_CLEANUP_LINES, Config, validate_config
from langgraph_cli.config import Config, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
from langgraph_cli.util import clean_empty_lines
@@ -144,7 +143,6 @@ services:
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
WORKDIR /deps/cli
develop:
@@ -178,9 +176,8 @@ def test_dockerfile_command_basic() -> None:
"""Test the 'dockerfile' command with basic configuration."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"node_version": "20", # Add any other necessary configuration fields
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
+37 -150
View File
@@ -2,13 +2,11 @@ import json
import os
import pathlib
import tempfile
import textwrap
import click
import pytest
from langgraph_cli.config import (
PIP_CLEANUP_LINES,
config_to_compose,
config_to_docker,
validate_config,
@@ -27,10 +25,8 @@ def test_validate_config():
"agent": "./agent.py:graph",
},
}
actual_config = validate_config(expected_config)
expected_config = {
"python_version": "3.11",
"node_version": None,
"pip_config_file": None,
"dockerfile_lines": [],
"env": {},
@@ -42,13 +38,13 @@ def test_validate_config():
"ui_config": None,
**expected_config,
}
actual_config = validate_config(expected_config)
assert actual_config == expected_config
# full config
env = ".env"
expected_config = {
"python_version": "3.12",
"node_version": None,
"pip_config_file": "pipconfig.txt",
"dockerfile_lines": ["ARG meow"],
"dependencies": [".", "langchain"],
@@ -71,12 +67,16 @@ def test_validate_config():
# check wrong python version raises
with pytest.raises(click.UsageError):
validate_config({"python_version": "3.9"})
validate_config(
{
"python_version": "3.9",
}
)
# check missing dependencies key raises
with pytest.raises(click.UsageError):
validate_config(
{"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}}
{"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}},
)
# check missing graphs key raises
@@ -194,47 +194,6 @@ def test_validate_config_file():
validate_config_file(config_path)
def test_validate_config_multiplatform():
# default node
config = validate_config(
{"dependencies": ["."], "graphs": {"js": "./js.mts:graph"}}
)
assert config["node_version"] == "20"
assert config["python_version"] is None
# default multiplatform
config = validate_config(
{
"node_version": "22",
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"python": "./python.py:graph", "js": "./js.mts:graph"},
}
)
assert config["node_version"] == "22"
assert config["python_version"] == "3.12"
# default multiplatform (full infer)
graphs = {"python": "./python.py:graph", "js": "./js.mts:graph"}
config = validate_config({"dependencies": ["."], "graphs": graphs})
assert config["node_version"] == "20"
assert config["python_version"] == "3.11"
# default multiplatform (partial node)
config = validate_config(
{"node_version": "22", "dependencies": ["."], "graphs": graphs}
)
assert config["node_version"] == "22"
assert config["python_version"] == "3.11"
# default multiplatform (partial python)
config = validate_config(
{"python_version": "3.12", "dependencies": ["."], "graphs": graphs}
)
assert config["node_version"] == "20"
assert config["python_version"] == "3.12"
# config_to_docker
def test_config_to_docker_simple():
graphs = {"agent": "./agent.py:graph"}
@@ -249,7 +208,7 @@ def test_config_to_docker_simple():
),
"langchain/langgraph-api",
)
expected_docker_stdin = f"""\
expected_docker_stdin = """\
FROM langchain/langgraph-api:3.11
# -- Installing local requirements --
COPY --from=__outer_requirements.txt requirements.txt /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt
@@ -283,9 +242,8 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGGRAPH_HTTP='{{"app": "/deps/examples/my_app.py:app"}}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{PIP_CLEANUP_LINES}
ENV LANGGRAPH_HTTP='{"app": "/deps/examples/my_app.py:app"}'
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
@@ -305,8 +263,7 @@ def test_config_to_docker_outside_path():
validate_config({"dependencies": [".", ".."], "graphs": graphs}),
"langchain/langgraph-api",
)
expected_docker_stdin = (
"""\
expected_docker_stdin = """\
FROM langchain/langgraph-api:3.11
# -- Adding non-package dependency unit_tests --
ADD . /deps/__outer_unit_tests/unit_tests
@@ -334,12 +291,8 @@ RUN set -ex && \\
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
"""
+ PIP_CLEANUP_LINES
+ """
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
)
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {
"__outer_tests": str(pathlib.Path(__file__).parent.parent.absolute()),
@@ -359,8 +312,7 @@ def test_config_to_docker_pipconfig():
),
"langchain/langgraph-api",
)
expected_docker_stdin = (
"""\
expected_docker_stdin = """\
FROM langchain/langgraph-api:3.11
ADD pipconfig.txt /pipconfig.txt
# -- Adding non-package dependency unit_tests --
@@ -378,12 +330,8 @@ RUN set -ex && \\
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
"""
+ PIP_CLEANUP_LINES
+ """
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
)
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -420,7 +368,7 @@ def test_config_to_docker_local_deps():
),
"langchain/langgraph-api-custom",
)
expected_docker_stdin = f"""\
expected_docker_stdin = """\
FROM langchain/langgraph-api-custom:3.11
# -- Adding non-package dependency graphs --
ADD ./graphs /deps/__outer_graphs/src
@@ -436,8 +384,7 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_graphs/src/agent.py:graph"}}'
{PIP_CLEANUP_LINES}\
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'\
"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -464,8 +411,7 @@ dependencies = ["langchain"]"""
"langchain/langgraph-api",
)
os.remove(pyproject_path)
expected_docker_stdin = (
"""FROM langchain/langgraph-api:3.11
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
# -- Adding local package . --
ADD . /deps/unit_tests
# -- End of local package . --
@@ -473,12 +419,7 @@ ADD . /deps/unit_tests
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/unit_tests/graphs/agent.py:graph"}'
"""
+ PIP_CLEANUP_LINES
+ "\n"
+ "WORKDIR /deps/unit_tests"
""
)
WORKDIR /deps/unit_tests"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -498,7 +439,7 @@ def test_config_to_docker_end_to_end():
),
"langchain/langgraph-api",
)
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.12
expected_docker_stdin = """FROM langchain/langgraph-api:3.12
ARG meow
ARG foo
ADD pipconfig.txt /pipconfig.txt
@@ -517,8 +458,7 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_graphs/src/agent.py:graph"}}'
{PIP_CLEANUP_LINES}"""
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -533,7 +473,6 @@ def test_config_to_docker_nodejs():
"node_version": "20",
"graphs": graphs,
"dockerfile_lines": ["ARG meow", "ARG foo"],
"auth": {"path": "./graphs/auth.mts:auth"},
"ui": {"agent": "./graphs/agent.ui.jsx"},
"ui_config": {"shared": ["nuqs"]},
}
@@ -545,10 +484,9 @@ ARG meow
ARG foo
ADD . /deps/unit_tests
RUN cd /deps/unit_tests && npm i
ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
WORKDIR /deps/unit_tests
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
@@ -571,7 +509,7 @@ def test_config_to_docker_gen_ui_python():
"langchain/langgraph-api",
)
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
RUN /storage/install-node.sh
# -- Adding non-package dependency unit_tests --
ADD . /deps/__outer_unit_tests/unit_tests
@@ -587,55 +525,12 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGGRAPH_UI='{{"agent": "./graphs/agent.ui.jsx"}}'
ENV LANGGRAPH_UI_CONFIG='{{"shared": ["nuqs"]}}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
# -- Installing JS dependencies --
ENV NODE_VERSION=20
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
# -- Installing UI dependencies --
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
# -- End of JS dependencies install --
{PIP_CLEANUP_LINES}
WORKDIR /deps/__outer_unit_tests/unit_tests"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
def test_config_to_docker_multiplatform():
graphs = {
"python": "./multiplatform/python.py:graph",
"js": "./multiplatform/js.mts:graph",
}
actual_docker_stdin, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{"node_version": "22", "dependencies": ["."], "graphs": graphs}
),
"langchain/langgraph-api",
)
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
RUN /storage/install-node.sh
# -- Adding non-package dependency unit_tests --
ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\
for line in '[project]' \\
'name = "unit_tests"' \\
'version = "0.1"' \\
'[tool.setuptools.package-data]' \\
'"*" = ["**/*"]'; do \\
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
done
# -- End of non-package dependency unit_tests --
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"python": "/deps/__outer_unit_tests/unit_tests/multiplatform/python.py:graph", "js": "/deps/__outer_unit_tests/unit_tests/multiplatform/js.mts:graph"}}'
# -- Installing JS dependencies --
ENV NODE_VERSION=22
RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
# -- End of JS dependencies install --
{PIP_CLEANUP_LINES}
# -- End of UI dependencies install --
WORKDIR /deps/__outer_unit_tests/unit_tests"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
@@ -645,8 +540,8 @@ WORKDIR /deps/__outer_unit_tests/unit_tests"""
# config_to_compose
def test_config_to_compose_simple_config():
graphs = {"agent": "./agent.py:graph"}
# Create a properly indented version of PIP_CLEANUP_LINES for compose files
expected_compose_stdin = f"""
expected_compose_stdin = """\
pull_policy: build
build:
context: .
@@ -666,8 +561,7 @@ def test_config_to_compose_simple_config():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
actual_compose_stdin = config_to_compose(
@@ -675,15 +569,12 @@ def test_config_to_compose_simple_config():
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
)
assert (
clean_empty_lines(actual_compose_stdin).strip()
== expected_compose_stdin.strip()
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
def test_config_to_compose_env_vars():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = f""" OPENAI_API_KEY: "key"
expected_compose_stdin = """ OPENAI_API_KEY: "key"
pull_policy: build
build:
@@ -704,8 +595,7 @@ def test_config_to_compose_env_vars():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
openai_api_key = "key"
@@ -725,7 +615,7 @@ def test_config_to_compose_env_vars():
def test_config_to_compose_env_file():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = f"""\
expected_compose_stdin = """\
env_file: .env
pull_policy: build
build:
@@ -746,8 +636,7 @@ def test_config_to_compose_env_file():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
actual_compose_stdin = config_to_compose(
@@ -760,7 +649,7 @@ def test_config_to_compose_env_file():
def test_config_to_compose_watch():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = f"""\
expected_compose_stdin = """\
pull_policy: build
build:
@@ -781,8 +670,7 @@ def test_config_to_compose_watch():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
develop:
@@ -804,7 +692,7 @@ def test_config_to_compose_watch():
def test_config_to_compose_end_to_end():
# test all of the above + langgraph API path
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = f"""\
expected_compose_stdin = """\
env_file: .env
pull_policy: build
build:
@@ -825,8 +713,7 @@ def test_config_to_compose_end_to_end():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
WORKDIR /deps/__outer_unit_tests/unit_tests
develop:
-4
View File
@@ -26,7 +26,6 @@ async def arun(graph: Pregel, input: dict):
"configurable": {"thread_id": str(uuid4())},
"recursion_limit": 1000000000,
},
checkpoint_during=False,
)
]
)
@@ -43,7 +42,6 @@ async def arun_first_event_latency(graph: Pregel, input: dict) -> None:
"configurable": {"thread_id": str(uuid4())},
"recursion_limit": 1000000000,
},
checkpoint_during=False,
)
try:
@@ -63,7 +61,6 @@ def run(graph: Pregel, input: dict):
"configurable": {"thread_id": str(uuid4())},
"recursion_limit": 1000000000,
},
checkpoint_during=False,
)
]
)
@@ -80,7 +77,6 @@ def run_first_event_latency(graph: Pregel, input: dict) -> None:
"configurable": {"thread_id": str(uuid4())},
"recursion_limit": 1000000000,
},
checkpoint_during=False,
)
try:
-2
View File
@@ -83,8 +83,6 @@ CONFIG_KEY_PREVIOUS = sys.intern("__pregel_previous")
# holds the previous return value from a stateful Pregel graph.
CONFIG_KEY_RUNNER_SUBMIT = sys.intern("__pregel_runner_submit")
# holds a function that receives tasks from runner, executes them and returns results
CONFIG_KEY_CHECKPOINT_DURING = sys.intern("__pregel_checkpoint_during")
# holds a boolean indicating whether to checkpoint during the run (or only at the end)
# --- Other constants ---
PUSH = sys.intern("__pregel_push")
+15 -280
View File
@@ -1,320 +1,55 @@
import functools
import logging
import weakref
from inspect import isclass
from typing import (
Any,
Callable,
Optional,
Type,
Union,
get_args,
get_origin,
get_type_hints,
)
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Annotated
__all__ = ["SchemaCoercionMapper"]
logger = logging.getLogger(__name__)
_cache: weakref.WeakKeyDictionary[Type[Any], dict[int, "SchemaCoercionMapper"]] = (
_cache: weakref.WeakKeyDictionary[Type[Any], "SchemaCoercionMapper"] = (
weakref.WeakKeyDictionary()
)
class SchemaCoercionMapper:
"""Lightweight coercion of *dict* → *BaseModel* instances."""
__slots__ = ("_inited", "schema", "_fields", "_construct", "_field_coercers")
def __new__(
cls,
schema: Type[Any],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
**kwargs: Any,
) -> "SchemaCoercionMapper":
by_depth = _cache.setdefault(schema, {})
if max_depth in by_depth:
return by_depth[max_depth]
if schema in _cache:
return _cache[schema]
inst = super().__new__(cls)
by_depth[max_depth] = inst
_cache[schema] = inst
return inst
def __init__(
self,
schema: Type[Any],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
) -> None:
if hasattr(self, "_initialised"):
**kwargs: Any,
):
if hasattr(self, "_inited"):
return
self._initialised = True
self.schema = schema
self.max_depth = max_depth
self.type_hints = (
type_hints
if type_hints is not None
else get_type_hints(schema, localns={schema.__name__: schema})
)
self._inited = True
if issubclass(schema, BaseModelV1):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.__fields__.items()
}
self._construct = schema.construct
self._construct: Callable[..., Any] = schema.parse_obj
elif issubclass(schema, BaseModel):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.model_fields.items()
}
self._construct: Callable[..., Any] = schema.model_construct # type: ignore
self._construct = schema.model_validate
else:
raise TypeError("Schema is neither a Pydantic v1 nor v2 model.")
self._field_coercers: Optional[dict[str, Callable[[Any, int], Any]]] = None
raise TypeError("Schema is neither valid Pydantic v1 nor v2 model.")
def __call__(self, input_data: Any, depth: Optional[int] = None) -> Any:
return self.coerce(input_data, depth)
def coerce(self, input_data: Any, depth: Optional[int] = None) -> Any:
if depth is None:
depth = self.max_depth
if not isinstance(input_data, dict) or depth <= 0:
if not isinstance(input_data, dict):
return input_data
if self._field_coercers is None:
self._field_coercers = {
n: self._build_coercer(t, depth - 1) for n, t in self._fields.items()
}
processed: dict[str, Any] = {}
for k, v in input_data.items():
fn = self._field_coercers.get(k)
processed[k] = fn(v, depth - 1) if fn else v
return self._construct(**processed)
def _build_coercer(
self, field_type: Any, depth: int, *, throw: bool = False
) -> Callable[[Any, Any], Any]:
if depth == 0:
return self._passthrough
origin = get_origin(field_type)
if (field_type in _IDENTITY_TYPES) or (origin in _IDENTITY_TYPES):
return self._passthrough
if origin is Annotated:
real_type, *_ = get_args(field_type)
sub = self._build_coercer(real_type, depth - 1)
return lambda v, d: sub(v, d)
if isclass(field_type):
# This is needed bcs. of issubclass issues on older versions of python
is_class_ = True
try:
is_bm_v2 = issubclass(field_type, BaseModel)
except TypeError:
# python < 3.11 issue.
is_class_ = False
is_bm_v2 = False
if is_bm_v2 or (is_class_ and issubclass(field_type, BaseModelV1)):
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
if origin is list:
args = get_args(field_type)
if len(args) != 1:
return self._passthrough
sub = self._build_coercer(args[0], depth - 1)
def list_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, (list, tuple)):
return v
return [sub(x, d - 1) for x in v]
return list_coercer
if origin is set or field_type is set:
args = get_args(field_type)
if len(args) > 1:
return self._passthrough
elif len(args) == 1:
sub = self._build_coercer(args[0], depth - 1)
else:
sub = None # type: ignore
def set_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, (list, tuple, set)):
return v
if sub is None:
return set(v)
return {sub(x, d - 1) for x in v}
return set_coercer
if origin is dict or field_type is dict:
args = get_args(field_type)
if len(args) != 2:
def dict_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError("Expected dict, got %s" % type(v))
return v
return dict_coercer
k_sub = self._build_coercer(args[0], depth - 1)
v_sub = self._build_coercer(args[1], depth - 1)
def dict_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError("Expected dict, got %s" % type(v))
return v
return {k_sub(k, d - 1): v_sub(val, d - 1) for k, val in v.items()}
return dict_coercer
if origin is tuple:
elem_types = get_args(field_type)
if not elem_types:
return self._passthrough
subs = [self._build_coercer(t, depth - 1) for t in elem_types]
return lambda v, d: (
tuple(
subs[i](v[i] if i < len(v) else None, d - 1)
for i in range(len(subs))
)
if isinstance(v, (list, tuple))
else v
)
if origin is Union:
uargs = get_args(field_type)
subs, none_in_union = [], False
for ix, arg in enumerate(uargs):
if arg is type(None):
none_in_union = True
else:
subs.append(
self._build_coercer(arg, depth - 1, throw=ix < len(uargs) - 1)
)
def union_coercer(v: Any, d: Any) -> Any:
if v is None and none_in_union:
return None
err = None
for sp in subs:
try:
return sp(v, d - 1)
except TypeError as e:
err = e
if err:
raise err
return v
return union_coercer
adapter_fn = _get_adapter(field_type)
return lambda v, _d: adapter_fn(v)
@staticmethod
def _passthrough(v: Any, _d: Any) -> Any: # noqa: D401
return v
_adapter_cache: dict[Any, Callable[[Any], Any]] = {}
_IDENTITY_TYPES: tuple[type[Any], ...] = (
int,
float,
str,
bool,
bytes,
bytearray,
complex,
memoryview,
type(None),
)
try:
# Pydantic v2.
from pydantic import TypeAdapter
try:
import pydantic.v1.types as v1_types_
from pydantic.v1 import parse_obj_as
v1_types = tuple(
v for k, v in vars(v1_types_).items() if k in v1_types_.__all__
)
except ImportError:
v1_types = ()
def parse_obj_as(tp: Any, v: Any) -> Any: # type: ignore
return v
try:
from pydantic.v1 import parse_obj_as
from pydantic.v1.main import create_model
except ImportError:
create_model = None # type: ignore
def _get_v1_parser(tp: Any) -> Any:
if create_model is not None:
try:
parser = create_model(
f"ParsingModel[{tp}]",
__root__=(tp, ...),
)
return lambda v: parser(__root__=v).__root__ # type: ignore
except RuntimeError:
return lambda v: v
return lambda v: parse_obj_as(tp, v)
@functools.lru_cache(maxsize=2048)
def _adapter_for(tp: Any) -> Callable[[Any], Any]: # noqa: D401
if tp in v1_types:
return _get_v1_parser(tp)
try:
return TypeAdapter(
tp, config={"arbitrary_types_allowed": True}
).validate_python
except TypeError:
# Delayed classes like ConstrainedList
return _get_v1_parser(tp)
except ImportError:
# Pydantic V1
from pydantic.v1.main import create_model
@functools.lru_cache(maxsize=2048)
def _adapter_for(tp: Any) -> Callable[[Any], Any]: # noqa: D401
try:
parser = create_model(
f"ParsingModel[{tp}]",
__root__=(tp, ...),
)
return lambda v: parser(__root__=v).__root__ # type: ignore
except RuntimeError:
return lambda v: v
def _get_adapter(tp: Any) -> Callable[[Any], Any]:
try:
return _adapter_cache[tp]
except KeyError:
fn = _adapter_for(tp)
_adapter_cache[tp] = fn
return fn
return self._construct(input_data)
+6 -6
View File
@@ -776,13 +776,13 @@ class CompiledStateGraph(CompiledGraph):
return updates
elif (t := type(input)) and get_type_hints(t):
# Pydantic v2
if isinstance(input, BaseModelV1):
keep: Optional[set[str]] = input.__fields_set__
defaults = {k: v.default for k, v in t.__fields__.items()}
elif isinstance(input, BaseModel):
keep = input.model_fields_set
if isinstance(input, BaseModel):
keep: Optional[set[str]] = input.model_fields_set
defaults = {k: v.default for k, v in input.model_fields.items()}
# Pydantic v1
elif isinstance(input, BaseModelV1):
keep = input.__fields_set__
defaults = {k: v.default for k, v in t.__fields__.items()}
else:
keep = None
defaults = {}
@@ -1060,7 +1060,7 @@ def _pick_mapper(
if issubclass(schema, dict):
return None
if issubclass(schema, (BaseModel, BaseModelV1)):
return SchemaCoercionMapper(schema, type_hints=type_hints)
return SchemaCoercionMapper(schema)
return partial(_coerce_state, schema)
@@ -53,7 +53,6 @@ from langgraph.checkpoint.base import (
)
from langgraph.constants import (
CONF,
CONFIG_KEY_CHECKPOINT_DURING,
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_CHECKPOINTER,
@@ -2099,7 +2098,6 @@ class Pregel(PregelProtocol):
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
subgraphs: bool = False,
) -> Iterator[Union[dict[str, Any], Any]]:
@@ -2121,7 +2119,6 @@ class Pregel(PregelProtocol):
output_keys: The keys to stream, defaults to all non-context channels.
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
debug: Whether to print debug information during execution, defaults to False.
subgraphs: Whether to stream subgraphs, defaults to False.
@@ -2283,9 +2280,6 @@ class Pregel(PregelProtocol):
config[CONF][CONFIG_KEY_STREAM_WRITER] = lambda c: stream.put(
((), "custom", c)
)
# set checkpointing mode for subgraphs
if checkpoint_during is not None:
config[CONF][CONFIG_KEY_CHECKPOINT_DURING] = checkpoint_during
with SyncPregelLoop(
input,
input_model=self.input_model,
@@ -2301,9 +2295,6 @@ class Pregel(PregelProtocol):
interrupt_after=interrupt_after_,
manager=run_manager,
debug=debug,
checkpoint_during=checkpoint_during
if checkpoint_during is not None
else config[CONF].get(CONFIG_KEY_CHECKPOINT_DURING, True),
trigger_to_nodes=self.trigger_to_nodes,
migrate_checkpoint=self._migrate_checkpoint,
) as loop:
@@ -2386,7 +2377,6 @@ class Pregel(PregelProtocol):
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
subgraphs: bool = False,
) -> AsyncIterator[Union[dict[str, Any], Any]]:
@@ -2408,7 +2398,6 @@ class Pregel(PregelProtocol):
output_keys: The keys to stream, defaults to all non-context channels.
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
debug: Whether to print debug information during execution, defaults to False.
subgraphs: Whether to stream subgraphs, defaults to False.
@@ -2590,9 +2579,6 @@ class Pregel(PregelProtocol):
stream.put_nowait, ((), "custom", c)
)
)
# set checkpointing mode for subgraphs
if checkpoint_during is not None:
config[CONF][CONFIG_KEY_CHECKPOINT_DURING] = checkpoint_during
async with AsyncPregelLoop(
input,
input_model=self.input_model,
@@ -2608,9 +2594,6 @@ class Pregel(PregelProtocol):
interrupt_after=interrupt_after_,
manager=run_manager,
debug=debug,
checkpoint_during=checkpoint_during
if checkpoint_during is not None
else config[CONF].get(CONFIG_KEY_CHECKPOINT_DURING, True),
trigger_to_nodes=self.trigger_to_nodes,
migrate_checkpoint=self._migrate_checkpoint,
) as loop:
@@ -2686,7 +2669,6 @@ class Pregel(PregelProtocol):
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> Union[dict[str, Any], Any]:
@@ -2718,7 +2700,6 @@ class Pregel(PregelProtocol):
output_keys=output_keys,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
checkpoint_during=checkpoint_during,
debug=debug,
**kwargs,
):
@@ -2740,7 +2721,6 @@ class Pregel(PregelProtocol):
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> Union[dict[str, Any], Any]:
@@ -2773,7 +2753,6 @@ class Pregel(PregelProtocol):
output_keys=output_keys,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
checkpoint_during=checkpoint_during,
debug=debug,
**kwargs,
):
+44 -101
View File
@@ -63,7 +63,6 @@ from langgraph.constants import (
RESUME,
SCHEDULED,
TAG_HIDDEN,
TASKS,
)
from langgraph.errors import (
CheckpointNotLatest,
@@ -156,7 +155,7 @@ class PregelLoop(LoopProtocol):
manager: Union[None, AsyncParentRunManager, ParentRunManager]
interrupt_after: Union[All, Sequence[str]]
interrupt_before: Union[All, Sequence[str]]
checkpoint_during: bool
checkpoint_every_step: bool
debug: bool
checkpointer_get_next_version: GetNextVersion
@@ -181,7 +180,6 @@ class PregelLoop(LoopProtocol):
channels: Mapping[str, BaseChannel]
managed: ManagedValueMapping
checkpoint: Checkpoint
checkpoint_id_saved: str
checkpoint_ns: tuple[str, ...]
checkpoint_config: RunnableConfig
checkpoint_metadata: CheckpointMetadata
@@ -217,7 +215,7 @@ class PregelLoop(LoopProtocol):
debug: bool = False,
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
checkpoint_during: bool = True,
checkpoint_every_step: bool = True,
) -> None:
super().__init__(
step=0,
@@ -243,7 +241,7 @@ class PregelLoop(LoopProtocol):
)
self._migrate_checkpoint = migrate_checkpoint
self.trigger_to_nodes = trigger_to_nodes
self.checkpoint_during = checkpoint_during
self.checkpoint_every_step = checkpoint_every_step
self.debug = debug
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
@@ -296,19 +294,29 @@ class PregelLoop(LoopProtocol):
"""Put writes for a task, to be read by the next tick."""
if not writes:
return
# always checkpoint writes containing Send, as they are fetched from the
# parent checkpoint, not the current one
checkpoint_during = self.checkpoint_during or any(w[0] == TASKS for w in writes)
# deduplicate writes to special channels, last write wins
if all(w[0] in WRITES_IDX_MAP for w in writes):
writes = list({w[0]: w for w in writes}.values())
# remove existing writes for this task
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[0] != task_id
]
# save writes
self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes)
if checkpoint_during and self.checkpointer_put_writes is not None:
for c, v in writes:
if (
c in WRITES_IDX_MAP
and (
idx := next(
(
i
for i, w in enumerate(self.checkpoint_pending_writes)
if w[0] == task_id and w[1] == c
),
None,
)
)
is not None
):
self.checkpoint_pending_writes[idx] = (task_id, c, v)
else:
self.checkpoint_pending_writes.append((task_id, c, v))
if self.checkpointer_put_writes is not None:
config = patch_configurable(
self.checkpoint_config,
{
@@ -341,46 +349,6 @@ class PregelLoop(LoopProtocol):
if hasattr(self, "tasks"):
self._output_writes(task_id, writes)
def _put_pending_writes(self) -> None:
if self.checkpointer_put_writes is None:
return
if not self.checkpoint_pending_writes:
return
# patch config
config = patch_configurable(
self.checkpoint_config,
{
CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
CONFIG_KEY_CHECKPOINT_NS, ""
),
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
)
# group by task id
by_task = defaultdict(list)
for task_id, channel, value in self.checkpoint_pending_writes:
by_task[task_id].append((channel, value))
# submit writes to checkpointer
for task_id, writes in by_task.items():
if self.checkpointer_put_writes_accepts_task_path and hasattr(
self, "tasks"
):
task = self.tasks.get(task_id)
self.submit(
self.checkpointer_put_writes,
config,
writes,
task_id,
task_path_str(task.path) if task else "",
)
else:
self.submit(
self.checkpointer_put_writes,
config,
writes,
task_id,
)
def accept_push(
self, task: PregelExecutableTask, write_idx: int, call: Optional[Call] = None
) -> Optional[PregelExecutableTask]:
@@ -743,44 +711,32 @@ class PregelLoop(LoopProtocol):
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
# assign step and parents
exiting = metadata is self.checkpoint_metadata
if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
# checkpoint already saved
return
if not exiting:
metadata["step"] = self.step
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
self.checkpoint_metadata = metadata
# debug flag
if self.debug:
print_step_checkpoint(
metadata,
self.channels,
(
[self.stream_keys]
if isinstance(self.stream_keys, str)
else self.stream_keys
),
)
self.checkpoint_id_prev = self.checkpoint["id"] if self.step > -1 else None
# do checkpoint?
do_checkpoint = self._checkpointer_put_after_previous is not None and (
exiting or self.checkpoint_during
)
# create new checkpoint
self.checkpoint = create_checkpoint(
self.checkpoint,
self.channels if do_checkpoint else None,
self.step,
id=self.checkpoint["id"] if exiting else None,
)
metadata["step"] = self.step
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
# debug flag
if self.debug:
print_step_checkpoint(
metadata,
self.channels,
(
[self.stream_keys]
if isinstance(self.stream_keys, str)
else self.stream_keys
),
)
# bail if no checkpointer
if do_checkpoint and self._checkpointer_put_after_previous is not None:
if self._checkpointer_put_after_previous is not None:
for k, v in self.config["metadata"].items():
if k in EXCLUDED_METADATA_KEYS:
continue
metadata.setdefault(k, v) # type: ignore
# create new checkpoint
self.checkpoint = create_checkpoint(
self.checkpoint, self.channels, self.step
)
self.checkpoint_metadata = metadata
self.prev_checkpoint_config = (
self.checkpoint_config
if CONFIG_KEY_CHECKPOINT_ID in self.checkpoint_config[CONF]
@@ -791,8 +747,6 @@ class PregelLoop(LoopProtocol):
**self.checkpoint_config,
CONF: {
**self.checkpoint_config[CONF],
# this is guaranteed to be set by code above
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint_id_prev,
CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
CONFIG_KEY_CHECKPOINT_NS, ""
),
@@ -823,9 +777,8 @@ class PregelLoop(LoopProtocol):
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
}
if not exiting:
# increment step
self.step += 1
# increment step
self.step += 1
def _update_mv(self, key: str, values: Sequence[Any]) -> None:
raise NotImplementedError
@@ -836,10 +789,6 @@ class PregelLoop(LoopProtocol):
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Optional[bool]:
# persist current checkpoint and writes
if not self.checkpoint_during:
self._put_checkpoint(self.checkpoint_metadata)
self._put_pending_writes()
# suppress interrupt
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
if suppress:
@@ -958,7 +907,6 @@ class SyncPregelLoop(PregelLoop, ContextManager):
debug: bool = False,
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
checkpoint_during: bool = True,
) -> None:
super().__init__(
input,
@@ -977,7 +925,6 @@ class SyncPregelLoop(PregelLoop, ContextManager):
debug=debug,
migrate_checkpoint=migrate_checkpoint,
trigger_to_nodes=trigger_to_nodes,
checkpoint_during=checkpoint_during,
)
self.stack = ExitStack()
if checkpointer:
@@ -1057,7 +1004,6 @@ class SyncPregelLoop(PregelLoop, ContextManager):
},
}
self.prev_checkpoint_config = saved.parent_config
self.checkpoint_id_saved = saved.checkpoint["id"]
self.checkpoint = saved.checkpoint
self.checkpoint_metadata = saved.metadata
self.checkpoint_pending_writes = (
@@ -1108,7 +1054,6 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
debug: bool = False,
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
checkpoint_during: bool = True,
) -> None:
super().__init__(
input,
@@ -1127,7 +1072,6 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
debug=debug,
migrate_checkpoint=migrate_checkpoint,
trigger_to_nodes=trigger_to_nodes,
checkpoint_during=checkpoint_during,
)
self.stack = AsyncExitStack()
if checkpointer:
@@ -1207,7 +1151,6 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
},
}
self.prev_checkpoint_config = saved.parent_config
self.checkpoint_id_saved = saved.checkpoint["id"]
self.checkpoint = saved.checkpoint
self.checkpoint_metadata = saved.metadata
self.checkpoint_pending_writes = (
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph"
version = "0.3.27"
version = "0.3.26"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
license = "MIT"
+14 -27
View File
@@ -1,20 +1,20 @@
import pytest
from pytest_mock import MockerFixture
from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph
from tests.conftest import (
REGULAR_CHECKPOINTERS_ASYNC,
REGULAR_CHECKPOINTERS_SYNC,
ALL_CHECKPOINTERS_ASYNC,
ALL_CHECKPOINTERS_SYNC,
awith_checkpointer,
)
pytestmark = pytest.mark.anyio
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_interruption_without_state_updates(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
) -> None:
"""Test interruption without state updates. This test confirms that
interrupting doesn't require a state key having been updated in the prev step"""
@@ -40,27 +40,20 @@ def test_interruption_without_state_updates(
initial_input = {"input": "hello world"}
thread = {"configurable": {"thread_id": "1"}}
graph.invoke(initial_input, thread, checkpoint_during=checkpoint_during)
graph.invoke(initial_input, thread, debug=True)
assert graph.get_state(thread).next == ("step_2",)
n_checkpoints = len([c for c in graph.get_state_history(thread)])
assert n_checkpoints == (3 if checkpoint_during else 1)
graph.invoke(None, thread, checkpoint_during=checkpoint_during)
graph.invoke(None, thread, debug=True)
assert graph.get_state(thread).next == ("step_3",)
n_checkpoints = len([c for c in graph.get_state_history(thread)])
assert n_checkpoints == (4 if checkpoint_during else 2)
graph.invoke(None, thread, checkpoint_during=checkpoint_during)
graph.invoke(None, thread, debug=True)
assert graph.get_state(thread).next == ()
n_checkpoints = len([c for c in graph.get_state_history(thread)])
assert n_checkpoints == (5 if checkpoint_during else 3)
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_interruption_without_state_updates_async(
checkpointer_name: str, checkpoint_during: bool
) -> None:
checkpointer_name: str, mocker: MockerFixture
):
"""Test interruption without state updates. This test confirms that
interrupting doesn't require a state key having been updated in the prev step"""
@@ -85,17 +78,11 @@ async def test_interruption_without_state_updates_async(
initial_input = {"input": "hello world"}
thread = {"configurable": {"thread_id": "1"}}
await graph.ainvoke(initial_input, thread, checkpoint_during=checkpoint_during)
await graph.ainvoke(initial_input, thread, debug=True)
assert (await graph.aget_state(thread)).next == ("step_2",)
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
assert n_checkpoints == (3 if checkpoint_during else 1)
await graph.ainvoke(None, thread, checkpoint_during=checkpoint_during)
await graph.ainvoke(None, thread, debug=True)
assert (await graph.aget_state(thread)).next == ("step_3",)
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
assert n_checkpoints == (4 if checkpoint_during else 2)
await graph.ainvoke(None, thread, checkpoint_during=checkpoint_during)
await graph.ainvoke(None, thread, debug=True)
assert (await graph.aget_state(thread)).next == ()
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
assert n_checkpoints == (5 if checkpoint_during else 3)
+17 -15
View File
@@ -7258,10 +7258,9 @@ def test_branch_then(
)
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_send_dedupe_on_resume(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
@@ -7317,7 +7316,7 @@ def test_send_dedupe_on_resume(
graph = builder.compile(checkpointer=checkpointer)
thread1 = {"configurable": {"thread_id": "1"}}
assert graph.invoke(["0"], thread1, checkpoint_during=checkpoint_during) == [
assert graph.invoke(["0"], thread1, debug=1) == [
"0",
"1",
"3.1",
@@ -7334,11 +7333,12 @@ def test_send_dedupe_on_resume(
pytest.xfail("TODO: shallow checkpointer reports wrong next set")
assert state.next == ("flaky",)
# check history
history = [c for c in graph.get_state_history(thread1)]
assert len(history) == (4 if checkpoint_during else 1)
if "shallow" not in checkpointer_name:
history = [c for c in graph.get_state_history(thread1)]
assert len(history) == 4
# resume execution
assert graph.invoke(None, thread1, checkpoint_during=checkpoint_during) == [
assert graph.invoke(None, thread1, debug=1) == [
"0",
"1",
"3.1",
@@ -7358,7 +7358,6 @@ def test_send_dedupe_on_resume(
assert state.next == ()
# check history
history = [c for c in graph.get_state_history(thread1)]
assert len(history) == (6 if checkpoint_during else 2)
expected_history = [
StateSnapshot(
values=[
@@ -7495,9 +7494,13 @@ def test_send_dedupe_on_resume(
name="flaky",
path=("__pregel_push", 1),
error=None,
interrupts=(Interrupt(value="Bahh", resumable=False, ns=None),),
interrupts=(
Interrupt(
value="Bahh", resumable=False, ns=None, when="during"
),
),
state=None,
result=["flaky|4"] if checkpoint_during else None,
result=["flaky|4"],
),
PregelTask(
id=AnyStr(),
@@ -7634,11 +7637,10 @@ def test_send_dedupe_on_resume(
),
),
]
if checkpoint_during:
assert history == expected_history
else:
assert history[0] == expected_history[0]
assert history[1] == expected_history[2]
if "shallow" in checkpointer_name:
expected_history = expected_history[:1]
assert history == expected_history
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
+53 -339
View File
@@ -1,14 +1,9 @@
import datetime
import decimal
import enum
import functools
import gc
import ipaddress
import json
import logging
import operator
import pathlib
import re
import threading
import time
import uuid
@@ -17,7 +12,6 @@ from collections import Counter, deque
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass, field
from enum import Enum
from random import randrange
from typing import (
Annotated,
@@ -1121,14 +1115,10 @@ def test_invoke_checkpoint_two(
assert checkpoint["channel_values"].get("total") == 5
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_pending_writes_resume(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
checkpointer: BaseCheckpointSaver = request.getfixturevalue(
f"checkpointer_{checkpointer_name}"
)
@@ -1154,19 +1144,17 @@ def test_pending_writes_resume(
self.calls = 0
one = AwhileMaker(0.1, {"value": 2})
two = AwhileMaker(0.2, ConnectionError("I'm not good"))
two = AwhileMaker(0.3, ConnectionError("I'm not good"))
builder = StateGraph(State)
builder.add_node("one", one)
builder.add_node(
"two", two, retry=RetryPolicy(max_attempts=2, initial_interval=0, jitter=False)
)
builder.add_node("two", two, retry=RetryPolicy(max_attempts=2))
builder.add_edge(START, "one")
builder.add_edge(START, "two")
graph = builder.compile(checkpointer=checkpointer)
thread1: RunnableConfig = {"configurable": {"thread_id": "1"}}
with pytest.raises(ConnectionError, match="I'm not good"):
graph.invoke({"value": 1}, thread1, checkpoint_during=checkpoint_during)
graph.invoke({"value": 1}, thread1)
# both nodes should have been called once
assert one.calls == 1
@@ -1212,7 +1200,7 @@ def test_pending_writes_resume(
# resume execution
with pytest.raises(ConnectionError, match="I'm not good"):
graph.invoke(None, thread1, checkpoint_during=checkpoint_during)
graph.invoke(None, thread1)
# node "one" succeeded previously, so shouldn't be called again
assert one.calls == 1
@@ -1226,9 +1214,7 @@ def test_pending_writes_resume(
# resume execution, without exception
two.rtn = {"value": 3}
# both the pending write and the new write were applied, 1 + 2 + 3 = 6
assert graph.invoke(None, thread1, checkpoint_during=checkpoint_during) == {
"value": 6
}
assert graph.invoke(None, thread1) == {"value": 6}
if "shallow" in checkpointer_name:
assert len(list(checkpointer.list(thread1))) == 1
@@ -1237,7 +1223,7 @@ def test_pending_writes_resume(
# check all final checkpoints
checkpoints = [c for c in checkpointer.list(thread1)]
# we should have 3
assert len(checkpoints) == (3 if checkpoint_during else 2)
assert len(checkpoints) == 3
# the last one not too interesting for this test
assert checkpoints[0] == CheckpointTuple(
config={
@@ -1339,26 +1325,15 @@ def test_pending_writes_resume(
"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"]
if checkpoint_during
else AnyStr(),
"checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"],
}
},
pending_writes=UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
(AnyStr(), "value", 3),
)
if checkpoint_during
else UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
# the write against the previous checkpoint is not saved, as it is
# produced in a run where only the next checkpoint (the last) is saved
),
)
if not checkpoint_during:
return
assert checkpoints[2] == CheckpointTuple(
config={
"configurable": {
@@ -1516,14 +1491,8 @@ def test_send_sequences() -> None:
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_imp_task(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> None:
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
mapper_calls = 0
@@ -1589,7 +1558,7 @@ def test_imp_task(
}
thread1 = {"configurable": {"thread_id": "1"}}
assert [*graph.stream([0, 1], thread1, checkpoint_during=checkpoint_during)] == [
assert [*graph.stream([0, 1], thread1)] == [
{"mapper": "00"},
{"mapper": "11"},
{
@@ -1605,23 +1574,17 @@ def test_imp_task(
]
assert mapper_calls == 2
assert graph.invoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
assert graph.invoke(Command(resume="answer"), thread1) == [
"00answer",
"11answer",
]
assert mapper_calls == 2
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_imp_nested(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
def mynode(input: list[str]) -> list[str]:
@@ -1663,7 +1626,7 @@ def test_imp_nested(
}
thread1 = {"configurable": {"thread_id": "1"}}
assert [*graph.stream([0, 1], thread1, checkpoint_during=checkpoint_during)] == [
assert [*graph.stream([0, 1], thread1)] == [
{"submapper": "0"},
{"mapper": "00"},
{"submapper": "1"},
@@ -1680,22 +1643,16 @@ def test_imp_nested(
},
]
assert graph.invoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
assert graph.invoke(Command(resume="answer"), thread1) == [
"00answera",
"11answera",
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_imp_stream_order(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
@task()
@@ -1718,10 +1675,7 @@ def test_imp_stream_order(
return fut_baz.result()
thread1 = {"configurable": {"thread_id": "1"}}
assert [
c
for c in graph.stream({"a": "0"}, thread1, checkpoint_during=checkpoint_during)
] == [
assert [c for c in graph.stream({"a": "0"}, thread1)] == [
{
"foo": (
"0foo",
@@ -2781,9 +2735,6 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
checkpointer_name: str,
) -> None:
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic.v1 import BaseModel as BaseModelV1
IS_V1 = BaseModel is BaseModelV1
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
setup = mocker.Mock()
@@ -2822,28 +2773,14 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
class InnerObject(BaseModel):
yo: int
if IS_V1:
class State(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
class State(BaseModel):
class Config:
arbitrary_types_allowed = True
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
else:
class State(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
class StateUpdate(BaseModel):
query: Optional[str] = None
@@ -3102,49 +3039,15 @@ def test_nested_pydantic_models(version: str) -> None:
"""Test that nested Pydantic models are properly constructed from leaf nodes up."""
# Define nested Pydantic models
# Import necessary modules
if version == "v1":
from pydantic.v1 import ( # type: ignore
BaseModel,
ByteSize,
Field,
SecretStr,
confloat,
conint,
conlist,
constr,
)
from pydantic.v1 import BaseModel, Field
else:
from pydantic import ( # type: ignore
BaseModel,
ByteSize,
Field,
SecretStr,
confloat,
conint,
conlist,
constr,
)
from pydantic.v1 import BaseModel as BaseModelV1
if BaseModel is BaseModelV1:
pytest.skip("Cannot test pydantic v2 using installed version < 2")
from pydantic import BaseModel, Field
class NestedModel(BaseModel):
value: int
name: str
# For constrained types
PositiveInt = Annotated[int, Field(gt=0)]
NonNegativeFloat = Annotated[float, Field(ge=0)]
# Enum type
class UserRole(Enum):
ADMIN = "admin"
USER = "user"
GUEST = "guest"
# Forward reference model
class RecursiveModel(BaseModel):
value: str
@@ -3165,19 +3068,12 @@ def test_nested_pydantic_models(version: str) -> None:
name: str
friends: list[str] = Field(default_factory=list) # IDs of friends
if version == "v2":
conlist_type = conlist(item_type=int, min_length=2, max_length=5)
else:
conlist_type = conlist(item_type=int, min_items=2, max_items=5)
class State(BaseModel):
# Basic nested model tests
top_level: str
auuid: uuid.UUID
nested: NestedModel
optional_nested: Annotated[Optional[NestedModel], lambda x, y: y, "Foo"]
dict_nested: dict[str, NestedModel]
simple_str_list: list[str]
list_nested: Annotated[
Union[dict, list[dict[str, NestedModel]]], lambda x, y: (x or []) + [y]
]
@@ -3194,51 +3090,15 @@ def test_nested_pydantic_models(version: str) -> None:
# Cyclic reference test
people: dict[str, Person] # Map of ID -> Person
# Rich type adapters
ip_address: ipaddress.IPv4Address
ip_address_v6: ipaddress.IPv6Address
amount: decimal.Decimal
file_path: pathlib.Path
timestamp: datetime.datetime
date_only: datetime.date
time_only: datetime.time
duration: datetime.timedelta
immutable_set: frozenset[int]
binary_data: bytes
pattern: re.Pattern
secret: SecretStr
file_size: ByteSize
# Constrained types
positive_value: PositiveInt
non_negative: NonNegativeFloat
limited_string: constr(min_length=3, max_length=10)
bounded_int: conint(ge=10, le=100)
restricted_float: confloat(gt=0, lt=1)
required_list: conlist_type
# Enum & Literal
role: UserRole
status: Literal["active", "inactive", "pending"]
# Annotated & NewType
validated_age: Annotated[int, Field(gt=0, lt=120)]
# Generic containers with validators
decimal_list: List[decimal.Decimal]
id_tuple: tuple[uuid.UUID, uuid.UUID]
inputs = {
# Basic nested models
"top_level": "initial",
"auuid": str(uuid.uuid4()),
"nested": {"value": 42, "name": "test"},
"optional_nested": {"value": 10, "name": "optional"},
"dict_nested": {"a": {"value": 5, "name": "a"}},
"list_nested": [{"a": {"value": 6, "name": "b"}}],
"tuple_nested": ["tuple-key", {"value": 7, "name": "tuple-value"}],
"tuple_list_nested": [[1, {"value": 8, "name": "tuple-in-list"}]],
"simple_str_list": ["siss", "boom", "bah"],
"complex_tuple": [
"complex",
{"nested": [9, {"value": 10, "name": "deep"}]},
@@ -3265,35 +3125,6 @@ def test_nested_pydantic_models(version: str) -> None:
"friends": ["1", "2"], # Charlie is friends with Alice and Bob
},
},
# Rich type adapters
"ip_address": "192.168.1.1",
"ip_address_v6": "2001:db8::1",
"amount": "123.45",
"file_path": "/tmp/test.txt",
"timestamp": "2025-04-07T10:58:04",
"date_only": "2025-04-07",
"time_only": "10:58:04",
"duration": 3600, # seconds
"immutable_set": [1, 2, 3, 4],
"binary_data": b"hello world",
"pattern": "^test$",
"secret": "password123",
"file_size": 1024,
# Constrained types
"positive_value": 42,
"non_negative": 0.0,
"limited_string": "test",
"bounded_int": 50,
"restricted_float": 0.5,
"required_list": [10, 20, 30],
# Enum & Literal
"role": "admin",
"status": "active",
# Annotated & NewType
"validated_age": 30,
# Generic containers with validators
"decimal_list": ["10.5", "20.75", "30.25"],
"id_tuple": [str(uuid.uuid4()), str(uuid.uuid4())],
}
update = {"top_level": "updated", "nested": {"value": 100, "name": "updated"}}
@@ -3301,42 +3132,7 @@ def test_nested_pydantic_models(version: str) -> None:
expected = State(**inputs)
def node_fn(state: State) -> dict:
# Basic assertions
assert isinstance(state.auuid, uuid.UUID)
assert state == expected
# Rich type assertions
assert isinstance(state.ip_address, ipaddress.IPv4Address)
assert isinstance(state.ip_address_v6, ipaddress.IPv6Address)
assert isinstance(state.amount, decimal.Decimal)
assert isinstance(state.file_path, pathlib.Path)
assert isinstance(state.timestamp, datetime.datetime)
assert isinstance(state.date_only, datetime.date)
assert isinstance(state.time_only, datetime.time)
assert isinstance(state.duration, datetime.timedelta)
assert isinstance(state.immutable_set, frozenset)
assert isinstance(state.binary_data, bytes)
assert isinstance(state.pattern, re.Pattern)
# Constrained types
assert state.positive_value > 0
assert state.non_negative >= 0
assert 3 <= len(state.limited_string) <= 10
assert 10 <= state.bounded_int <= 100
assert 0 < state.restricted_float < 1
assert 2 <= len(state.required_list) <= 5
# Enum & Literal
assert state.role == UserRole.ADMIN
assert state.status == "active"
# Annotated
assert 0 < state.validated_age < 120
# Generic containers
assert len(state.decimal_list) == 3
assert len(state.id_tuple) == 2
return update
builder = StateGraph(State)
@@ -3847,14 +3643,10 @@ def test_nested_graph(snapshot: SnapshotAssertion) -> None:
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_subgraph_checkpoint_true(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Unsupported combo")
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
class InnerState(TypedDict):
@@ -3886,12 +3678,7 @@ def test_subgraph_checkpoint_true(
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "2"}}
assert [
c
for c in app.stream(
{"my_key": ""}, config, subgraphs=True, checkpoint_during=checkpoint_during
)
] == [
assert [c for c in app.stream({"my_key": ""}, config, subgraphs=True)] == [
(("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}),
(("inner",), {"inner_2": {"my_key": " and there"}}),
((), {"inner": {"my_key": " got here and there"}}),
@@ -3916,14 +3703,10 @@ def test_subgraph_checkpoint_true(
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_subgraph_checkpoint_true_interrupt(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Unsupported combo")
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
# Define subgraph
@@ -3962,18 +3745,15 @@ def test_subgraph_checkpoint_true_interrupt(
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "1"}}
assert graph.invoke(
{"foo": "foo"}, config, checkpoint_during=checkpoint_during
) == {"foo": "hi! foo"}
assert graph.invoke({"foo": "foo"}, config) == {"foo": "hi! foo"}
assert graph.get_state(config, subgraphs=True).tasks[0].state.values == {
"bar": "hi! foo"
}
assert graph.invoke(
Command(resume="baz"), config, checkpoint_during=checkpoint_during
) == {"foo": "hi! foobaz"}
assert graph.invoke(Command(resume="baz"), config) == {"foo": "hi! foobaz"}
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
@@ -4089,14 +3869,10 @@ def test_stream_buffering_single_node(
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_nested_graph_interrupts_parallel(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Unsupported combo")
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
class InnerState(TypedDict):
@@ -4143,11 +3919,11 @@ def test_nested_graph_interrupts_parallel(
# test invoke w/ nested interrupt
config = {"configurable": {"thread_id": "1"}}
assert app.invoke({"my_key": ""}, config, checkpoint_during=checkpoint_during) == {
assert app.invoke({"my_key": ""}, config, debug=True) == {
"my_key": " and parallel",
}
assert app.invoke(None, config, checkpoint_during=checkpoint_during) == {
assert app.invoke(None, config, debug=True) == {
"my_key": "got here and there and parallel and back again",
}
@@ -4156,17 +3932,13 @@ def test_nested_graph_interrupts_parallel(
# - the writes of outer are persisted in 1st call and used in 2nd call, ie outer isn't called again (because we dont see outer_1 output again in 2nd stream)
# test stream updates w/ nested interrupt
config = {"configurable": {"thread_id": "2"}}
assert [
*app.stream(
{"my_key": ""}, config, subgraphs=True, checkpoint_during=checkpoint_during
)
] == [
assert [*app.stream({"my_key": ""}, config, subgraphs=True)] == [
# we got to parallel node first
((), {"outer_1": {"my_key": " and parallel"}}),
((AnyStr("inner:"),), {"inner_1": {"my_key": "got here", "my_other_key": ""}}),
((), {"__interrupt__": ()}),
]
assert [*app.stream(None, config, checkpoint_during=checkpoint_during)] == [
assert [*app.stream(None, config)] == [
{"outer_1": {"my_key": " and parallel"}, "__metadata__": {"cached": True}},
{"inner": {"my_key": "got here and there"}},
{"outer_2": {"my_key": " and back again"}},
@@ -4174,22 +3946,11 @@ def test_nested_graph_interrupts_parallel(
# test stream values w/ nested interrupt
config = {"configurable": {"thread_id": "3"}}
assert [
*app.stream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [*app.stream(None, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
@@ -4198,28 +3959,15 @@ def test_nested_graph_interrupts_parallel(
# test interrupts BEFORE the parallel node
app = graph.compile(checkpointer=checkpointer, interrupt_before=["outer_1"])
config = {"configurable": {"thread_id": "4"}}
assert [
*app.stream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [{"my_key": ""}]
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
{"my_key": ""}
]
# while we're waiting for the node w/ interrupt inside to finish
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [*app.stream(None, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [*app.stream(None, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
@@ -4228,43 +3976,24 @@ def test_nested_graph_interrupts_parallel(
# test interrupts AFTER the parallel node
app = graph.compile(checkpointer=checkpointer, interrupt_after=["outer_1"])
config = {"configurable": {"thread_id": "5"}}
assert [
*app.stream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [*app.stream(None, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
]
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [*app.stream(None, config, stream_mode="values")] == [
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_doubly_nested_graph_interrupts(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Unsupported combo")
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
class State(TypedDict):
@@ -4318,13 +4047,11 @@ def test_doubly_nested_graph_interrupts(
# test invoke w/ nested interrupt
config = {"configurable": {"thread_id": "1"}}
assert app.invoke(
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
) == {
assert app.invoke({"my_key": "my value"}, config, debug=True) == {
"my_key": "hi my value",
}
assert app.invoke(None, config, checkpoint_during=checkpoint_during) == {
assert app.invoke(None, config, debug=True) == {
"my_key": "hi my value here and there and back again",
}
@@ -4333,14 +4060,12 @@ def test_doubly_nested_graph_interrupts(
config = {
"configurable": {"thread_id": "2", CONFIG_KEY_NODE_FINISHED: nodes.append}
}
assert [
*app.stream({"my_key": "my value"}, config, checkpoint_during=checkpoint_during)
] == [
assert [*app.stream({"my_key": "my value"}, config)] == [
{"parent_1": {"my_key": "hi my value"}},
{"__interrupt__": ()},
]
assert nodes == ["parent_1", "grandchild_1"]
assert [*app.stream(None, config, checkpoint_during=checkpoint_during)] == [
assert [*app.stream(None, config)] == [
{"child": {"my_key": "hi my value here and there"}},
{"parent_2": {"my_key": "hi my value here and there and back again"}},
]
@@ -4355,22 +4080,11 @@ def test_doubly_nested_graph_interrupts(
# test stream values w/ nested interrupt
config = {"configurable": {"thread_id": "3"}}
assert [
*app.stream(
{"my_key": "my value"},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
assert [*app.stream({"my_key": "my value"}, config, stream_mode="values")] == [
{"my_key": "my value"},
{"my_key": "hi my value"},
]
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [*app.stream(None, config, stream_mode="values")] == [
{"my_key": "hi my value"},
{"my_key": "hi my value here and there"},
{"my_key": "hi my value here and there and back again"},
+53 -348
View File
@@ -1947,14 +1947,10 @@ async def test_invoke_checkpoint(mocker: MockerFixture, checkpointer_name: str)
assert checkpoint["channel_values"].get("total") == 5
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_pending_writes_resume(
checkpointer_name: str, checkpoint_during: bool
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
class State(TypedDict):
value: Annotated[int, operator.add]
@@ -1976,12 +1972,10 @@ async def test_pending_writes_resume(
self.calls = 0
one = AwhileMaker(0.1, {"value": 2})
two = AwhileMaker(0.2, ConnectionError("I'm not good"))
two = AwhileMaker(0.3, ConnectionError("I'm not good"))
builder = StateGraph(State)
builder.add_node("one", one)
builder.add_node(
"two", two, retry=RetryPolicy(max_attempts=2, initial_interval=0, jitter=False)
)
builder.add_node("two", two, retry=RetryPolicy(max_attempts=2))
builder.add_edge(START, "one")
builder.add_edge(START, "two")
async with awith_checkpointer(checkpointer_name) as checkpointer:
@@ -1989,9 +1983,7 @@ async def test_pending_writes_resume(
thread1: RunnableConfig = {"configurable": {"thread_id": "1"}}
with pytest.raises(ConnectionError, match="I'm not good"):
await graph.ainvoke(
{"value": 1}, thread1, checkpoint_during=checkpoint_during
)
await graph.ainvoke({"value": 1}, thread1)
# both nodes should have been called once
assert one.calls == 1
@@ -2042,7 +2034,7 @@ async def test_pending_writes_resume(
# resume execution
with pytest.raises(ConnectionError, match="I'm not good"):
await graph.ainvoke(None, thread1, checkpoint_during=checkpoint_during)
await graph.ainvoke(None, thread1)
# node "one" succeeded previously, so shouldn't be called again
assert one.calls == 1
@@ -2056,9 +2048,7 @@ async def test_pending_writes_resume(
# resume execution, without exception
two.rtn = {"value": 3}
# both the pending write and the new write were applied, 1 + 2 + 3 = 6
assert await graph.ainvoke(
None, thread1, checkpoint_during=checkpoint_during
) == {"value": 6}
assert await graph.ainvoke(None, thread1) == {"value": 6}
if "shallow" in checkpointer_name:
assert len([c async for c in checkpointer.alist(thread1)]) == 1
@@ -2067,7 +2057,7 @@ async def test_pending_writes_resume(
# check all final checkpoints
checkpoints = [c async for c in checkpointer.alist(thread1)]
# we should have 3
assert len(checkpoints) == (3 if checkpoint_during else 2)
assert len(checkpoints) == 3
# the last one not too interesting for this test
assert checkpoints[0] == CheckpointTuple(
config={
@@ -2173,26 +2163,15 @@ async def test_pending_writes_resume(
"checkpoint_ns": "",
"checkpoint_id": checkpoints[2].config["configurable"][
"checkpoint_id"
]
if checkpoint_during
else AnyStr(),
],
}
},
pending_writes=UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
(AnyStr(), "value", 3),
)
if checkpoint_during
else UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
# the write against the previous checkpoint is not saved, as it is
# produced in a run where only the next checkpoint (the last) is saved
),
)
if not checkpoint_during:
return
assert checkpoints[2] == CheckpointTuple(
config={
"configurable": {
@@ -2230,7 +2209,7 @@ async def test_pending_writes_resume(
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_run_from_checkpoint_id_retains_previous_writes(
checkpointer_name: str,
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
) -> None:
class MyState(TypedDict):
myval: Annotated[int, operator.add]
@@ -2275,8 +2254,8 @@ async def test_run_from_checkpoint_id_retains_previous_writes(
history = [c async for c in graph.aget_state_history(thread1)]
assert len(history) == 4
assert history[0].values == {"myval": 4, "otherval": False}
assert history[-1].values == {"myval": 0}
assert history[0].values == {"myval": 4, "otherval": False}
second_run_config = {
**thread1,
@@ -2453,12 +2432,8 @@ async def test_send_sequences(checkpointer_name: str) -> None:
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_task(checkpointer_name: str, checkpoint_during: bool) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async def test_imp_task(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
mapper_calls = 0
@@ -2478,12 +2453,7 @@ async def test_imp_task(checkpointer_name: str, checkpoint_during: bool) -> None
tracer = FakeTracer()
thread1 = {"configurable": {"thread_id": "1"}, "callbacks": [tracer]}
assert [
c
async for c in graph.astream(
[0, 1], thread1, checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in graph.astream([0, 1], thread1)] == [
{"mapper": "00"},
{"mapper": "11"},
{
@@ -2507,9 +2477,7 @@ async def test_imp_task(checkpointer_name: str, checkpoint_during: bool) -> None
assert any(r.inputs == {"input": 0} for r in mapper_runs)
assert any(r.inputs == {"input": 1} for r in mapper_runs)
assert await graph.ainvoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
"00answer",
"11answer",
]
@@ -2517,12 +2485,8 @@ async def test_imp_task(checkpointer_name: str, checkpoint_during: bool) -> None
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_nested(checkpointer_name: str, checkpoint_during: bool) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async def test_imp_nested(checkpointer_name: str) -> None:
async def mynode(input: list[str]) -> list[str]:
return [it + "a" for it in input]
@@ -2562,12 +2526,7 @@ async def test_imp_nested(checkpointer_name: str, checkpoint_during: bool) -> No
}
thread1 = {"configurable": {"thread_id": "1"}}
assert [
c
async for c in graph.astream(
[0, 1], thread1, checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in graph.astream([0, 1], thread1)] == [
{"submapper": "0"},
{"mapper": "00"},
{"submapper": "1"},
@@ -2584,21 +2543,15 @@ async def test_imp_nested(checkpointer_name: str, checkpoint_during: bool) -> No
},
]
assert await graph.ainvoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
"00answera",
"11answera",
]
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_task_cancel(checkpointer_name: str, checkpoint_during: bool) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async def test_imp_task_cancel(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
mapper_calls = 0
mapper_cancels = 0
@@ -2624,12 +2577,7 @@ async def test_imp_task_cancel(checkpointer_name: str, checkpoint_during: bool)
return [m + answer for m in mapped]
thread1 = {"configurable": {"thread_id": "1"}}
assert [
c
async for c in graph.astream(
[0, 1], thread1, checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in graph.astream([0, 1], thread1)] == [
{"mapper": "00"},
{
"__interrupt__": (
@@ -2645,9 +2593,7 @@ async def test_imp_task_cancel(checkpointer_name: str, checkpoint_during: bool)
assert mapper_calls == 2
assert mapper_cancels == 1
assert await graph.ainvoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
"00answer",
]
assert mapper_calls == 3
@@ -2655,14 +2601,8 @@ async def test_imp_task_cancel(checkpointer_name: str, checkpoint_during: bool)
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_sync_from_async(
checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async def test_imp_sync_from_async(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
@task()
@@ -2685,12 +2625,7 @@ async def test_imp_sync_from_async(
return fut_baz.result()
thread1 = {"configurable": {"thread_id": "1"}}
assert [
c
async for c in graph.astream(
{"a": "0"}, thread1, checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in graph.astream({"a": "0"}, thread1)] == [
{"foo": {"a": "0foo", "b": "bar"}},
{"bar": {"a": "0foobar", "c": "bark"}},
{"baz": {"a": "0foobarbaz", "c": "something else"}},
@@ -2699,14 +2634,8 @@ async def test_imp_sync_from_async(
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_stream_order(
checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async def test_imp_stream_order(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
@task()
@@ -2730,12 +2659,7 @@ async def test_imp_stream_order(
return await fut_baz
thread1 = {"configurable": {"thread_id": "1"}}
assert [
c
async for c in graph.astream(
{"a": "0"}, thread1, checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in graph.astream({"a": "0"}, thread1)] == [
{"foo": {"a": "0foo", "b": "bar"}},
{"bar": {"a": "0foobar", "c": "bark"}},
{"baz": {"a": "0foobarbaz", "c": "something else"}},
@@ -2743,11 +2667,8 @@ async def test_imp_stream_order(
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_send_dedupe_on_resume(
checkpointer_name: str, checkpoint_during: bool
) -> None:
async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
class InterruptOnce:
ticks: int = 0
@@ -2798,9 +2719,7 @@ async def test_send_dedupe_on_resume(
async with awith_checkpointer(checkpointer_name) as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
thread1 = {"configurable": {"thread_id": "1"}}
assert await graph.ainvoke(
["0"], thread1, checkpoint_during=checkpoint_during
) == [
assert await graph.ainvoke(["0"], thread1, debug=1) == [
"0",
"1",
"3.1",
@@ -2812,9 +2731,7 @@ async def test_send_dedupe_on_resume(
assert builder.nodes["2"].runnable.func.ticks == 3
assert builder.nodes["flaky"].runnable.func.ticks == 1
# resume execution
assert await graph.ainvoke(
None, thread1, checkpoint_during=checkpoint_during
) == [
assert await graph.ainvoke(None, thread1, debug=1) == [
"0",
"1",
"3.1",
@@ -2831,8 +2748,7 @@ async def test_send_dedupe_on_resume(
assert builder.nodes["flaky"].runnable.func.ticks == 2
# check history
history = [c async for c in graph.aget_state_history(thread1)]
assert len(history) == (6 if checkpoint_during else 2)
expected_history = [
assert history == [
StateSnapshot(
values=[
"0",
@@ -2968,9 +2884,13 @@ async def test_send_dedupe_on_resume(
name="flaky",
path=("__pregel_push", 1),
error=None,
interrupts=(Interrupt(value="Bahh", resumable=False, ns=None),),
interrupts=(
Interrupt(
value="Bahh", resumable=False, ns=None, when="during"
),
),
state=None,
result=["flaky|4"] if checkpoint_during else None,
result=["flaky|4"],
),
PregelTask(
id=AnyStr(),
@@ -3107,11 +3027,6 @@ async def test_send_dedupe_on_resume(
),
),
]
if checkpoint_during:
assert history == expected_history
else:
assert history[0] == expected_history[0]
assert history[1] == expected_history[2]
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
@@ -4638,7 +4553,6 @@ async def test_nested_pydantic_models(version: str) -> None:
optional_nested: Optional[NestedModel] = None
dict_nested: dict[str, NestedModel]
my_set: set[int]
another_set: set
my_enum: MyEnum
list_nested: Annotated[
Union[dict, list[dict[str, NestedModel]]], lambda x, y: (x or []) + [y]
@@ -4667,7 +4581,6 @@ async def test_nested_pydantic_models(version: str) -> None:
"nested": {"value": 42, "name": "test"},
"optional_nested": {"value": 10, "name": "optional"},
"my_set": [1, 2, 7],
"another_set": ["foo", 3],
"my_enum": MyEnum.B,
"my_typed_dict": {"x": 1, "my_enum": MyEnum.A},
"dict_nested": {"a": {"value": 5, "name": "a"}},
@@ -5435,132 +5348,6 @@ async def test_nested_graph(snapshot: SnapshotAssertion) -> None:
assert times_called == 1
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_subgraph_checkpoint_true(
checkpointer_name: str, checkpoint_during: bool
) -> None:
class InnerState(TypedDict):
my_key: Annotated[str, operator.add]
my_other_key: str
def inner_1(state: InnerState):
return {"my_key": " got here", "my_other_key": state["my_key"]}
def inner_2(state: InnerState):
return {"my_key": " and there"}
inner = StateGraph(InnerState)
inner.add_node("inner_1", inner_1)
inner.add_node("inner_2", inner_2)
inner.add_edge("inner_1", "inner_2")
inner.set_entry_point("inner_1")
inner.set_finish_point("inner_2")
class State(TypedDict):
my_key: str
graph = StateGraph(State)
graph.add_node("inner", inner.compile(checkpointer=True))
graph.add_edge(START, "inner")
graph.add_conditional_edges(
"inner", lambda s: "inner" if s["my_key"].count("there") < 2 else END
)
async with awith_checkpointer(checkpointer_name) as checkpointer:
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "2"}}
assert [
c
async for c in app.astream(
{"my_key": ""},
config,
subgraphs=True,
checkpoint_during=checkpoint_during,
)
] == [
(("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}),
(("inner",), {"inner_2": {"my_key": " and there"}}),
((), {"inner": {"my_key": " got here and there"}}),
(
("inner",),
{
"inner_1": {
"my_key": " got here",
"my_other_key": " got here and there got here and there",
}
},
),
(("inner",), {"inner_2": {"my_key": " and there"}}),
(
(),
{
"inner": {
"my_key": " got here and there got here and there got here and there"
}
},
),
]
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_subgraph_checkpoint_true_interrupt(
checkpointer_name: str, checkpoint_during: bool
) -> None:
# Define subgraph
class SubgraphState(TypedDict):
# note that none of these keys are shared with the parent graph state
bar: str
baz: str
def subgraph_node_1(state: SubgraphState):
baz_value = interrupt("Provide baz value")
return {"baz": baz_value}
def subgraph_node_2(state: SubgraphState):
return {"bar": state["bar"] + state["baz"]}
subgraph_builder = StateGraph(SubgraphState)
subgraph_builder.add_node(subgraph_node_1)
subgraph_builder.add_node(subgraph_node_2)
subgraph_builder.add_edge(START, "subgraph_node_1")
subgraph_builder.add_edge("subgraph_node_1", "subgraph_node_2")
subgraph = subgraph_builder.compile(checkpointer=True)
class ParentState(TypedDict):
foo: str
def node_1(state: ParentState):
return {"foo": "hi! " + state["foo"]}
async def node_2(state: ParentState, config: RunnableConfig):
response = await subgraph.ainvoke({"bar": state["foo"]})
return {"foo": response["bar"]}
builder = StateGraph(ParentState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
async with awith_checkpointer(checkpointer_name) as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "1"}}
assert await graph.ainvoke(
{"foo": "foo"}, config, checkpoint_during=checkpoint_during
) == {"foo": "hi! foo"}
assert (await graph.aget_state(config, subgraphs=True)).tasks[
0
].state.values == {"bar": "hi! foo"}
assert await graph.ainvoke(
Command(resume="baz"), config, checkpoint_during=checkpoint_during
) == {"foo": "hi! foobaz"}
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_stream_subgraphs_during_execution(checkpointer_name: str) -> None:
class InnerState(TypedDict):
@@ -5669,11 +5456,8 @@ async def test_stream_buffering_single_node(checkpointer_name: str) -> None:
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_nested_graph_interrupts_parallel(
checkpointer_name: str, checkpoint_during: bool
) -> None:
async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
class InnerState(TypedDict):
my_key: Annotated[str, operator.add]
my_other_key: str
@@ -5722,13 +5506,11 @@ async def test_nested_graph_interrupts_parallel(
# test invoke w/ nested interrupt
config = {"configurable": {"thread_id": "1"}}
assert await app.ainvoke(
{"my_key": ""}, config, checkpoint_during=checkpoint_during
) == {
assert await app.ainvoke({"my_key": ""}, config, debug=True) == {
"my_key": " and parallel",
}
assert await app.ainvoke(None, config, checkpoint_during=checkpoint_during) == {
assert await app.ainvoke(None, config, debug=True) == {
"my_key": "got here and there and parallel and back again",
}
@@ -5738,13 +5520,7 @@ async def test_nested_graph_interrupts_parallel(
# test stream updates w/ nested interrupt
config = {"configurable": {"thread_id": "2"}}
assert [
c
async for c in app.astream(
{"my_key": ""},
config,
subgraphs=True,
checkpoint_during=checkpoint_during,
)
c async for c in app.astream({"my_key": ""}, config, subgraphs=True)
] == [
# we got to parallel node first
((), {"outer_1": {"my_key": " and parallel"}}),
@@ -5754,12 +5530,7 @@ async def test_nested_graph_interrupts_parallel(
),
((), {"__interrupt__": ()}),
]
assert [
c
async for c in app.astream(
None, config, checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream(None, config)] == [
{"outer_1": {"my_key": " and parallel"}, "__metadata__": {"cached": True}},
{"inner": {"my_key": "got here and there"}},
{"outer_2": {"my_key": " and back again"}},
@@ -5768,23 +5539,12 @@ async def test_nested_graph_interrupts_parallel(
# test stream values w/ nested interrupt
config = {"configurable": {"thread_id": "3"}}
assert [
c
async for c in app.astream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
@@ -5794,32 +5554,16 @@ async def test_nested_graph_interrupts_parallel(
app = graph.compile(checkpointer=checkpointer, interrupt_before=["outer_1"])
config = {"configurable": {"thread_id": "4"}}
assert [
c
async for c in app.astream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
] == [
{"my_key": ""},
]
# while we're waiting for the node w/ interrupt inside to finish
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
@@ -5829,42 +5573,23 @@ async def test_nested_graph_interrupts_parallel(
app = graph.compile(checkpointer=checkpointer, interrupt_after=["outer_1"])
config = {"configurable": {"thread_id": "5"}}
assert [
c
async for c in app.astream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
]
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_doubly_nested_graph_interrupts(
checkpointer_name: str, checkpoint_during: bool
) -> None:
async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
class State(TypedDict):
my_key: str
@@ -5917,13 +5642,11 @@ async def test_doubly_nested_graph_interrupts(
# test invoke w/ nested interrupt
config = {"configurable": {"thread_id": "1"}}
assert await app.ainvoke(
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
) == {
assert await app.ainvoke({"my_key": "my value"}, config, debug=True) == {
"my_key": "hi my value",
}
assert await app.ainvoke(None, config, checkpoint_during=checkpoint_during) == {
assert await app.ainvoke(None, config, debug=True) == {
"my_key": "hi my value here and there and back again",
}
@@ -5932,22 +5655,12 @@ async def test_doubly_nested_graph_interrupts(
config = {
"configurable": {"thread_id": "2", CONFIG_KEY_NODE_FINISHED: nodes.append}
}
assert [
c
async for c in app.astream(
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream({"my_key": "my value"}, config)] == [
{"parent_1": {"my_key": "hi my value"}},
{"__interrupt__": ()},
]
assert nodes == ["parent_1", "grandchild_1"]
assert [
c
async for c in app.astream(
None, config, checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream(None, config)] == [
{"child": {"my_key": "hi my value here and there"}},
{"parent_2": {"my_key": "hi my value here and there and back again"}},
]
@@ -5965,21 +5678,13 @@ async def test_doubly_nested_graph_interrupts(
assert [
c
async for c in app.astream(
{"my_key": "my value"},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
{"my_key": "my value"}, config, stream_mode="values"
)
] == [
{"my_key": "my value"},
{"my_key": "hi my value"},
]
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
{"my_key": "hi my value"},
{"my_key": "hi my value here and there"},
{"my_key": "hi my value here and there and back again"},
-4
View File
@@ -6,10 +6,6 @@ client.cjs
client.js
client.d.ts
client.d.cts
auth.cjs
auth.js
auth.d.ts
auth.d.cts
react.cjs
react.js
react.d.ts
-1
View File
@@ -14,7 +14,6 @@ export const config = {
entrypoints: {
index: "index",
client: "client",
auth: "auth/index",
react: "react/index",
"react-ui": "react-ui/index",
"react-ui/server": "react-ui/server/index",
+2 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.66",
"version": "0.0.63",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
@@ -11,7 +11,7 @@
"format": "prettier --write src",
"lint": "prettier --check src && tsc --noEmit",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts",
"typedoc": "typedoc && typedoc src/react/index.ts --out docs/react --options typedoc.react.json && typedoc src/auth/index.ts --out docs/auth --options typedoc.auth.json"
"typedoc": "typedoc && typedoc src/react/index.ts --out docs/react --options typedoc.react.json"
},
"main": "index.js",
"license": "MIT",
@@ -72,15 +72,6 @@
"import": "./client.js",
"require": "./client.cjs"
},
"./auth": {
"types": {
"import": "./auth.d.ts",
"require": "./auth.d.cts",
"default": "./auth.d.ts"
},
"import": "./auth.js",
"require": "./auth.cjs"
},
"./react": {
"types": {
"import": "./react.d.ts",
@@ -120,10 +111,6 @@
"client.js",
"client.d.ts",
"client.d.cts",
"auth.cjs",
"auth.js",
"auth.d.ts",
"auth.d.cts",
"react.cjs",
"react.js",
"react.d.ts",
-80
View File
@@ -1,80 +0,0 @@
const HTTP_STATUS_MAPPING: { [key: number]: string } = {
100: "Continue",
101: "Switching Protocols",
102: "Processing",
103: "Early Hints",
200: "OK",
201: "Created",
202: "Accepted",
203: "Non-Authoritative Information",
204: "No Content",
205: "Reset Content",
206: "Partial Content",
207: "Multi-Status",
208: "Already Reported",
226: "IM Used",
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Request Entity Too Large",
414: "Request-URI Too Long",
415: "Unsupported Media Type",
416: "Requested Range Not Satisfiable",
417: "Expectation Failed",
418: "I'm a Teapot",
421: "Misdirected Request",
422: "Unprocessable Entity",
423: "Locked",
424: "Failed Dependency",
425: "Too Early",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
431: "Request Header Fields Too Large",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
510: "Not Extended",
511: "Network Authentication Required",
};
export class HTTPException extends Error {
status: number;
headers: HeadersInit;
constructor(
status: number,
options?: { message?: string; headers?: HeadersInit; cause?: unknown },
) {
super(options?.message ?? HTTP_STATUS_MAPPING[status] ?? "Unknown error", {
cause: options?.cause,
});
this.status = status;
this.headers = options?.headers ?? {};
}
}
-46
View File
@@ -1,46 +0,0 @@
import type {
AuthenticateCallback,
AnyCallback,
CallbackEvent,
OnCallback,
BaseAuthReturn,
ToUserLike,
BaseUser,
} from "./types.js";
export class Auth<
TExtra = {},
TAuthReturn extends BaseAuthReturn = BaseAuthReturn,
TUser extends BaseUser = ToUserLike<TAuthReturn>,
> {
/**
* @internal
* @ignore
*/
"~handlerCache": {
authenticate?: AuthenticateCallback<BaseAuthReturn>;
callbacks?: Record<string, AnyCallback>;
} = {};
authenticate<T extends BaseAuthReturn>(
cb: AuthenticateCallback<T>,
): Auth<TExtra, T> {
this["~handlerCache"].authenticate = cb;
return this as unknown as Auth<TExtra, T>;
}
on<T extends CallbackEvent>(event: T, callback: OnCallback<T, TUser>): this {
this["~handlerCache"].callbacks ??= {};
const events: string[] = Array.isArray(event) ? event : [event];
for (const event of events) {
this["~handlerCache"].callbacks[event] = callback as AnyCallback;
}
return this;
}
}
export type {
Filters as AuthFilters,
EventValueMap as AuthEventValueMap,
} from "./types.js";
export { HTTPException } from "./error.js";
-411
View File
@@ -1,411 +0,0 @@
type Maybe<T> = T | null | undefined;
type PromiseMaybe<T> = Promise<T> | T;
interface AssistantConfig {
tags?: Maybe<string[]>;
recursion_limit?: Maybe<number>;
configurable?: Maybe<{
thread_id?: Maybe<string>;
thread_ts?: Maybe<string>;
[key: string]: unknown;
}>;
}
/**
* @inline
*/
interface AssistantCreate {
assistant_id?: Maybe<string>;
metadata?: Maybe<Record<string, unknown>>;
config?: Maybe<AssistantConfig>;
if_exists?: Maybe<"raise" | "do_nothing">;
name?: Maybe<string>;
graph_id: string;
}
/**
* @inline
*/
interface AssistantRead {
assistant_id: string;
metadata?: Maybe<Record<string, unknown>>;
}
/**
* @inline
*/
interface AssistantUpdate {
assistant_id: string;
metadata?: Maybe<Record<string, unknown>>;
config?: Maybe<AssistantConfig>;
graph_id?: Maybe<string>;
name?: Maybe<string>;
version?: Maybe<number>;
}
/**
* @inline
*/
interface AssistantDelete {
assistant_id: string;
}
/**
* @inline
*/
interface AssistantSearch {
graph_id?: Maybe<string>;
metadata?: Maybe<Record<string, unknown>>;
limit?: Maybe<number>;
offset?: Maybe<number>;
}
/**
* @inline
*/
interface ThreadCreate {
thread_id?: Maybe<string>;
metadata?: Maybe<Record<string, unknown>>;
if_exists?: Maybe<"raise" | "do_nothing">;
}
/**
* @inline
*/
interface ThreadRead {
thread_id?: Maybe<string>;
}
/**
* @inline
*/
interface ThreadUpdate {
thread_id?: Maybe<string>;
metadata?: Maybe<Record<string, unknown>>;
action?: Maybe<"interrupt" | "rollback">;
}
/**
* @inline
*/
interface ThreadDelete {
thread_id?: Maybe<string>;
run_id?: Maybe<string>;
}
/**
* @inline
*/
interface ThreadSearch {
thread_id?: Maybe<string>;
status?: Maybe<"idle" | "busy" | "interrupted" | "error" | (string & {})>;
metadata?: Maybe<Record<string, unknown>>;
values?: Maybe<Record<string, unknown>>;
limit?: Maybe<number>;
offset?: Maybe<number>;
}
/**
* @inline
*/
interface CronCreate {
payload?: Maybe<Record<string, unknown>>;
schedule: string;
cron_id?: Maybe<string>;
thread_id?: Maybe<string>;
user_id?: Maybe<string>;
end_time?: Maybe<string>;
}
/**
* @inline
*/
interface CronRead {
cron_id: string;
}
/**
* @inline
*/
interface CronUpdate {
cron_id: string;
payload?: Maybe<Record<string, unknown>>;
schedule?: Maybe<string>;
}
/**
* @inline
*/
interface CronDelete {
cron_id: string;
}
/**
* @inline
*/
interface CronSearch {
assistant_id?: Maybe<string>;
thread_id?: Maybe<string>;
limit?: Maybe<number>;
offset?: Maybe<number>;
}
/**
* @inline
*/
interface StorePut {
namespace: string[];
key: string;
value: Record<string, unknown>;
}
/**
* @inline
*/
interface StoreGet {
namespace: Maybe<string[]>;
key: string;
}
/**
* @inline
*/
interface StoreSearch {
namespace?: Maybe<string[]>;
filter?: Maybe<Record<string, unknown>>;
limit?: Maybe<number>;
offset?: Maybe<number>;
query?: Maybe<string>;
}
/**
* @inline
*/
interface StoreListNamespaces {
namespace?: Maybe<string[]>;
suffix?: Maybe<string[]>;
max_depth?: Maybe<number>;
limit?: Maybe<number>;
offset?: Maybe<number>;
}
/**
* @inline
*/
interface StoreDelete {
namespace?: Maybe<string[]>;
key: string;
}
/**
* @inline
*/
interface RunsCreate {
thread_id?: Maybe<string>;
assistant_id: string;
run_id: string;
status: Maybe<
"pending" | "running" | "error" | "success" | "timeout" | "interrupted"
>;
metadata?: Maybe<Record<string, unknown>>;
prevent_insert_if_inflight?: Maybe<boolean>;
multitask_strategy?: Maybe<"interrupt" | "rollback" | "reject" | "enqueue">;
if_not_exists?: Maybe<"reject" | "create">;
after_seconds?: Maybe<number>;
kwargs: Record<string, unknown>;
}
export interface EventValueMap {
["threads:create"]: ThreadCreate;
["threads:read"]: ThreadRead;
["threads:update"]: ThreadUpdate;
["threads:delete"]: ThreadDelete;
["threads:search"]: ThreadSearch;
["threads:create_run"]: RunsCreate;
["assistants:create"]: AssistantCreate;
["assistants:read"]: AssistantRead;
["assistants:update"]: AssistantUpdate;
["assistants:delete"]: AssistantDelete;
["assistants:search"]: AssistantSearch;
["crons:create"]: CronCreate;
["crons:read"]: CronRead;
["crons:update"]: CronUpdate;
["crons:delete"]: CronDelete;
["crons:search"]: CronSearch;
["store:put"]: StorePut;
["store:get"]: StoreGet;
["store:search"]: StoreSearch;
["store:list_namespaces"]: StoreListNamespaces;
["store:delete"]: StoreDelete;
}
interface ResourceType {
threads:
| "threads:create"
| "threads:read"
| "threads:update"
| "threads:delete"
| "threads:search"
| "threads:create_run";
assistants:
| "assistants:create"
| "assistants:read"
| "assistants:update"
| "assistants:delete"
| "assistants:search";
crons:
| "crons:create"
| "crons:read"
| "crons:update"
| "crons:delete"
| "crons:search";
store:
| "store:put"
| "store:get"
| "store:search"
| "store:list_namespaces"
| "store:delete";
}
interface ActionType {
"*:create": "threads:create" | "assistants:create" | "crons:create";
"*:read": "threads:read" | "assistants:read" | "crons:read";
"*:update": "threads:update" | "assistants:update" | "crons:update";
"*:delete":
| "threads:delete"
| "assistants:delete"
| "crons:delete"
| "store:delete";
"*:search":
| "threads:search"
| "assistants:search"
| "crons:search"
| "store:search";
"*:create_run": "threads:create_run";
"*:put": "store:put";
"*:get": "store:get";
"*:list_namespaces": "store:list_namespaces";
}
export type BaseAuthReturn =
| {
is_authenticated?: boolean;
display_name?: string;
identity: string;
permissions: string[];
}
| string;
export interface BaseUser {
is_authenticated: boolean;
display_name: string;
identity: string;
permissions: string[];
}
export type ToUserLike<T extends BaseAuthReturn> = T extends string
? {
is_authenticated: boolean;
display_name: string;
identity: string;
permissions: string[];
}
: Omit<T, "is_authenticated" | "display_name"> & {
is_authenticated: boolean;
display_name: string;
};
type CallbackParameter<
Event extends string = string,
Resource extends string = string,
Action extends string = string,
Value extends unknown = unknown,
TUser extends BaseUser = BaseUser,
> = {
event: Event;
resource: Resource;
action: Action;
value: Value;
user: TUser;
permissions: string[];
};
type ContextMap = {
[EventType in keyof EventValueMap]: CallbackParameter<
EventType,
EventType extends `${infer Resource}:${string}` ? Resource : never,
EventType extends `${string}:${infer Action}` ? Action : never,
EventValueMap[EventType],
BaseUser
>;
};
type ActionCallbackParameter<
T extends keyof ActionType,
TUser extends BaseUser = BaseUser,
> = ContextMap[ActionType[T]] & { user: TUser };
type AuthCallbackParameter<
T extends keyof EventValueMap,
TUser extends BaseUser = BaseUser,
> = ContextMap[T] & { user: TUser };
type ResourceCallbackParameter<
T extends keyof ResourceType,
TUser extends BaseUser = BaseUser,
> = ContextMap[ResourceType[T]] & { user: TUser };
export type Filters<TKey extends string | number | symbol> = {
[key in TKey]: string | { [op in "$contains" | "$eq"]?: string };
};
export interface AuthenticateCallback<T extends BaseAuthReturn> {
(request: Request): PromiseMaybe<T>;
}
type OnKey = keyof ResourceType | keyof ActionType | keyof EventValueMap;
type OnSingleParameter<
T extends OnKey,
TUser extends BaseUser = BaseUser,
> = T extends keyof ResourceType
? ResourceCallbackParameter<T, TUser>
: T extends keyof ActionType
? ActionCallbackParameter<T, TUser>
: T extends keyof EventValueMap
? AuthCallbackParameter<T, TUser>
: never;
type OnParameter<
T extends "*" | OnKey | OnKey[],
TUser extends BaseUser = BaseUser,
> = T extends OnKey[]
? OnSingleParameter<T[number], TUser>
: T extends "*"
? AuthCallbackParameter<keyof EventValueMap, TUser>
: T extends OnKey
? OnSingleParameter<T, TUser>
: never;
export type AnyCallback = (
request: CallbackParameter,
) => void | boolean | Filters<string>;
export type CallbackEvent = "*" | OnKey | OnKey[];
export type OnCallback<
T extends CallbackEvent,
TUser extends BaseUser = BaseUser,
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> = (
request: OnParameter<T, TUser>,
) => void | boolean | Filters<keyof TMetadata>;
+5 -1
View File
@@ -12,9 +12,11 @@ const STATUS_NO_RETRY = [
406, // Not Acceptable
407, // Proxy Authentication Required
408, // Request Timeout
409, // Conflict
422, // Unprocessable Entity
];
const STATUS_IGNORE = [
409, // Conflict
];
type ResponseCallback = (response?: Response) => Promise<boolean>;
@@ -169,6 +171,8 @@ export class AsyncCaller {
if (error instanceof HTTPError) {
if (STATUS_NO_RETRY.includes(error.status)) {
throw error;
} else if (STATUS_IGNORE.includes(error.status)) {
return;
}
if (onFailedResponseHook && error.response) {
await onFailedResponseHook(error.response);
+19 -5
View File
@@ -2,7 +2,11 @@
"extends": "@tsconfig/recommended",
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "ES2022.Object", "ES2022.Error", "DOM"],
"lib": [
"ES2021",
"ES2022.Object",
"DOM"
],
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
@@ -18,14 +22,24 @@
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "coverage"],
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"coverage"
],
"includeVersion": true,
"typedocOptions": {
"entryPoints": ["src/client.ts"],
"entryPoints": [
"src/client.ts"
],
"readme": "none",
"out": "docs",
"plugin": ["typedoc-plugin-markdown"],
"plugin": [
"typedoc-plugin-markdown"
],
"excludePrivate": true,
"excludeProtected": true,
"excludeExternals": false
-5
View File
@@ -1,5 +0,0 @@
{
"pageTitleTemplates": {
"index": "{projectName}/auth"
}
}