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()