From 6bd3e8b1e5b732cfc9d434977dc14d1dc9a5b38a Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Tue, 10 Mar 2026 10:24:46 -0700 Subject: [PATCH] version-done --- libs/cli/langgraph_cli/api_version.py | 95 +++++++++++ libs/cli/langgraph_cli/cli.py | 16 +- libs/cli/langgraph_cli/config.py | 37 +---- libs/cli/tests/unit_tests/cli/test_cli.py | 22 ++- libs/cli/tests/unit_tests/test_api_version.py | 156 ++++++++++++++++++ libs/cli/tests/unit_tests/test_config.py | 46 ------ .../cli/tests/unit_tests/test_host_backend.py | 4 +- 7 files changed, 278 insertions(+), 98 deletions(-) create mode 100644 libs/cli/langgraph_cli/api_version.py create mode 100644 libs/cli/tests/unit_tests/test_api_version.py diff --git a/libs/cli/langgraph_cli/api_version.py b/libs/cli/langgraph_cli/api_version.py new file mode 100644 index 000000000..b349820ad --- /dev/null +++ b/libs/cli/langgraph_cli/api_version.py @@ -0,0 +1,95 @@ +"""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)." + ) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 32edb185e..f67b7a2a5 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -23,6 +23,7 @@ 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 @@ -352,6 +353,7 @@ def up( image: str | None, base_image: str | None, ): + api_version = resolve_langgraph_api_version(config, api_version) click.secho("Starting LangGraph API server...", fg="green") click.secho( """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment. @@ -557,6 +559,7 @@ def build( install_command: str | None, build_command: str | None, ): + api_version = resolve_langgraph_api_version(config, api_version) if install_command and langgraph_cli.config.has_disallowed_build_command_content( install_command ): @@ -689,6 +692,7 @@ def deploy( no_wait: bool, docker_build_args: Sequence[str], ): + deploy_api_version = resolve_langgraph_api_version(config, api_version) click.secho( "Note: 'langgraph deploy' is in beta. Expect frequent updates and improvements.", fg="yellow", @@ -721,7 +725,6 @@ def deploy( "node_version" ) deploy_engine_runtime_mode = "distributed" if is_python else "combined_queue_server" - deploy_api_version = api_version or config_json.get("api_version") # Use buildx to cross-compile for amd64 when running on a non-x86_64 host # (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient. @@ -1180,6 +1183,7 @@ def dockerfile( api_version: str | None = None, engine_runtime_mode: str = "combined_queue_worker", ) -> None: + api_version = resolve_langgraph_api_version(config, api_version) 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) @@ -1529,16 +1533,6 @@ def prepare( config_json = langgraph_cli.config.validate_config_file(config_path) warn_non_wolfi_distro(config_json) - if engine_runtime_mode == "distributed" and not api_version and not image: - click.secho( - "Resolving latest published version for distributed runtime...", - fg="cyan", - ) - api_version = langgraph_cli.config.fetch_latest_api_version() - click.secho( - f"Using version {api_version} for all distributed images.", fg="cyan" - ) - # pull latest images if pull: runner.run( diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 8ea7dedbf..32ae63ea2 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -4,7 +4,6 @@ import os import pathlib import re import textwrap -import urllib.request from collections import Counter from typing import Literal, NamedTuple @@ -1234,37 +1233,6 @@ def node_config_to_docker( return os.linesep.join(docker_file_contents), {} -VERSION_MARKER_REPO = "langchain/langgraph-published-version-marker" -_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+") - - -def fetch_latest_api_version() -> str: - """Fetch the latest published API version from the Docker Hub version marker. - - The marker repo is tagged with ````, ````, and ``latest``. - We pick the first tag that looks like a semver version. - """ - url = f"https://hub.docker.com/v2/repositories/{VERSION_MARKER_REPO}/tags/?page_size=10" - 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 latest API version from {VERSION_MARKER_REPO}: {exc}\n" - "You can specify the version explicitly with --api-version." - ) from exc - - for tag in data.get("results", []): - name = tag.get("name", "") - if _VERSION_RE.match(name): - return name - - raise click.ClickException( - f"Could not find a semver tag in {VERSION_MARKER_REPO}.\n" - "You can specify the version explicitly with --api-version." - ) - - def default_base_image( config: Config, engine_runtime_mode: str = "combined_queue_worker" ) -> str: @@ -1308,6 +1276,11 @@ def docker_tag( else: full_tag = version_distro_tag + # 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] + return f"{base_image}:{full_tag}" diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 48572f4ae..ee1c807ca 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -382,9 +382,10 @@ def test_dockerfile_command_with_base_image() -> None: assert save_path.exists() with open(save_path) as f: dockerfile = f.read() - assert re.match("FROM langchain/langgraph-server:0.2-py3.*", dockerfile), ( - "\n".join(dockerfile.splitlines()[:3]) - ) + assert re.match( + r"FROM langchain/langgraph-server:\d+\.\d+\.\d+-py3\..*", + dockerfile, + ), "\n".join(dockerfile.splitlines()[:3]) def test_dockerfile_command_with_docker_compose() -> None: @@ -856,7 +857,10 @@ def test_dockerfile_command_distributed_mode() -> None: assert save_path.exists() with open(save_path) as f: dockerfile = f.read() - assert "FROM langchain/langgraph-executor:3.11" in dockerfile + assert re.search( + r"FROM langchain/langgraph-executor:\d+\.\d+\.\d+-py3\.11", + dockerfile, + ), dockerfile.splitlines()[0] def test_dockerfile_command_combined_mode() -> None: @@ -889,7 +893,10 @@ def test_dockerfile_command_combined_mode() -> None: assert save_path.exists() with open(save_path) as f: dockerfile = f.read() - assert "FROM langchain/langgraph-api:3.11" in dockerfile + 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: @@ -924,7 +931,10 @@ def test_dockerfile_command_distributed_with_explicit_base_image() -> None: assert save_path.exists() with open(save_path) as f: dockerfile = f.read() - assert "FROM my-custom-executor:latest" in dockerfile + 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: diff --git a/libs/cli/tests/unit_tests/test_api_version.py b/libs/cli/tests/unit_tests/test_api_version.py new file mode 100644 index 000000000..3d5d58466 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_api_version.py @@ -0,0 +1,156 @@ +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") diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 92b8cfe13..66810f632 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -15,7 +15,6 @@ from langgraph_cli.config import ( config_to_docker, default_base_image, docker_tag, - fetch_latest_api_version, has_disallowed_build_command_content, validate_config, validate_config_file, @@ -1892,51 +1891,6 @@ def test_config_to_compose_distributed_executor_gets_correct_paths(): ) -def test_fetch_latest_api_version_parses_semver(monkeypatch): - """fetch_latest_api_version should return the first semver-like tag.""" - import io - import urllib.request - - 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_latest_api_version() == "0.7.67" - - -def test_fetch_latest_api_version_no_semver_raises(monkeypatch): - """Should raise ClickException when no semver tag is found.""" - import io - import urllib.request - - 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 semver tag"): - fetch_latest_api_version() - - -def test_fetch_latest_api_version_network_error_raises(monkeypatch): - """Should raise ClickException on network failure.""" - import urllib.request - - 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_latest_api_version() - - 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"} diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index 0a08a4668..810ce82b1 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -169,9 +169,7 @@ def test_update_deployment_with_engine_runtime_mode(): headers={"X-Api-Key": "test-key", "Accept": "application/json"}, timeout=30, ) - result = c.update_deployment( - "dep-1", "img:v1", engine_runtime_mode="distributed" - ) + result = c.update_deployment("dep-1", "img:v1", engine_runtime_mode="distributed") assert result == {"id": "dep-1"}