mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-26 17:01:36 +01:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc1741ce96 | ||
|
|
244ca657e4 | ||
|
|
c696d4e79f | ||
|
|
34c7f58655 | ||
|
|
1b1aee689d | ||
|
|
27aeb75efd | ||
|
|
c18a42fcba | ||
|
|
b54566b620 |
No files matched your search
@@ -24,6 +24,39 @@ TEST=path/to/test.py make test
|
||||
|
||||
Other pytest arguments can also be supplied inside the `TEST` variable.
|
||||
|
||||
## Direction: LangGraph is a runtime
|
||||
|
||||
LangGraph is consolidating as the low-level runtime for agents and workflows:
|
||||
graph execution, checkpointing, streaming, and interrupt/resume. Critical
|
||||
production systems and agent harnesses run on it, so correctness and stability
|
||||
come first. It is moving away from being a friendly SDK for building agents
|
||||
directly; agent development belongs one layer up, on a harness — Deep Agents
|
||||
(github.com/langchain-ai/deepagents, a separate repo) is the recommended one.
|
||||
|
||||
Optimize contributions for runtime correctness, reliability, performance, and
|
||||
simplicity — not for the shortest agent example.
|
||||
|
||||
- One way to do each thing. A change never adds a way; removals are always
|
||||
deliberate (see below). The number of ways only goes down. No aliases,
|
||||
overloads, extra accepted types, implicit defaults, or customization hooks
|
||||
for convenience: ergonomics alone never justify new public surface.
|
||||
- The mandate applies to everything that implements runtime behavior, in
|
||||
whichever repo it lives: the graph engine (`langgraph`, `langgraph-core`),
|
||||
the checkpointers (`checkpoint*`), and the server (`langgraph-api`). The
|
||||
client SDKs and CLI stay ergonomic but are thin, faithful clients, never a
|
||||
second implementation of runtime behavior.
|
||||
- Agent-level packages (`prebuilt`, `langgraph-supervisor`, `langgraph-swarm`,
|
||||
`langgraph-cua`) are maintenance-only: fix bugs, don't add agent features.
|
||||
New examples and docs that build agents should use Deep Agents.
|
||||
- Remove deliberately, not opportunistically. Removing or changing public
|
||||
behavior must be the stated purpose of the PR and go through the deprecation
|
||||
process — replacement in place, removal version recorded, migration note.
|
||||
Never drop a default or overload as a side effect of an unrelated change.
|
||||
- Behavior changes land in Python and JS together (the implementations live in
|
||||
separate repos; note in the PR when the other one needs a matching change).
|
||||
- If a task asks for something this section rules out, say so and propose the
|
||||
runtime-level or harness-level alternative instead of quietly implementing it.
|
||||
|
||||
## Libraries
|
||||
|
||||
The repository contains several Python and JavaScript/TypeScript libraries.
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.38"
|
||||
__version__ = "0.4.39"
|
||||
@@ -1738,7 +1738,6 @@ def docker_tag(
|
||||
api_version = _resolve_compatible_api_version(
|
||||
api_version, base_image, f"{language}{version_distro_tag}"
|
||||
)
|
||||
api_version = re.sub(r"(?<=\d)dev(?=\d+$)", ".dev", api_version)
|
||||
full_tag = f"{api_version}-{language}{version_distro_tag}"
|
||||
elif "/langgraph-server" in base_image and version_distro_tag not in base_image:
|
||||
return f"{base_image}-{language}{version_distro_tag}"
|
||||
|
||||
+116
-120
@@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Protocol, TypeVar
|
||||
from typing import Generic, Protocol, TypeVar
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
@@ -26,7 +26,6 @@ from langgraph_cli.dependency_tracking import find_tracked_packages
|
||||
from langgraph_cli.docker import build_docker_image, can_build_locally
|
||||
from langgraph_cli.exec import CommandRunner, Runner, subp_exec
|
||||
from langgraph_cli.host_backend import (
|
||||
MAX_PAGE_SIZE,
|
||||
ControlPlaneEndpoints,
|
||||
HostBackendClient,
|
||||
HostBackendError,
|
||||
@@ -183,12 +182,15 @@ class Listener:
|
||||
)
|
||||
return cls(
|
||||
identifier,
|
||||
str(resource.get("compute_id", "")),
|
||||
str(resource.get("compute_id") or ""),
|
||||
tuple(str(namespace) for namespace in namespaces)
|
||||
if isinstance(namespaces, list)
|
||||
else (),
|
||||
)
|
||||
|
||||
def cells(self) -> tuple[str | None, ...]:
|
||||
return (self.id, self.compute_id, ", ".join(self.namespaces))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Unplaced:
|
||||
@@ -276,8 +278,6 @@ def _describe_listeners(listeners: Sequence[Listener]) -> str:
|
||||
]
|
||||
if len(listeners) > len(shown):
|
||||
lines.append(f" ... and {len(listeners) - len(shown)} more")
|
||||
if len(listeners) == MAX_PAGE_SIZE:
|
||||
lines.append(f" (only the first {MAX_PAGE_SIZE} listeners were read)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -499,16 +499,9 @@ def _source_of(resource: object) -> str | None:
|
||||
def find_deployment_by_name(
|
||||
client: HostBackendClient, name: str
|
||||
) -> ExistingDeployment | None:
|
||||
listed = client.list_deployments(name=name, name_contains=name, limit=MAX_PAGE_SIZE)
|
||||
for resource in listed:
|
||||
for resource in client.list_deployments(name=name, name_contains=name):
|
||||
if resource.get("name") == name and resource.get("id"):
|
||||
return ExistingDeployment(str(resource["id"]), _source_of(resource))
|
||||
if len(listed) >= MAX_PAGE_SIZE:
|
||||
raise click.ClickException(
|
||||
"This workspace has more deployments than the CLI can search, so it "
|
||||
f"cannot tell whether '{name}' already exists. Pass --deployment-id to "
|
||||
"update an existing deployment."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -583,103 +576,96 @@ def _validate_prebuilt_image(
|
||||
_get_emitter().info(f"Image is available for {_DEPLOYMENT_PLATFORM}")
|
||||
|
||||
|
||||
def _extract_deployment_url(deployment: dict[str, object]) -> str:
|
||||
source_config = deployment.get("source_config")
|
||||
if isinstance(source_config, dict):
|
||||
custom_url = source_config.get("custom_url")
|
||||
if isinstance(custom_url, str) and custom_url:
|
||||
return custom_url
|
||||
return "-"
|
||||
Resource = Mapping[str, object]
|
||||
RowT = TypeVar("RowT")
|
||||
|
||||
|
||||
def format_deployments_table(deployments: Sequence[dict[str, object]]) -> str:
|
||||
headers = ("Deployment ID", "Deployment Name", "Deployment URL")
|
||||
rows = [
|
||||
(
|
||||
str(deployment.get("id", "-") or "-"),
|
||||
str(deployment.get("name", "-") or "-"),
|
||||
_extract_deployment_url(deployment),
|
||||
)
|
||||
for deployment in deployments
|
||||
]
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
]
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Table(Generic[RowT]):
|
||||
headers: tuple[str, ...]
|
||||
cells: Callable[[RowT], tuple[str | None, ...]]
|
||||
|
||||
def format_row(row: Sequence[str]) -> str:
|
||||
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
|
||||
|
||||
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
|
||||
lines.extend(format_row(row) for row in rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _extract_listener_namespaces(listener: dict[str, object]) -> str:
|
||||
compute_config = listener.get("compute_config")
|
||||
namespaces = (
|
||||
compute_config.get("k8s_namespaces")
|
||||
if isinstance(compute_config, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(namespaces, list) and namespaces:
|
||||
return ", ".join(str(namespace) for namespace in namespaces)
|
||||
return "-"
|
||||
|
||||
|
||||
def format_listeners_table(listeners: Sequence[dict[str, object]]) -> str:
|
||||
headers = ("Listener ID", "Compute ID", "Namespaces")
|
||||
rows = [
|
||||
(
|
||||
str(listener.get("id", "-") or "-"),
|
||||
str(listener.get("compute_id", "-") or "-"),
|
||||
_extract_listener_namespaces(listener),
|
||||
)
|
||||
for listener in listeners
|
||||
]
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
]
|
||||
|
||||
def format_row(row: Sequence[str]) -> str:
|
||||
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
|
||||
|
||||
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
|
||||
lines.extend(format_row(row) for row in rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_revisions_table(revisions: Sequence[dict[str, object]]) -> str:
|
||||
headers = ("Revision ID", "Status", "Created At")
|
||||
latest_deployed_seen = False
|
||||
rows = []
|
||||
for revision in revisions:
|
||||
status = str(revision.get("status", "-") or "-")
|
||||
if status == "DEPLOYED":
|
||||
if latest_deployed_seen:
|
||||
status = "REPLACED"
|
||||
else:
|
||||
latest_deployed_seen = True
|
||||
rows.append(
|
||||
(
|
||||
str(revision.get("id", "-") or "-"),
|
||||
status,
|
||||
str(revision.get("created_at", "-") or "-"),
|
||||
def render(self, rows: Sequence[RowT]) -> str:
|
||||
grid = [
|
||||
self.headers,
|
||||
*(tuple(cell or "-" for cell in self.cells(row)) for row in rows),
|
||||
]
|
||||
widths = [max(map(len, column)) for column in zip(*grid, strict=True)]
|
||||
rule = tuple("-" * width for width in widths)
|
||||
return "\n".join(
|
||||
" ".join(
|
||||
cell.ljust(width) for cell, width in zip(line, widths, strict=True)
|
||||
)
|
||||
for line in (grid[0], rule, *grid[1:])
|
||||
)
|
||||
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
|
||||
def _text(resource: Resource, key: str) -> str | None:
|
||||
value = resource.get(key)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _deployment_url(deployment: Resource) -> str | None:
|
||||
source_config = deployment.get("source_config")
|
||||
custom_url = (
|
||||
source_config.get("custom_url") if isinstance(source_config, Mapping) else None
|
||||
)
|
||||
return custom_url if isinstance(custom_url, str) and custom_url else None
|
||||
|
||||
|
||||
def _with_replaced_status(revisions: Sequence[Resource]) -> list[Resource]:
|
||||
live_index = next(
|
||||
(
|
||||
index
|
||||
for index, revision in enumerate(revisions)
|
||||
if revision.get("status") == "DEPLOYED"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return [
|
||||
{**revision, "status": "REPLACED"}
|
||||
if revision.get("status") == "DEPLOYED" and index != live_index
|
||||
else revision
|
||||
for index, revision in enumerate(revisions)
|
||||
]
|
||||
|
||||
def format_row(row: Sequence[str]) -> str:
|
||||
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
|
||||
|
||||
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
|
||||
lines.extend(format_row(row) for row in rows)
|
||||
return "\n".join(lines)
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeploymentRow:
|
||||
id: str
|
||||
name: str | None
|
||||
url: str | None
|
||||
|
||||
@classmethod
|
||||
def from_resource(cls, deployment: Resource) -> "DeploymentRow":
|
||||
identifier = _text(deployment, "id")
|
||||
if identifier is None:
|
||||
raise HostBackendError(
|
||||
"The control plane returned a deployment without an id."
|
||||
)
|
||||
return cls(identifier, _text(deployment, "name"), _deployment_url(deployment))
|
||||
|
||||
def cells(self) -> tuple[str | None, ...]:
|
||||
return (self.id, self.name, self.url)
|
||||
|
||||
def as_json(self) -> dict[str, str | None]:
|
||||
return {"id": self.id, "name": self.name}
|
||||
|
||||
|
||||
DEPLOYMENTS_TABLE = Table(
|
||||
("Deployment ID", "Deployment Name", "Deployment URL"), DeploymentRow.cells
|
||||
)
|
||||
|
||||
LISTENERS_TABLE = Table(("Listener ID", "Compute ID", "Namespaces"), Listener.cells)
|
||||
|
||||
REVISIONS_TABLE = Table(
|
||||
("Revision ID", "Status", "Created At"),
|
||||
lambda revision: (
|
||||
_text(revision, "id"),
|
||||
_text(revision, "status"),
|
||||
_text(revision, "created_at"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def format_timestamp(ts) -> str:
|
||||
@@ -841,7 +827,6 @@ def _find_deployment(
|
||||
lambda c: c.list_deployments(
|
||||
agent_id=selector.agent_id,
|
||||
agent_environment=selector.environment,
|
||||
limit=MAX_PAGE_SIZE,
|
||||
),
|
||||
)
|
||||
if len(existing) > 1:
|
||||
@@ -1007,20 +992,15 @@ def _print_deployment_result(
|
||||
dashboard_label: str,
|
||||
status_url: str | None = None,
|
||||
) -> None:
|
||||
"""Print final deployment status and raise on failure."""
|
||||
em = _get_emitter()
|
||||
dep_info = client.get_deployment(deployment_id)
|
||||
custom_url = None
|
||||
if isinstance(dep_info, dict):
|
||||
sc = dep_info.get("source_config")
|
||||
if isinstance(sc, dict):
|
||||
custom_url = sc.get("custom_url")
|
||||
deployment = client.get_deployment(deployment_id)
|
||||
url = _deployment_url(deployment) if isinstance(deployment, Mapping) else None
|
||||
|
||||
if last_status == "DEPLOYED":
|
||||
em.result(
|
||||
"succeeded",
|
||||
deployment_id=deployment_id,
|
||||
url=custom_url,
|
||||
url=url,
|
||||
status_url=status_url,
|
||||
)
|
||||
elif last_status in ("BUILD_FAILED", "DEPLOY_FAILED", "CREATE_FAILED"):
|
||||
@@ -2594,6 +2574,13 @@ def _deploy_cmd(
|
||||
default="",
|
||||
help="Only show deployments whose names contain this value.",
|
||||
)
|
||||
@click.option(
|
||||
"--json",
|
||||
"json_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print the deployments as a JSON array of {id, name} instead of a table.",
|
||||
)
|
||||
@deploy.command("list", help="[Beta] List LangSmith Deployments.")
|
||||
def deploy_list(
|
||||
api_key: str | None,
|
||||
@@ -2601,11 +2588,13 @@ def deploy_list(
|
||||
name_contains: str,
|
||||
agent_id: str | None,
|
||||
environment: str | None,
|
||||
json_output: bool,
|
||||
) -> None:
|
||||
if agent_id is not None or environment is not None:
|
||||
click.secho(
|
||||
"Note: --agent-id and --agent-environment flags are in private beta",
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
if agent_id is not None and not agent_id.strip():
|
||||
raise click.UsageError("--agent-id must not be empty.")
|
||||
@@ -2614,15 +2603,19 @@ def deploy_list(
|
||||
filters["agent_id"] = agent_id
|
||||
if environment is not None:
|
||||
filters["agent_environment"] = environment
|
||||
client = _create_host_backend_client(host_url, api_key)
|
||||
deployments = _call_host_backend_with_optional_tenant(
|
||||
client = _create_host_backend_client(host_url, api_key, no_input=json_output)
|
||||
resources = _call_host_backend_with_optional_tenant(
|
||||
client,
|
||||
lambda c: c.list_deployments(name_contains=name_contains, **filters),
|
||||
no_input=json_output,
|
||||
)
|
||||
if not deployments:
|
||||
deployments = [DeploymentRow.from_resource(resource) for resource in resources]
|
||||
if json_output:
|
||||
click.echo(json_mod.dumps([deployment.as_json() for deployment in deployments]))
|
||||
elif not deployments:
|
||||
click.echo("No deployments found.")
|
||||
return
|
||||
click.echo(format_deployments_table(deployments))
|
||||
else:
|
||||
click.echo(DEPLOYMENTS_TABLE.render(deployments))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2665,11 +2658,14 @@ def deploy_listeners_list(
|
||||
)
|
||||
if json_output:
|
||||
click.echo(json_mod.dumps(listeners))
|
||||
return
|
||||
if not listeners:
|
||||
elif not listeners:
|
||||
click.echo("No listeners found for this workspace.")
|
||||
return
|
||||
click.echo(format_listeners_table(listeners))
|
||||
else:
|
||||
click.echo(
|
||||
LISTENERS_TABLE.render(
|
||||
[Listener.from_resource(listener) for listener in listeners]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2712,7 +2708,7 @@ def deploy_revisions_list(
|
||||
if not revisions:
|
||||
click.echo(f"No revisions found for deployment {deployment_id}.")
|
||||
return
|
||||
click.echo(format_revisions_table(revisions))
|
||||
click.echo(REVISIONS_TABLE.render(_with_replaced_status(revisions)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
@@ -220,27 +221,51 @@ class HostBackendClient:
|
||||
*,
|
||||
name: str | None = None,
|
||||
name_contains: str | None = None,
|
||||
limit: int | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_environment: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> list[dict[str, object]]:
|
||||
given = (
|
||||
("name", name),
|
||||
("name_contains", name_contains),
|
||||
("limit", limit),
|
||||
("agent_id", agent_id),
|
||||
("agent_environment", agent_environment),
|
||||
)
|
||||
params = {key: value for key, value in given if value is not None}
|
||||
return _resources(self._request("GET", "/v2/deployments", params=params))
|
||||
filters = {key: value for key, value in given if value is not None}
|
||||
return self._every_page("/v2/deployments", filters)
|
||||
|
||||
def _every_page(
|
||||
self, path: str, filters: Mapping[str, object]
|
||||
) -> list[dict[str, object]]:
|
||||
resources: list[dict[str, object]] = []
|
||||
seen_ids: set[object] = set()
|
||||
while True:
|
||||
page = _resources(
|
||||
self._request(
|
||||
"GET",
|
||||
path,
|
||||
params={
|
||||
**filters,
|
||||
"limit": MAX_PAGE_SIZE,
|
||||
"offset": len(resources),
|
||||
},
|
||||
)
|
||||
)
|
||||
page_ids = {resource.get("id") for resource in page}
|
||||
if page_ids and page_ids <= seen_ids:
|
||||
raise HostBackendError(
|
||||
f"GET {path} returned the same resources again: this control "
|
||||
"plane ignores 'offset', so the CLI cannot read past the first page."
|
||||
)
|
||||
seen_ids |= page_ids
|
||||
resources.extend(page)
|
||||
if len(page) < MAX_PAGE_SIZE:
|
||||
return resources
|
||||
|
||||
def get_listener(self, listener_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/v2/listeners/{listener_id}")
|
||||
|
||||
def list_listeners(self) -> list[dict[str, Any]]:
|
||||
return _resources(
|
||||
self._request("GET", "/v2/listeners", params={"limit": MAX_PAGE_SIZE})
|
||||
)
|
||||
def list_listeners(self) -> list[dict[str, object]]:
|
||||
return self._every_page("/v2/listeners", {})
|
||||
|
||||
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/v2/deployments/{deployment_id}")
|
||||
|
||||
@@ -482,6 +482,177 @@ def test_deploy_list_command_no_results(monkeypatch) -> None:
|
||||
assert result.output.strip() == "No deployments found."
|
||||
|
||||
|
||||
def test_deploy_list_prints_an_aligned_table(monkeypatch) -> None:
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_deployments(self, name_contains: str = ""):
|
||||
return [
|
||||
{
|
||||
"id": "dep-123",
|
||||
"name": "alpha",
|
||||
"source_config": {"custom_url": "https://alpha.example.com"},
|
||||
},
|
||||
{"id": "dep-4567", "name": "beta-agent", "source_config": {}},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.splitlines() == [
|
||||
"Deployment ID Deployment Name Deployment URL ",
|
||||
"------------- --------------- -------------------------",
|
||||
"dep-123 alpha https://alpha.example.com",
|
||||
"dep-4567 beta-agent - ",
|
||||
]
|
||||
|
||||
|
||||
def _deploy_list_json(
|
||||
monkeypatch, deployments: list[dict], *extra_args: str
|
||||
) -> click.testing.Result:
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_deployments(
|
||||
self,
|
||||
name_contains: str = "",
|
||||
agent_id: str | None = None,
|
||||
agent_environment: str | None = None,
|
||||
):
|
||||
return deployments
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
return CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"list",
|
||||
"--json",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
*extra_args,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_deploy_list_command_json(monkeypatch) -> None:
|
||||
result = _deploy_list_json(
|
||||
monkeypatch,
|
||||
[
|
||||
{
|
||||
"id": "dep-123",
|
||||
"name": "alpha",
|
||||
"source_config": {"custom_url": "https://alpha.example.com"},
|
||||
},
|
||||
{"id": "dep-456", "name": "beta", "source_config": {}},
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.stdout) == [
|
||||
{"id": "dep-123", "name": "alpha"},
|
||||
{"id": "dep-456", "name": "beta"},
|
||||
]
|
||||
|
||||
|
||||
def test_deploy_list_json_never_prints_deployment_secrets(monkeypatch) -> None:
|
||||
result = _deploy_list_json(
|
||||
monkeypatch,
|
||||
[
|
||||
{
|
||||
"id": "dep-123",
|
||||
"name": "alpha",
|
||||
"source_config": {"custom_url": None},
|
||||
"secrets": [{"name": "OPENAI_API_KEY", "value": "sk-plain-value"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "sk-plain-value" not in result.output
|
||||
assert json.loads(result.stdout) == [{"id": "dep-123", "name": "alpha"}]
|
||||
|
||||
|
||||
def test_deploy_list_command_json_no_results(monkeypatch) -> None:
|
||||
result = _deploy_list_json(monkeypatch, [])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.stdout) == []
|
||||
|
||||
|
||||
def test_deploy_list_rejects_a_deployment_without_an_id(monkeypatch) -> None:
|
||||
result = _deploy_list_json(monkeypatch, [{"name": "alpha"}])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "deployment without an id" in result.output
|
||||
|
||||
|
||||
def test_deploy_list_json_keeps_the_agent_note_off_stdout(monkeypatch) -> None:
|
||||
result = _deploy_list_json(
|
||||
monkeypatch,
|
||||
[{"id": "dep-123", "name": "alpha"}],
|
||||
"--agent-id",
|
||||
"customer-support",
|
||||
"--agent-environment",
|
||||
"staging",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.stdout) == [{"id": "dep-123", "name": "alpha"}]
|
||||
assert "private beta" in result.stderr
|
||||
|
||||
|
||||
def test_deploy_list_json_never_prompts_for_a_workspace(monkeypatch) -> None:
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_deployments(self, name_contains: str = ""):
|
||||
raise HostBackendError("requires workspace specification", status_code=403)
|
||||
|
||||
def set_tenant(self, tenant_id: str) -> None:
|
||||
raise AssertionError("must not retry with a prompted tenant with --json")
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
prompt_calls = []
|
||||
monkeypatch.setattr(
|
||||
click, "prompt", lambda *a, **k: prompt_calls.append((a, k)) or "unreachable"
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"list",
|
||||
"--json",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert prompt_calls == []
|
||||
assert result.exit_code != 0
|
||||
assert "LANGSMITH_TENANT_ID" in result.output
|
||||
|
||||
|
||||
def test_deploy_listeners_list_command(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
captured: dict[str, str] = {}
|
||||
@@ -564,6 +735,84 @@ def test_deploy_listeners_list_command_no_results(monkeypatch) -> None:
|
||||
assert result.output.strip() == "No listeners found for this workspace."
|
||||
|
||||
|
||||
def test_deploy_listeners_list_prints_an_aligned_table(monkeypatch) -> None:
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_listeners(self):
|
||||
return [
|
||||
{
|
||||
"id": "listener-1",
|
||||
"compute_id": "prod-cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents"]},
|
||||
},
|
||||
{
|
||||
"id": "listener-22",
|
||||
"compute_id": "multi",
|
||||
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
|
||||
},
|
||||
{"id": "listener-3", "compute_id": "", "compute_config": {}},
|
||||
{
|
||||
"id": "listener-4",
|
||||
"compute_id": None,
|
||||
"compute_config": {"k8s_namespaces": ["agents"]},
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"listeners",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.splitlines() == [
|
||||
"Listener ID Compute ID Namespaces ",
|
||||
"----------- ------------ ----------------------",
|
||||
"listener-1 prod-cluster agents ",
|
||||
"listener-22 multi agents, agents-staging",
|
||||
"listener-3 - - ",
|
||||
"listener-4 - agents ",
|
||||
]
|
||||
|
||||
|
||||
def test_deploy_listeners_list_rejects_a_listener_without_an_id(monkeypatch) -> None:
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_listeners(self):
|
||||
return [{"compute_id": "prod-cluster"}]
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"listeners",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "listener without an id" in result.output
|
||||
|
||||
|
||||
def test_deploy_listeners_list_command_json(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -766,6 +1015,54 @@ def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
|
||||
assert result.output.strip() == "No revisions found for deployment dep-123."
|
||||
|
||||
|
||||
def test_deploy_revisions_list_marks_older_deployed_revisions_as_replaced(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||
return [
|
||||
{
|
||||
"id": "rev-3",
|
||||
"status": "DEPLOYING",
|
||||
"created_at": "2026-10-20T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rev-2",
|
||||
"status": "DEPLOYED",
|
||||
"created_at": "2026-10-19T10:00:00Z",
|
||||
},
|
||||
{"id": "rev-1", "status": "DEPLOYED", "created_at": None},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"revisions",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
"dep-123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.splitlines() == [
|
||||
"Revision ID Status Created At ",
|
||||
"----------- --------- --------------------",
|
||||
"rev-3 DEPLOYING 2026-10-20T10:00:00Z",
|
||||
"rev-2 DEPLOYED 2026-10-19T10:00:00Z",
|
||||
"rev-1 REPLACED - ",
|
||||
]
|
||||
|
||||
|
||||
def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
@@ -111,7 +111,11 @@ class ControlPlaneDouble:
|
||||
def _respond(self, request: httpx.Request) -> httpx.Response:
|
||||
method, path = request.method, request.url.path
|
||||
if (method, path) == ("GET", "/v2/listeners"):
|
||||
return httpx.Response(200, json={"resources": self.listeners})
|
||||
offset = int(request.url.params.get("offset", 0))
|
||||
limit = int(request.url.params.get("limit", 20))
|
||||
return httpx.Response(
|
||||
200, json={"resources": self.listeners[offset : offset + limit]}
|
||||
)
|
||||
if method == "GET" and path.startswith("/v2/listeners/"):
|
||||
listener_id = path.rsplit("/", 1)[-1]
|
||||
if not _looks_like_a_uuid(listener_id):
|
||||
@@ -1288,14 +1292,16 @@ def test_listener_flags_without_push_to_make_no_call_at_all(
|
||||
assert deploy_project.timeline == []
|
||||
|
||||
|
||||
def test_a_truncated_listener_page_says_so(deploy_project: DeployProject) -> None:
|
||||
def test_listeners_beyond_the_first_page_are_counted(
|
||||
deploy_project: DeployProject,
|
||||
) -> None:
|
||||
deploy_project.control_plane.listeners = [
|
||||
{
|
||||
"id": str(uuid.UUID(int=index)),
|
||||
"compute_id": "cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents"]},
|
||||
}
|
||||
for index in range(100)
|
||||
for index in range(101)
|
||||
]
|
||||
|
||||
result = deploy_project.run(
|
||||
@@ -1303,7 +1309,8 @@ def test_a_truncated_listener_page_says_so(deploy_project: DeployProject) -> Non
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "first 100" in result.output
|
||||
assert "... and 91 more" in result.output
|
||||
assert "first 100" not in result.output
|
||||
|
||||
|
||||
def test_a_managed_build_in_a_listener_workspace_points_at_push_to(
|
||||
|
||||
@@ -3333,37 +3333,20 @@ def test_docker_tag_with_api_version(in_config: bool):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("in_config", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"runtime,image", [("python", "langgraph-api"), ("node", "langgraphjs-api")]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
("0.9.0rc1", "0.9.0rc1"),
|
||||
("0.9.0.dev1", "0.9.0.dev1"),
|
||||
("0.18.0dev11", "0.18.0.dev11"),
|
||||
],
|
||||
)
|
||||
def test_docker_tag_with_prerelease_api_version(
|
||||
version: str, expected: str, in_config: bool, runtime: str, image: str
|
||||
):
|
||||
@pytest.mark.parametrize("version", ["0.9.0rc1", "0.9.0.dev1"])
|
||||
def test_docker_tag_with_prerelease_api_version(version: str, in_config: bool):
|
||||
"""Test docker_tag with prerelease and dev api_version values."""
|
||||
config = validate_config(
|
||||
{
|
||||
f"{runtime}_version": "3.12" if runtime == "python" else "20",
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph"
|
||||
if runtime == "python"
|
||||
else "./agent.js:graph"
|
||||
},
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"api_version": version if in_config else None,
|
||||
}
|
||||
)
|
||||
|
||||
tag = docker_tag(config, api_version=version if not in_config else None)
|
||||
suffix = "py3.12" if runtime == "python" else "node20"
|
||||
assert tag == f"langchain/{image}:{expected}-{suffix}"
|
||||
assert tag == f"langchain/langgraph-api:{version}-py3.11"
|
||||
|
||||
|
||||
def test_docker_tag_with_compatible_api_version_promotes_to_latest_patch():
|
||||
@@ -3395,8 +3378,7 @@ def test_docker_tag_with_compatible_api_version_promotes_to_latest_patch():
|
||||
assert tag == "langchain/langgraph-api:0.11.1-py3.12-wolfi"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dev_version", ["0.11.0.dev5", "0.11.0dev5"])
|
||||
def test_docker_tag_with_compatible_api_version_freezes_dev_until_rc(dev_version: str):
|
||||
def test_docker_tag_with_compatible_api_version_freezes_dev_until_rc():
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
@@ -3409,7 +3391,7 @@ def test_docker_tag_with_compatible_api_version_freezes_dev_until_rc(dev_version
|
||||
with patch(
|
||||
"langgraph_cli.config._get_pypi_versions",
|
||||
return_value=[
|
||||
dev_version,
|
||||
"0.11.0.dev5",
|
||||
"0.11.0.dev6",
|
||||
"0.11.0.dev7",
|
||||
],
|
||||
|
||||
@@ -75,6 +75,7 @@ def test_agent_create(deployment_api, tmp_path, monkeypatch):
|
||||
"agent_id": "customer-support",
|
||||
"agent_environment": "staging",
|
||||
"limit": "100",
|
||||
"offset": "0",
|
||||
}
|
||||
payload = json.loads(requests[1].content)
|
||||
assert payload["agent"] == {
|
||||
|
||||
@@ -33,6 +33,7 @@ from langgraph_cli.deploy import (
|
||||
_Emitter,
|
||||
_env_without_deployment_name,
|
||||
_parse_env_from_config,
|
||||
_print_deployment_result,
|
||||
_resolve_env_path,
|
||||
_resolve_pushed_image_digest,
|
||||
_resource_spec,
|
||||
@@ -536,6 +537,39 @@ class TestEmitterTextMode:
|
||||
assert "URL: https://app.test" in lines
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("deployment", "expected_url_lines"),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"id": "dep-1",
|
||||
"source_config": {"custom_url": "https://agent.example.com"},
|
||||
},
|
||||
["URL: https://agent.example.com"],
|
||||
id="custom_url_is_printed",
|
||||
),
|
||||
pytest.param(
|
||||
{"id": "dep-1", "source_config": {}}, [], id="no_custom_url_no_line"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_a_deployed_result_prints_the_deployment_url(
|
||||
capsys, monkeypatch, deployment, expected_url_lines
|
||||
):
|
||||
class FakeClient:
|
||||
def get_deployment(self, deployment_id: str) -> dict:
|
||||
return deployment
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "_emitter", None)
|
||||
|
||||
_print_deployment_result(
|
||||
FakeClient(), "dep-1", "DEPLOYED", dashboard_label="Deployment dashboard"
|
||||
)
|
||||
|
||||
lines = [line.strip() for line in capsys.readouterr().out.splitlines()]
|
||||
assert [line for line in lines if line.startswith("URL:")] == expected_url_lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --no-input guard on _create_host_backend_client
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1210,6 +1244,7 @@ def test_finding_a_deployment_by_name_narrows_the_search_for_every_server_versio
|
||||
"name": "agent",
|
||||
"name_contains": "agent",
|
||||
"limit": "100",
|
||||
"offset": "0",
|
||||
}
|
||||
assert found == ExistingDeployment("dep-1", "github")
|
||||
|
||||
@@ -1237,20 +1272,29 @@ def test_a_server_that_ignores_the_exact_name_filter_never_matches_another_deplo
|
||||
assert find_deployment_by_name(client, "brand-new-agent") is None
|
||||
|
||||
|
||||
def test_a_full_page_without_a_match_refuses_to_claim_the_name_is_free():
|
||||
page = [
|
||||
{"id": f"dep-{index}", "name": f"other-agent-{index}"} for index in range(100)
|
||||
]
|
||||
client = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"key",
|
||||
transport=httpx.MockTransport(
|
||||
lambda req: httpx.Response(200, json={"resources": page})
|
||||
def test_a_deployment_beyond_the_first_page_is_found_by_name():
|
||||
deployments = [
|
||||
*(
|
||||
{"id": f"dep-{index}", "name": f"other-agent-{index}"}
|
||||
for index in range(100)
|
||||
),
|
||||
{"id": "dep-target", "name": "agent", "source": "external_docker"},
|
||||
]
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
offset = int(req.url.params["offset"])
|
||||
limit = int(req.url.params["limit"])
|
||||
return httpx.Response(
|
||||
200, json={"resources": deployments[offset : offset + limit]}
|
||||
)
|
||||
|
||||
client = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
with pytest.raises(click.ClickException, match="--deployment-id"):
|
||||
find_deployment_by_name(client, "brand-new-agent")
|
||||
assert find_deployment_by_name(client, "agent") == ExistingDeployment(
|
||||
"dep-target", "external_docker"
|
||||
)
|
||||
|
||||
|
||||
def test_a_partial_page_without_a_match_means_the_name_is_free():
|
||||
|
||||
@@ -575,7 +575,7 @@ def test_list_listeners_asks_for_a_full_page():
|
||||
)
|
||||
|
||||
assert c.list_listeners() == [{"id": "listener-1"}]
|
||||
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100"
|
||||
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100&offset=0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -602,29 +602,30 @@ def test_is_cloud_recognises_the_managed_control_plane(control_plane_url, expect
|
||||
assert endpoints.is_cloud is expected
|
||||
|
||||
|
||||
FIRST_PAGE = {"limit": "100", "offset": "0"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected_params"),
|
||||
[
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(name="agent"),
|
||||
{"name": "agent"},
|
||||
{"name": "agent", **FIRST_PAGE},
|
||||
id="exact_name_filters_server_side",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(name_contains="age"),
|
||||
{"name_contains": "age"},
|
||||
{"name_contains": "age", **FIRST_PAGE},
|
||||
id="substring_search_keeps_its_own_parameter",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(),
|
||||
{},
|
||||
id="no_filter_sends_no_parameters",
|
||||
FIRST_PAGE,
|
||||
id="no_filter_sends_only_the_page",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(
|
||||
name="agent", name_contains="agent", limit=100
|
||||
),
|
||||
{"name": "agent", "name_contains": "agent", "limit": "100"},
|
||||
lambda c: c.list_deployments(name="agent", name_contains="agent"),
|
||||
{"name": "agent", "name_contains": "agent", **FIRST_PAGE},
|
||||
id="both_filters_travel_together_for_older_servers",
|
||||
),
|
||||
],
|
||||
@@ -645,6 +646,68 @@ def test_list_deployments_sends_one_name_filter(call, expected_params):
|
||||
assert seen == expected_params
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"list_every",
|
||||
[
|
||||
pytest.param(HostBackendClient.list_deployments, id="deployments"),
|
||||
pytest.param(HostBackendClient.list_listeners, id="listeners"),
|
||||
],
|
||||
)
|
||||
def test_list_endpoints_read_every_page(list_every):
|
||||
resources = [{"id": f"resource-{index}"} for index in range(101)]
|
||||
offsets: list[str] = []
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
offset = int(req.url.params["offset"])
|
||||
limit = int(req.url.params["limit"])
|
||||
offsets.append(req.url.params["offset"])
|
||||
return httpx.Response(
|
||||
200, json={"resources": resources[offset : offset + limit]}
|
||||
)
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
assert list_every(c) == resources
|
||||
assert offsets == ["0", "100"]
|
||||
|
||||
|
||||
FIRST_PAGE_OF_DEPLOYMENTS = [
|
||||
{"id": f"dep-{index}", "status": "DEPLOYED"} for index in range(100)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"repeated_page",
|
||||
[
|
||||
pytest.param(FIRST_PAGE_OF_DEPLOYMENTS, id="same_page_again"),
|
||||
pytest.param(
|
||||
[
|
||||
{"id": "dep-99", "status": "DEPLOYING"},
|
||||
*FIRST_PAGE_OF_DEPLOYMENTS[:99],
|
||||
],
|
||||
id="same_deployments_reordered_and_updated",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_list_deployments_fails_when_the_server_ignores_the_offset(repeated_page):
|
||||
pages = iter([FIRST_PAGE_OF_DEPLOYMENTS, repeated_page])
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
page = next(pages, None)
|
||||
if page is None:
|
||||
return httpx.Response(500, json={"detail": "the guard let paging run on"})
|
||||
return httpx.Response(200, json={"resources": page})
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
with pytest.raises(HostBackendError, match="ignores 'offset'"):
|
||||
c.list_deployments()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "expected"),
|
||||
[
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from langgraph_cli.deploy import (
|
||||
_extract_deployment_url,
|
||||
format_deployments_table,
|
||||
format_listeners_table,
|
||||
format_revisions_table,
|
||||
)
|
||||
from langgraph_cli.deploy import Table
|
||||
from langgraph_cli.util import clean_empty_lines, warn_non_wolfi_distro
|
||||
|
||||
|
||||
@@ -194,96 +189,7 @@ def test_warn_non_wolfi_distro_does_not_modify_config():
|
||||
assert config_copy == original_config # Config should remain unchanged
|
||||
|
||||
|
||||
def test_extract_deployment_url_uses_custom_url():
|
||||
deployment = {"source_config": {"custom_url": "https://example.com/custom"}}
|
||||
assert _extract_deployment_url(deployment) == "https://example.com/custom"
|
||||
def test_a_table_without_rows_still_renders_its_headers():
|
||||
table = Table(("Name", "Status"), lambda resource: (None, None))
|
||||
|
||||
|
||||
def test_extract_deployment_url_defaults_to_dash():
|
||||
assert _extract_deployment_url({"id": "dep-123"}) == "-"
|
||||
|
||||
|
||||
def test_format_deployments_table():
|
||||
output = format_deployments_table(
|
||||
[
|
||||
{
|
||||
"id": "dep-123",
|
||||
"name": "alpha",
|
||||
"source_config": {"custom_url": "https://alpha.example.com"},
|
||||
},
|
||||
{
|
||||
"id": "dep-456",
|
||||
"name": "beta",
|
||||
"url": "https://beta.example.com",
|
||||
},
|
||||
]
|
||||
)
|
||||
assert "Deployment ID" in output
|
||||
assert "Deployment Name" in output
|
||||
assert "Deployment URL" in output
|
||||
assert "dep-123" in output
|
||||
assert "alpha" in output
|
||||
assert "https://alpha.example.com" in output
|
||||
assert "dep-456" in output
|
||||
|
||||
|
||||
def test_format_revisions_table():
|
||||
output = format_revisions_table(
|
||||
[
|
||||
{
|
||||
"id": "rev-123",
|
||||
"status": "DEPLOYED",
|
||||
"created_at": "2023-11-09T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rev-456",
|
||||
"status": "CREATING",
|
||||
"created_at": "2023-11-07T05:31:56Z",
|
||||
},
|
||||
{
|
||||
"id": "rev-789",
|
||||
"status": "DEPLOYED",
|
||||
"created_at": "2023-11-08T10:00:00Z",
|
||||
},
|
||||
]
|
||||
)
|
||||
assert "Revision ID" in output
|
||||
assert "Status" in output
|
||||
assert "Created At" in output
|
||||
assert "rev-123" in output
|
||||
assert "CREATING" in output
|
||||
assert "2023-11-07T05:31:56Z" in output
|
||||
assert "rev-456" in output
|
||||
assert "rev-789" in output
|
||||
assert "REPLACED" in output
|
||||
|
||||
|
||||
def test_format_listeners_table():
|
||||
output = format_listeners_table(
|
||||
[
|
||||
{
|
||||
"id": "listener-1",
|
||||
"compute_id": "prod-cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents"]},
|
||||
},
|
||||
{
|
||||
"id": "listener-2",
|
||||
"compute_id": "multi-cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
|
||||
},
|
||||
{
|
||||
"id": "listener-3",
|
||||
"compute_id": "broken-cluster",
|
||||
},
|
||||
]
|
||||
)
|
||||
assert "Listener ID" in output
|
||||
assert "Compute ID" in output
|
||||
assert "Namespaces" in output
|
||||
assert "listener-1" in output
|
||||
assert "prod-cluster" in output
|
||||
assert "agents" in output
|
||||
assert "listener-2" in output
|
||||
assert "agents, agents-staging" in output
|
||||
assert "listener-3" in output
|
||||
assert "broken-cluster" in output
|
||||
assert table.render([]).splitlines() == ["Name Status", "---- ------"]
|
||||
@@ -347,6 +347,7 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
|
||||
recursion_limit=DEFAULT_RECURSION_LIMIT,
|
||||
configurable={},
|
||||
)
|
||||
inherited_tags: set[str] = set()
|
||||
if var_config := var_child_runnable_config.get():
|
||||
empty.update(
|
||||
{
|
||||
@@ -355,6 +356,7 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
|
||||
if _is_not_empty(v)
|
||||
},
|
||||
)
|
||||
inherited_tags.update(empty["tags"])
|
||||
# An explicit config that supplies its own checkpoint coordinate (a
|
||||
# thread_id, or any checkpoint_ns/checkpoint_id/checkpoint_map) is addressing
|
||||
# its own checkpoint lineage, so drop the inherited ambient configurable
|
||||
@@ -407,12 +409,14 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
|
||||
elif k == "tags":
|
||||
# Concatenate tags across configs so values bound via
|
||||
# with_config(...) are preserved when later configs
|
||||
# supply additional tags. Matches merge_configs.
|
||||
# supply additional tags. Matches merge_configs. Tags the
|
||||
# ambient config already has are skipped: a subgraph gets
|
||||
# its parent's config both explicitly and as the ambient
|
||||
# one, so they would double at every nesting level.
|
||||
existing_tags: list[str] | None = empty.get("tags")
|
||||
new_tags = [t for t in cast(list, v) if t not in inherited_tags]
|
||||
empty["tags"] = (
|
||||
[*existing_tags, *cast(list, v)]
|
||||
if existing_tags
|
||||
else list(cast(list, v))
|
||||
[*existing_tags, *new_tags] if existing_tags else new_tags
|
||||
)
|
||||
else:
|
||||
empty[k] = v # type: ignore[literal-required]
|
||||
|
||||
@@ -657,48 +657,11 @@ class PregelLoop:
|
||||
# check if iteration limit is reached
|
||||
if self.step > self.stop:
|
||||
self.status = "out_of_steps"
|
||||
# stream the checkpoint the last step saved, as a tick that runs would
|
||||
self._prepare_tasks_and_emit_checkpoint()
|
||||
return False
|
||||
|
||||
# prepare next tasks
|
||||
self.tasks = prepare_next_tasks(
|
||||
self.checkpoint,
|
||||
self.checkpoint_pending_writes,
|
||||
self.nodes,
|
||||
self.channels,
|
||||
self.managed,
|
||||
self.config,
|
||||
self.step,
|
||||
self.stop,
|
||||
for_execution=True,
|
||||
manager=self.manager,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
trigger_to_nodes=self.trigger_to_nodes,
|
||||
updated_channels=self.updated_channels,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
)
|
||||
|
||||
# produce debug output
|
||||
if self._checkpointer_put_after_previous is not None:
|
||||
self._emit(
|
||||
"checkpoints",
|
||||
map_debug_checkpoint,
|
||||
{
|
||||
**self.checkpoint_config,
|
||||
CONF: {
|
||||
**self.checkpoint_config[CONF],
|
||||
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
|
||||
},
|
||||
},
|
||||
self.channels,
|
||||
self.stream_keys,
|
||||
self.checkpoint_metadata,
|
||||
self.tasks.values(),
|
||||
self.checkpoint_pending_writes,
|
||||
self.prev_checkpoint_config,
|
||||
self.output_keys,
|
||||
)
|
||||
self._prepare_tasks_and_emit_checkpoint()
|
||||
|
||||
# if no more tasks, we're done
|
||||
if not self.tasks:
|
||||
@@ -806,10 +769,53 @@ class PregelLoop:
|
||||
self.checkpoint, self.interrupt_after, self.tasks.values()
|
||||
):
|
||||
self.status = "interrupt_after"
|
||||
# the next tick, which streams this checkpoint, never runs
|
||||
self._prepare_tasks_and_emit_checkpoint()
|
||||
raise GraphInterrupt()
|
||||
# unset resuming flag
|
||||
self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
|
||||
|
||||
def _prepare_tasks_and_emit_checkpoint(self) -> None:
|
||||
self.tasks = prepare_next_tasks(
|
||||
self.checkpoint,
|
||||
self.checkpoint_pending_writes,
|
||||
self.nodes,
|
||||
self.channels,
|
||||
self.managed,
|
||||
self.config,
|
||||
self.step,
|
||||
self.stop,
|
||||
for_execution=True,
|
||||
manager=self.manager,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
trigger_to_nodes=self.trigger_to_nodes,
|
||||
updated_channels=self.updated_channels,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
)
|
||||
|
||||
# produce debug output
|
||||
if self._checkpointer_put_after_previous is not None:
|
||||
self._emit(
|
||||
"checkpoints",
|
||||
map_debug_checkpoint,
|
||||
{
|
||||
**self.checkpoint_config,
|
||||
CONF: {
|
||||
**self.checkpoint_config[CONF],
|
||||
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
|
||||
},
|
||||
},
|
||||
self.channels,
|
||||
self.stream_keys,
|
||||
self.checkpoint_metadata,
|
||||
self.tasks.values(),
|
||||
self.checkpoint_pending_writes,
|
||||
self.prev_checkpoint_config,
|
||||
self.output_keys,
|
||||
)
|
||||
|
||||
def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -4410,6 +4410,52 @@ def test_debug_retry(sync_checkpointer: BaseCheckpointSaver):
|
||||
assert stream_parent_conf == history_parent_conf
|
||||
|
||||
|
||||
def test_interrupt_after_streams_the_checkpoint_it_pauses_on(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
class State(TypedDict):
|
||||
x: int
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("one", lambda state: {"x": 1})
|
||||
.add_node("two", lambda state: {"x": 2})
|
||||
.add_edge(START, "one")
|
||||
.add_edge("one", "two")
|
||||
.compile(checkpointer=sync_checkpointer, interrupt_after=["one"])
|
||||
)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
checkpoints = list(graph.stream({"x": 0}, config, stream_mode="checkpoints"))
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert checkpoints[-1]["config"] == state.config
|
||||
assert checkpoints[-1]["next"] == ["two"]
|
||||
|
||||
|
||||
def test_recursion_limit_streams_the_last_checkpoint_saved(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
class State(TypedDict):
|
||||
x: int
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("loop", lambda state: {"x": state["x"] + 1})
|
||||
.add_edge(START, "loop")
|
||||
.add_edge("loop", "loop")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": "1"}, "recursion_limit": 3}
|
||||
checkpoints = []
|
||||
|
||||
with pytest.raises(GraphRecursionError):
|
||||
for chunk in graph.stream({"x": 0}, config, stream_mode="checkpoints"):
|
||||
checkpoints.append(chunk)
|
||||
|
||||
assert checkpoints[-1]["config"] == graph.get_state(config).config
|
||||
|
||||
|
||||
def test_debug_subgraphs(
|
||||
sync_checkpointer: BaseCheckpointSaver, durability: Durability
|
||||
):
|
||||
@@ -6880,6 +6926,22 @@ def test_named_tasks_functional() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_invoke_tags_reach_a_node_three_subgraphs_deep_once() -> None:
|
||||
class State(TypedDict):
|
||||
copies: int
|
||||
|
||||
def leaf(state: State, config: RunnableConfig) -> State:
|
||||
return {"copies": config["tags"].count("my-tag")}
|
||||
|
||||
graph = StateGraph(State).add_node("leaf", leaf).add_edge(START, "leaf").compile()
|
||||
for _ in range(3):
|
||||
graph = (
|
||||
StateGraph(State).add_node("sub", graph).add_edge(START, "sub").compile()
|
||||
)
|
||||
|
||||
assert graph.invoke({"copies": 0}, {"tags": ["my-tag"]}) == {"copies": 1}
|
||||
|
||||
|
||||
def test_tags_stream_mode_messages() -> None:
|
||||
model = GenericFakeChatModel(messages=iter(["foo"]), tags=["meow"])
|
||||
graph = (
|
||||
|
||||
@@ -5875,6 +5875,55 @@ async def test_debug_retry(async_checkpointer: BaseCheckpointSaver):
|
||||
assert stream_parent_conf == history_parent_conf
|
||||
|
||||
|
||||
async def test_interrupt_after_streams_the_checkpoint_it_pauses_on(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
class State(TypedDict):
|
||||
x: int
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("one", lambda state: {"x": 1})
|
||||
.add_node("two", lambda state: {"x": 2})
|
||||
.add_edge(START, "one")
|
||||
.add_edge("one", "two")
|
||||
.compile(checkpointer=async_checkpointer, interrupt_after=["one"])
|
||||
)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
checkpoints = [
|
||||
chunk
|
||||
async for chunk in graph.astream({"x": 0}, config, stream_mode="checkpoints")
|
||||
]
|
||||
|
||||
state = await graph.aget_state(config)
|
||||
assert checkpoints[-1]["config"] == state.config
|
||||
assert checkpoints[-1]["next"] == ["two"]
|
||||
|
||||
|
||||
async def test_recursion_limit_streams_the_last_checkpoint_saved(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
class State(TypedDict):
|
||||
x: int
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("loop", lambda state: {"x": state["x"] + 1})
|
||||
.add_edge(START, "loop")
|
||||
.add_edge("loop", "loop")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": "1"}, "recursion_limit": 3}
|
||||
checkpoints = []
|
||||
|
||||
with pytest.raises(GraphRecursionError):
|
||||
async for chunk in graph.astream({"x": 0}, config, stream_mode="checkpoints"):
|
||||
checkpoints.append(chunk)
|
||||
|
||||
assert checkpoints[-1]["config"] == (await graph.aget_state(config)).config
|
||||
|
||||
|
||||
async def test_debug_subgraphs(
|
||||
async_checkpointer: BaseCheckpointSaver, durability: Durability
|
||||
):
|
||||
|
||||
@@ -799,6 +799,15 @@ def test_ensure_config_tags_concat_preserves_order_and_duplicates() -> None:
|
||||
assert merged["tags"] == ["shared", "alpha", "shared", "beta"]
|
||||
|
||||
|
||||
def test_ensure_config_skips_tags_inherited_from_ambient() -> None:
|
||||
token = var_child_runnable_config.set({"tags": ["inherited"]})
|
||||
try:
|
||||
merged = ensure_config({"tags": ["inherited", "own"]})
|
||||
finally:
|
||||
var_child_runnable_config.reset(token)
|
||||
assert merged["tags"] == ["inherited", "own"]
|
||||
|
||||
|
||||
def test_ensure_config_merges_callbacks_across_configs() -> None:
|
||||
a_cb = _TrackingCB("a")
|
||||
b_cb = _TrackingCB("b")
|
||||
|
||||
@@ -179,7 +179,7 @@ class HttpClient:
|
||||
return await self.request_reconnect(
|
||||
loc,
|
||||
"GET",
|
||||
headers=request_headers,
|
||||
headers=headers,
|
||||
# don't pass on_response so it's only called once
|
||||
reconnect_limit=reconnect_limit - 1,
|
||||
)
|
||||
@@ -217,7 +217,7 @@ class HttpClient:
|
||||
current_headers = dict(
|
||||
request_headers if reconnect_path is None else reconnect_headers
|
||||
)
|
||||
if last_event_id is not None:
|
||||
if last_event_id:
|
||||
current_headers["Last-Event-ID"] = last_event_id
|
||||
|
||||
current_method = method if reconnect_path is None else "GET"
|
||||
|
||||
@@ -99,6 +99,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
version: Literal["v1"] = "v1",
|
||||
) -> AsyncIterator[StreamPart]: ...
|
||||
|
||||
@@ -131,6 +132,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
version: Literal["v2"],
|
||||
) -> AsyncIterator[StreamPartV2]: ...
|
||||
|
||||
@@ -147,6 +149,7 @@ class RunsClient:
|
||||
stream_resumable: bool = False,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
checkpoint_during: bool | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
@@ -160,6 +163,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
version: Literal["v1"] = "v1",
|
||||
) -> AsyncIterator[StreamPart]: ...
|
||||
|
||||
@@ -176,6 +180,7 @@ class RunsClient:
|
||||
stream_resumable: bool = False,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
checkpoint_during: bool | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
@@ -189,6 +194,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
version: Literal["v2"],
|
||||
) -> AsyncIterator[StreamPartV2]: ...
|
||||
|
||||
@@ -385,6 +391,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> Run: ...
|
||||
|
||||
@overload
|
||||
@@ -414,6 +421,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> Run: ...
|
||||
|
||||
async def create(
|
||||
@@ -647,6 +655,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
@overload
|
||||
@@ -673,6 +682,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
async def wait(
|
||||
|
||||
@@ -179,7 +179,7 @@ class SyncHttpClient:
|
||||
return self.request_reconnect(
|
||||
loc,
|
||||
"GET",
|
||||
headers=request_headers,
|
||||
headers=headers,
|
||||
# don't pass on_response so it's only called once
|
||||
reconnect_limit=reconnect_limit - 1,
|
||||
)
|
||||
@@ -219,7 +219,7 @@ class SyncHttpClient:
|
||||
current_headers = dict(
|
||||
request_headers if reconnect_path is None else reconnect_headers
|
||||
)
|
||||
if last_event_id is not None:
|
||||
if last_event_id:
|
||||
current_headers["Last-Event-ID"] = last_event_id
|
||||
|
||||
current_method = method if reconnect_path is None else "GET"
|
||||
@@ -286,7 +286,9 @@ class SyncHttpClient:
|
||||
break
|
||||
|
||||
|
||||
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
|
||||
def _encode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
|
||||
if json is None:
|
||||
return {}, None
|
||||
body = orjson.dumps(
|
||||
json,
|
||||
_orjson_default,
|
||||
|
||||
@@ -80,6 +80,7 @@ class SyncRunsClient:
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
stream_resumable: bool = False,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
@@ -98,6 +99,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
version: Literal["v1"] = "v1",
|
||||
) -> Iterator[StreamPart]: ...
|
||||
|
||||
@@ -111,6 +113,7 @@ class SyncRunsClient:
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
stream_resumable: bool = False,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
@@ -129,6 +132,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
version: Literal["v2"],
|
||||
) -> Iterator[StreamPartV2]: ...
|
||||
|
||||
@@ -159,6 +163,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
version: Literal["v1"] = "v1",
|
||||
) -> Iterator[StreamPart]: ...
|
||||
|
||||
@@ -189,6 +194,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
version: Literal["v2"],
|
||||
) -> Iterator[StreamPartV2]: ...
|
||||
|
||||
@@ -380,6 +386,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> Run: ...
|
||||
|
||||
@overload
|
||||
@@ -409,6 +416,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> Run: ...
|
||||
|
||||
def create(
|
||||
@@ -642,6 +650,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
@overload
|
||||
@@ -668,6 +677,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
def wait(
|
||||
@@ -815,7 +825,6 @@ class SyncRunsClient:
|
||||
"checkpoint_during": checkpoint_during,
|
||||
"on_completion": on_completion,
|
||||
"after_seconds": after_seconds,
|
||||
"raise_error": raise_error,
|
||||
"durability": durability,
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
@@ -830,7 +839,7 @@ class SyncRunsClient:
|
||||
if thread_id is not None
|
||||
else "/runs/wait"
|
||||
)
|
||||
return self.http.request_reconnect(
|
||||
response = self.http.request_reconnect(
|
||||
endpoint,
|
||||
"POST",
|
||||
json={k: v for k, v in payload.items() if v is not None},
|
||||
@@ -838,6 +847,16 @@ class SyncRunsClient:
|
||||
headers=headers,
|
||||
on_response=on_response if on_run_created else None,
|
||||
)
|
||||
if (
|
||||
raise_error
|
||||
and isinstance(response, dict)
|
||||
and "__error__" in response
|
||||
and isinstance(response["__error__"], dict)
|
||||
):
|
||||
raise Exception(
|
||||
f"{response['__error__'].get('error')}: {response['__error__'].get('message')}"
|
||||
)
|
||||
return response
|
||||
|
||||
def list(
|
||||
self,
|
||||
|
||||
@@ -79,18 +79,27 @@ class SSEDecoder:
|
||||
def __init__(self) -> None:
|
||||
self._event = ""
|
||||
self._data = bytearray()
|
||||
self._last_event_id = ""
|
||||
self._last_event_id: str | None = None
|
||||
self._retry: int | None = None
|
||||
self._at_stream_start = True
|
||||
|
||||
@property
|
||||
def last_event_id(self) -> str | None:
|
||||
"""Return the last event identifier that was seen."""
|
||||
"""Return the last event identifier that was seen.
|
||||
|
||||
return self._last_event_id or None
|
||||
`None` until the stream sends an `id` field, and `""` after an empty
|
||||
one, which resets the reconnect cursor.
|
||||
"""
|
||||
|
||||
return self._last_event_id
|
||||
|
||||
def decode(self, line: bytes) -> StreamPart | None:
|
||||
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
|
||||
|
||||
if self._at_stream_start:
|
||||
self._at_stream_start = False
|
||||
line = line.removeprefix(b"\xef\xbb\xbf")
|
||||
|
||||
if not line:
|
||||
if (
|
||||
not self._event
|
||||
@@ -103,7 +112,7 @@ class SSEDecoder:
|
||||
sse = StreamPart(
|
||||
event=self._event,
|
||||
data=orjson.loads(self._data) if self._data else None, # ty: ignore[invalid-argument-type]
|
||||
id=self.last_event_id,
|
||||
id=self._last_event_id or None,
|
||||
)
|
||||
|
||||
# NOTE: as per the SSE spec, do not reset last_event_id.
|
||||
@@ -124,6 +133,8 @@ class SSEDecoder:
|
||||
if fieldname == b"event":
|
||||
self._event = value.decode()
|
||||
elif fieldname == b"data":
|
||||
if self._data:
|
||||
self._data.extend(b"\n")
|
||||
self._data.extend(value)
|
||||
elif fieldname == b"id":
|
||||
if b"\0" in value:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -128,3 +130,45 @@ def test_sync_api_matches_async(async_cls, sync_cls):
|
||||
f"Return annotation mismatch for {async_cls.__name__}.{name}: "
|
||||
f"async={a_ret}, sync={s_ret}"
|
||||
)
|
||||
|
||||
|
||||
# Stateless overloads (`thread_id: None`) leave out the thread-only parameters,
|
||||
# and stateful ones (`thread_id: str`) leave out `on_completion`, which only
|
||||
# applies to stateless runs.
|
||||
_RUN_OVERLOAD_OMISSIONS = {
|
||||
"None": {"checkpoint", "checkpoint_id", "multitask_strategy"},
|
||||
"str": {"on_completion"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("client_cls", [RunsClient, SyncRunsClient])
|
||||
def test_run_overloads_accept_implementation_params(client_cls) -> None:
|
||||
tree = ast.parse(Path(inspect.getfile(client_cls)).read_text())
|
||||
cls = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == client_cls.__name__
|
||||
)
|
||||
defs = [
|
||||
node
|
||||
for node in cls.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name in {"stream", "create", "wait"}
|
||||
]
|
||||
impl_params = {
|
||||
node.name: {arg.arg for arg in node.args.kwonlyargs}
|
||||
for node in defs
|
||||
if not node.decorator_list
|
||||
}
|
||||
gaps = []
|
||||
for stub in defs:
|
||||
if not stub.decorator_list:
|
||||
continue
|
||||
thread_id = next(arg for arg in stub.args.args if arg.arg == "thread_id")
|
||||
assert thread_id.annotation is not None
|
||||
kind = ast.unparse(thread_id.annotation)
|
||||
expected = impl_params[stub.name] - _RUN_OVERLOAD_OMISSIONS[kind]
|
||||
if missing := expected - {arg.arg for arg in stub.args.kwonlyargs}:
|
||||
gaps.append(f"{stub.name}(thread_id: {kind}) line {stub.lineno}: {missing}")
|
||||
|
||||
assert not gaps, gaps
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import orjson
|
||||
import pytest
|
||||
from typing_extensions import assert_type
|
||||
|
||||
@@ -110,6 +111,25 @@ def test_stream_sse():
|
||||
assert len(parts) == 79
|
||||
|
||||
|
||||
def test_sse_decoder_joins_data_lines_with_a_newline():
|
||||
decoder = SSEDecoder()
|
||||
for line in (b"data: [1,", b"data: 2]"):
|
||||
decoder.decode(line)
|
||||
assert decoder.decode(b"") == StreamPart(event="", data=[1, 2], id=None) # ty: ignore[invalid-argument-type]
|
||||
|
||||
for line in (b"data: [1", b"data: 0]"):
|
||||
decoder.decode(line)
|
||||
with pytest.raises(orjson.JSONDecodeError):
|
||||
decoder.decode(b"")
|
||||
|
||||
|
||||
def test_sse_decoder_strips_a_leading_bom():
|
||||
decoder = SSEDecoder()
|
||||
for line in (b"\xef\xbb\xbfevent: values", b"data: 1"):
|
||||
decoder.decode(line)
|
||||
assert decoder.decode(b"") == StreamPart(event="values", data=1, id=None) # ty: ignore[invalid-argument-type]
|
||||
|
||||
|
||||
# --- HTTP client streaming ---
|
||||
|
||||
|
||||
@@ -292,6 +312,52 @@ async def test_http_client_stream_recovers_after_disconnect():
|
||||
]
|
||||
|
||||
|
||||
def _drop_after_an_empty_id(stream_cls: type, reconnects: list[httpx.Request]):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "POST":
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream", "Location": "/reconnect"},
|
||||
stream=stream_cls(
|
||||
[
|
||||
b"id: 1\nevent: values\ndata: 1\n\n",
|
||||
b"id:\nevent: values\ndata: 2\n\n",
|
||||
],
|
||||
httpx.RemoteProtocolError("incomplete chunked read"),
|
||||
),
|
||||
)
|
||||
reconnects.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
stream=stream_cls([b"event: end\ndata: null\n\n"]),
|
||||
)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id():
|
||||
reconnects: list[httpx.Request] = []
|
||||
transport = httpx.MockTransport(_drop_after_an_empty_id(ListByteStream, reconnects))
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
list(SyncHttpClient(client).stream("/stream", "POST", json={}))
|
||||
|
||||
assert "last-event-id" not in reconnects[0].headers
|
||||
|
||||
|
||||
async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id():
|
||||
reconnects: list[httpx.Request] = []
|
||||
transport = httpx.MockTransport(
|
||||
_drop_after_an_empty_id(AsyncListByteStream, reconnects)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
[part async for part in HttpClient(client).stream("/stream", "POST", json={})]
|
||||
|
||||
assert "last-event-id" not in reconnects[0].headers
|
||||
|
||||
|
||||
# --- _sse_to_v2_dict conversion ---
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk.client import (
|
||||
HttpClient,
|
||||
RunsClient,
|
||||
SyncHttpClient,
|
||||
SyncRunsClient,
|
||||
)
|
||||
|
||||
|
||||
class _BodyDropsMidRead(httpx.SyncByteStream, httpx.AsyncByteStream):
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
yield b'{"partial'
|
||||
raise httpx.ReadError("connection dropped")
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
yield b'{"partial'
|
||||
raise httpx.ReadError("connection dropped")
|
||||
|
||||
|
||||
def _wait_handler(requests: list[httpx.Request]):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
if request.method == "POST":
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Location": "/threads/t1/runs/r1/join"},
|
||||
stream=_BodyDropsMidRead(),
|
||||
)
|
||||
return httpx.Response(200, json={"done": True})
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _assert_bodyless_get(request: httpx.Request) -> None:
|
||||
assert request.method == "GET"
|
||||
assert request.url.path == "/threads/t1/runs/r1/join"
|
||||
assert "content-length" not in request.headers, request.headers
|
||||
assert "content-type" not in request.headers, request.headers
|
||||
assert request.read() == b""
|
||||
|
||||
|
||||
def test_sync_wait_reconnects_with_a_bodyless_get():
|
||||
requests: list[httpx.Request] = []
|
||||
transport = httpx.MockTransport(_wait_handler(requests))
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
runs = SyncRunsClient(SyncHttpClient(client))
|
||||
with pytest.warns(UserWarning, match="attempting reconnect"):
|
||||
result = runs.wait("t1", "agent", input={"question": "hi"})
|
||||
|
||||
assert result == {"done": True}
|
||||
_assert_bodyless_get(requests[1])
|
||||
|
||||
|
||||
async def test_async_wait_reconnects_with_a_bodyless_get():
|
||||
requests: list[httpx.Request] = []
|
||||
transport = httpx.MockTransport(_wait_handler(requests))
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
runs = RunsClient(HttpClient(client))
|
||||
with pytest.warns(UserWarning, match="attempting reconnect"):
|
||||
result = await runs.wait("t1", "agent", input={"question": "hi"})
|
||||
|
||||
assert result == {"done": True}
|
||||
_assert_bodyless_get(requests[1])
|
||||
|
||||
|
||||
def test_sync_wait_raises_the_run_error():
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200, json={"__error__": {"error": "RuntimeError", "message": "boom"}}
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
runs = SyncRunsClient(SyncHttpClient(client))
|
||||
with pytest.raises(Exception, match="RuntimeError: boom"):
|
||||
runs.wait("t1", "agent", input={}, raise_error=True)
|
||||
|
||||
assert "raise_error" not in json.loads(requests[0].content)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_wait_returns_the_run_error_without_raise_error(asynchronous):
|
||||
requests: list[httpx.Request] = []
|
||||
run_error = {"__error__": {"error": "RuntimeError", "message": "boom"}}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json=run_error)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
if asynchronous:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
result = await RunsClient(HttpClient(client)).wait(
|
||||
"t1", "agent", input={}, raise_error=False
|
||||
)
|
||||
else:
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
result = SyncRunsClient(SyncHttpClient(client)).wait(
|
||||
"t1", "agent", input={}, raise_error=False
|
||||
)
|
||||
|
||||
assert result == run_error
|
||||
assert "raise_error" not in json.loads(requests[0].content)
|
||||
Reference in new issue
Block a user