mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-18 05:35:43 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e524d96c |
@@ -1,5 +1,7 @@
|
||||
"""CLI entrypoint for LangGraph API server."""
|
||||
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
@@ -165,6 +167,10 @@ def cli():
|
||||
pass
|
||||
|
||||
|
||||
def _format_json(value: object) -> str:
|
||||
return json.dumps(value, indent=2, sort_keys=True) + os.linesep
|
||||
|
||||
|
||||
@OPT_RECREATE
|
||||
@OPT_PULL
|
||||
@OPT_PORT
|
||||
@@ -606,6 +612,160 @@ def dockerfile(
|
||||
)
|
||||
|
||||
|
||||
@cli.group(
|
||||
"build-spec",
|
||||
help="🧾 Export or verify a machine-readable build specification for hermetic builds.",
|
||||
)
|
||||
def build_spec() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@OPT_CONFIG
|
||||
@click.option(
|
||||
"--base-image",
|
||||
help="Base image to use for the LangGraph API server. Defaults to langchain/langgraph-api or langchain/langgraphjs-api.",
|
||||
)
|
||||
@OPT_API_VERSION
|
||||
@click.option(
|
||||
"--install-command",
|
||||
help="Custom install command (Node projects only). If omitted, auto-detects based on package manager files.",
|
||||
)
|
||||
@click.option(
|
||||
"--build-command",
|
||||
help="Custom build command to run from the langgraph.json directory (Node projects only).",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(
|
||||
exists=False,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
resolve_path=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
help="Path to write the build spec JSON. If omitted, prints to stdout.",
|
||||
)
|
||||
@build_spec.command("export")
|
||||
@log_command
|
||||
def build_spec_export(
|
||||
config: pathlib.Path,
|
||||
base_image: str | None,
|
||||
api_version: str | None,
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
output: pathlib.Path | None,
|
||||
) -> None:
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
warn_non_wolfi_distro(config_json)
|
||||
|
||||
is_js_project = config_json.get("node_version") and not config_json.get(
|
||||
"python_version"
|
||||
)
|
||||
if is_js_project and (build_command or install_command):
|
||||
build_context = str(pathlib.Path.cwd())
|
||||
else:
|
||||
build_context = str(config.parent)
|
||||
|
||||
spec = langgraph_cli.config.config_to_build_spec(
|
||||
config_path=config,
|
||||
config=config_json,
|
||||
base_image=base_image,
|
||||
api_version=api_version,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
build_context=build_context,
|
||||
)
|
||||
rendered = _format_json(spec)
|
||||
|
||||
if output is None:
|
||||
click.echo(rendered, nl=False)
|
||||
return
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(rendered)
|
||||
secho(f"✅ Created build spec: {output}", fg="green")
|
||||
|
||||
|
||||
@OPT_CONFIG
|
||||
@click.argument(
|
||||
"spec_path",
|
||||
type=click.Path(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
resolve_path=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--base-image",
|
||||
help="Base image to use for the LangGraph API server. Defaults to langchain/langgraph-api or langchain/langgraphjs-api.",
|
||||
)
|
||||
@OPT_API_VERSION
|
||||
@click.option(
|
||||
"--install-command",
|
||||
help="Custom install command (Node projects only). If omitted, auto-detects based on package manager files.",
|
||||
)
|
||||
@click.option(
|
||||
"--build-command",
|
||||
help="Custom build command to run from the langgraph.json directory (Node projects only).",
|
||||
)
|
||||
@build_spec.command("verify")
|
||||
@log_command
|
||||
def build_spec_verify(
|
||||
config: pathlib.Path,
|
||||
spec_path: pathlib.Path,
|
||||
base_image: str | None,
|
||||
api_version: str | None,
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
) -> None:
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
warn_non_wolfi_distro(config_json)
|
||||
|
||||
is_js_project = config_json.get("node_version") and not config_json.get(
|
||||
"python_version"
|
||||
)
|
||||
if is_js_project and (build_command or install_command):
|
||||
build_context = str(pathlib.Path.cwd())
|
||||
else:
|
||||
build_context = str(config.parent)
|
||||
|
||||
expected = langgraph_cli.config.config_to_build_spec(
|
||||
config_path=config,
|
||||
config=config_json,
|
||||
base_image=base_image,
|
||||
api_version=api_version,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
build_context=build_context,
|
||||
)
|
||||
expected_str = _format_json(expected)
|
||||
|
||||
with open(spec_path, encoding="utf-8") as f:
|
||||
actual = json.load(f)
|
||||
actual_str = _format_json(actual)
|
||||
|
||||
if actual_str == expected_str:
|
||||
secho(f"✅ Build spec is in sync: {spec_path}", fg="green")
|
||||
return
|
||||
|
||||
diff = "\n".join(
|
||||
difflib.unified_diff(
|
||||
actual_str.splitlines(),
|
||||
expected_str.splitlines(),
|
||||
fromfile=str(spec_path),
|
||||
tofile="generated",
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
raise click.ClickException(
|
||||
f"Build spec is out of sync: {spec_path}\n\n{diff}"
|
||||
) from None
|
||||
|
||||
|
||||
@click.option(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
|
||||
@@ -4,7 +4,7 @@ import pathlib
|
||||
import re
|
||||
import textwrap
|
||||
from collections import Counter
|
||||
from typing import Literal, NamedTuple
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
import click
|
||||
|
||||
@@ -874,6 +874,55 @@ def get_build_tools_to_uninstall(config: Config) -> tuple[str]:
|
||||
)
|
||||
|
||||
|
||||
def _build_langgraph_env(
|
||||
config: Config, *, runtime: Literal["python", "node"]
|
||||
) -> dict[str, str]:
|
||||
"""Build runtime environment variables that are serialized from config."""
|
||||
env_map: dict[str, str] = {}
|
||||
|
||||
if (store_config := config.get("store")) is not None:
|
||||
env_map["LANGGRAPH_STORE"] = json.dumps(store_config)
|
||||
|
||||
if (auth_config := config.get("auth")) is not None:
|
||||
env_map["LANGGRAPH_AUTH"] = json.dumps(auth_config)
|
||||
|
||||
if (encryption_config := config.get("encryption")) is not None:
|
||||
env_map["LANGGRAPH_ENCRYPTION"] = json.dumps(encryption_config)
|
||||
|
||||
if (http_config := config.get("http")) is not None:
|
||||
env_map["LANGGRAPH_HTTP"] = json.dumps(http_config)
|
||||
|
||||
if (webhooks_config := config.get("webhooks")) is not None:
|
||||
env_map["LANGGRAPH_WEBHOOKS"] = json.dumps(webhooks_config)
|
||||
|
||||
if (checkpointer_config := config.get("checkpointer")) is not None:
|
||||
env_map["LANGGRAPH_CHECKPOINTER"] = json.dumps(checkpointer_config)
|
||||
|
||||
# Keep Python/Node behavior consistent with current Dockerfile generation:
|
||||
# Python emits UI vars when explicitly set (including empty dict), while
|
||||
# Node currently emits them only when truthy.
|
||||
if runtime == "python":
|
||||
if (ui := config.get("ui")) is not None:
|
||||
env_map["LANGGRAPH_UI"] = json.dumps(ui)
|
||||
if (ui_config := config.get("ui_config")) is not None:
|
||||
env_map["LANGGRAPH_UI_CONFIG"] = json.dumps(ui_config)
|
||||
else:
|
||||
if ui := config.get("ui"):
|
||||
env_map["LANGGRAPH_UI"] = json.dumps(ui)
|
||||
if ui_config := config.get("ui_config"):
|
||||
env_map["LANGGRAPH_UI_CONFIG"] = json.dumps(ui_config)
|
||||
|
||||
env_map["LANGSERVE_GRAPHS"] = json.dumps(config["graphs"])
|
||||
return env_map
|
||||
|
||||
|
||||
def _extract_workdir(dockerfile: str) -> str | None:
|
||||
for line in dockerfile.splitlines():
|
||||
if line.startswith("WORKDIR "):
|
||||
return line.removeprefix("WORKDIR ").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def python_config_to_docker(
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
@@ -1006,36 +1055,8 @@ ADD {relpath} /deps/{name}
|
||||
)
|
||||
)
|
||||
|
||||
env_vars = []
|
||||
|
||||
if (store_config := config.get("store")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
|
||||
|
||||
if (auth_config := config.get("auth")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
|
||||
|
||||
if (encryption_config := config.get("encryption")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
|
||||
|
||||
if (http_config := config.get("http")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
|
||||
|
||||
# Inject webhooks configuration if provided
|
||||
if (webhooks_config := config.get("webhooks")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
|
||||
|
||||
if (checkpointer_config := config.get("checkpointer")) is not None:
|
||||
env_vars.append(
|
||||
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
|
||||
)
|
||||
|
||||
if (ui := config.get("ui")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
|
||||
|
||||
if (ui_config := config.get("ui_config")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
|
||||
|
||||
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
|
||||
env_map = _build_langgraph_env(config, runtime="python")
|
||||
env_vars = [f"ENV {key}='{value}'" for key, value in env_map.items()]
|
||||
|
||||
js_inst_str: str = ""
|
||||
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir:
|
||||
@@ -1137,36 +1158,8 @@ def node_config_to_docker(
|
||||
|
||||
image_str = docker_tag(config, base_image, api_version)
|
||||
|
||||
env_vars: list[str] = []
|
||||
|
||||
if (store_config := config.get("store")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
|
||||
|
||||
if (auth_config := config.get("auth")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
|
||||
|
||||
if (encryption_config := config.get("encryption")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
|
||||
|
||||
if (http_config := config.get("http")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
|
||||
|
||||
# Inject webhooks configuration if provided
|
||||
if (webhooks_config := config.get("webhooks")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_WEBHOOKS='{json.dumps(webhooks_config)}'")
|
||||
|
||||
if (checkpointer_config := config.get("checkpointer")) is not None:
|
||||
env_vars.append(
|
||||
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
|
||||
)
|
||||
|
||||
if ui := config.get("ui"):
|
||||
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
|
||||
|
||||
if ui_config := config.get("ui_config"):
|
||||
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
|
||||
|
||||
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
|
||||
env_map = _build_langgraph_env(config, runtime="node")
|
||||
env_vars = [f"ENV {key}='{value}'" for key, value in env_map.items()]
|
||||
|
||||
# For monorepo support, we need to handle install and build commands differently
|
||||
if build_context:
|
||||
@@ -1292,6 +1285,50 @@ def config_to_docker(
|
||||
)
|
||||
|
||||
|
||||
def config_to_build_spec(
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
*,
|
||||
base_image: str | None = None,
|
||||
api_version: str | None = None,
|
||||
install_command: str | None = None,
|
||||
build_command: str | None = None,
|
||||
build_context: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a machine-readable, versioned build specification."""
|
||||
# Normalize via JSON round-trip to avoid mutating the caller's dictionary.
|
||||
normalized: Config = json.loads(json.dumps(config))
|
||||
is_node_runtime = bool(
|
||||
normalized.get("node_version") and not normalized.get("python_version")
|
||||
)
|
||||
runtime: Literal["python", "node"] = "node" if is_node_runtime else "python"
|
||||
resolved_base_image = docker_tag(normalized, base_image, api_version)
|
||||
|
||||
dockerfile, additional_contexts = config_to_docker(
|
||||
config_path=config_path,
|
||||
config=normalized,
|
||||
base_image=base_image,
|
||||
api_version=api_version,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
build_context=build_context,
|
||||
)
|
||||
env_map = _build_langgraph_env(normalized, runtime=runtime)
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"kind": "langgraph.build_spec",
|
||||
"runtime": runtime,
|
||||
"resolved_base_image": resolved_base_image,
|
||||
"build_context": build_context,
|
||||
"additional_contexts": additional_contexts,
|
||||
"env": env_map,
|
||||
"graphs": normalized["graphs"],
|
||||
"working_dir": _extract_workdir(dockerfile),
|
||||
"dockerfile": dockerfile,
|
||||
}
|
||||
|
||||
|
||||
def config_to_compose(
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
|
||||
@@ -287,6 +287,148 @@ def test_version_option() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_build_spec_export_command_python_to_stdout() -> None:
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"python_version": "3.11",
|
||||
"image_distro": "wolfi",
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
"dependencies": ["."],
|
||||
}
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
(temp_dir / "agent.py").touch()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["build-spec", "export", "--config", str(temp_dir / "config.json")],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
spec = json.loads(result.output)
|
||||
assert spec["schema_version"] == 1
|
||||
assert spec["kind"] == "langgraph.build_spec"
|
||||
assert spec["runtime"] == "python"
|
||||
assert spec["resolved_base_image"] == "langchain/langgraph-api:3.11-wolfi"
|
||||
assert spec["env"]["LANGSERVE_GRAPHS"] == '{"agent": "agent.py:graph"}'
|
||||
assert spec["working_dir"] is not None
|
||||
assert spec["working_dir"].startswith("/deps/")
|
||||
|
||||
|
||||
def test_build_spec_export_command_node_to_file() -> None:
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"node_version": "20",
|
||||
"image_distro": "wolfi",
|
||||
"graphs": {"agent": "src/agent.ts:graph"},
|
||||
"dependencies": ["."],
|
||||
}
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
spec_path = temp_dir / "buildspec.json"
|
||||
(temp_dir / "src").mkdir(parents=True, exist_ok=True)
|
||||
(temp_dir / "src" / "agent.ts").touch()
|
||||
(temp_dir / "package.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"build-spec",
|
||||
"export",
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--output",
|
||||
str(spec_path),
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert spec_path.exists()
|
||||
with open(spec_path, encoding="utf-8") as f:
|
||||
spec = json.load(f)
|
||||
assert spec["runtime"] == "node"
|
||||
assert spec["resolved_base_image"] == "langchain/langgraphjs-api:20-wolfi"
|
||||
assert spec["working_dir"] is not None
|
||||
assert spec["working_dir"].startswith("/deps/")
|
||||
|
||||
|
||||
def test_build_spec_verify_command_in_sync() -> None:
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"python_version": "3.11",
|
||||
"image_distro": "wolfi",
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
"dependencies": ["."],
|
||||
}
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
spec_path = temp_dir / "buildspec.json"
|
||||
(temp_dir / "agent.py").touch()
|
||||
export_result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"build-spec",
|
||||
"export",
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--output",
|
||||
str(spec_path),
|
||||
],
|
||||
)
|
||||
assert export_result.exit_code == 0, export_result.output
|
||||
|
||||
verify_result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"build-spec",
|
||||
"verify",
|
||||
str(spec_path),
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
],
|
||||
)
|
||||
assert verify_result.exit_code == 0, verify_result.output
|
||||
assert "Build spec is in sync" in verify_result.output
|
||||
|
||||
|
||||
def test_build_spec_verify_command_out_of_sync() -> None:
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"python_version": "3.11",
|
||||
"image_distro": "wolfi",
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
"dependencies": ["."],
|
||||
}
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
spec_path = temp_dir / "buildspec.json"
|
||||
(temp_dir / "agent.py").touch()
|
||||
export_result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"build-spec",
|
||||
"export",
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--output",
|
||||
str(spec_path),
|
||||
],
|
||||
)
|
||||
assert export_result.exit_code == 0, export_result.output
|
||||
|
||||
with open(spec_path, encoding="utf-8") as f:
|
||||
spec = json.load(f)
|
||||
spec["runtime"] = "node"
|
||||
with open(spec_path, "w", encoding="utf-8") as f:
|
||||
json.dump(spec, f, indent=2, sort_keys=True)
|
||||
|
||||
verify_result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"build-spec",
|
||||
"verify",
|
||||
str(spec_path),
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
],
|
||||
)
|
||||
assert verify_result.exit_code != 0
|
||||
assert "Build spec is out of sync" in verify_result.output
|
||||
|
||||
|
||||
def test_dockerfile_command_basic() -> None:
|
||||
"""Test the 'dockerfile' command with basic configuration."""
|
||||
runner = CliRunner()
|
||||
|
||||
Reference in New Issue
Block a user