mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-14 04:05:27 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f676e94f48 | ||
|
|
d2f043e866 | ||
|
|
6258e8b1c4 | ||
|
|
2ad283fd8b | ||
|
|
5b0a9f9082 | ||
|
|
6360492357 |
+48
-18
@@ -1,35 +1,65 @@
|
||||
# LangGraph
|
||||
|
||||
LangGraph documentation has moved to docs.langchain.com.
|
||||
> LangGraph is a framework for building stateful, multi-actor applications with LLMs. Gain control with LangGraph to design agents that reliably handle complex tasks.
|
||||
|
||||
## Overview
|
||||
LangGraph documentation has moved to https://docs.langchain.com/oss/python/langgraph/overview.
|
||||
|
||||
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs.
|
||||
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features.
|
||||
## Docs
|
||||
|
||||
- [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview): Gain control with LangGraph to design agents that reliably handle complex tasks
|
||||
- [Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart): Build your first LangGraph agent
|
||||
- [Install LangGraph](https://docs.langchain.com/oss/python/langgraph/install): Install the LangGraph package
|
||||
- [Thinking in LangGraph](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph): Learn how to think about building agents with LangGraph
|
||||
- [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Understand the difference between workflows and agents in LangGraph
|
||||
|
||||
## APIs
|
||||
|
||||
- [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn about the Graph API for building stateful applications
|
||||
- [Use the graph API](https://docs.langchain.com/oss/python/langgraph/use-graph-api): Build agents using the Graph API
|
||||
- [Functional API overview](https://docs.langchain.com/oss/python/langgraph/functional-api): Learn about the Functional API for building agents
|
||||
- [Use the functional API](https://docs.langchain.com/oss/python/langgraph/use-functional-api): Build agents using the Functional API
|
||||
- [Choosing between Graph and Functional APIs](https://docs.langchain.com/oss/python/langgraph/choosing-apis): Decide which API to use for your use case
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges.
|
||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX.
|
||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs.
|
||||
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory.
|
||||
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph.
|
||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Save and restore graph state
|
||||
- [Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Add memory to your agents
|
||||
- [Memory overview](https://docs.langchain.com/oss/python/langgraph/memory): Understand memory concepts in LangGraph
|
||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graphs
|
||||
- [Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts): Pause and resume graph execution for human-in-the-loop workflows
|
||||
- [Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs with subgraphs
|
||||
- [Durable execution](https://docs.langchain.com/oss/python/langgraph/durable-execution): Build resilient agents with durable execution
|
||||
- [Use time-travel](https://docs.langchain.com/oss/python/langgraph/use-time-travel): Debug and replay agent execution
|
||||
|
||||
## How-To Guides
|
||||
## Architecture
|
||||
|
||||
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs.
|
||||
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs.
|
||||
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors.
|
||||
- [Application structure](https://docs.langchain.com/oss/python/langgraph/application-structure): Structure your LangGraph applications
|
||||
- [LangGraph runtime](https://docs.langchain.com/oss/python/langgraph/pregel): Understand the LangGraph runtime
|
||||
|
||||
## Deployment & Operations
|
||||
|
||||
- [LangSmith Deployment](https://docs.langchain.com/oss/python/langgraph/deploy): Deploy LangGraph applications to LangSmith
|
||||
- [Run a local server](https://docs.langchain.com/oss/python/langgraph/local-server): Run a LangGraph server locally
|
||||
- [LangSmith Observability](https://docs.langchain.com/oss/python/langgraph/observability): Monitor and trace LangGraph applications
|
||||
- [LangSmith Studio](https://docs.langchain.com/oss/python/langgraph/studio): Debug and visualize LangGraph applications
|
||||
- [Agent Chat UI](https://docs.langchain.com/oss/python/langgraph/ui): Build chat interfaces for your agents
|
||||
- [Test](https://docs.langchain.com/oss/python/langgraph/test): Test your LangGraph applications
|
||||
|
||||
## Tutorials
|
||||
|
||||
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph.
|
||||
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph.
|
||||
- [Build a custom RAG agent with LangGraph](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build a retrieval-augmented generation agent
|
||||
- [Build a custom SQL agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Build an agent that queries databases
|
||||
|
||||
## Reference
|
||||
|
||||
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph.
|
||||
- [Case studies](https://docs.langchain.com/oss/python/langgraph/case-studies): Real-world LangGraph implementations
|
||||
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-py): Python package changelog
|
||||
|
||||
## LangGraph Platform
|
||||
## Errors
|
||||
|
||||
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server).
|
||||
- [GRAPH_RECURSION_LIMIT](https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT): Graph exceeded maximum recursion depth
|
||||
- [INVALID_CHAT_HISTORY](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CHAT_HISTORY): Invalid chat history format
|
||||
- [INVALID_CONCURRENT_GRAPH_UPDATE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE): Concurrent graph update conflict
|
||||
- [INVALID_GRAPH_NODE_RETURN_VALUE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_GRAPH_NODE_RETURN_VALUE): Invalid return value from graph node
|
||||
- [MISSING_CHECKPOINTER](https://docs.langchain.com/oss/python/langgraph/errors/MISSING_CHECKPOINTER): Checkpointer required but not configured
|
||||
- [MULTIPLE_SUBGRAPHS](https://docs.langchain.com/oss/python/langgraph/errors/MULTIPLE_SUBGRAPHS): Multiple subgraphs error
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Resolve the LangGraph API version from CLI flags and langgraph.json."""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
import click
|
||||
|
||||
VERSION_MARKER_REPO = "langchain/langgraph-published-version-marker"
|
||||
_PATCH_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
def resolve_langgraph_api_version(
|
||||
config_path: pathlib.Path,
|
||||
api_version_cli_param: str | None,
|
||||
) -> str:
|
||||
"""Resolve the API version from the CLI flag and/or langgraph.json.
|
||||
|
||||
Returns the resolved patch-level version string. When neither source
|
||||
provides a version, the latest published version is fetched from Docker Hub.
|
||||
|
||||
Raises `click.ClickException` when both sources specify a version, or
|
||||
when a version cannot be resolved via Docker Hub.
|
||||
"""
|
||||
api_version_langgraph_json = _read_api_version_from_config(config_path)
|
||||
|
||||
if api_version_cli_param and api_version_langgraph_json:
|
||||
raise click.ClickException(
|
||||
"API version specified in both --api-version CLI flag "
|
||||
f"({api_version_cli_param!r}) and langgraph.json "
|
||||
f"({api_version_langgraph_json!r}). Please use only one."
|
||||
)
|
||||
|
||||
preferred_api_version = api_version_cli_param or api_version_langgraph_json
|
||||
|
||||
if preferred_api_version and _PATCH_VERSION_RE.match(preferred_api_version):
|
||||
return preferred_api_version
|
||||
|
||||
version_prefix = preferred_api_version or ""
|
||||
if version_prefix:
|
||||
click.secho(
|
||||
f"Resolving API version matching {version_prefix!r} from Docker Hub...",
|
||||
fg="cyan",
|
||||
)
|
||||
else:
|
||||
click.secho(
|
||||
"Resolving latest API version from Docker Hub...",
|
||||
fg="cyan",
|
||||
)
|
||||
resolved = _fetch_matching_version(version_prefix)
|
||||
click.secho(f"Resolved API version: {resolved}", fg="cyan")
|
||||
return resolved
|
||||
|
||||
|
||||
def _read_api_version_from_config(config_path: pathlib.Path) -> str | None:
|
||||
"""Read the `api_version` field from langgraph.json (if present)."""
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
raw_config = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return raw_config.get("api_version")
|
||||
|
||||
|
||||
def _fetch_matching_version(version_prefix: str = "") -> str:
|
||||
"""Query Docker Hub for the latest patch version matching *version_prefix*.
|
||||
|
||||
When *version_prefix* is empty, returns the latest published version.
|
||||
"""
|
||||
url = f"https://hub.docker.com/v2/repositories/{VERSION_MARKER_REPO}/tags/?page_size=10"
|
||||
if version_prefix:
|
||||
url += f"&name={version_prefix}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except Exception as exc:
|
||||
raise click.ClickException(
|
||||
f"Failed to fetch API version from {VERSION_MARKER_REPO}: {exc}\n"
|
||||
"You can specify an exact version with --api-version (e.g. 0.7.67)."
|
||||
) from exc
|
||||
|
||||
for tag in data.get("results", []):
|
||||
name = tag.get("name", "")
|
||||
if _SEMVER_RE.match(name):
|
||||
return name
|
||||
|
||||
if version_prefix:
|
||||
msg = f"Could not find a version matching {version_prefix!r} in {VERSION_MARKER_REPO}."
|
||||
else:
|
||||
msg = f"Could not find a published version in {VERSION_MARKER_REPO}."
|
||||
raise click.ClickException(
|
||||
f"{msg}\nYou can specify an exact version with --api-version (e.g. 0.7.67)."
|
||||
)
|
||||
@@ -13,7 +13,6 @@ import tempfile
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
@@ -23,11 +22,9 @@ from dotenv import dotenv_values
|
||||
import langgraph_cli.config
|
||||
import langgraph_cli.docker
|
||||
from langgraph_cli.analytics import log_command
|
||||
from langgraph_cli.api_version import resolve_langgraph_api_version
|
||||
from langgraph_cli.config import Config
|
||||
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
|
||||
from langgraph_cli.docker import DockerCapabilities
|
||||
from langgraph_cli.engine_runtime_mode import resolve_engine_runtime_mode
|
||||
from langgraph_cli.exec import Runner, subp_exec
|
||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||
from langgraph_cli.progress import Progress
|
||||
@@ -290,13 +287,6 @@ OPT_API_VERSION = click.option(
|
||||
help="API server version to use for the base image. If unspecified, the latest version will be used.",
|
||||
)
|
||||
|
||||
OPT_ENGINE_RUNTIME_MODE = click.option(
|
||||
"--engine-runtime-mode",
|
||||
type=click.Choice(["combined_queue_worker", "distributed"]),
|
||||
default=None,
|
||||
help="Runtime mode. 'distributed' uses separate executor and orchestrator containers. Defaults to distributed.",
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version=__version__, prog_name="LangGraph CLI")
|
||||
@@ -315,7 +305,6 @@ def cli():
|
||||
@OPT_WATCH
|
||||
@OPT_POSTGRES_URI
|
||||
@OPT_API_VERSION
|
||||
@OPT_ENGINE_RUNTIME_MODE
|
||||
@click.option(
|
||||
"--image",
|
||||
type=str,
|
||||
@@ -350,14 +339,9 @@ def up(
|
||||
debugger_base_url: str | None,
|
||||
postgres_uri: str | None,
|
||||
api_version: str | None,
|
||||
engine_runtime_mode: str | None,
|
||||
image: str | None,
|
||||
base_image: str | None,
|
||||
):
|
||||
api_version = resolve_langgraph_api_version(config, api_version)
|
||||
engine_runtime_mode = resolve_engine_runtime_mode(
|
||||
config, api_version, engine_runtime_mode
|
||||
)
|
||||
click.secho("Starting LangGraph API server...", fg="green")
|
||||
click.secho(
|
||||
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
|
||||
@@ -378,7 +362,6 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
api_version=api_version,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
image=image,
|
||||
base_image=base_image,
|
||||
)
|
||||
@@ -535,7 +518,6 @@ def _build(
|
||||
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
|
||||
)
|
||||
@OPT_API_VERSION
|
||||
@OPT_ENGINE_RUNTIME_MODE
|
||||
@click.option(
|
||||
"--install-command",
|
||||
help="Custom install command to run from the build context root. If not provided, auto-detects based on package manager files.",
|
||||
@@ -557,16 +539,11 @@ def build(
|
||||
docker_build_args: Sequence[str],
|
||||
base_image: str | None,
|
||||
api_version: str | None,
|
||||
engine_runtime_mode: str | None,
|
||||
pull: bool,
|
||||
tag: str,
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
):
|
||||
api_version = resolve_langgraph_api_version(config, api_version)
|
||||
engine_runtime_mode = resolve_engine_runtime_mode(
|
||||
config, api_version, engine_runtime_mode
|
||||
)
|
||||
if install_command and langgraph_cli.config.has_disallowed_build_command_content(
|
||||
install_command
|
||||
):
|
||||
@@ -584,17 +561,12 @@ def build(
|
||||
raise click.UsageError("Docker not installed") from None
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
warn_non_wolfi_distro(config_json)
|
||||
effective_base_image = base_image
|
||||
if engine_runtime_mode == "distributed" and not base_image:
|
||||
effective_base_image = langgraph_cli.config.default_base_image(
|
||||
config_json, engine_runtime_mode=engine_runtime_mode
|
||||
)
|
||||
_build(
|
||||
runner,
|
||||
set,
|
||||
config,
|
||||
config_json,
|
||||
effective_base_image,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
tag,
|
||||
@@ -699,8 +671,6 @@ def deploy(
|
||||
no_wait: bool,
|
||||
docker_build_args: Sequence[str],
|
||||
):
|
||||
api_version = resolve_langgraph_api_version(config, api_version)
|
||||
engine_runtime_mode = resolve_engine_runtime_mode(config, api_version, None)
|
||||
click.secho(
|
||||
"Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements.",
|
||||
fg="yellow",
|
||||
@@ -871,16 +841,13 @@ def deploy(
|
||||
|
||||
if needs_creation:
|
||||
log_step(f"{step}. Creating deployment '{name}'")
|
||||
payload: dict[str, Any] = {
|
||||
payload = {
|
||||
"name": name,
|
||||
"source": "internal_docker",
|
||||
"source_config": {"deployment_type": deployment_type},
|
||||
"source_revision_config": {},
|
||||
"secrets": secrets,
|
||||
"engine_runtime_mode": engine_runtime_mode,
|
||||
}
|
||||
if api_version:
|
||||
payload["deployed_api_version"] = api_version
|
||||
created = client.create_deployment(payload)
|
||||
created_id = created.get("id") if isinstance(created, dict) else None
|
||||
if not isinstance(created_id, str) or not created_id:
|
||||
@@ -989,13 +956,7 @@ def deploy(
|
||||
|
||||
# -- Step: Update deployment --
|
||||
log_step(f"{step}. Updating deployment {deployment_id}")
|
||||
updated = client.update_deployment(
|
||||
deployment_id,
|
||||
remote_image,
|
||||
secrets=secrets,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
deployed_api_version=api_version,
|
||||
)
|
||||
updated = client.update_deployment(deployment_id, remote_image, secrets=secrets)
|
||||
tenant_id = updated.get("tenant_id") if isinstance(updated, dict) else None
|
||||
if tenant_id:
|
||||
status_url = (
|
||||
@@ -1175,7 +1136,6 @@ tests
|
||||
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
|
||||
)
|
||||
@OPT_API_VERSION
|
||||
@OPT_ENGINE_RUNTIME_MODE
|
||||
@log_command
|
||||
def dockerfile(
|
||||
save_path: str,
|
||||
@@ -1183,29 +1143,18 @@ def dockerfile(
|
||||
add_docker_compose: bool,
|
||||
base_image: str | None = None,
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str | None = None,
|
||||
) -> None:
|
||||
api_version = resolve_langgraph_api_version(config, api_version)
|
||||
engine_runtime_mode = resolve_engine_runtime_mode(
|
||||
config, api_version, engine_runtime_mode
|
||||
)
|
||||
save_path = pathlib.Path(save_path).absolute()
|
||||
secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
warn_non_wolfi_distro(config_json)
|
||||
secho("✅ Configuration validated!", fg="green")
|
||||
|
||||
effective_base_image = base_image
|
||||
if engine_runtime_mode == "distributed" and not base_image:
|
||||
effective_base_image = langgraph_cli.config.default_base_image(
|
||||
config_json, engine_runtime_mode=engine_runtime_mode
|
||||
)
|
||||
|
||||
secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow")
|
||||
dockerfile, additional_contexts = langgraph_cli.config.config_to_docker(
|
||||
config_path=config,
|
||||
config=config_json,
|
||||
base_image=effective_base_image,
|
||||
base_image=base_image,
|
||||
api_version=api_version,
|
||||
)
|
||||
with open(str(save_path), "w", encoding="utf-8") as f:
|
||||
@@ -1476,7 +1425,6 @@ def prepare_args_and_stdin(
|
||||
debugger_base_url: str | None = None,
|
||||
postgres_uri: str | None = None,
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
# Like "my-tag" (if you already built it locally)
|
||||
image: str | None = None,
|
||||
# Like "langchain/langgraphjs-api" or "langchain/langgraph-api
|
||||
@@ -1490,10 +1438,9 @@ def prepare_args_and_stdin(
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
image=image,
|
||||
image=image, # Pass image to compose YAML generator
|
||||
base_image=base_image,
|
||||
api_version=api_version,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
)
|
||||
args = [
|
||||
"--project-directory",
|
||||
@@ -1511,7 +1458,6 @@ def prepare_args_and_stdin(
|
||||
base_image=langgraph_cli.config.default_base_image(config),
|
||||
api_version=api_version,
|
||||
image=image,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
)
|
||||
return args, stdin
|
||||
|
||||
@@ -1530,14 +1476,12 @@ def prepare(
|
||||
debugger_base_url: str | None = None,
|
||||
postgres_uri: str | None = None,
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
image: str | None = None,
|
||||
base_image: str | None = None,
|
||||
) -> tuple[list[str], str]:
|
||||
"""Prepare the arguments and stdin for running the LangGraph API server."""
|
||||
config_json = langgraph_cli.config.validate_config_file(config_path)
|
||||
warn_non_wolfi_distro(config_json)
|
||||
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
@@ -1548,28 +1492,6 @@ def prepare(
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
if engine_runtime_mode == "distributed":
|
||||
executor_base = langgraph_cli.config.default_base_image(
|
||||
config_json, engine_runtime_mode="distributed"
|
||||
)
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
langgraph_cli.config.docker_tag(
|
||||
config_json, executor_base, api_version
|
||||
),
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
f"langchain/langgraph-orchestrator-licensed:{api_version}",
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
args, stdin = prepare_args_and_stdin(
|
||||
capabilities=capabilities,
|
||||
@@ -1582,7 +1504,6 @@ def prepare(
|
||||
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
|
||||
postgres_uri=postgres_uri,
|
||||
api_version=api_version,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
image=image,
|
||||
base_image=base_image,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
@@ -1233,15 +1232,11 @@ def node_config_to_docker(
|
||||
return os.linesep.join(docker_file_contents), {}
|
||||
|
||||
|
||||
def default_base_image(
|
||||
config: Config, engine_runtime_mode: str = "combined_queue_worker"
|
||||
) -> str:
|
||||
def default_base_image(config: Config) -> str:
|
||||
if config.get("base_image"):
|
||||
return config["base_image"]
|
||||
if config.get("node_version") and not config.get("python_version"):
|
||||
return "langchain/langgraphjs-api"
|
||||
if engine_runtime_mode == "distributed":
|
||||
return "langchain/langgraph-executor"
|
||||
return "langchain/langgraph-api"
|
||||
|
||||
|
||||
@@ -1269,11 +1264,6 @@ def docker_tag(
|
||||
version_distro_tag = f"{version}{distro_tag}"
|
||||
|
||||
# Prepend API version if provided
|
||||
# Strip an existing tag from base_image so we don't produce two colons
|
||||
# (e.g. "langchain/langgraph-server:0.2" → "langchain/langgraph-server").
|
||||
if ":" in base_image:
|
||||
base_image = base_image.rsplit(":", 1)[0]
|
||||
|
||||
if api_version:
|
||||
full_tag = f"{api_version}-{language}{version_distro_tag}"
|
||||
elif "/langgraph-server" in base_image and version_distro_tag not in base_image:
|
||||
@@ -1339,7 +1329,6 @@ def config_to_compose(
|
||||
api_version: str | None = None,
|
||||
image: str | None = None,
|
||||
watch: bool = False,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
) -> str:
|
||||
base_image = base_image or default_base_image(config)
|
||||
|
||||
@@ -1373,11 +1362,6 @@ def config_to_compose(
|
||||
"""
|
||||
|
||||
else:
|
||||
# Save a pristine copy before config_to_docker mutates graph paths
|
||||
config_snapshot = (
|
||||
copy.deepcopy(config) if engine_runtime_mode == "distributed" else None
|
||||
)
|
||||
|
||||
dockerfile, additional_contexts = config_to_docker(
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
@@ -1395,7 +1379,7 @@ def config_to_compose(
|
||||
additional_contexts:
|
||||
{additional_contexts_str}"""
|
||||
|
||||
result = f"""
|
||||
return f"""
|
||||
{textwrap.indent(env_vars_str, " ")}
|
||||
{env_file_str}
|
||||
pull_policy: build
|
||||
@@ -1405,60 +1389,3 @@ def config_to_compose(
|
||||
{textwrap.indent(dockerfile, " ")}
|
||||
{watch_str}
|
||||
"""
|
||||
|
||||
if engine_runtime_mode == "distributed":
|
||||
executor_base_image = default_base_image(
|
||||
config_snapshot, engine_runtime_mode="distributed"
|
||||
)
|
||||
executor_dockerfile, executor_additional_contexts = config_to_docker(
|
||||
config_path=config_path,
|
||||
config=config_snapshot,
|
||||
base_image=executor_base_image,
|
||||
api_version=api_version,
|
||||
escape_variables=True,
|
||||
)
|
||||
|
||||
executor_additional_contexts_str = "\n".join(
|
||||
f" - {name}: {path}"
|
||||
for name, path in executor_additional_contexts.items()
|
||||
)
|
||||
if executor_additional_contexts_str:
|
||||
executor_additional_contexts_str = f"""
|
||||
additional_contexts:
|
||||
{executor_additional_contexts_str}"""
|
||||
|
||||
postgres_uri = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
|
||||
result += f""" langgraph-orchestrator:
|
||||
image: langchain/langgraph-orchestrator-licensed:{api_version}
|
||||
depends_on:
|
||||
langgraph-api:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URI: {postgres_uri}
|
||||
EXECUTOR_TARGET: langgraph-executor:8188
|
||||
{env_file_str}
|
||||
langgraph-executor:
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
langgraph-api:
|
||||
condition: service_healthy
|
||||
entrypoint: ["sh", "/storage/executor_entrypoint.sh"]
|
||||
environment:
|
||||
DATABASE_URI: {postgres_uri}
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
EXECUTOR_GRPC_PORT: "8188"
|
||||
ENGINE_GRPC_ADDRESS: "langgraph-orchestrator:50054"
|
||||
LSD_GRPC_SERVER_ADDRESS: "localhost:50050"
|
||||
LANGGRAPH_HTTP: ""
|
||||
{env_file_str}
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .{executor_additional_contexts_str}
|
||||
dockerfile_inline: |
|
||||
{textwrap.indent(executor_dockerfile, " ")}
|
||||
"""
|
||||
|
||||
return result
|
||||
|
||||
@@ -149,7 +149,6 @@ def compose_as_dict(
|
||||
base_image: str | None = None,
|
||||
# API version of the base image
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
) -> dict:
|
||||
"""Create a docker compose file as a dictionary in YML style."""
|
||||
if postgres_uri is None:
|
||||
@@ -208,19 +207,15 @@ def compose_as_dict(
|
||||
)["langgraph-debugger"]
|
||||
|
||||
# Add langgraph-api service
|
||||
api_environment = {
|
||||
"REDIS_URI": "redis://langgraph-redis:6379",
|
||||
"POSTGRES_URI": postgres_uri,
|
||||
}
|
||||
if engine_runtime_mode == "distributed":
|
||||
api_environment["N_JOBS_PER_WORKER"] = '"0"'
|
||||
|
||||
services["langgraph-api"] = {
|
||||
"ports": [f'"{port}:8000"'],
|
||||
"depends_on": {
|
||||
"langgraph-redis": {"condition": "service_healthy"},
|
||||
},
|
||||
"environment": api_environment,
|
||||
"environment": {
|
||||
"REDIS_URI": "redis://langgraph-redis:6379",
|
||||
"POSTGRES_URI": postgres_uri,
|
||||
},
|
||||
}
|
||||
if image:
|
||||
services["langgraph-api"]["image"] = image
|
||||
@@ -260,7 +255,6 @@ def compose(
|
||||
image: str | None = None,
|
||||
base_image: str | None = None,
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
) -> str:
|
||||
"""Create a docker compose file as a string."""
|
||||
compose_content = compose_as_dict(
|
||||
@@ -272,7 +266,6 @@ def compose(
|
||||
image=image,
|
||||
base_image=base_image,
|
||||
api_version=api_version,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
)
|
||||
compose_str = dict_to_yaml(compose_content)
|
||||
return compose_str
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Resolve the LangGraph engine runtime mode."""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import click
|
||||
|
||||
_DISTRIBUTED_MIN_VERSION = (0, 7, 68)
|
||||
|
||||
|
||||
def resolve_engine_runtime_mode(
|
||||
config_path: pathlib.Path,
|
||||
api_version: str,
|
||||
engine_runtime_mode_cli_param: str | None,
|
||||
) -> str:
|
||||
"""Resolve the engine runtime mode.
|
||||
|
||||
*api_version* must already be resolved to a patch-level semver string.
|
||||
|
||||
Returns ``"distributed"`` or ``"combined_queue_worker"``.
|
||||
|
||||
Raises `click.ClickException` when distributed mode is requested but
|
||||
not supported (JavaScript project or api_version <= 0.7.67).
|
||||
"""
|
||||
requires_combined = _requires_combined(config_path, api_version)
|
||||
|
||||
if engine_runtime_mode_cli_param == "distributed":
|
||||
if requires_combined:
|
||||
reasons = _constraint_reasons(config_path, api_version)
|
||||
raise click.ClickException(
|
||||
f"Distributed runtime is not supported for {' and '.join(reasons)}."
|
||||
)
|
||||
return "distributed"
|
||||
|
||||
if engine_runtime_mode_cli_param == "combined_queue_worker":
|
||||
return "combined_queue_worker"
|
||||
|
||||
# No explicit choice → default to distributed
|
||||
return "distributed"
|
||||
|
||||
|
||||
def _requires_combined(config_path: pathlib.Path, api_version: str) -> bool:
|
||||
return _is_javascript_project(config_path) or _version_too_old(api_version)
|
||||
|
||||
|
||||
def _version_too_old(api_version: str) -> bool:
|
||||
try:
|
||||
parts = tuple(int(x) for x in api_version.split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return True
|
||||
return parts < _DISTRIBUTED_MIN_VERSION
|
||||
|
||||
|
||||
def _is_javascript_project(config_path: pathlib.Path) -> bool:
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
return bool(cfg.get("node_version")) and not cfg.get("python_version")
|
||||
|
||||
|
||||
def _constraint_reasons(config_path: pathlib.Path, api_version: str) -> list[str]:
|
||||
reasons: list[str] = []
|
||||
if _is_javascript_project(config_path):
|
||||
reasons.append("JavaScript projects")
|
||||
if _version_too_old(api_version):
|
||||
reasons.append(f"API version {api_version} (<= 0.7.67)")
|
||||
return reasons
|
||||
@@ -82,18 +82,12 @@ class HostBackendClient:
|
||||
deployment_id: str,
|
||||
image_uri: str,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
engine_runtime_mode: str | None = None,
|
||||
deployed_api_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"source_revision_config": {"image_uri": image_uri},
|
||||
}
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
if engine_runtime_mode is not None:
|
||||
payload["engine_runtime_mode"] = engine_runtime_mode
|
||||
if deployed_api_version is not None:
|
||||
payload["deployed_api_version"] = deployed_api_version
|
||||
return self._request(
|
||||
"PATCH",
|
||||
f"/v2/deployments/{deployment_id}",
|
||||
|
||||
@@ -382,10 +382,9 @@ def test_dockerfile_command_with_base_image() -> None:
|
||||
assert save_path.exists()
|
||||
with open(save_path) as f:
|
||||
dockerfile = f.read()
|
||||
assert re.match(
|
||||
r"FROM langchain/langgraph-server:\d+\.\d+\.\d+-py3\..*",
|
||||
dockerfile,
|
||||
), "\n".join(dockerfile.splitlines()[:3])
|
||||
assert re.match("FROM langchain/langgraph-server:0.2-py3.*", dockerfile), (
|
||||
"\n".join(dockerfile.splitlines()[:3])
|
||||
)
|
||||
|
||||
|
||||
def test_dockerfile_command_with_docker_compose() -> None:
|
||||
@@ -568,8 +567,6 @@ def test_build_generate_proper_build_context():
|
||||
"test-image",
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--engine-runtime-mode",
|
||||
"combined_queue_worker",
|
||||
],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
@@ -605,8 +602,6 @@ def test_dockerfile_command_with_api_version() -> None:
|
||||
str(temp_dir / "config.json"),
|
||||
"--api-version",
|
||||
"0.2.74",
|
||||
"--engine-runtime-mode",
|
||||
"combined_queue_worker",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -723,8 +718,6 @@ def test_build_command_with_api_version() -> None:
|
||||
str(temp_dir / "config.json"),
|
||||
"--api-version",
|
||||
"0.2.74",
|
||||
"--engine-runtime-mode",
|
||||
"combined_queue_worker",
|
||||
"--no-pull", # Avoid pulling non-existent images
|
||||
],
|
||||
catch_exceptions=True,
|
||||
@@ -829,172 +822,3 @@ def test_prepare_args_and_stdin_with_api_version_and_image() -> None:
|
||||
# When image is provided, api_version should be ignored for the image
|
||||
# but the stdin should not contain a build section (since image is provided)
|
||||
assert "pull_policy: build" not in actual_stdin
|
||||
|
||||
|
||||
def test_dockerfile_command_distributed_mode() -> None:
|
||||
"""Test the 'dockerfile' command with --engine-runtime-mode distributed."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
"dependencies": ["."],
|
||||
}
|
||||
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
agent_path = temp_dir / "agent.py"
|
||||
agent_path.touch()
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"dockerfile",
|
||||
str(save_path),
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--engine-runtime-mode",
|
||||
"distributed",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "✅ Created: Dockerfile" in result.output
|
||||
|
||||
assert save_path.exists()
|
||||
with open(save_path) as f:
|
||||
dockerfile = f.read()
|
||||
assert re.search(
|
||||
r"FROM langchain/langgraph-executor:\d+\.\d+\.\d+-py3\.11",
|
||||
dockerfile,
|
||||
), dockerfile.splitlines()[0]
|
||||
|
||||
|
||||
def test_dockerfile_command_combined_mode() -> None:
|
||||
"""Test the 'dockerfile' command with --engine-runtime-mode combined_queue_worker."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
"dependencies": ["."],
|
||||
}
|
||||
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
agent_path = temp_dir / "agent.py"
|
||||
agent_path.touch()
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"dockerfile",
|
||||
str(save_path),
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--engine-runtime-mode",
|
||||
"combined_queue_worker",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert save_path.exists()
|
||||
with open(save_path) as f:
|
||||
dockerfile = f.read()
|
||||
assert re.search(
|
||||
r"FROM langchain/langgraph-api:\d+\.\d+\.\d+-py3\.11",
|
||||
dockerfile,
|
||||
), dockerfile.splitlines()[0]
|
||||
|
||||
|
||||
def test_dockerfile_command_distributed_with_explicit_base_image() -> None:
|
||||
"""Test distributed mode with explicit --base-image overrides executor default."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
"dependencies": ["."],
|
||||
}
|
||||
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
agent_path = temp_dir / "agent.py"
|
||||
agent_path.touch()
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"dockerfile",
|
||||
str(save_path),
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--engine-runtime-mode",
|
||||
"distributed",
|
||||
"--base-image",
|
||||
"my-custom-executor:latest",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert save_path.exists()
|
||||
with open(save_path) as f:
|
||||
dockerfile = f.read()
|
||||
assert re.search(
|
||||
r"FROM my-custom-executor:\d+\.\d+\.\d+-py3\.11",
|
||||
dockerfile,
|
||||
), dockerfile.splitlines()[0]
|
||||
|
||||
|
||||
def test_prepare_args_and_stdin_distributed_mode() -> None:
|
||||
"""Test prepare_args_and_stdin with distributed mode includes all services."""
|
||||
config_path = pathlib.Path(__file__).parent / "langgraph.json"
|
||||
config = validate_config(
|
||||
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
|
||||
)
|
||||
port = 8000
|
||||
|
||||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
docker_compose=None,
|
||||
port=port,
|
||||
watch=False,
|
||||
engine_runtime_mode="distributed",
|
||||
api_version="0.7.67",
|
||||
)
|
||||
|
||||
# API service should use langgraph-api base image with pinned version
|
||||
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_stdin
|
||||
|
||||
# Distributed mode sets N_JOBS_PER_WORKER=0 on the API service
|
||||
assert 'N_JOBS_PER_WORKER: "0"' in actual_stdin
|
||||
|
||||
# Orchestrator service present with pinned version
|
||||
assert "langgraph-orchestrator:" in actual_stdin
|
||||
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_stdin
|
||||
|
||||
# Executor service present with correct base image
|
||||
assert "langgraph-executor:" in actual_stdin
|
||||
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_stdin
|
||||
assert "executor_entrypoint.sh" in actual_stdin
|
||||
|
||||
|
||||
def test_prepare_args_and_stdin_distributed_with_api_version() -> None:
|
||||
"""All 3 images should use the same api_version in distributed mode."""
|
||||
config_path = pathlib.Path(__file__).parent / "langgraph.json"
|
||||
config = validate_config(
|
||||
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
|
||||
)
|
||||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
docker_compose=None,
|
||||
port=8000,
|
||||
watch=False,
|
||||
engine_runtime_mode="distributed",
|
||||
api_version="0.7.67",
|
||||
)
|
||||
|
||||
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_stdin
|
||||
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_stdin
|
||||
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_stdin
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.api_version import (
|
||||
_fetch_matching_version,
|
||||
resolve_langgraph_api_version,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def config_dir(tmp_path: pathlib.Path) -> pathlib.Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_config(
|
||||
config_dir: pathlib.Path, api_version: str | None = None
|
||||
) -> pathlib.Path:
|
||||
cfg: dict = {"dependencies": ["."], "graphs": {"agent": "agent.py:graph"}}
|
||||
if api_version is not None:
|
||||
cfg["api_version"] = api_version
|
||||
path = config_dir / "langgraph.json"
|
||||
path.write_text(json.dumps(cfg))
|
||||
return path
|
||||
|
||||
|
||||
class TestResolveLanggraphApiVersion:
|
||||
def test_exact_patch_from_cli(self, config_dir: pathlib.Path) -> None:
|
||||
path = _write_config(config_dir)
|
||||
assert resolve_langgraph_api_version(path, "0.7.67") == "0.7.67"
|
||||
|
||||
def test_exact_patch_from_json(self, config_dir: pathlib.Path) -> None:
|
||||
path = _write_config(config_dir, api_version="0.7.67")
|
||||
assert resolve_langgraph_api_version(path, None) == "0.7.67"
|
||||
|
||||
def test_neither_source_fetches_latest(
|
||||
self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = _write_config(config_dir)
|
||||
|
||||
fake_body = json.dumps(
|
||||
{"results": [{"name": "latest"}, {"name": "0.9.2"}]}
|
||||
).encode()
|
||||
|
||||
def mock_urlopen(url, *, timeout=None):
|
||||
assert "name=" not in url
|
||||
return io.BytesIO(fake_body)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
||||
assert resolve_langgraph_api_version(path, None) == "0.9.2"
|
||||
|
||||
def test_both_sources_raises(self, config_dir: pathlib.Path) -> None:
|
||||
path = _write_config(config_dir, api_version="0.7.67")
|
||||
with pytest.raises(click.ClickException, match="both"):
|
||||
resolve_langgraph_api_version(path, "0.8.0")
|
||||
|
||||
def test_partial_version_resolves_from_dockerhub(
|
||||
self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = _write_config(config_dir, api_version="0.7")
|
||||
|
||||
fake_body = json.dumps(
|
||||
{"results": [{"name": "latest"}, {"name": "0.7.67"}]}
|
||||
).encode()
|
||||
|
||||
def mock_urlopen(url, *, timeout=None):
|
||||
assert "name=0.7" in url
|
||||
return io.BytesIO(fake_body)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
||||
assert resolve_langgraph_api_version(path, None) == "0.7.67"
|
||||
|
||||
def test_partial_cli_version_resolves_from_dockerhub(
|
||||
self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = _write_config(config_dir)
|
||||
|
||||
fake_body = json.dumps(
|
||||
{"results": [{"name": "0.8.1"}, {"name": "0.8.0"}]}
|
||||
).encode()
|
||||
|
||||
def mock_urlopen(url, *, timeout=None):
|
||||
assert "name=0.8" in url
|
||||
return io.BytesIO(fake_body)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
||||
assert resolve_langgraph_api_version(path, "0.8") == "0.8.1"
|
||||
|
||||
def test_missing_config_file_fetches_latest(
|
||||
self, config_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = config_dir / "nonexistent.json"
|
||||
|
||||
fake_body = json.dumps({"results": [{"name": "0.9.2"}]}).encode()
|
||||
|
||||
def mock_urlopen(url, *, timeout=None):
|
||||
return io.BytesIO(fake_body)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
||||
assert resolve_langgraph_api_version(path, None) == "0.9.2"
|
||||
|
||||
def test_missing_config_file_with_cli_version(
|
||||
self, config_dir: pathlib.Path
|
||||
) -> None:
|
||||
path = config_dir / "nonexistent.json"
|
||||
assert resolve_langgraph_api_version(path, "0.7.67") == "0.7.67"
|
||||
|
||||
|
||||
class TestFetchMatchingVersion:
|
||||
def test_empty_prefix_returns_latest(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake_body = json.dumps(
|
||||
{"results": [{"name": "latest"}, {"name": "0.9.2"}, {"name": "abc123"}]}
|
||||
).encode()
|
||||
|
||||
def mock_urlopen(url, *, timeout=None):
|
||||
assert "name=" not in url
|
||||
return io.BytesIO(fake_body)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
||||
assert _fetch_matching_version() == "0.9.2"
|
||||
|
||||
def test_returns_first_semver(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake_body = json.dumps(
|
||||
{"results": [{"name": "latest"}, {"name": "abc1234"}, {"name": "0.7.67"}]}
|
||||
).encode()
|
||||
|
||||
def mock_urlopen(url, *, timeout=None):
|
||||
return io.BytesIO(fake_body)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
||||
assert _fetch_matching_version("0.7") == "0.7.67"
|
||||
|
||||
def test_no_semver_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake_body = json.dumps(
|
||||
{"results": [{"name": "latest"}, {"name": "abc1234"}]}
|
||||
).encode()
|
||||
|
||||
def mock_urlopen(url, *, timeout=None):
|
||||
return io.BytesIO(fake_body)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
||||
with pytest.raises(click.ClickException, match="Could not find a version"):
|
||||
_fetch_matching_version("0.7")
|
||||
|
||||
def test_network_error_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def mock_urlopen(url, *, timeout=None):
|
||||
raise urllib.error.URLError("connection refused")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
|
||||
with pytest.raises(click.ClickException, match="Failed to fetch"):
|
||||
_fetch_matching_version("0.7")
|
||||
@@ -13,7 +13,6 @@ from langgraph_cli.config import (
|
||||
_get_pip_cleanup_lines,
|
||||
config_to_compose,
|
||||
config_to_docker,
|
||||
default_base_image,
|
||||
docker_tag,
|
||||
has_disallowed_build_command_content,
|
||||
validate_config,
|
||||
@@ -1696,229 +1695,6 @@ def test_config_to_compose_with_api_version():
|
||||
assert "FROM langchain/langgraphjs-api:0.2.74-node20" in actual_compose_str
|
||||
|
||||
|
||||
def test_default_base_image_combined_mode():
|
||||
"""Test default_base_image returns langgraph-api for combined_queue_worker mode."""
|
||||
config = validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
assert default_base_image(config) == "langchain/langgraph-api"
|
||||
assert (
|
||||
default_base_image(config, engine_runtime_mode="combined_queue_worker")
|
||||
== "langchain/langgraph-api"
|
||||
)
|
||||
|
||||
|
||||
def test_default_base_image_distributed_mode():
|
||||
"""Test default_base_image returns langgraph-executor for distributed mode."""
|
||||
config = validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
assert (
|
||||
default_base_image(config, engine_runtime_mode="distributed")
|
||||
== "langchain/langgraph-executor"
|
||||
)
|
||||
|
||||
|
||||
def test_default_base_image_distributed_with_explicit_base():
|
||||
"""Test default_base_image returns explicit base_image even in distributed mode."""
|
||||
config = validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"base_image": "my-custom-image:latest",
|
||||
}
|
||||
)
|
||||
assert (
|
||||
default_base_image(config, engine_runtime_mode="distributed")
|
||||
== "my-custom-image:latest"
|
||||
)
|
||||
|
||||
|
||||
def test_default_base_image_nodejs():
|
||||
"""Test default_base_image returns langgraphjs-api for Node.js config."""
|
||||
config = validate_config(
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
}
|
||||
)
|
||||
assert default_base_image(config) == "langchain/langgraphjs-api"
|
||||
|
||||
|
||||
def test_config_to_docker_executor_base_image():
|
||||
"""Test config_to_docker with executor base image for distributed mode."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
config = validate_config({"dependencies": ["."], "graphs": graphs})
|
||||
actual_docker_stdin, _ = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
config,
|
||||
base_image="langchain/langgraph-executor",
|
||||
)
|
||||
assert "FROM langchain/langgraph-executor:3.11" in actual_docker_stdin
|
||||
assert "LANGSERVE_GRAPHS=" in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_distributed_mode():
|
||||
"""Test config_to_compose with engine_runtime_mode='distributed'."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
engine_runtime_mode="distributed",
|
||||
api_version="0.7.67",
|
||||
)
|
||||
|
||||
# API service uses langchain/langgraph-api base image
|
||||
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual_compose_stdin
|
||||
|
||||
# Orchestrator service is present with pinned version
|
||||
assert "langgraph-orchestrator:" in actual_compose_stdin
|
||||
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual_compose_stdin
|
||||
assert "EXECUTOR_TARGET: langgraph-executor:8188" in actual_compose_stdin
|
||||
|
||||
# Executor service is present with correct base image
|
||||
assert "langgraph-executor:" in actual_compose_stdin
|
||||
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual_compose_stdin
|
||||
assert (
|
||||
'entrypoint: ["sh", "/storage/executor_entrypoint.sh"]' in actual_compose_stdin
|
||||
)
|
||||
|
||||
# Executor has required environment variables
|
||||
assert "EXECUTOR_GRPC_PORT:" in actual_compose_stdin
|
||||
assert "ENGINE_GRPC_ADDRESS:" in actual_compose_stdin
|
||||
assert "LSD_GRPC_SERVER_ADDRESS:" in actual_compose_stdin
|
||||
assert 'LANGGRAPH_HTTP: ""' in actual_compose_stdin
|
||||
assert "REDIS_URI: redis://langgraph-redis:6379" in actual_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_distributed_mode_with_env_file():
|
||||
"""Test config_to_compose distributed mode propagates env_file to all services."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
|
||||
"langchain/langgraph-api",
|
||||
engine_runtime_mode="distributed",
|
||||
api_version="0.7.67",
|
||||
)
|
||||
|
||||
# env_file should appear multiple times: API, orchestrator, executor
|
||||
env_file_count = actual_compose_stdin.count("env_file: .env")
|
||||
assert env_file_count == 3, (
|
||||
f"Expected env_file to appear 3 times (api, orchestrator, executor), "
|
||||
f"got {env_file_count}"
|
||||
)
|
||||
|
||||
|
||||
def test_config_to_compose_distributed_mode_generates_two_dockerfiles():
|
||||
"""Test that distributed mode generates separate Dockerfiles for API and executor."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
engine_runtime_mode="distributed",
|
||||
api_version="0.7.67",
|
||||
)
|
||||
|
||||
# Should contain two different FROM lines
|
||||
from_lines = [
|
||||
line.strip()
|
||||
for line in actual_compose_stdin.splitlines()
|
||||
if line.strip().startswith("FROM ")
|
||||
]
|
||||
assert len(from_lines) == 2
|
||||
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in from_lines[0]
|
||||
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in from_lines[1]
|
||||
|
||||
|
||||
def test_config_to_compose_combined_mode_no_orchestrator():
|
||||
"""Test that combined_queue_worker mode does NOT generate orchestrator/executor."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
engine_runtime_mode="combined_queue_worker",
|
||||
)
|
||||
assert "langgraph-orchestrator:" not in actual_compose_stdin
|
||||
assert "langgraph-executor:" not in actual_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_default_mode_no_orchestrator():
|
||||
"""Test that default mode (no engine_runtime_mode) has no orchestrator/executor."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
assert "langgraph-orchestrator:" not in actual_compose_stdin
|
||||
assert "langgraph-executor:" not in actual_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_distributed_executor_gets_correct_paths():
|
||||
"""Test that executor Dockerfile gets correct host paths despite API Dockerfile
|
||||
mutation. This validates the deep copy fix in config_to_compose -- without it,
|
||||
the executor's config_to_docker call would see already-mutated container paths
|
||||
from the API's config_to_docker call, causing FileNotFoundError."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
engine_runtime_mode="distributed",
|
||||
api_version="0.7.67",
|
||||
)
|
||||
|
||||
# Both API and executor Dockerfiles should contain valid LANGSERVE_GRAPHS
|
||||
# referencing container paths (not host paths). If the deep copy was missing,
|
||||
# the executor Dockerfile would fail to generate or have wrong paths.
|
||||
from_lines = [
|
||||
line.strip()
|
||||
for line in actual_compose_stdin.splitlines()
|
||||
if "LANGSERVE_GRAPHS=" in line.strip()
|
||||
]
|
||||
assert len(from_lines) == 2, (
|
||||
f"Expected 2 LANGSERVE_GRAPHS lines (api + executor), got {len(from_lines)}"
|
||||
)
|
||||
|
||||
|
||||
def test_config_to_compose_distributed_orchestrator_uses_api_version():
|
||||
"""Orchestrator image tag should use the api_version when provided."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
engine_runtime_mode="distributed",
|
||||
api_version="0.7.67",
|
||||
)
|
||||
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual
|
||||
|
||||
|
||||
def test_config_to_compose_distributed_all_images_same_version():
|
||||
"""All 3 images (api, executor, orchestrator) should use the same api_version."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
engine_runtime_mode="distributed",
|
||||
api_version="0.7.67",
|
||||
)
|
||||
assert "FROM langchain/langgraph-api:0.7.67-py3.11" in actual
|
||||
assert "FROM langchain/langgraph-executor:0.7.67-py3.11" in actual
|
||||
assert "langchain/langgraph-orchestrator-licensed:0.7.67" in actual
|
||||
|
||||
|
||||
class TestHasDisallowedBuildCommandContent:
|
||||
"""Tests for has_disallowed_build_command_content."""
|
||||
|
||||
|
||||
@@ -368,61 +368,6 @@ services:
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_distributed_mode_with_custom_db():
|
||||
"""Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0."""
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES,
|
||||
port=port,
|
||||
postgres_uri=custom_postgres_uri,
|
||||
engine_runtime_mode="distributed",
|
||||
)
|
||||
expected_compose_str = f"""services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {custom_postgres_uri}
|
||||
N_JOBS_PER_WORKER: "0\""""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_distributed_mode_with_default_db():
|
||||
"""Test compose distributed mode with default DB includes N_JOBS_PER_WORKER=0."""
|
||||
port = 8123
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES,
|
||||
port=port,
|
||||
engine_runtime_mode="distributed",
|
||||
)
|
||||
assert 'N_JOBS_PER_WORKER: "0"' in actual_compose_str
|
||||
assert "langgraph-postgres:" in actual_compose_str
|
||||
assert "langgraph-redis:" in actual_compose_str
|
||||
|
||||
|
||||
def test_compose_combined_mode_has_no_n_jobs():
|
||||
"""Test compose with default combined_queue_worker mode does NOT set N_JOBS_PER_WORKER."""
|
||||
port = 8123
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES,
|
||||
port=port,
|
||||
engine_runtime_mode="combined_queue_worker",
|
||||
)
|
||||
assert "N_JOBS_PER_WORKER" not in actual_compose_str
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_str,expected",
|
||||
[
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.engine_runtime_mode import resolve_engine_runtime_mode
|
||||
|
||||
|
||||
def _write_config(
|
||||
tmp_path: pathlib.Path,
|
||||
*,
|
||||
python_version: str | None = "3.11",
|
||||
node_version: str | None = None,
|
||||
) -> pathlib.Path:
|
||||
cfg: dict = {"dependencies": ["."], "graphs": {"agent": "agent.py:graph"}}
|
||||
if python_version is not None:
|
||||
cfg["python_version"] = python_version
|
||||
if node_version is not None:
|
||||
cfg["node_version"] = node_version
|
||||
path = tmp_path / "langgraph.json"
|
||||
path.write_text(json.dumps(cfg))
|
||||
return path
|
||||
|
||||
|
||||
class TestResolveEngineRuntimeMode:
|
||||
# -- cli_param == "distributed" -------------------------------------------
|
||||
|
||||
def test_distributed_explicit_new_version(self, tmp_path: pathlib.Path) -> None:
|
||||
path = _write_config(tmp_path)
|
||||
assert (
|
||||
resolve_engine_runtime_mode(path, "0.7.68", "distributed") == "distributed"
|
||||
)
|
||||
|
||||
def test_distributed_explicit_old_version_raises(
|
||||
self, tmp_path: pathlib.Path
|
||||
) -> None:
|
||||
path = _write_config(tmp_path)
|
||||
with pytest.raises(click.ClickException, match="0.7.67"):
|
||||
resolve_engine_runtime_mode(path, "0.7.67", "distributed")
|
||||
|
||||
def test_distributed_explicit_js_raises(self, tmp_path: pathlib.Path) -> None:
|
||||
path = _write_config(tmp_path, python_version=None, node_version="20")
|
||||
with pytest.raises(click.ClickException, match="JavaScript"):
|
||||
resolve_engine_runtime_mode(path, "0.8.0", "distributed")
|
||||
|
||||
def test_distributed_explicit_js_and_old_version_raises(
|
||||
self, tmp_path: pathlib.Path
|
||||
) -> None:
|
||||
path = _write_config(tmp_path, python_version=None, node_version="20")
|
||||
with pytest.raises(click.ClickException, match="JavaScript.*0.7.60"):
|
||||
resolve_engine_runtime_mode(path, "0.7.60", "distributed")
|
||||
|
||||
# -- cli_param == "combined_queue_worker" ----------------------------------
|
||||
|
||||
def test_combined_explicit(self, tmp_path: pathlib.Path) -> None:
|
||||
path = _write_config(tmp_path)
|
||||
assert (
|
||||
resolve_engine_runtime_mode(path, "0.8.0", "combined_queue_worker")
|
||||
== "combined_queue_worker"
|
||||
)
|
||||
|
||||
def test_combined_explicit_old_version(self, tmp_path: pathlib.Path) -> None:
|
||||
path = _write_config(tmp_path)
|
||||
assert (
|
||||
resolve_engine_runtime_mode(path, "0.7.67", "combined_queue_worker")
|
||||
== "combined_queue_worker"
|
||||
)
|
||||
|
||||
# -- cli_param is None (default) -------------------------------------------
|
||||
|
||||
def test_default_is_distributed(self, tmp_path: pathlib.Path) -> None:
|
||||
path = _write_config(tmp_path)
|
||||
assert resolve_engine_runtime_mode(path, "0.8.0", None) == "distributed"
|
||||
|
||||
def test_default_old_version(self, tmp_path: pathlib.Path) -> None:
|
||||
path = _write_config(tmp_path)
|
||||
assert resolve_engine_runtime_mode(path, "0.7.67", None) == "distributed"
|
||||
|
||||
def test_default_js(self, tmp_path: pathlib.Path) -> None:
|
||||
path = _write_config(tmp_path, python_version=None, node_version="20")
|
||||
assert resolve_engine_runtime_mode(path, "0.8.0", None) == "distributed"
|
||||
|
||||
# -- edge: version boundary ------------------------------------------------
|
||||
|
||||
def test_version_boundary_0_7_67_blocks_distributed(
|
||||
self, tmp_path: pathlib.Path
|
||||
) -> None:
|
||||
path = _write_config(tmp_path)
|
||||
with pytest.raises(click.ClickException):
|
||||
resolve_engine_runtime_mode(path, "0.7.67", "distributed")
|
||||
|
||||
def test_version_boundary_0_7_68_allows_distributed(
|
||||
self, tmp_path: pathlib.Path
|
||||
) -> None:
|
||||
path = _write_config(tmp_path)
|
||||
assert (
|
||||
resolve_engine_runtime_mode(path, "0.7.68", "distributed") == "distributed"
|
||||
)
|
||||
@@ -152,77 +152,6 @@ def test_update_deployment_no_secrets(client):
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_update_deployment_with_engine_runtime_mode():
|
||||
"""Verify engine_runtime_mode is included in the PATCH payload."""
|
||||
import json
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(req.content)
|
||||
assert body["engine_runtime_mode"] == "distributed"
|
||||
assert body["source_revision_config"]["image_uri"] == "img:v1"
|
||||
return httpx.Response(200, json={"id": "dep-1"})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
result = c.update_deployment("dep-1", "img:v1", engine_runtime_mode="distributed")
|
||||
assert result == {"id": "dep-1"}
|
||||
|
||||
|
||||
def test_update_deployment_with_deployed_api_version():
|
||||
"""Verify deployed_api_version is included in the PATCH payload."""
|
||||
import json
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(req.content)
|
||||
assert body["deployed_api_version"] == "0.3.5"
|
||||
assert body["engine_runtime_mode"] == "distributed"
|
||||
assert body["source_revision_config"]["image_uri"] == "img:v2"
|
||||
assert body["secrets"] == [{"name": "K", "value": "V"}]
|
||||
return httpx.Response(200, json={"id": "dep-2"})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
result = c.update_deployment(
|
||||
"dep-2",
|
||||
"img:v2",
|
||||
secrets=[{"name": "K", "value": "V"}],
|
||||
engine_runtime_mode="distributed",
|
||||
deployed_api_version="0.3.5",
|
||||
)
|
||||
assert result == {"id": "dep-2"}
|
||||
|
||||
|
||||
def test_update_deployment_omits_none_fields():
|
||||
"""Verify None values for optional fields are not sent in payload."""
|
||||
import json
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(req.content)
|
||||
assert "engine_runtime_mode" not in body
|
||||
assert "deployed_api_version" not in body
|
||||
assert "secrets" not in body
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
c.update_deployment("dep-3", "img:v1")
|
||||
|
||||
|
||||
def test_list_revisions(client):
|
||||
result = client.list_revisions("dep-123", limit=5)
|
||||
assert result == {"ok": True}
|
||||
|
||||
Reference in New Issue
Block a user