Skip to content

Commit

Permalink
[CLI] Validate node version (#2489)
Browse files Browse the repository at this point in the history
  • Loading branch information
hinthornw authored Nov 21, 2024
1 parent 588373c commit a933776
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 29 deletions.
3 changes: 1 addition & 2 deletions .github/scripts/run_langgraph_cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
27 changes: 10 additions & 17 deletions libs/cli/langgraph_cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
import pathlib
import shutil
import sys
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -435,8 +433,7 @@ def _get_docker_ignore_content() -> str:
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")
Expand Down Expand Up @@ -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],
Expand All @@ -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

with open(config, encoding="utf-8") as f:
config_data = json.load(f)
config_json = langgraph_cli.config.validate_config_file(config)

graphs = config_data.get("graphs", {})
graphs = config_json.get("graphs", {})
run_server(
host,
port,
Expand Down Expand Up @@ -674,18 +668,17 @@ 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(
subp_exec(
"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,
)
Expand All @@ -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,
Expand Down
77 changes: 69 additions & 8 deletions libs/cli/langgraph_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

import click

MIN_NODE_VERSION = "20"
MIN_PYTHON_VERSION = "3.11"


class Config(TypedDict):
python_version: str
Expand All @@ -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:
Expand All @@ -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 = (
{
Expand All @@ -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"]
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion libs/cli/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
73 changes: 72 additions & 1 deletion libs/cli/tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"}
Expand Down

0 comments on commit a933776

Please sign in to comment.