[CLI] Validate node version (#2489)

This commit is contained in:
William FH
2024-11-20 17:24:34 -08:00
committed by GitHub
parent 588373c2d5
commit a933776436
5 changed files with 153 additions and 29 deletions
+1 -2
View File
@@ -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 = [
+10 -17
View File
@@ -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,
+69 -8
View File
@@ -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 -1
View File
@@ -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"
+72 -1
View File
@@ -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"}