diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index ce6be3b86..993a69f64 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -31,7 +31,11 @@ from langgraph_cli.helpers import format_log_entry, level_fg, resolve_deployment from langgraph_cli.host_backend import HostBackendClient, HostBackendError from langgraph_cli.progress import Progress from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new -from langgraph_cli.util import format_deployments_table, warn_non_wolfi_distro +from langgraph_cli.util import ( + format_deployments_table, + format_revisions_table, + warn_non_wolfi_distro, +) from langgraph_cli.version import __version__ RESERVED_ENV_VARS = frozenset( @@ -331,25 +335,25 @@ class NestedHelpGroup(click.Group): self, ctx: click.Context, formatter: click.HelpFormatter ) -> None: command_entries: list[tuple[str, click.Command]] = [] - # Collect the top-level commands first, then append one level of nested - # subcommands using names like "deploy list" so they show up in the - # top-level help output. - for command_name in self.list_commands(ctx): - command = self.get_command(ctx, command_name) - if command is None or command.hidden: - continue - command_entries.append((command_name, command)) - if isinstance(command, click.Group): - # Build a child context so Click resolves the subcommands the same - # way it would for the nested group itself. - sub_ctx = click.Context(command, info_name=command_name, parent=ctx) - for subcommand_name in command.list_commands(sub_ctx): - subcommand = command.get_command(sub_ctx, subcommand_name) - if subcommand is None or subcommand.hidden: - continue - command_entries.append( - (f"{command_name} {subcommand_name}", subcommand) + + def collect_commands( + group: click.Group, parent_ctx: click.Context, prefix: str = "" + ) -> None: + for command_name in group.list_commands(parent_ctx): + command = group.get_command(parent_ctx, command_name) + if command is None or command.hidden: + continue + qualified_name = f"{prefix} {command_name}" if prefix else command_name + command_entries.append((qualified_name, command)) + if isinstance(command, click.Group): + sub_ctx = click.Context( + command, + info_name=qualified_name, + parent=parent_ctx, ) + collect_commands(command, sub_ctx, qualified_name) + + collect_commands(self, ctx) # Compute the available width for help text up front so we can truncate # descriptions before handing them to Click. That keeps each command on @@ -373,13 +377,30 @@ class DeployGroup(NestedHelpGroup): """Group that treats leading '-' args as passthrough docker flags.""" def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + """Treat leading option-like subcommand tokens as passthrough args. + + Click stores the unresolved nested command token on the context after + ``Group.parse_args()`` runs, but the backing attribute changed across + supported Click versions. Click 8.1.x stores the value directly on + ``protected_args``, while Click 8.2+ stores it on ``_protected_args`` + and exposes ``protected_args`` as a deprecated compatibility property. + Since this package allows ``click>=8.1.7``, we need to check both + names to support the full version range without relying on one + version-specific internal detail. + """ result = super().parse_args(ctx, args) - if ctx._protected_args and ctx._protected_args[0].startswith("-"): + protected_args = ctx.__dict__.get("protected_args") + if protected_args is None: + protected_args = ctx.__dict__.get("_protected_args", []) + if protected_args and protected_args[0].startswith("-"): # Click stores the would-be subcommand in _protected_args; if it looks # like an option (e.g. --build-arg) treat it as passthrough docker # args instead of insisting on a nested command. - ctx.args = [*ctx._protected_args, *ctx.args] - ctx._protected_args = [] + ctx.args = [*protected_args, *ctx.args] + if "protected_args" in ctx.__dict__: + ctx.protected_args = [] + elif "_protected_args" in ctx.__dict__: + ctx._protected_args = [] return ctx.args return result @@ -788,6 +809,13 @@ def deploy(ctx: click.Context, **_: object): return ctx.forward(_deploy, docker_build_args=docker_build_args) +@deploy.group( + "revisions", cls=NestedHelpGroup, help="[Beta] Manage deployment revisions." +) +def deploy_revisions() -> None: + pass + + @_deploy_base_options() @click.command(context_settings=dict(ignore_unknown_options=True)) def _deploy( @@ -1225,6 +1253,39 @@ def deploy_list(api_key: str | None, host_url: str | None, name_contains: str) - click.echo(format_deployments_table(deployments)) +@OPT_HOST_API_KEY +@OPT_HOST_URL +@click.option( + "--limit", + type=int, + default=10, + show_default=True, + help="Maximum number of revisions to return.", +) +@click.argument("deployment_id") +@deploy_revisions.command( + "list", + help=( + "[Beta] List revisions for a LangSmith Deployment.\n\n" + "Use the `deploy list` command to list deployment IDs." + ), +) +def deploy_revisions_list( + api_key: str | None, host_url: str | None, limit: int, deployment_id: str +) -> None: + client = _create_host_backend_client(host_url, api_key) + response = _call_host_backend_with_optional_tenant( + client, + lambda c: c.list_revisions(deployment_id, limit=limit), + ) + resources = response.get("resources", []) if isinstance(response, dict) else [] + revisions = [item for item in resources if isinstance(item, dict)] + if not revisions: + click.echo(f"No revisions found for deployment {deployment_id}.") + return + click.echo(format_revisions_table(revisions)) + + @OPT_HOST_API_KEY @OPT_HOST_URL @click.option( diff --git a/libs/cli/langgraph_cli/util.py b/libs/cli/langgraph_cli/util.py index 7e75e4020..f414172fb 100644 --- a/libs/cli/langgraph_cli/util.py +++ b/libs/cli/langgraph_cli/util.py @@ -57,3 +57,35 @@ def format_deployments_table(deployments: Sequence[dict[str, object]]) -> str: 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 "-"), + ) + ) + + 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) diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 7768b9641..475521c4f 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -297,6 +297,8 @@ def test_top_level_help_shows_deploy_subcommands() -> None: assert "deploy" in result.output assert "deploy list" in result.output assert "deploy delete" in result.output + assert "deploy revisions" in result.output + assert "deploy revisions list" in result.output assert "[Beta] List LangSmith Deployments." in result.output @@ -402,6 +404,140 @@ def test_deploy_list_command_no_results(monkeypatch) -> None: assert result.output.strip() == "No deployments found." +def test_deploy_revisions_list_command(monkeypatch) -> None: + runner = CliRunner() + captured: dict[str, str] = {} + + class FakeClient: + def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None): + captured["host_url"] = host_url + captured["api_key"] = api_key + captured["tenant_id"] = tenant_id or "" + + def list_revisions(self, deployment_id: str, limit: int = 1): + captured["deployment_id"] = deployment_id + captured["limit"] = str(limit) + return { + "resources": [ + { + "id": "rev-123", + "status": "CREATING", + "created_at": "2023-11-07T05:31:56Z", + }, + { + "id": "rev-456", + "status": "DEPLOYED", + "created_at": "2023-11-08T10:00:00Z", + }, + ] + } + + monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient) + + result = runner.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 captured == { + "host_url": "https://api.example.com", + "api_key": "test-key", + "tenant_id": "", + "deployment_id": "dep-123", + "limit": "10", + } + assert "Revision ID" in result.output + assert "Status" in result.output + assert "Created At" in result.output + assert "rev-123" in result.output + assert "2023-11-08T10:00:00Z" in result.output + + +def test_deploy_revisions_list_command_no_results(monkeypatch) -> None: + runner = CliRunner() + + 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 {"resources": []} + + monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient) + + result = runner.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.strip() == "No revisions found for deployment dep-123." + + +def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None: + runner = CliRunner() + captured: dict[str, str] = {} + + 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): + captured["deployment_id"] = deployment_id + captured["limit"] = str(limit) + return {"resources": []} + + monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient) + + result = runner.invoke( + cli, + [ + "deploy", + "revisions", + "list", + "--api-key", + "test-key", + "--host-url", + "https://api.example.com", + "--limit", + "25", + "dep-123", + ], + ) + + assert result.exit_code == 0, result.output + assert captured == {"deployment_id": "dep-123", "limit": "25"} + assert result.output.strip() == "No revisions found for deployment dep-123." + + +def test_deploy_revisions_list_missing_deployment_id_shows_usage() -> None: + runner = CliRunner() + + result = runner.invoke(cli, ["deploy", "revisions", "list"]) + + assert result.exit_code == 2, result.output + assert "Missing argument 'DEPLOYMENT_ID'" in result.output + + def test_deploy_delete_command(monkeypatch) -> None: runner = CliRunner() captured: dict[str, str] = {} diff --git a/libs/cli/tests/unit_tests/test_util.py b/libs/cli/tests/unit_tests/test_util.py index 3e05becdd..667cdd027 100644 --- a/libs/cli/tests/unit_tests/test_util.py +++ b/libs/cli/tests/unit_tests/test_util.py @@ -4,6 +4,7 @@ from langgraph_cli.util import ( _extract_deployment_url, clean_empty_lines, format_deployments_table, + format_revisions_table, warn_non_wolfi_distro, ) @@ -224,3 +225,34 @@ def test_format_deployments_table(): 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