a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
753 lines
28 KiB
Python
753 lines
28 KiB
Python
import asyncio
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
from unittest.mock import MagicMock
|
|
|
|
import click
|
|
import click.exceptions
|
|
import httpx
|
|
import pytest
|
|
|
|
import langgraph_cli.deploy as deploy_mod
|
|
from langgraph_cli.deploy import (
|
|
_call_host_backend_with_optional_tenant,
|
|
_create_host_backend_client,
|
|
_docker_config_for_token,
|
|
_Emitter,
|
|
_env_without_deployment_name,
|
|
_parse_env_from_config,
|
|
_resolve_env_path,
|
|
_resolve_pushed_image_digest,
|
|
_smith_dashboard_base_url,
|
|
_validate_prebuilt_image,
|
|
normalize_image_tag,
|
|
normalize_name,
|
|
)
|
|
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
|
|
|
|
|
class TestDockerConfigForToken:
|
|
def test_creates_config_json(self):
|
|
with _docker_config_for_token("us-docker.pkg.dev", "my-token") as cfg:
|
|
config_path = os.path.join(cfg, "config.json")
|
|
assert os.path.isfile(config_path)
|
|
with open(config_path) as f:
|
|
data = json.load(f)
|
|
expected_auth = base64.b64encode(b"oauth2accesstoken:my-token").decode()
|
|
assert data == {"auths": {"us-docker.pkg.dev": {"auth": expected_auth}}}
|
|
|
|
def test_tempdir_cleaned_up(self):
|
|
with _docker_config_for_token("registry.example.com", "tok") as cfg:
|
|
assert os.path.isdir(cfg)
|
|
assert not os.path.exists(cfg)
|
|
|
|
def test_different_registries(self):
|
|
with _docker_config_for_token("gcr.io", "token123") as cfg:
|
|
with open(os.path.join(cfg, "config.json")) as f:
|
|
data = json.load(f)
|
|
assert "gcr.io" in data["auths"]
|
|
|
|
|
|
class TestNormalizeName:
|
|
def test_simple_name(self):
|
|
assert normalize_name("myapp") == "myapp"
|
|
|
|
def test_uppercase_lowered(self):
|
|
assert normalize_name("MyApp") == "myapp"
|
|
|
|
def test_special_chars_replaced(self):
|
|
assert normalize_name("my app!@#v2") == "my-app-v2"
|
|
|
|
def test_dots_replaced_with_hyphens(self):
|
|
assert normalize_name("my-app.v2") == "my-app-v2"
|
|
|
|
def test_underscores_replaced_with_hyphens(self):
|
|
assert normalize_name("simple_graph_name") == "simple-graph-name"
|
|
|
|
def test_leading_trailing_stripped(self):
|
|
assert normalize_name("--my-app..") == "my-app"
|
|
|
|
def test_empty_string_returns_app(self):
|
|
assert normalize_name("") == "app"
|
|
|
|
def test_none_returns_app(self):
|
|
assert normalize_name(None) == "app"
|
|
|
|
def test_all_invalid_chars_returns_app(self):
|
|
assert normalize_name("!!!") == "app"
|
|
|
|
|
|
class TestNormalizeImageTag:
|
|
def test_valid_tag(self):
|
|
assert normalize_image_tag("v1.2.3") == "v1.2.3"
|
|
|
|
def test_empty_defaults_to_latest(self):
|
|
assert normalize_image_tag("") == "latest"
|
|
|
|
def test_alphanumeric_and_special(self):
|
|
assert normalize_image_tag("my_tag-1.0") == "my_tag-1.0"
|
|
|
|
def test_invalid_chars_raises(self):
|
|
with pytest.raises(click.UsageError, match="Image tag may only contain"):
|
|
normalize_image_tag("v1.0:bad")
|
|
|
|
def test_spaces_raises(self):
|
|
with pytest.raises(click.UsageError, match="Image tag may only contain"):
|
|
normalize_image_tag("has space")
|
|
|
|
|
|
class _FakeRunner:
|
|
def run(self, coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
class TestValidatePrebuiltImage:
|
|
def test_accepts_linux_amd64(self, monkeypatch):
|
|
calls = []
|
|
|
|
async def fake_subp_exec(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return "linux/amd64\n", None
|
|
|
|
monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)
|
|
|
|
_validate_prebuilt_image(_FakeRunner(), "repo/app:tag", verbose=False)
|
|
|
|
assert calls == [
|
|
(
|
|
(
|
|
"docker",
|
|
"image",
|
|
"inspect",
|
|
"--format",
|
|
"{{.Os}}/{{.Architecture}}",
|
|
"repo/app:tag",
|
|
),
|
|
{"verbose": False, "collect": True},
|
|
)
|
|
]
|
|
|
|
def test_missing_docker_binary_raises_actionable_error(self, monkeypatch):
|
|
async def fake_subp_exec(*args, **kwargs):
|
|
raise FileNotFoundError("docker")
|
|
|
|
monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)
|
|
|
|
with pytest.raises(click.ClickException, match="Docker is required"):
|
|
_validate_prebuilt_image(_FakeRunner(), "repo/app:tag", verbose=False)
|
|
|
|
def test_missing_image_raises_actionable_error(self, monkeypatch):
|
|
async def fake_subp_exec(*args, **kwargs):
|
|
raise click.exceptions.Exit(1)
|
|
|
|
monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)
|
|
|
|
with pytest.raises(click.ClickException, match="not found locally"):
|
|
_validate_prebuilt_image(_FakeRunner(), "missing:tag", verbose=False)
|
|
|
|
def test_rejects_non_amd64_platform(self, monkeypatch):
|
|
async def fake_subp_exec(*args, **kwargs):
|
|
return "linux/arm64\n", None
|
|
|
|
monkeypatch.setattr(deploy_mod, "subp_exec", fake_subp_exec)
|
|
|
|
with pytest.raises(click.ClickException, match="requires linux/amd64"):
|
|
_validate_prebuilt_image(_FakeRunner(), "repo/app:arm", verbose=False)
|
|
|
|
|
|
class TestParseEnvFromConfig:
|
|
def test_env_dict(self, tmp_path):
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
result = _parse_env_from_config({"env": {"FOO": "bar", "NUM": 42}}, config_path)
|
|
assert result == {"FOO": "bar", "NUM": "42"}
|
|
|
|
def test_env_string_dotenv_file(self, tmp_path):
|
|
env_file = tmp_path / "my.env"
|
|
env_file.write_text("KEY1=val1\nKEY2=val2\n")
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
result = _parse_env_from_config({"env": "my.env"}, config_path)
|
|
assert result == {"KEY1": "val1", "KEY2": "val2"}
|
|
|
|
def test_env_missing_falls_back_to_dotenv(self, tmp_path, monkeypatch):
|
|
env_file = tmp_path / ".env"
|
|
env_file.write_text("DEFAULT_KEY=default_val\n")
|
|
monkeypatch.chdir(tmp_path)
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
result = _parse_env_from_config({}, config_path)
|
|
assert result == {"DEFAULT_KEY": "default_val"}
|
|
|
|
def test_env_empty_dict_falls_back_to_dotenv(self, tmp_path, monkeypatch):
|
|
"""validate_config defaults env to {}, should still fall back to .env."""
|
|
env_file = tmp_path / ".env"
|
|
env_file.write_text("MY_KEY=my_val\n")
|
|
monkeypatch.chdir(tmp_path)
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
result = _parse_env_from_config({"env": {}}, config_path)
|
|
assert result == {"MY_KEY": "my_val"}
|
|
|
|
def test_env_missing_no_dotenv_returns_empty(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
result = _parse_env_from_config({}, config_path)
|
|
assert result == {}
|
|
|
|
def test_env_dotenv_filters_none_values(self, tmp_path):
|
|
# Lines like "KEY=" produce empty string, lines like "KEY" produce None
|
|
env_file = tmp_path / "test.env"
|
|
env_file.write_text("GOOD=value\nEMPTY=\n")
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
result = _parse_env_from_config({"env": "test.env"}, config_path)
|
|
assert "GOOD" in result
|
|
assert result["GOOD"] == "value"
|
|
# EMPTY= gives empty string, not None, so it should be present
|
|
assert result["EMPTY"] == ""
|
|
|
|
|
|
class TestResolveEnvPath:
|
|
def test_inline_env_dict_returns_none(self, tmp_path):
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
assert _resolve_env_path({"env": {"FOO": "bar"}}, config_path) is None
|
|
|
|
def test_relative_env_path_resolves(self, tmp_path):
|
|
env_file = tmp_path / "custom.env"
|
|
env_file.write_text("FOO=bar\n")
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
|
|
resolved = _resolve_env_path({"env": "custom.env"}, config_path)
|
|
assert resolved == env_file.resolve()
|
|
|
|
def test_missing_env_file_returns_none(self, tmp_path):
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
assert _resolve_env_path({"env": "missing.env"}, config_path) is None
|
|
|
|
def test_default_env_is_cwd_dotenv(self, tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
config_path = tmp_path / "langgraph.json"
|
|
config_path.touch()
|
|
assert _resolve_env_path({}, config_path) == tmp_path / ".env"
|
|
|
|
|
|
class TestEnvWithoutDeploymentName:
|
|
def test_removes_deployment_name_only(self):
|
|
env = {
|
|
"LANGSMITH_DEPLOYMENT_NAME": "my-deploy",
|
|
"KEEP_ME": "value",
|
|
}
|
|
cleaned = _env_without_deployment_name(env)
|
|
|
|
assert "LANGSMITH_DEPLOYMENT_NAME" not in cleaned
|
|
assert cleaned["KEEP_ME"] == "value"
|
|
# Original dict should be unchanged.
|
|
assert env["LANGSMITH_DEPLOYMENT_NAME"] == "my-deploy"
|
|
|
|
def test_noop_when_deployment_name_absent(self):
|
|
env = {"FOO": "bar"}
|
|
assert _env_without_deployment_name(env) == {"FOO": "bar"}
|
|
|
|
|
|
class TestCallHostBackendWithOptionalTenant:
|
|
def _make_client(self, handler):
|
|
c = HostBackendClient("https://api.example.com", "test-key")
|
|
c._client = httpx.Client(
|
|
base_url="https://api.example.com",
|
|
transport=httpx.MockTransport(handler),
|
|
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
timeout=30,
|
|
)
|
|
return c
|
|
|
|
def _make_eu_client(self, handler):
|
|
c = HostBackendClient("https://eu.api.host.langchain.com", "test-key")
|
|
c._client = httpx.Client(
|
|
base_url="https://eu.api.host.langchain.com",
|
|
transport=httpx.MockTransport(handler),
|
|
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
timeout=30,
|
|
)
|
|
return c
|
|
|
|
def test_success_passes_through(self):
|
|
client = self._make_client(lambda req: httpx.Response(200, json={"ok": True}))
|
|
result = _call_host_backend_with_optional_tenant(
|
|
client, lambda c: c.list_deployments()
|
|
)
|
|
assert result == {"ok": True}
|
|
|
|
def test_403_not_enabled_gives_actionable_error(self):
|
|
detail = (
|
|
'{"detail":"LangSmith Deployment is not enabled for this organization"}'
|
|
)
|
|
client = self._make_client(lambda req: httpx.Response(403, text=detail))
|
|
with pytest.raises(HostBackendError, match="not enabled") as exc_info:
|
|
_call_host_backend_with_optional_tenant(
|
|
client, lambda c: c.list_deployments()
|
|
)
|
|
assert exc_info.value.status_code == 403
|
|
assert "smith.langchain.com" in exc_info.value.message
|
|
|
|
def test_403_not_enabled_eu_url(self):
|
|
detail = (
|
|
'{"detail":"LangSmith Deployment is not enabled for this organization"}'
|
|
)
|
|
client = self._make_eu_client(lambda req: httpx.Response(403, text=detail))
|
|
with pytest.raises(HostBackendError, match="not enabled") as exc_info:
|
|
_call_host_backend_with_optional_tenant(
|
|
client, lambda c: c.list_deployments()
|
|
)
|
|
assert "eu.smith.langchain.com" in exc_info.value.message
|
|
|
|
def test_workspace_retry_then_not_enabled_gives_actionable_error(self, monkeypatch):
|
|
requires_workspace = '{"detail":"requires workspace specification"}'
|
|
not_enabled = (
|
|
'{"detail":"LangSmith Deployment is not enabled for this organization"}'
|
|
)
|
|
seen_tenant_ids = []
|
|
|
|
def handler(req):
|
|
seen_tenant_ids.append(req.headers.get("X-Tenant-ID"))
|
|
if len(seen_tenant_ids) == 1:
|
|
return httpx.Response(403, text=requires_workspace)
|
|
if len(seen_tenant_ids) == 2:
|
|
return httpx.Response(403, text=not_enabled)
|
|
raise AssertionError("unexpected extra request")
|
|
|
|
monkeypatch.setattr(click, "prompt", lambda _text: "workspace-123")
|
|
client = self._make_client(handler)
|
|
|
|
with pytest.raises(HostBackendError, match="not enabled") as exc_info:
|
|
_call_host_backend_with_optional_tenant(
|
|
client, lambda c: c.list_deployments()
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "smith.langchain.com" in exc_info.value.message
|
|
assert seen_tenant_ids == [None, "workspace-123"]
|
|
assert client._client.headers["X-Tenant-ID"] == "workspace-123"
|
|
|
|
def test_other_403_re_raises_original(self):
|
|
client = self._make_client(
|
|
lambda req: httpx.Response(403, text='{"detail":"some other error"}')
|
|
)
|
|
with pytest.raises(HostBackendError, match="some other error"):
|
|
_call_host_backend_with_optional_tenant(
|
|
client, lambda c: c.list_deployments()
|
|
)
|
|
|
|
def test_workspace_prompt_blocked_by_no_input(self, monkeypatch):
|
|
"""With _no_input=True, 403 requiring workspace should raise ClickException."""
|
|
import langgraph_cli.deploy as deploy_mod
|
|
|
|
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
|
|
|
requires_workspace = '{"detail":"requires workspace specification"}'
|
|
client = self._make_client(
|
|
lambda req: httpx.Response(403, text=requires_workspace)
|
|
)
|
|
with pytest.raises(click.ClickException, match="workspace"):
|
|
_call_host_backend_with_optional_tenant(
|
|
client, lambda c: c.list_deployments()
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _Emitter JSON mode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEmitterJsonMode:
|
|
"""Verify that _Emitter in json_mode writes valid JSON-lines to stdout."""
|
|
|
|
def _capture(self, fn):
|
|
"""Run fn with stdout captured and return parsed JSON objects."""
|
|
buf = io.StringIO()
|
|
old = sys.stdout
|
|
sys.stdout = buf
|
|
try:
|
|
fn()
|
|
finally:
|
|
sys.stdout = old
|
|
lines = [line for line in buf.getvalue().splitlines() if line.strip()]
|
|
return [json.loads(line) for line in lines]
|
|
|
|
def test_step_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.step(1, "Building image"))
|
|
assert len(events) == 1
|
|
assert events[0]["event"] == "step"
|
|
assert events[0]["step"] == 1
|
|
assert events[0]["message"] == "Building image"
|
|
|
|
def test_info_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.info("All good"))
|
|
assert events[0]["event"] == "info"
|
|
assert events[0]["message"] == "All good"
|
|
|
|
def test_warn_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.warn("Careful"))
|
|
assert events[0]["event"] == "warn"
|
|
|
|
def test_error_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.error("Boom"))
|
|
assert events[0]["event"] == "error"
|
|
assert events[0]["message"] == "Boom"
|
|
|
|
def test_status_change_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.status_change("building", 12.345))
|
|
assert events[0]["event"] == "status_change"
|
|
assert events[0]["status"] == "building"
|
|
assert events[0]["elapsed_seconds"] == 12.3
|
|
assert events[0]["message"] == "building... (12s)"
|
|
|
|
def test_status_change_event_with_minutes(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.status_change("deploying", 95.0))
|
|
assert events[0]["message"] == "deploying... (1m 35s)"
|
|
|
|
def test_log_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.log("some output"))
|
|
assert events[0] == {"event": "log", "message": "some output"}
|
|
|
|
def test_status_url_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(
|
|
lambda: em.status_url("https://smith.langchain.com/deploy/123")
|
|
)
|
|
assert events[0]["event"] == "status_url"
|
|
assert events[0]["url"] == "https://smith.langchain.com/deploy/123"
|
|
|
|
def test_result_event_full(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(
|
|
lambda: em.result(
|
|
"succeeded",
|
|
deployment_id="dep-1",
|
|
url="https://app.example.com",
|
|
status_url="https://smith.langchain.com/deploy/dep-1",
|
|
)
|
|
)
|
|
assert events[0]["event"] == "result"
|
|
assert events[0]["status"] == "succeeded"
|
|
assert events[0]["deployment_id"] == "dep-1"
|
|
assert events[0]["message"] == "Deployment successful!"
|
|
assert events[0]["url"] == "https://app.example.com"
|
|
assert events[0]["status_url"] == "https://smith.langchain.com/deploy/dep-1"
|
|
|
|
def test_result_event_minimal(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.result("failed", deployment_id="dep-2"))
|
|
assert events[0]["event"] == "result"
|
|
assert events[0]["status"] == "failed"
|
|
assert events[0]["message"] == "Deployment failed"
|
|
assert "url" not in events[0]
|
|
assert "status_url" not in events[0]
|
|
|
|
def test_heartbeat_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.heartbeat("building", 30.789))
|
|
assert events[0]["event"] == "heartbeat"
|
|
assert events[0]["elapsed_seconds"] == 30.8
|
|
assert events[0]["message"] == "building... (30s)"
|
|
|
|
def test_heartbeat_silent_in_text_mode(self, capsys):
|
|
em = _Emitter(json_mode=False)
|
|
em.heartbeat("building", 10.0)
|
|
captured = capsys.readouterr()
|
|
assert captured.out == ""
|
|
|
|
def test_upload_progress_event(self):
|
|
em = _Emitter(json_mode=True)
|
|
events = self._capture(lambda: em.upload_progress(5.678, 42))
|
|
assert events[0]["event"] == "upload_progress"
|
|
assert events[0]["size_mb"] == 5.7
|
|
assert events[0]["pct"] == 42
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _Emitter text mode (non-json)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEmitterTextMode:
|
|
"""Verify that _Emitter in text mode uses click.echo/click.secho."""
|
|
|
|
def test_step_writes_text(self, capsys):
|
|
em = _Emitter(json_mode=False)
|
|
em.step(1, "Hello")
|
|
captured = capsys.readouterr()
|
|
assert "1. Hello" in captured.out
|
|
|
|
def test_log_writes_text(self, capsys):
|
|
em = _Emitter(json_mode=False)
|
|
em.log("my line")
|
|
captured = capsys.readouterr()
|
|
assert "my line" in captured.out
|
|
|
|
def test_result_succeeded_text(self, capsys):
|
|
em = _Emitter(json_mode=False)
|
|
em.result("succeeded", deployment_id="d1", url="https://app.test")
|
|
captured = capsys.readouterr()
|
|
lines = [line.strip() for line in captured.out.splitlines() if line.strip()]
|
|
assert "Deployment successful!" in lines
|
|
assert "URL: https://app.test" in lines
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --no-input guard on _create_host_backend_client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateHostBackendClientNoInput:
|
|
def test_raises_when_no_api_key_and_no_input(self, monkeypatch, tmp_path):
|
|
import langgraph_cli.deploy as deploy_mod
|
|
|
|
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
|
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
|
|
monkeypatch.delenv("LANGCHAIN_API_KEY", raising=False)
|
|
monkeypatch.delenv("LANGGRAPH_HOST_API_KEY", raising=False)
|
|
|
|
with pytest.raises(click.ClickException, match="API key"):
|
|
_create_host_backend_client(
|
|
host_url="https://api.example.com",
|
|
api_key=None,
|
|
env_vars={},
|
|
)
|
|
|
|
def test_succeeds_with_api_key_in_env(self, monkeypatch, tmp_path):
|
|
import langgraph_cli.deploy as deploy_mod
|
|
|
|
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
|
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
|
|
|
client = _create_host_backend_client(
|
|
host_url="https://api.example.com",
|
|
api_key=None,
|
|
env_vars={},
|
|
)
|
|
assert client is not None
|
|
|
|
|
|
class TestSmithDashboardBaseUrl:
|
|
def test_none_returns_default(self):
|
|
assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"
|
|
|
|
def test_empty_returns_default(self):
|
|
assert _smith_dashboard_base_url("") == "https://smith.langchain.com"
|
|
|
|
def test_prod_host_url(self):
|
|
assert (
|
|
_smith_dashboard_base_url("https://api.host.langchain.com")
|
|
== "https://smith.langchain.com"
|
|
)
|
|
|
|
def test_dev_host_url(self):
|
|
assert (
|
|
_smith_dashboard_base_url("https://dev.api.host.langchain.com")
|
|
== "https://dev.smith.langchain.com"
|
|
)
|
|
|
|
def test_eu_host_url(self):
|
|
assert (
|
|
_smith_dashboard_base_url("https://eu.api.host.langchain.com")
|
|
== "https://eu.smith.langchain.com"
|
|
)
|
|
|
|
def test_staging_host_url(self):
|
|
assert (
|
|
_smith_dashboard_base_url("https://staging.api.host.langchain.com")
|
|
== "https://staging.smith.langchain.com"
|
|
)
|
|
|
|
def test_localhost(self):
|
|
assert (
|
|
_smith_dashboard_base_url("http://localhost:8080")
|
|
== "http://localhost:8080"
|
|
)
|
|
|
|
def test_localhost_trailing_slash(self):
|
|
assert (
|
|
_smith_dashboard_base_url("http://localhost:8080/")
|
|
== "http://localhost:8080"
|
|
)
|
|
|
|
def test_127_0_0_1(self):
|
|
assert (
|
|
_smith_dashboard_base_url("http://127.0.0.1:3000")
|
|
== "http://127.0.0.1:3000"
|
|
)
|
|
|
|
def test_unknown_domain_returns_default(self):
|
|
assert (
|
|
_smith_dashboard_base_url("https://custom.example.com")
|
|
== "https://smith.langchain.com"
|
|
)
|
|
|
|
|
|
class TestResolvePushedImageDigest:
|
|
"""Tests for ``_resolve_pushed_image_digest`` — runner is mocked to
|
|
return the ``(stdout, stderr)`` tuple that ``subp_exec(collect=True)``
|
|
would produce.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _runner(stdout: str | None) -> MagicMock:
|
|
# Close the unawaited subp_exec coroutine to silence gc warnings.
|
|
runner = MagicMock()
|
|
|
|
def _run(coro, *args, **kwargs):
|
|
if hasattr(coro, "close"):
|
|
coro.close()
|
|
return (stdout, "")
|
|
|
|
runner.run.side_effect = _run
|
|
return runner
|
|
|
|
def test_happy_path_returns_digest(self):
|
|
runner = self._runner('["us-central1-docker.pkg.dev/proj/repo@sha256:abc123"]')
|
|
out = _resolve_pushed_image_digest(
|
|
runner,
|
|
remote_image="us-central1-docker.pkg.dev/proj/repo:latest",
|
|
docker_config_dir=None,
|
|
verbose=False,
|
|
)
|
|
assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123"
|
|
|
|
def test_filters_to_matching_repo(self):
|
|
# Same image ID can hold digests for multiple repos — pick the one
|
|
# matching the just-pushed repo.
|
|
runner = self._runner(
|
|
json.dumps(
|
|
[
|
|
"other-registry.example.com/some/repo@sha256:000000",
|
|
"us-central1-docker.pkg.dev/proj/repo@sha256:abc123",
|
|
]
|
|
)
|
|
)
|
|
out = _resolve_pushed_image_digest(
|
|
runner,
|
|
remote_image="us-central1-docker.pkg.dev/proj/repo:v1.2.3",
|
|
docker_config_dir=None,
|
|
verbose=False,
|
|
)
|
|
assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123"
|
|
|
|
def test_empty_repodigests_falls_back_with_warning(self, mocker):
|
|
emitter = mocker.MagicMock()
|
|
mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
|
|
runner = self._runner("[]")
|
|
remote = "us-central1-docker.pkg.dev/proj/repo:latest"
|
|
out = _resolve_pushed_image_digest(
|
|
runner,
|
|
remote_image=remote,
|
|
docker_config_dir=None,
|
|
verbose=False,
|
|
)
|
|
assert out == remote
|
|
assert emitter.warn.called
|
|
assert remote in emitter.warn.call_args.args[0]
|
|
|
|
def test_null_repodigests_falls_back_with_warning(self, mocker):
|
|
# ``docker inspect --format '{{json .RepoDigests}}'`` emits ``null``
|
|
# when the field is absent.
|
|
emitter = mocker.MagicMock()
|
|
mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
|
|
runner = self._runner("null")
|
|
remote = "us-central1-docker.pkg.dev/proj/repo:latest"
|
|
out = _resolve_pushed_image_digest(
|
|
runner,
|
|
remote_image=remote,
|
|
docker_config_dir=None,
|
|
verbose=False,
|
|
)
|
|
assert out == remote
|
|
assert emitter.warn.called
|
|
|
|
def test_no_matching_repo_falls_back_with_warning(self, mocker):
|
|
# No matching digest for the pushed repo — warn and fall back to the
|
|
# tag-based ref rather than failing the deploy.
|
|
emitter = mocker.MagicMock()
|
|
mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
|
|
runner = self._runner('["other-registry.example.com/some/repo@sha256:000000"]')
|
|
remote = "us-central1-docker.pkg.dev/proj/repo:latest"
|
|
out = _resolve_pushed_image_digest(
|
|
runner,
|
|
remote_image=remote,
|
|
docker_config_dir=None,
|
|
verbose=False,
|
|
)
|
|
assert out == remote
|
|
assert emitter.warn.called
|
|
|
|
def test_registry_with_port_in_host(self):
|
|
# Only the rightmost ``:`` (the ``:latest`` tag) should be stripped.
|
|
runner = self._runner('["localhost:5000/repo@sha256:deadbeef"]')
|
|
out = _resolve_pushed_image_digest(
|
|
runner,
|
|
remote_image="localhost:5000/repo:latest",
|
|
docker_config_dir=None,
|
|
verbose=False,
|
|
)
|
|
assert out == "localhost:5000/repo@sha256:deadbeef"
|
|
|
|
@staticmethod
|
|
def _capturing_runner(stdout: str) -> tuple[MagicMock, dict]:
|
|
"""Like ``_runner`` but exposes the coroutine for arg introspection.
|
|
Caller must close ``captured["coro"]``.
|
|
"""
|
|
runner = MagicMock()
|
|
captured: dict = {}
|
|
|
|
def _run(coro, *args, **kwargs):
|
|
captured["coro"] = coro
|
|
return (stdout, "")
|
|
|
|
runner.run.side_effect = _run
|
|
return runner, captured
|
|
|
|
def test_passes_docker_config_dir(self):
|
|
runner, captured = self._capturing_runner(
|
|
'["us-central1-docker.pkg.dev/proj/repo@sha256:abc"]'
|
|
)
|
|
_resolve_pushed_image_digest(
|
|
runner,
|
|
remote_image="us-central1-docker.pkg.dev/proj/repo:latest",
|
|
docker_config_dir="/tmp/some-cfg",
|
|
verbose=False,
|
|
)
|
|
frame_locals = captured["coro"].cr_frame.f_locals
|
|
assert frame_locals["cmd"] == "docker"
|
|
assert "--config" in frame_locals["args"]
|
|
cfg_idx = frame_locals["args"].index("--config")
|
|
assert frame_locals["args"][cfg_idx + 1] == "/tmp/some-cfg"
|
|
captured["coro"].close()
|
|
|
|
def test_omits_docker_config_dir_when_none(self):
|
|
runner, captured = self._capturing_runner(
|
|
'["us-central1-docker.pkg.dev/proj/repo@sha256:abc"]'
|
|
)
|
|
_resolve_pushed_image_digest(
|
|
runner,
|
|
remote_image="us-central1-docker.pkg.dev/proj/repo:latest",
|
|
docker_config_dir=None,
|
|
verbose=False,
|
|
)
|
|
frame_locals = captured["coro"].cr_frame.f_locals
|
|
assert "--config" not in frame_locals["args"]
|
|
captured["coro"].close()
|