mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-20 06:35:46 +02:00
<!-- Replace everything above this line with a 1-2 sentence description of your change. Keep the "Fixes #xx" keyword and update the issue number. --> - Add a property to revisions table metadata column for Google ADK version - This helps us track the deployments that use Google ADK - Similar to this PR: https://github.com/langchain-ai/langchainplus/pull/22087 Read the full contributing guidelines: https://docs.langchain.com/oss/python/contributing/overview > **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy). If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED! Thank you for contributing to LangGraph! Follow these steps to have your pull request considered as ready for review. 1. PR title: Should follow the format: TYPE(SCOPE): DESCRIPTION - feat(langgraph): add multi-tenant support - Allowed TYPE and SCOPE values: https://github.com/langchain-ai/langgraph/blob/main/.github/workflows/pr_lint.yml#L19-L43 2. PR description: - Write 1-2 sentences summarizing the change. - The `Fixes #xx` line at the top is **required** for external contributions — update the issue number and keep the keyword. This links your PR to the approved issue and auto-closes it on merge. - If there are any breaking changes, please clearly describe them. - If this PR depends on another PR being merged first, please include "Depends on #PR_NUMBER" in the description. 3. Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified. - We will not consider a PR unless these three are passing in CI. 4. How did you verify your code works? Additional guidelines: - All external PRs must link to an issue or discussion where a solution has been approved by a maintainer, and you must be assigned to that issue. PRs without prior approval will be closed. - PRs should not touch more than one package unless absolutely necessary. - Do not update the `uv.lock` files or add dependencies to `pyproject.toml` files (even optional ones) unless you have explicit permission to do so by a maintainer. ## Social handles (optional) <!-- If you'd like a shoutout on release, add your socials below --> Twitter: @ LinkedIn: https://linkedin.com/in/
290 lines
9.4 KiB
Python
290 lines
9.4 KiB
Python
import json
|
|
|
|
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", deployment_type="dev", source="internal_docker"
|
|
)
|
|
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_list_deployments_sends_query_params():
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
assert req.url.path == "/v2/deployments"
|
|
assert req.url.params["name_contains"] == "my app"
|
|
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.list_deployments("my app")
|
|
assert result == {"ok": True}
|
|
|
|
|
|
def test_delete_deployment(client):
|
|
result = client.delete_deployment("dep-123")
|
|
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 _capturing_client(captured: dict) -> HostBackendClient:
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
captured["body"] = req.read()
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
c = HostBackendClient("https://api.example.com", "key")
|
|
c._client = httpx.Client(
|
|
base_url="https://api.example.com",
|
|
transport=httpx.MockTransport(handler),
|
|
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
|
timeout=30,
|
|
)
|
|
return c
|
|
|
|
|
|
def test_update_deployment_forwards_tracked_packages():
|
|
captured: dict = {}
|
|
c = _capturing_client(captured)
|
|
c.update_deployment(
|
|
"dep-123",
|
|
"image:latest",
|
|
tracked_packages=["google-adk:1.0.0"],
|
|
)
|
|
body = json.loads(captured["body"])
|
|
assert body["tracked_packages"] == ["google-adk:1.0.0"]
|
|
assert "tracked_packages" not in body["source_revision_config"]
|
|
|
|
|
|
def test_update_deployment_omits_tracked_packages_when_absent():
|
|
captured: dict = {}
|
|
c = _capturing_client(captured)
|
|
c.update_deployment("dep-123", "image:latest")
|
|
body = json.loads(captured["body"])
|
|
assert "tracked_packages" not in body
|
|
|
|
|
|
def test_update_deployment_internal_source_forwards_tracked_packages():
|
|
captured: dict = {}
|
|
c = _capturing_client(captured)
|
|
c.update_deployment_internal_source(
|
|
"dep-123",
|
|
source_tarball_path="path/to/tarball",
|
|
config_path="langgraph.json",
|
|
tracked_packages=["google-adk:>=0.5"],
|
|
)
|
|
body = json.loads(captured["body"])
|
|
assert body["tracked_packages"] == ["google-adk:>=0.5"]
|
|
assert body["source_revision_config"]["source_tarball_path"] == "path/to/tarball"
|
|
assert "tracked_packages" not in body["source_revision_config"]
|
|
|
|
|
|
def test_update_deployment_internal_source_omits_tracked_packages_when_absent():
|
|
captured: dict = {}
|
|
c = _capturing_client(captured)
|
|
c.update_deployment_internal_source(
|
|
"dep-123",
|
|
source_tarball_path="path/to/tarball",
|
|
config_path="langgraph.json",
|
|
)
|
|
body = json.loads(captured["body"])
|
|
assert "tracked_packages" not in body
|
|
|
|
|
|
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}
|
|
|
|
|
|
def test_get_build_logs(client):
|
|
result = client.get_build_logs("proj-1", "rev-1", {"limit": 10})
|
|
assert result == {"ok": True}
|
|
|
|
|
|
def test_get_deploy_logs_all_revisions():
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
assert "/v1/projects/proj-1/deploy_logs" in str(req.url)
|
|
assert "/revisions/" not in str(req.url)
|
|
return httpx.Response(200, json={"logs": [{"message": "running"}]})
|
|
|
|
c = HostBackendClient("https://api.example.com", "key")
|
|
c._client = httpx.Client(
|
|
base_url="https://api.example.com",
|
|
transport=httpx.MockTransport(handler),
|
|
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
|
timeout=30,
|
|
)
|
|
result = c.get_deploy_logs("proj-1", {"limit": 10})
|
|
assert result == {"logs": [{"message": "running"}]}
|
|
|
|
|
|
def test_get_deploy_logs_specific_revision():
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
|
|
return httpx.Response(200, json={"logs": []})
|
|
|
|
c = HostBackendClient("https://api.example.com", "key")
|
|
c._client = httpx.Client(
|
|
base_url="https://api.example.com",
|
|
transport=httpx.MockTransport(handler),
|
|
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
|
timeout=30,
|
|
)
|
|
result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
|
|
assert result == {"logs": []}
|