mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-12 12:47:53 +02:00
[CLI] Validate node version (#2489)
This commit is contained in:
@@ -22,8 +22,7 @@ def test(
|
||||
# check docker available
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
# open config
|
||||
with open(config) as f:
|
||||
config_json = langgraph_cli.config.validate_config(json.load(f))
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
|
||||
set("Running...")
|
||||
args = [
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
@@ -354,8 +353,7 @@ def build(
|
||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||
if shutil.which("docker") is None:
|
||||
raise click.UsageError("Docker not installed") from None
|
||||
with open(config) as f:
|
||||
config_json = langgraph_cli.config.validate_config(json.load(f))
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
_build(
|
||||
runner, set, config, config_json, base_image, pull, tag, docker_build_args
|
||||
)
|
||||
@@ -435,8 +433,7 @@ tests
|
||||
def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -> None:
|
||||
save_path = pathlib.Path(save_path).absolute()
|
||||
secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
|
||||
with open(config, encoding="utf-8") as f:
|
||||
config_json = langgraph_cli.config.validate_config(json.load(f))
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
secho("✅ Configuration validated!", fg="green")
|
||||
|
||||
secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow")
|
||||
@@ -575,7 +572,7 @@ def dev(
|
||||
host: str,
|
||||
port: int,
|
||||
no_reload: bool,
|
||||
config: str,
|
||||
config: pathlib.Path,
|
||||
n_jobs_per_worker: Optional[int],
|
||||
no_browser: bool,
|
||||
debug_port: Optional[int],
|
||||
@@ -601,12 +598,9 @@ def dev(
|
||||
"Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\""
|
||||
) from None
|
||||
|
||||
import json
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
|
||||
with open(config, encoding="utf-8") as f:
|
||||
config_data = json.load(f)
|
||||
|
||||
graphs = config_data.get("graphs", {})
|
||||
graphs = config_json.get("graphs", {})
|
||||
run_server(
|
||||
host,
|
||||
port,
|
||||
@@ -674,8 +668,7 @@ def prepare(
|
||||
debugger_base_url: Optional[str] = None,
|
||||
postgres_uri: Optional[str] = None,
|
||||
):
|
||||
with open(config_path) as f:
|
||||
config = langgraph_cli.config.validate_config(json.load(f))
|
||||
config_json = langgraph_cli.config.validate_config_file(config_path)
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
@@ -683,9 +676,9 @@ def prepare(
|
||||
"docker",
|
||||
"pull",
|
||||
(
|
||||
f"langchain/langgraphjs-api:{config['node_version']}"
|
||||
if config.get("node_version")
|
||||
else f"langchain/langgraph-api:{config['python_version']}"
|
||||
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,
|
||||
)
|
||||
@@ -694,7 +687,7 @@ def prepare(
|
||||
args, stdin = prepare_args_and_stdin(
|
||||
capabilities=capabilities,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
config=config_json,
|
||||
docker_compose=docker_compose,
|
||||
port=port,
|
||||
watch=watch,
|
||||
|
||||
@@ -6,6 +6,9 @@ from typing import NamedTuple, Optional, TypedDict, Union
|
||||
|
||||
import click
|
||||
|
||||
MIN_NODE_VERSION = "20"
|
||||
MIN_PYTHON_VERSION = "3.11"
|
||||
|
||||
|
||||
class Config(TypedDict):
|
||||
python_version: str
|
||||
@@ -17,9 +20,6 @@ class Config(TypedDict):
|
||||
env: Union[dict[str, str], str]
|
||||
|
||||
|
||||
MIN_PYTHON_VERSION = "3.11"
|
||||
|
||||
|
||||
def _parse_version(version_str: str) -> tuple[int, int]:
|
||||
"""Parse a version string into a tuple of (major, minor)."""
|
||||
try:
|
||||
@@ -29,6 +29,19 @@ def _parse_version(version_str: str) -> tuple[int, int]:
|
||||
raise click.UsageError(f"Invalid version format: {version_str}") from None
|
||||
|
||||
|
||||
def _parse_node_version(version_str: str) -> int:
|
||||
"""Parse a Node.js version string into a major version number."""
|
||||
try:
|
||||
if "." in version_str:
|
||||
raise ValueError("Node.js version must be major version only")
|
||||
return int(version_str)
|
||||
except ValueError:
|
||||
raise click.UsageError(
|
||||
f"Invalid Node.js version format: {version_str}. "
|
||||
"Use major version only (e.g., '20')."
|
||||
) from None
|
||||
|
||||
|
||||
def validate_config(config: Config) -> Config:
|
||||
config = (
|
||||
{
|
||||
@@ -49,11 +62,17 @@ def validate_config(config: Config) -> Config:
|
||||
)
|
||||
|
||||
if config.get("node_version"):
|
||||
if config["node_version"] not in ("20",):
|
||||
raise click.UsageError(
|
||||
f"Unsupported Node.js version: {config['node_version']}. "
|
||||
"Currently only `node_version: \"20\"` is supported."
|
||||
)
|
||||
node_version = config["node_version"]
|
||||
try:
|
||||
major = _parse_node_version(node_version)
|
||||
min_major = _parse_node_version(MIN_NODE_VERSION)
|
||||
if major < min_major:
|
||||
raise click.UsageError(
|
||||
f"Node.js version {node_version} is not supported. "
|
||||
f"Minimum required version is {MIN_NODE_VERSION}."
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
if config.get("python_version"):
|
||||
pyversion = config["python_version"]
|
||||
@@ -85,6 +104,48 @@ def validate_config(config: Config) -> Config:
|
||||
return config
|
||||
|
||||
|
||||
def validate_config_file(config_path: pathlib.Path) -> Config:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
validated = validate_config(config)
|
||||
# Enforce the package.json doesn't enforce an
|
||||
# incompatible Node.js version
|
||||
if validated.get("node_version"):
|
||||
package_json_path = config_path.parent / "package.json"
|
||||
if package_json_path.is_file():
|
||||
try:
|
||||
with open(package_json_path) as f:
|
||||
package_json = json.load(f)
|
||||
if "engines" in package_json:
|
||||
engines = package_json["engines"]
|
||||
if any(engine != "node" for engine in engines.keys()):
|
||||
raise click.UsageError(
|
||||
"Only 'node' engine is supported in package.json engines."
|
||||
f" Got engines: {list(engines.keys())}"
|
||||
)
|
||||
if engines:
|
||||
node_version = engines["node"]
|
||||
try:
|
||||
major = _parse_node_version(node_version)
|
||||
min_major = _parse_node_version(MIN_NODE_VERSION)
|
||||
if major < min_major:
|
||||
raise click.UsageError(
|
||||
f"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} "
|
||||
f"(major version only), got '{node_version}'. Minor/patch versions "
|
||||
"(like '20.x.y') are not supported to prevent deployment issues "
|
||||
"when new Node.js versions are released."
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
except json.JSONDecodeError:
|
||||
raise click.UsageError(
|
||||
"Invalid package.json found in langgraph "
|
||||
f"config directory {package_json_path}: file is not valid JSON"
|
||||
) from None
|
||||
return validated
|
||||
|
||||
|
||||
class LocalDeps(NamedTuple):
|
||||
pip_reqs: list[tuple[pathlib.Path, str]]
|
||||
real_pkgs: dict[pathlib.Path, str]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.55"
|
||||
version = "0.1.56"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.config import config_to_compose, config_to_docker, validate_config
|
||||
from langgraph_cli.config import (
|
||||
config_to_compose,
|
||||
config_to_docker,
|
||||
validate_config,
|
||||
validate_config_file,
|
||||
)
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json"
|
||||
@@ -81,6 +88,70 @@ def test_validate_config():
|
||||
assert "Minimum required version" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_file():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
|
||||
config_path = tmpdir_path / "langgraph.json"
|
||||
|
||||
node_config = {"node_version": "20", "graphs": {"agent": "./agent.js:graph"}}
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(node_config, f)
|
||||
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json = {"name": "test", "engines": {"node": "20"}}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"]["node"] = "20.18"
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="Use major version only"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"] = {"node": "18"}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="must be >= 20"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"] = {"node": "20", "deno": "1.0"}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="Only 'node' engine is supported"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
f.write("{invalid json")
|
||||
with pytest.raises(click.UsageError, match="Invalid package.json"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
python_config = {
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(python_config, f)
|
||||
|
||||
validate_config_file(config_path)
|
||||
|
||||
for package_content in [
|
||||
{"name": "test"},
|
||||
{"engines": {"node": "18"}},
|
||||
{"engines": {"node": "20", "deno": "1.0"}},
|
||||
"{invalid json",
|
||||
]:
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
if isinstance(package_content, dict):
|
||||
json.dump(package_content, f)
|
||||
else:
|
||||
f.write(package_content)
|
||||
validate_config_file(config_path)
|
||||
|
||||
|
||||
# config_to_docker
|
||||
def test_config_to_docker_simple():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
|
||||
Reference in New Issue
Block a user