From ddd8666ff7d6b5899919151fe6fb219b97dcf800 Mon Sep 17 00:00:00 2001 From: hari-dhanushkodi Date: Thu, 5 Mar 2026 16:26:47 -0500 Subject: [PATCH] switch to httpx + add tests --- libs/cli/langgraph_cli/cli.py | 19 +- libs/cli/langgraph_cli/host_backend.py | 57 +++--- libs/cli/pyproject.toml | 1 + .../tests/unit_tests/test_deploy_helpers.py | 124 ++++++++++++++ .../cli/tests/unit_tests/test_host_backend.py | 162 ++++++++++++++++++ libs/cli/uv.lock | 6 +- 6 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 libs/cli/tests/unit_tests/test_deploy_helpers.py create mode 100644 libs/cli/tests/unit_tests/test_host_backend.py diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 710145d5b..5e616772f 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -635,7 +635,9 @@ def build( @click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED) @cli.command( help=( - "Build and deploy a LangGraph image to LangSmith Deployments.\n\n" + "[Beta] Build and deploy a LangGraph image to LangSmith Deployments.\n\n" + "This command is in beta and under active development. " + "Expect frequent updates and improvements.\n\n" "Run from the root of your LangGraph project (where langgraph.json " "is located). This command also accepts build flags (--base-image, " "--pull, etc.). See 'langgraph build --help' for details." @@ -689,12 +691,23 @@ def deploy( with Runner() as runner: if shutil.which("docker") is None: - raise click.UsageError("Docker not installed") + raise click.UsageError( + "Docker is required but not installed.\n" + "Install Docker Desktop: https://docs.docker.com/get-docker/\n\n" + "Remote builds (no Docker required) are coming in a future update." + ) if needs_buildx: try: runner.run(subp_exec("docker", "buildx", "version", collect=True)) except click.exceptions.Exit: - raise click.UsageError("Docker Buildx not installed") from None + raise click.UsageError( + "Docker Buildx is required but not installed.\n" + "Your machine architecture (" + + platform.machine() + + ") requires Buildx to cross-compile images for linux/amd64.\n" + "Install Buildx: https://docs.docker.com/build/install-buildx/\n\n" + "Remote builds (no Docker required) are coming in a future update." + ) from None def log_step(message: str) -> None: click.secho(message, fg="cyan") diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index c4a5cb423..4d486524b 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -2,13 +2,10 @@ from __future__ import annotations -import json -import urllib.error -import urllib.parse -import urllib.request from typing import Any import click +import httpx class HostBackendError(click.ClickException): @@ -21,53 +18,45 @@ class HostBackendClient: def __init__(self, base_url: str, api_key: str): if not base_url: raise click.UsageError("Host backend URL is required") - base_url = base_url.rstrip("/") - self._base_url = base_url - self._api_key = api_key + transport = httpx.HTTPTransport(retries=3) + self._client = httpx.Client( + base_url=base_url.rstrip("/"), + headers={ + "X-Api-Key": api_key, + "Accept": "application/json", + }, + transport=transport, + timeout=30, + ) def _request( self, method: str, path: str, payload: dict[str, Any] | None = None ) -> Any: - url = f"{self._base_url}{path}" - data: bytes | None - if payload is not None: - data = json.dumps(payload).encode("utf-8") - else: - data = None - headers: dict[str, str] = { - "X-Api-Key": self._api_key, - "Accept": "application/json", - } - if data is not None: - headers["Content-Type"] = "application/json" - req = urllib.request.Request(url, data=data, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=30) as resp: - body = resp.read() - except urllib.error.HTTPError as err: - detail = err.read().decode("utf-8", errors="ignore") - message = detail or err.reason + resp = self._client.request(method, path, json=payload) + resp.raise_for_status() + except httpx.HTTPStatusError as err: + detail = err.response.text or str(err.response.status_code) raise HostBackendError( - f"{method} {path} failed with status {err.code}: {message}" + f"{method} {path} failed with status {err.response.status_code}: {detail}" ) from None - except urllib.error.URLError as err: - raise HostBackendError(str(err.reason)) from None + except httpx.TransportError as err: + raise HostBackendError(str(err)) from None - if not body: + if not resp.content: return None try: - return json.loads(body) - except json.JSONDecodeError as err: + return resp.json() + except ValueError as err: raise HostBackendError( - f"Failed to decode response from {path}: {err.msg}" + f"Failed to decode response from {path}: {err}" ) from None def create_deployment(self, payload: dict[str, Any]) -> dict[str, Any]: return self._request("POST", "/v2/deployments", payload) def list_deployments(self, name_contains: str) -> dict[str, Any]: - encoded = urllib.parse.quote(name_contains, safe="") - return self._request("GET", f"/v2/deployments?name_contains={encoded}") + return self._request("GET", f"/v2/deployments?name_contains={name_contains}") def get_deployment(self, deployment_id: str) -> dict[str, Any]: return self._request("GET", f"/v2/deployments/{deployment_id}") diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index cbff1b7f4..89f5693c9 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -13,6 +13,7 @@ license = "MIT" license-files = ['LICENSE'] dependencies = [ "click>=8.1.7", + "httpx>=0.24.0", "langgraph-sdk>=0.1.0 ; python_version >= '3.11'", "python-dotenv>=0.8.0", ] diff --git a/libs/cli/tests/unit_tests/test_deploy_helpers.py b/libs/cli/tests/unit_tests/test_deploy_helpers.py new file mode 100644 index 000000000..62b8c7194 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_deploy_helpers.py @@ -0,0 +1,124 @@ +import base64 +import json +import os + +import click +import pytest + +from langgraph_cli.cli import ( + _docker_config_for_token, + _normalize_image_name, + _normalize_image_tag, + _parse_env_from_config, +) + + +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 TestNormalizeImageName: + def test_simple_name(self): + assert _normalize_image_name("myapp") == "myapp" + + def test_uppercase_lowered(self): + assert _normalize_image_name("MyApp") == "myapp" + + def test_special_chars_replaced(self): + assert _normalize_image_name("my app!@#v2") == "my-app-v2" + + def test_dots_and_hyphens_kept(self): + assert _normalize_image_name("my-app.v2") == "my-app.v2" + + def test_leading_trailing_stripped(self): + assert _normalize_image_name("--my-app..") == "my-app" + + def test_empty_string_returns_app(self): + assert _normalize_image_name("") == "app" + + def test_none_returns_app(self): + assert _normalize_image_name(None) == "app" + + def test_all_invalid_chars_returns_app(self): + assert _normalize_image_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 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_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"] == "" diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py new file mode 100644 index 000000000..3e91d4553 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -0,0 +1,162 @@ +import httpx +import pytest + +from langgraph_cli.host_backend import HostBackendClient, HostBackendError + + +@pytest.fixture +def mock_transport(): + return httpx.MockTransport(lambda req: httpx.Response(200, json={"ok": True})) + + +@pytest.fixture +def client(mock_transport): + c = HostBackendClient("https://api.example.com", "test-key") + c._client = httpx.Client( + base_url="https://api.example.com", + transport=mock_transport, + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + return c + + +def test_constructor_strips_trailing_slash(): + c = HostBackendClient("https://api.example.com/", "key") + assert str(c._client.base_url) == "https://api.example.com" + + +def test_constructor_empty_url_raises(): + with pytest.raises(Exception, match="Host backend URL is required"): + HostBackendClient("", "key") + + +def test_request_sends_headers(): + def handler(req: httpx.Request) -> httpx.Response: + assert req.headers["x-api-key"] == "test-key" + assert req.headers["accept"] == "application/json" + return httpx.Response(200, json={"ok": True}) + + 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, + ) + result = c._request("GET", "/test") + assert result == {"ok": True} + + +def test_request_sends_json_payload(): + def handler(req: httpx.Request) -> httpx.Response: + assert req.headers["content-type"] == "application/json" + assert req.content == b'{"key":"value"}' + return httpx.Response(200, json={"created": True}) + + 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, + ) + result = c._request("POST", "/test", {"key": "value"}) + assert result == {"created": True} + + +def test_request_empty_body_returns_none(): + transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b"")) + c = HostBackendClient("https://api.example.com", "test-key") + c._client = httpx.Client( + base_url="https://api.example.com", + transport=transport, + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + assert c._request("DELETE", "/test") is None + + +def test_request_http_error_raises(): + transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found")) + c = HostBackendClient("https://api.example.com", "test-key") + c._client = httpx.Client( + base_url="https://api.example.com", + transport=transport, + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + with pytest.raises(HostBackendError, match="404"): + c._request("GET", "/missing") + + +def test_request_invalid_json_raises(): + transport = httpx.MockTransport( + lambda req: httpx.Response(200, content=b"not json") + ) + c = HostBackendClient("https://api.example.com", "test-key") + c._client = httpx.Client( + base_url="https://api.example.com", + transport=transport, + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + with pytest.raises(HostBackendError, match="Failed to decode"): + c._request("GET", "/bad-json") + + +def test_request_transport_error_raises(): + def handler(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused") + + 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, + ) + with pytest.raises(HostBackendError, match="connection refused"): + c._request("GET", "/test") + + +def test_create_deployment(client): + result = client.create_deployment({"name": "my-deploy"}) + assert result == {"ok": True} + + +def test_get_deployment(client): + result = client.get_deployment("dep-123") + assert result == {"ok": True} + + +def test_list_deployments(client): + result = client.list_deployments("my-app") + assert result == {"ok": True} + + +def test_request_push_token(client): + result = client.request_push_token("dep-123") + assert result == {"ok": True} + + +def test_update_deployment(client): + result = client.update_deployment( + "dep-123", "image:latest", secrets=[{"name": "KEY", "value": "val"}] + ) + assert result == {"ok": True} + + +def test_update_deployment_no_secrets(client): + result = client.update_deployment("dep-123", "image:latest") + assert result == {"ok": True} + + +def test_list_revisions(client): + result = client.list_revisions("dep-123", limit=5) + assert result == {"ok": True} + + +def test_get_revision(client): + result = client.get_revision("dep-123", "rev-456") + assert result == {"ok": True} diff --git a/libs/cli/uv.lock b/libs/cli/uv.lock index 65171d95a..076d9999c 100644 --- a/libs/cli/uv.lock +++ b/libs/cli/uv.lock @@ -983,7 +983,9 @@ name = "langgraph-cli" source = { editable = "." } dependencies = [ { name = "click" }, + { name = "httpx" }, { name = "langgraph-sdk", marker = "python_full_version >= '3.11'" }, + { name = "python-dotenv" }, ] [package.optional-dependencies] @@ -1021,10 +1023,12 @@ test = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.7" }, + { name = "click", specifier = ">=8.1.7" }, + { name = "httpx", specifier = ">=0.24.0" }, { name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.8.0" }, { name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.7" }, { name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" }, - { name = "python-dotenv", marker = "extra == 'inmem'", specifier = ">=0.8.0" }, + { name = "python-dotenv", specifier = ">=0.8.0" }, ] provides-extras = ["inmem"]