mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-27 20:15:00 +02:00
version-done
This commit is contained in:
@@ -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)."
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ``<semver>``, ``<sha>``, 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}"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user