mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-06 17:57:49 +02:00
switch to httpx + add tests
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"] == ""
|
||||
@@ -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}
|
||||
Generated
+5
-1
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user