From 53e0ee8eecdd7a0e47ac6c5f87f3c92b49e52354 Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 13:17:33 +0200 Subject: [PATCH 01/10] feat(cli): add shared delete command registration --- python/lightning_sdk/cli/utils/delete.py | 73 +++++++++++++++++++ python/tests/cli/utils/test_delete.py | 90 ++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 python/lightning_sdk/cli/utils/delete.py create mode 100644 python/tests/cli/utils/test_delete.py diff --git a/python/lightning_sdk/cli/utils/delete.py b/python/lightning_sdk/cli/utils/delete.py new file mode 100644 index 00000000..b3cb3b0a --- /dev/null +++ b/python/lightning_sdk/cli/utils/delete.py @@ -0,0 +1,73 @@ +"""Shared registration for resource deletion commands.""" + +from __future__ import annotations + +from functools import partial +from typing import Any, Callable, Optional, Type + +import rich_click as click + +from lightning_sdk.cli.utils.logging import LightningCommand + +DeleteAction = Callable[[], None] +DeleteResolver = Callable[[Type[Any], str, Optional[str]], DeleteAction] + + +def _default_delete_resolver( + resource_cls: Type[Any], + identifier: str, + context: Optional[str], + *, + context_option: str, + resource_kwargs: dict[str, Any], +) -> DeleteAction: + resource = resource_cls( + name=identifier, + **{context_option: context}, + **resource_kwargs, + ) + return resource.delete + + +def register_delete_command( + group: click.Group, + resource_cls: Type[Any], + *, + label: str, + help: str, # noqa: A002 + identifier: str = "name", + context_option: str = "teamspace", + context_help: Optional[str] = None, + resolve_delete: Optional[DeleteResolver] = None, + resource_kwargs: Optional[dict[str, Any]] = None, +) -> click.Command: + """Create and directly attach a resource delete command.""" + resolver = resolve_delete or partial( + _default_delete_resolver, + context_option=context_option, + resource_kwargs=dict(resource_kwargs or {}), + ) + + def callback(**params: Any) -> None: + delete = resolver(resource_cls, params[identifier], params[context_option]) + if not params["yes"]: + click.confirm( + "Are you sure you want to delete?", + default=True, + abort=True, + ) + delete() + click.echo(f"{label} deleted") + + callback = click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Delete without prompting for confirmation.", + )(callback) + callback = click.option(f"--{context_option}", help=context_help)(callback) + callback = click.argument(identifier)(callback) + command = click.command("delete", cls=LightningCommand, help=help)(callback) + group.add_command(command) + return command diff --git a/python/tests/cli/utils/test_delete.py b/python/tests/cli/utils/test_delete.py new file mode 100644 index 00000000..867c1d57 --- /dev/null +++ b/python/tests/cli/utils/test_delete.py @@ -0,0 +1,90 @@ +from typing import Optional + +import rich_click as click +from click.testing import CliRunner + +from lightning_sdk.cli.utils.delete import register_delete_command + + +class FakeResource: + instances = [] + + def __init__(self, name: str, teamspace: Optional[str] = None, marker: str = "") -> None: + self.name = name + self.teamspace = teamspace + self.marker = marker + self.deleted = False + self.__class__.instances.append(self) + + def delete(self) -> None: + self.deleted = True + + +def _group() -> click.Group: + group = click.Group() + register_delete_command( + group, + FakeResource, + label="Widget", + help="Delete a widget.", + resource_kwargs={"marker": "registered"}, + ) + return group + + +def test_delete_prompts_with_default_yes_and_prints_success() -> None: + FakeResource.instances.clear() + result = CliRunner().invoke(_group(), ["delete", "demo"], input="\n") + + assert result.exit_code == 0 + assert result.output == "Are you sure you want to delete? [Y/n]: \nWidget deleted\n" + resource = FakeResource.instances[-1] + assert (resource.name, resource.teamspace, resource.marker, resource.deleted) == ( + "demo", + None, + "registered", + True, + ) + + +def test_delete_no_aborts_without_deleting() -> None: + FakeResource.instances.clear() + result = CliRunner().invoke(_group(), ["delete", "demo"], input="n\n") + + assert result.exit_code == 1 + assert "Aborted!" in result.output + assert FakeResource.instances[-1].deleted is False + + +def test_delete_yes_flag_skips_prompt() -> None: + FakeResource.instances.clear() + result = CliRunner().invoke(_group(), ["delete", "demo", "-y"]) + + assert result.exit_code == 0 + assert result.output == "Widget deleted\n" + assert FakeResource.instances[-1].deleted is True + + +def test_delete_uses_registration_local_resolver() -> None: + calls = [] + + def resolve_delete(resource_cls, identifier: str, context: Optional[str]): + calls.append((resource_cls, identifier, context)) + return lambda: calls.append("deleted") + + group = click.Group() + register_delete_command( + group, + FakeResource, + label="API key", + help="Delete an API key.", + identifier="key_id", + context_option="org", + resolve_delete=resolve_delete, + ) + + result = CliRunner().invoke(group, ["delete", "key-123", "--org", "acme", "-y"]) + + assert result.exit_code == 0 + assert result.output == "API key deleted\n" + assert calls == [(FakeResource, "key-123", "acme"), "deleted"] From 3657c435d9c2ed3809917818e33aacff08fd3e88 Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 13:19:07 +0200 Subject: [PATCH 02/10] feat(cli): unify compute resource deletion --- python/lightning_sdk/cli/job/__init__.py | 11 ++- python/lightning_sdk/cli/job/delete.py | 33 -------- python/lightning_sdk/cli/mmt/__init__.py | 11 ++- python/lightning_sdk/cli/mmt/delete.py | 33 -------- python/lightning_sdk/cli/studio/__init__.py | 12 ++- python/lightning_sdk/cli/studio/delete.py | 48 ----------- python/tests/cli/job/test_delete.py | 58 +++++--------- python/tests/cli/mmt/test_delete.py | 39 +++++---- python/tests/cli/studio/test_delete.py | 88 +++++---------------- 9 files changed, 87 insertions(+), 246 deletions(-) delete mode 100644 python/lightning_sdk/cli/job/delete.py delete mode 100644 python/lightning_sdk/cli/mmt/delete.py delete mode 100644 python/lightning_sdk/cli/studio/delete.py diff --git a/python/lightning_sdk/cli/job/__init__.py b/python/lightning_sdk/cli/job/__init__.py index f3fb8202..5e1218bc 100644 --- a/python/lightning_sdk/cli/job/__init__.py +++ b/python/lightning_sdk/cli/job/__init__.py @@ -5,13 +5,14 @@ def register_commands(group: click.Group) -> None: """Register job commands with the given group.""" - from lightning_sdk.cli.job.delete import delete_job + from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.cli.job.inspect import inspect_job from lightning_sdk.cli.job.list import list_jobs from lightning_sdk.cli.job.logs import logs_job from lightning_sdk.cli.job.run import run_job from lightning_sdk.cli.job.ssh import ssh_job from lightning_sdk.cli.job.stop import stop_job + from lightning_sdk.job import Job group.add_command(run_job, name="run") group.add_command(list_jobs, name="list") @@ -19,4 +20,10 @@ def register_commands(group: click.Group) -> None: group.add_command(logs_job, name="logs") group.add_command(ssh_job, name="ssh") group.add_command(stop_job, name="stop") - group.add_command(delete_job, name="delete") + register_delete_command( + group, + Job, + label="Job", + help="Delete a job.", + context_help="Override default teamspace (format: owner/teamspace).", + ) diff --git a/python/lightning_sdk/cli/job/delete.py b/python/lightning_sdk/cli/job/delete.py deleted file mode 100644 index 211eb316..00000000 --- a/python/lightning_sdk/cli/job/delete.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Job delete command.""" - -from typing import Optional - -import rich_click as click -from rich.console import Console - -from lightning_sdk.cli.utils.json_output import echo_json -from lightning_sdk.cli.utils.logging import LightningCommand -from lightning_sdk.cli.utils.resource_resolution import resolve_job, resolve_teamspace - - -@click.command("delete", cls=LightningCommand) -@click.argument("name") -@click.option( - "--teamspace", - default=None, - help=( - "The teamspace to delete the job from. " - "Should be specified as {owner}/{name} " - "If not provided, uses the configured default teamspace." - ), -) -@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.") -def delete_job(name: str, teamspace: Optional[str] = None, as_json: bool = False) -> None: - """Delete a job.""" - resolved_teamspace = resolve_teamspace(teamspace) - job = resolve_job(name, resolved_teamspace) - job.delete() - if as_json: - echo_json({"name": job.name, "deleted": True}) - return - Console().print(f"Successfully deleted {job.name}!") diff --git a/python/lightning_sdk/cli/mmt/__init__.py b/python/lightning_sdk/cli/mmt/__init__.py index 7594982f..88781e90 100644 --- a/python/lightning_sdk/cli/mmt/__init__.py +++ b/python/lightning_sdk/cli/mmt/__init__.py @@ -5,13 +5,14 @@ def register_commands(group: click.Group) -> None: """Register MMT commands with the given group.""" - from lightning_sdk.cli.mmt.delete import delete_mmt + from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.cli.mmt.inspect import inspect_mmt from lightning_sdk.cli.mmt.list import list_mmts from lightning_sdk.cli.mmt.logs import logs_mmt from lightning_sdk.cli.mmt.run import run_mmt from lightning_sdk.cli.mmt.ssh import ssh_mmt from lightning_sdk.cli.mmt.stop import stop_mmt + from lightning_sdk.mmt import MMT group.add_command(run_mmt, name="run") group.add_command(list_mmts, name="list") @@ -19,4 +20,10 @@ def register_commands(group: click.Group) -> None: group.add_command(logs_mmt, name="logs") group.add_command(ssh_mmt, name="ssh") group.add_command(stop_mmt, name="stop") - group.add_command(delete_mmt, name="delete") + register_delete_command( + group, + MMT, + label="Multi-machine job", + help="Delete a multi-machine job.", + context_help="Override default teamspace (format: owner/teamspace).", + ) diff --git a/python/lightning_sdk/cli/mmt/delete.py b/python/lightning_sdk/cli/mmt/delete.py deleted file mode 100644 index 1f07446a..00000000 --- a/python/lightning_sdk/cli/mmt/delete.py +++ /dev/null @@ -1,33 +0,0 @@ -"""MMT delete command.""" - -from typing import Optional - -import rich_click as click -from rich.console import Console - -from lightning_sdk.cli.utils.json_output import echo_json -from lightning_sdk.cli.utils.logging import LightningCommand -from lightning_sdk.cli.utils.resource_resolution import resolve_mmt, resolve_teamspace - - -@click.command("delete", cls=LightningCommand) -@click.argument("name") -@click.option( - "--teamspace", - default=None, - help=( - "The teamspace to delete the job from. " - "Should be specified as {owner}/{name} " - "If not provided, uses the configured default teamspace." - ), -) -@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.") -def delete_mmt(name: str, teamspace: Optional[str] = None, as_json: bool = False) -> None: - """Delete a multi-machine job.""" - resolved_teamspace = resolve_teamspace(teamspace) - mmt = resolve_mmt(name, resolved_teamspace) - mmt.delete() - if as_json: - echo_json({"name": mmt.name, "deleted": True}) - return - Console().print(f"Successfully deleted {mmt.name}!") diff --git a/python/lightning_sdk/cli/studio/__init__.py b/python/lightning_sdk/cli/studio/__init__.py index 74beab8a..d041f3c7 100644 --- a/python/lightning_sdk/cli/studio/__init__.py +++ b/python/lightning_sdk/cli/studio/__init__.py @@ -5,10 +5,10 @@ def register_commands(group: click.Group) -> None: """Register studio commands with the given group.""" + from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.cli.studio.connect import connect_studio from lightning_sdk.cli.studio.cp import cp_studio_file from lightning_sdk.cli.studio.create import create_studio - from lightning_sdk.cli.studio.delete import delete_studio from lightning_sdk.cli.studio.list import list_studios from lightning_sdk.cli.studio.ls import ls_studio from lightning_sdk.cli.studio.open import open_studio @@ -17,8 +17,16 @@ def register_commands(group: click.Group) -> None: from lightning_sdk.cli.studio.start import start_studio from lightning_sdk.cli.studio.stop import stop_studio from lightning_sdk.cli.studio.switch import switch_studio + from lightning_sdk.studio import Studio - group.add_command(delete_studio) + register_delete_command( + group, + Studio, + label="Studio", + help="Delete a Studio.", + context_help="Override default teamspace (format: owner/teamspace).", + resource_kwargs={"create_ok": False}, + ) group.add_command(create_studio) group.add_command(list_studios) group.add_command(ssh_studio) diff --git a/python/lightning_sdk/cli/studio/delete.py b/python/lightning_sdk/cli/studio/delete.py deleted file mode 100644 index 594239ef..00000000 --- a/python/lightning_sdk/cli/studio/delete.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Studio delete command.""" - -from typing import Optional - -import rich_click as click - -from lightning_sdk.cli.utils.json_output import echo_json -from lightning_sdk.cli.utils.logging import LightningCommand -from lightning_sdk.cli.utils.resource_resolution import resolve_studio, resolve_teamspace - - -@click.command("delete", cls=LightningCommand) -@click.option( - "--name", - help="Studio to use. Falls back to the current Studio or configured default.", -) -@click.option("--teamspace", help="Override default teamspace (format: owner/teamspace)") -@click.option("--yes", "-y", is_flag=True, default=False, help="Confirm deletion without prompting.") -@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.") -def delete_studio( - name: Optional[str] = None, - teamspace: Optional[str] = None, - yes: bool = False, - as_json: bool = False, -) -> None: - """Delete a Studio. - - Example: - lightning studio delete --name my-studio - - """ - return delete_impl(name=name, teamspace=teamspace, yes=yes, as_json=as_json) - - -def delete_impl(name: Optional[str], teamspace: Optional[str], yes: bool, as_json: bool = False) -> None: - if not yes: - raise click.UsageError("Deleting a studio requires --yes.") - - resolved_teamspace = resolve_teamspace(teamspace) - studio = resolve_studio(name, resolved_teamspace) - - studio.delete() - - if as_json: - echo_json({"name": studio.name, "deleted": True}) - return - - click.echo(f"{studio._cls_name} '{studio.name}' deleted successfully") diff --git a/python/tests/cli/job/test_delete.py b/python/tests/cli/job/test_delete.py index 1af2bd8d..7655c704 100644 --- a/python/tests/cli/job/test_delete.py +++ b/python/tests/cli/job/test_delete.py @@ -1,17 +1,34 @@ -from unittest.mock import MagicMock, patch - +import rich_click as click from click.testing import CliRunner +from lightning_sdk.cli.job import register_commands from tests.cli.help import assert_help_contains, mock_command_logging @mock_command_logging def test_job_delete_help() -> None: assert_help_contains( - "lightning job delete --help", "Usage: lightning job delete", "Delete a job.", "uses the configured" + "lightning job delete --help", + "Usage: lightning job delete [OPTIONS] NAME", + "Delete a job.", + "--yes", + "-y", ) +def test_delete_job_uses_shared_command() -> None: + resource = MagicMock() + with patch("lightning_sdk.job.Job", return_value=resource) as job_cls: + group = click.Group() + register_commands(group) + result = CliRunner().invoke(group, ["delete", "my-job", "-y"]) + + assert result.exit_code == 0 + assert result.output == "Job deleted\n" + job_cls.assert_called_once_with(name="my-job", teamspace=None) + resource.delete.assert_called_once_with() + + @mock_command_logging def test_jobs_delete_help() -> None: assert_help_contains("lightning jobs delete --help", "Usage: lightning jobs delete", "Delete a job.") @@ -39,38 +56,3 @@ def test_delete_job_legacy_help() -> None: "Usage: lightning delete job [OPTIONS] NAME", ) - -@mock_command_logging -def test_job_delete_resolves_exact_name() -> None: - from lightning_sdk.cli.job.delete import delete_job - - teamspace = MagicMock() - job = MagicMock() - job.name = "train" - with patch("lightning_sdk.cli.job.delete.resolve_teamspace", return_value=teamspace) as resolve_teamspace, patch( - "lightning_sdk.cli.job.delete.resolve_job", return_value=job - ) as resolve_job: - result = CliRunner().invoke(delete_job, ["train", "--teamspace", "org/teamspace"]) - - assert result.exit_code == 0 - resolve_teamspace.assert_called_once_with("org/teamspace") - resolve_job.assert_called_once_with("train", teamspace) - job.delete.assert_called_once_with() - - -def test_job_delete_json() -> None: - import json - - from lightning_sdk.cli.job.delete import delete_job - - teamspace = MagicMock() - job = MagicMock() - job.name = "train" - with patch("lightning_sdk.cli.job.delete.resolve_teamspace", return_value=teamspace), patch( - "lightning_sdk.cli.job.delete.resolve_job", return_value=job - ): - result = CliRunner().invoke(delete_job, ["train", "--teamspace", "org/teamspace", "--json"]) - - assert result.exit_code == 0, result.output - job.delete.assert_called_once_with() - assert json.loads(result.output) == {"name": "train", "deleted": True} diff --git a/python/tests/cli/mmt/test_delete.py b/python/tests/cli/mmt/test_delete.py index 290edbb5..f32c80d9 100644 --- a/python/tests/cli/mmt/test_delete.py +++ b/python/tests/cli/mmt/test_delete.py @@ -1,7 +1,7 @@ -from unittest.mock import MagicMock, patch - +import rich_click as click from click.testing import CliRunner +from lightning_sdk.cli.mmt import register_commands from tests.cli.help import assert_help_contains, mock_command_logging @@ -9,12 +9,26 @@ def test_mmt_delete_help() -> None: assert_help_contains( "lightning mmt delete --help", - "Usage: lightning mmt delete", + "Usage: lightning mmt delete [OPTIONS] NAME", "Delete a multi-machine job.", - "uses the configured", + "--yes", + "-y", ) +def test_delete_mmt_uses_shared_command() -> None: + resource = MagicMock() + with patch("lightning_sdk.mmt.MMT", return_value=resource) as mmt_cls: + group = click.Group() + register_commands(group) + result = CliRunner().invoke(group, ["delete", "my-mmt", "-y"]) + + assert result.exit_code == 0 + assert result.output == "Multi-machine job deleted\n" + mmt_cls.assert_called_once_with(name="my-mmt", teamspace=None) + resource.delete.assert_called_once_with() + + @mock_command_logging def test_mmts_delete_help() -> None: assert_help_contains("lightning mmts delete --help", "Usage: lightning mmts delete", "Delete a multi-machine job.") @@ -29,20 +43,3 @@ def test_delete_mmt_legacy_help() -> None: "Usage: lightning delete mmt [OPTIONS] NAME", ) - -@mock_command_logging -def test_mmt_delete_resolves_exact_name() -> None: - from lightning_sdk.cli.mmt.delete import delete_mmt - - teamspace = MagicMock() - mmt = MagicMock() - mmt.name = "distributed" - with patch("lightning_sdk.cli.mmt.delete.resolve_teamspace", return_value=teamspace) as resolve_teamspace, patch( - "lightning_sdk.cli.mmt.delete.resolve_mmt", return_value=mmt - ) as resolve_mmt: - result = CliRunner().invoke(delete_mmt, ["distributed", "--teamspace", "org/teamspace"]) - - assert result.exit_code == 0 - resolve_teamspace.assert_called_once_with("org/teamspace") - resolve_mmt.assert_called_once_with("distributed", teamspace) - mmt.delete.assert_called_once_with() diff --git a/python/tests/cli/studio/test_delete.py b/python/tests/cli/studio/test_delete.py index a48eeeab..576ce946 100644 --- a/python/tests/cli/studio/test_delete.py +++ b/python/tests/cli/studio/test_delete.py @@ -1,81 +1,35 @@ -from tests.cli.help import assert_help_contains, command_text, mock_command_logging - - -def test_delete_invalid_studio_does_not_confirm_or_delete() -> None: - """An unresolved studio exits before the destructive confirmation and delete call.""" - from unittest.mock import MagicMock, patch - - import rich_click as click - from click.testing import CliRunner - - from lightning_sdk.cli.studio.delete import delete_studio - - confirm = MagicMock() - with patch( - "lightning_sdk.cli.studio.delete.resolve_teamspace", - return_value=MagicMock(), - ), patch( - "lightning_sdk.cli.studio.delete.resolve_studio", - side_effect=click.UsageError("Unknown studio"), - ), patch("lightning_sdk.cli.studio.delete.click.confirm", confirm): - result = CliRunner().invoke(delete_studio, ["--name", "missing", "--yes"]) - - assert result.exit_code != 0 - confirm.assert_not_called() - - -def test_studio_delete_requires_yes_without_prompting_or_deleting() -> None: - """Missing ``--yes`` must stop before resolving a teamspace or studio.""" - from unittest.mock import patch - - from click.testing import CliRunner - - from lightning_sdk.cli.studio.delete import delete_studio +from unittest.mock import MagicMock, patch - with patch( - "lightning_sdk.cli.studio.delete.resolve_teamspace", - side_effect=AssertionError("teamspace resolution must not start"), - ), patch( - "lightning_sdk.cli.studio.delete.resolve_studio", - side_effect=AssertionError("studio resolution must not start"), - ), patch("lightning_sdk.cli.studio.delete.click.confirm", side_effect=AssertionError("must not prompt")): - result = CliRunner().invoke(delete_studio, ["--name", "dev"]) +import rich_click as click +from click.testing import CliRunner - assert result.exit_code != 0 - assert "--yes" in result.output - - -def test_studio_delete_yes_deletes_without_prompting() -> None: - """``--yes`` authorizes studio deletion without reading confirmation input.""" - from unittest.mock import MagicMock, patch - - from click.testing import CliRunner - - from lightning_sdk.cli.studio.delete import delete_studio - - studio = MagicMock() - studio._cls_name = "Studio" - studio.name = "dev" - studio.teamspace.owner.name = "acme" - studio.teamspace.name = "platform" - with patch("lightning_sdk.cli.studio.delete.resolve_teamspace", return_value=MagicMock()), patch( - "lightning_sdk.cli.studio.delete.resolve_studio", return_value=studio - ), patch("lightning_sdk.cli.studio.delete.click.confirm", side_effect=AssertionError("must not prompt")): - result = CliRunner().invoke(delete_studio, ["--name", "dev", "--yes"], input="no input") - - assert result.exit_code == 0, result.output - studio.delete.assert_called_once_with() +from lightning_sdk.cli.studio import register_commands +from tests.cli.help import assert_help_contains, command_text, mock_command_logging @mock_command_logging def test_delete_studio(): result_text = command_text("lightning studio delete --help") - assert "Usage: lightning studio delete [OPTIONS]" in result_text + assert "Usage: lightning studio delete [OPTIONS] NAME" in result_text assert "Delete a Studio." in result_text - assert "--name" in result_text + assert "--name" not in result_text assert "--teamspace" in result_text assert "--yes" in result_text + assert "-y" in result_text + + +def test_delete_studio_uses_positional_name_and_yes_flag() -> None: + resource = MagicMock() + with patch("lightning_sdk.studio.Studio", return_value=resource) as studio_cls: + group = click.Group() + register_commands(group) + result = CliRunner().invoke(group, ["delete", "my-studio", "-y"]) + + assert result.exit_code == 0 + assert result.output == "Studio deleted\n" + studio_cls.assert_called_once_with(name="my-studio", teamspace=None, create_ok=False) + resource.delete.assert_called_once_with() @mock_command_logging From e8887ef00875e14253305558914aa90319e6ad43 Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 13:21:12 +0200 Subject: [PATCH 03/10] feat(cli): unify API-backed resource deletion --- python/lightning_sdk/cli/api_key/__init__.py | 15 ++++++- python/lightning_sdk/cli/api_key/delete.py | 27 ++++++----- .../lightning_sdk/cli/container/__init__.py | 12 ++++- python/lightning_sdk/cli/container/delete.py | 45 +++++++++++-------- .../lightning_sdk/cli/deployment/__init__.py | 13 +++++- python/lightning_sdk/cli/deployment/delete.py | 33 +++++--------- python/tests/cli/api_key/test_api_key.py | 11 +++-- python/tests/cli/container/test_delete.py | 32 +++++++++++++ python/tests/cli/deployment/test_delete.py | 28 ++++++++++++ 9 files changed, 151 insertions(+), 65 deletions(-) create mode 100644 python/tests/cli/deployment/test_delete.py diff --git a/python/lightning_sdk/cli/api_key/__init__.py b/python/lightning_sdk/cli/api_key/__init__.py index 96c84f2f..fb8099c4 100644 --- a/python/lightning_sdk/cli/api_key/__init__.py +++ b/python/lightning_sdk/cli/api_key/__init__.py @@ -5,12 +5,23 @@ def register_commands(group: click.Group) -> None: """Register API key commands with the given group.""" + from lightning_sdk.cli.api_key.common import ORG_OPTION_HELP from lightning_sdk.cli.api_key.create import create_api_key - from lightning_sdk.cli.api_key.delete import delete_api_key + from lightning_sdk.cli.api_key.delete import ApiKeyApi, resolve_api_key_delete from lightning_sdk.cli.api_key.get import get_api_key from lightning_sdk.cli.api_key.list import list_api_keys + from lightning_sdk.cli.utils.delete import register_delete_command group.add_command(get_api_key) group.add_command(create_api_key) group.add_command(list_api_keys) - group.add_command(delete_api_key) + register_delete_command( + group, + ApiKeyApi, + label="API key", + help="Delete an org-scoped API key.", + identifier="key_id", + context_option="org", + context_help=ORG_OPTION_HELP, + resolve_delete=resolve_api_key_delete, + ) diff --git a/python/lightning_sdk/cli/api_key/delete.py b/python/lightning_sdk/cli/api_key/delete.py index 80bab2b6..55311c23 100644 --- a/python/lightning_sdk/cli/api_key/delete.py +++ b/python/lightning_sdk/cli/api_key/delete.py @@ -1,20 +1,19 @@ -"""Delete an org-scoped API key.""" +"""API-key deletion resolver.""" +from functools import partial from typing import Optional -import rich_click as click - from lightning_sdk.api.api_key_api import ApiKeyApi -from lightning_sdk.cli.api_key.common import ORG_OPTION_HELP, resolve_org -from lightning_sdk.cli.utils.logging import LightningCommand +from lightning_sdk.cli.api_key.common import resolve_org +from lightning_sdk.cli.utils.delete import DeleteAction -@click.command("delete", cls=LightningCommand) -@click.argument("key_id") -@click.option("--org", help=ORG_OPTION_HELP) -def delete_api_key(key_id: str, org: Optional[str]) -> None: - """Delete an org-scoped API key.""" - organization = resolve_org(org) - api = ApiKeyApi() - api.delete(organization.id, key_id) - click.echo(f"Deleted API key {key_id}.") +def resolve_api_key_delete( + resource_cls: type[ApiKeyApi], + key_id: str, + org_name: Optional[str], +) -> DeleteAction: + """Resolve an API key and return its bound deletion action.""" + organization = resolve_org(org_name) + api = resource_cls() + return partial(api.delete, organization.id, key_id) diff --git a/python/lightning_sdk/cli/container/__init__.py b/python/lightning_sdk/cli/container/__init__.py index f49b0264..3703766a 100644 --- a/python/lightning_sdk/cli/container/__init__.py +++ b/python/lightning_sdk/cli/container/__init__.py @@ -5,12 +5,20 @@ def register_commands(group: click.Group) -> None: """Register container commands with the given group.""" - from lightning_sdk.cli.container.delete import delete_container + from lightning_sdk.cli.container.delete import LitContainer, resolve_container_delete from lightning_sdk.cli.container.download import download_container from lightning_sdk.cli.container.list import list_containers from lightning_sdk.cli.container.upload import upload_container + from lightning_sdk.cli.utils.delete import register_delete_command group.add_command(list_containers, name="list") group.add_command(upload_container, name="upload") group.add_command(download_container, name="download") - group.add_command(delete_container, name="delete") + register_delete_command( + group, + LitContainer, + label="Container", + help="Delete the docker container NAME.", + context_help="The teamspace to delete the container from.", + resolve_delete=resolve_container_delete, + ) diff --git a/python/lightning_sdk/cli/container/delete.py b/python/lightning_sdk/cli/container/delete.py index 399ec1a6..e29f171c 100644 --- a/python/lightning_sdk/cli/container/delete.py +++ b/python/lightning_sdk/cli/container/delete.py @@ -1,25 +1,32 @@ -"""Container delete command.""" +"""Container deletion resolver.""" from typing import Optional -import rich_click as click +from lightning_sdk.cli.legacy.exceptions import StudioCliError +from lightning_sdk.cli.utils.delete import DeleteAction +from lightning_sdk.cli.utils.resource_resolution import resolve_teamspace +from lightning_sdk.lit_container import LitContainer -from lightning_sdk.cli.legacy.delete import container as _delete_container -from lightning_sdk.cli.utils.logging import LightningCommand +def resolve_container_delete( + resource_cls: type[LitContainer], + name: str, + teamspace: Optional[str], +) -> DeleteAction: + """Resolve a container deletion and return its bound action.""" + resolved_teamspace = resolve_teamspace(teamspace) + api = resource_cls() -@click.command("delete", cls=LightningCommand) -@click.argument("name") -@click.option( - "--teamspace", - default=None, - help=( - "The teamspace to delete the container from. " - "Should be specified as {owner}/{name}. " - "Defaults to the configured teamspace." - ), -) -@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.") -def delete_container(name: str, teamspace: Optional[str] = None, as_json: bool = False) -> None: - """Delete the docker container NAME.""" - _delete_container.callback(name=name, teamspace=teamspace, as_json=as_json) + def delete() -> None: + try: + api.delete_container( + name, + resolved_teamspace.name, + resolved_teamspace.owner.name, + ) + except Exception as ex: + raise StudioCliError( + f"Could not delete container {name} from project {resolved_teamspace.name}: {ex}" + ) from None + + return delete diff --git a/python/lightning_sdk/cli/deployment/__init__.py b/python/lightning_sdk/cli/deployment/__init__.py index 75e75fb0..bea64a76 100644 --- a/python/lightning_sdk/cli/deployment/__init__.py +++ b/python/lightning_sdk/cli/deployment/__init__.py @@ -5,18 +5,27 @@ def register_commands(group: click.Group) -> None: """Register deployment commands with the given group.""" + from lightning_sdk.api.deployment_api import DeploymentApi from lightning_sdk.cli.deployment.create import create_deployment - from lightning_sdk.cli.deployment.delete import delete_deployment + from lightning_sdk.cli.deployment.delete import resolve_deployment_delete from lightning_sdk.cli.deployment.inspect import inspect_deployment from lightning_sdk.cli.deployment.list import list_deployments from lightning_sdk.cli.deployment.logs import deployment_logs from lightning_sdk.cli.deployment.reload_weights import reload_weights from lightning_sdk.cli.deployment.update import update_deployment + from lightning_sdk.cli.utils.delete import register_delete_command group.add_command(create_deployment, name="create") group.add_command(list_deployments, name="list") group.add_command(inspect_deployment, name="inspect") group.add_command(update_deployment, name="update") - group.add_command(delete_deployment, name="delete") + register_delete_command( + group, + DeploymentApi, + label="Deployment", + help="Delete a deployment.", + context_help="Override default teamspace (format: owner/teamspace).", + resolve_delete=resolve_deployment_delete, + ) group.add_command(deployment_logs, name="logs") group.add_command(reload_weights, name="reload-weights") diff --git a/python/lightning_sdk/cli/deployment/delete.py b/python/lightning_sdk/cli/deployment/delete.py index 5e8e5546..9cc93dd9 100644 --- a/python/lightning_sdk/cli/deployment/delete.py +++ b/python/lightning_sdk/cli/deployment/delete.py @@ -1,32 +1,21 @@ -"""Deployment delete command.""" +"""Deployment deletion resolver.""" +from functools import partial from typing import Optional -import rich_click as click - from lightning_sdk.api.deployment_api import DeploymentApi from lightning_sdk.cli.deployment.common import resolve_deployment -from lightning_sdk.cli.utils.json_output import echo_json -from lightning_sdk.cli.utils.logging import LightningCommand +from lightning_sdk.cli.utils.delete import DeleteAction from lightning_sdk.cli.utils.teamspace_option import resolve_teamspace -@click.command("delete", cls=LightningCommand) -@click.argument("name") -@click.option("--teamspace", help="Override default teamspace (format: owner/teamspace).") -@click.option("--yes", "-y", is_flag=True, default=False, help="Do not prompt for confirmation.") -@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.") -def delete_deployment(name: str, teamspace: Optional[str] = None, yes: bool = False, as_json: bool = False) -> None: - """Delete a deployment.""" - if not yes: - raise click.UsageError("Deleting a deployment requires --yes.") - +def resolve_deployment_delete( + resource_cls: type[DeploymentApi], + name: str, + teamspace: Optional[str], +) -> DeleteAction: + """Resolve a deployment and return its bound deletion action.""" resolved_teamspace = resolve_teamspace(teamspace) - api = DeploymentApi() + api = resource_cls() deployment = resolve_deployment(api, resolved_teamspace.id, name) - - api.delete_deployment(deployment) - if as_json: - echo_json({"name": deployment.name, "deleted": True}) - return - click.echo(f"Deleted deployment {deployment.name}.") + return partial(api.delete_deployment, deployment) diff --git a/python/tests/cli/api_key/test_api_key.py b/python/tests/cli/api_key/test_api_key.py index b575ada0..d75548b1 100644 --- a/python/tests/cli/api_key/test_api_key.py +++ b/python/tests/cli/api_key/test_api_key.py @@ -5,8 +5,8 @@ import click from click.testing import CliRunner +from lightning_sdk.cli.api_key import register_commands from lightning_sdk.cli.api_key.create import create_api_key -from lightning_sdk.cli.api_key.delete import delete_api_key from lightning_sdk.cli.api_key.get import get_api_key from lightning_sdk.cli.api_key.list import list_api_keys from tests.cli.help import assert_help_contains, mock_command_logging @@ -213,7 +213,7 @@ def test_list_cli_all_users() -> None: @mock_command_logging -def test_delete_cli_prints_confirmation() -> None: +def test_delete_cli_uses_shared_command() -> None: runner = CliRunner() org = _mock_org() @@ -221,8 +221,11 @@ def test_delete_cli_prints_confirmation() -> None: patch("lightning_sdk.cli.api_key.delete.resolve_org", return_value=org), patch("lightning_sdk.cli.api_key.delete.ApiKeyApi") as api_cls, ): - result = runner.invoke(delete_api_key, ["key-123"]) + group = click.Group() + register_commands(group) + result = runner.invoke(group, ["delete", "key-123", "--org", "test-org", "-y"]) assert result.exit_code == 0 - assert result.output.strip() == "Deleted API key key-123." + assert result.output == "API key deleted\n" + api_cls.assert_called_once_with() api_cls.return_value.delete.assert_called_once_with("org-1", "key-123") diff --git a/python/tests/cli/container/test_delete.py b/python/tests/cli/container/test_delete.py index e4ad73f5..264dd0d9 100644 --- a/python/tests/cli/container/test_delete.py +++ b/python/tests/cli/container/test_delete.py @@ -1,3 +1,10 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import rich_click as click +from click.testing import CliRunner + +from lightning_sdk.cli.container import register_commands from tests.cli.help import assert_help_contains, mock_command_logging @@ -28,3 +35,28 @@ def test_delete_container_legacy_help() -> None: "Use `lightning container delete` instead of `lightning delete container`.", "Usage: lightning delete container [OPTIONS] NAME", ) + + +def test_delete_container_uses_shared_command() -> None: + api = MagicMock() + resolved_teamspace = SimpleNamespace( + name="teamspace", + owner=SimpleNamespace(name="owner"), + ) + + with ( + patch("lightning_sdk.cli.container.delete.LitContainer", return_value=api, create=True) as api_cls, + patch( + "lightning_sdk.cli.container.delete.TeamspacesMenu", + return_value=MagicMock(return_value=resolved_teamspace), + create=True, + ), + ): + group = click.Group() + register_commands(group) + result = CliRunner().invoke(group, ["delete", "image", "-y"]) + + assert result.exit_code == 0 + assert result.output == "Container deleted\n" + api_cls.assert_called_once_with() + api.delete_container.assert_called_once_with("image", "teamspace", "owner") diff --git a/python/tests/cli/deployment/test_delete.py b/python/tests/cli/deployment/test_delete.py new file mode 100644 index 00000000..e3dff57f --- /dev/null +++ b/python/tests/cli/deployment/test_delete.py @@ -0,0 +1,28 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import rich_click as click +from click.testing import CliRunner + +from lightning_sdk.cli.deployment import register_commands + + +def test_delete_deployment_uses_shared_command() -> None: + api = MagicMock() + teamspace = SimpleNamespace(id="teamspace-1") + deployment = SimpleNamespace(name="demo") + + with ( + patch("lightning_sdk.api.deployment_api.DeploymentApi", return_value=api) as api_cls, + patch("lightning_sdk.cli.deployment.delete.resolve_teamspace", return_value=teamspace), + patch("lightning_sdk.cli.deployment.delete.resolve_deployment", return_value=deployment) as resolve_deployment, + ): + group = click.Group() + register_commands(group) + result = CliRunner().invoke(group, ["delete", "demo", "-y"]) + + assert result.exit_code == 0 + assert result.output == "Deployment deleted\n" + api_cls.assert_called_once_with() + resolve_deployment.assert_called_once_with(api, "teamspace-1", "demo") + api.delete_deployment.assert_called_once_with(deployment) From 78152e9b108bf176780bdddbb1dc9c9a90186383 Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 13:23:05 +0200 Subject: [PATCH 04/10] test(cli): cover delete command aliases --- .../lightning_sdk/cli/container/__init__.py | 6 ++++- python/lightning_sdk/cli/job/__init__.py | 2 +- python/lightning_sdk/cli/mmt/__init__.py | 2 +- python/lightning_sdk/cli/studio/__init__.py | 2 +- python/lightning_sdk/cli/utils/delete.py | 16 ++++++------- python/tests/cli/api_key/test_api_key.py | 4 +++- python/tests/cli/container/test_delete.py | 17 ++++++++++---- python/tests/cli/deployment/test_delete.py | 23 +++++++++++++++++++ python/tests/cli/job/test_delete.py | 10 +++++++- python/tests/cli/mmt/test_delete.py | 10 +++++++- python/tests/cli/studio/test_delete.py | 16 ++++++++++--- python/tests/cli/utils/test_delete.py | 4 ++-- 12 files changed, 87 insertions(+), 25 deletions(-) diff --git a/python/lightning_sdk/cli/container/__init__.py b/python/lightning_sdk/cli/container/__init__.py index 3703766a..cee01ff5 100644 --- a/python/lightning_sdk/cli/container/__init__.py +++ b/python/lightning_sdk/cli/container/__init__.py @@ -19,6 +19,10 @@ def register_commands(group: click.Group) -> None: LitContainer, label="Container", help="Delete the docker container NAME.", - context_help="The teamspace to delete the container from.", + context_help=( + "The teamspace to delete the container from. " + "Should be specified as {owner}/{name}. " + "Defaults to the configured teamspace." + ), resolve_delete=resolve_container_delete, ) diff --git a/python/lightning_sdk/cli/job/__init__.py b/python/lightning_sdk/cli/job/__init__.py index 5e1218bc..a4e7cfc9 100644 --- a/python/lightning_sdk/cli/job/__init__.py +++ b/python/lightning_sdk/cli/job/__init__.py @@ -5,13 +5,13 @@ def register_commands(group: click.Group) -> None: """Register job commands with the given group.""" - from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.cli.job.inspect import inspect_job from lightning_sdk.cli.job.list import list_jobs from lightning_sdk.cli.job.logs import logs_job from lightning_sdk.cli.job.run import run_job from lightning_sdk.cli.job.ssh import ssh_job from lightning_sdk.cli.job.stop import stop_job + from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.job import Job group.add_command(run_job, name="run") diff --git a/python/lightning_sdk/cli/mmt/__init__.py b/python/lightning_sdk/cli/mmt/__init__.py index 88781e90..1d88f413 100644 --- a/python/lightning_sdk/cli/mmt/__init__.py +++ b/python/lightning_sdk/cli/mmt/__init__.py @@ -5,13 +5,13 @@ def register_commands(group: click.Group) -> None: """Register MMT commands with the given group.""" - from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.cli.mmt.inspect import inspect_mmt from lightning_sdk.cli.mmt.list import list_mmts from lightning_sdk.cli.mmt.logs import logs_mmt from lightning_sdk.cli.mmt.run import run_mmt from lightning_sdk.cli.mmt.ssh import ssh_mmt from lightning_sdk.cli.mmt.stop import stop_mmt + from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.mmt import MMT group.add_command(run_mmt, name="run") diff --git a/python/lightning_sdk/cli/studio/__init__.py b/python/lightning_sdk/cli/studio/__init__.py index d041f3c7..0f083970 100644 --- a/python/lightning_sdk/cli/studio/__init__.py +++ b/python/lightning_sdk/cli/studio/__init__.py @@ -5,7 +5,6 @@ def register_commands(group: click.Group) -> None: """Register studio commands with the given group.""" - from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.cli.studio.connect import connect_studio from lightning_sdk.cli.studio.cp import cp_studio_file from lightning_sdk.cli.studio.create import create_studio @@ -17,6 +16,7 @@ def register_commands(group: click.Group) -> None: from lightning_sdk.cli.studio.start import start_studio from lightning_sdk.cli.studio.stop import stop_studio from lightning_sdk.cli.studio.switch import switch_studio + from lightning_sdk.cli.utils.delete import register_delete_command from lightning_sdk.studio import Studio register_delete_command( diff --git a/python/lightning_sdk/cli/utils/delete.py b/python/lightning_sdk/cli/utils/delete.py index b3cb3b0a..aea8fac2 100644 --- a/python/lightning_sdk/cli/utils/delete.py +++ b/python/lightning_sdk/cli/utils/delete.py @@ -3,20 +3,20 @@ from __future__ import annotations from functools import partial -from typing import Any, Callable, Optional, Type +from typing import Any, Callable import rich_click as click from lightning_sdk.cli.utils.logging import LightningCommand DeleteAction = Callable[[], None] -DeleteResolver = Callable[[Type[Any], str, Optional[str]], DeleteAction] +DeleteResolver = Callable[[type[Any], str, str | None], DeleteAction] def _default_delete_resolver( - resource_cls: Type[Any], + resource_cls: type[Any], identifier: str, - context: Optional[str], + context: str | None, *, context_option: str, resource_kwargs: dict[str, Any], @@ -31,15 +31,15 @@ def _default_delete_resolver( def register_delete_command( group: click.Group, - resource_cls: Type[Any], + resource_cls: type[Any], *, label: str, help: str, # noqa: A002 identifier: str = "name", context_option: str = "teamspace", - context_help: Optional[str] = None, - resolve_delete: Optional[DeleteResolver] = None, - resource_kwargs: Optional[dict[str, Any]] = None, + context_help: str | None = None, + resolve_delete: DeleteResolver | None = None, + resource_kwargs: dict[str, Any] | None = None, ) -> click.Command: """Create and directly attach a resource delete command.""" resolver = resolve_delete or partial( diff --git a/python/tests/cli/api_key/test_api_key.py b/python/tests/cli/api_key/test_api_key.py index d75548b1..9b1c8df2 100644 --- a/python/tests/cli/api_key/test_api_key.py +++ b/python/tests/cli/api_key/test_api_key.py @@ -59,8 +59,10 @@ def test_api_key_list_help() -> None: def test_api_key_delete_help() -> None: assert_help_contains( "lightning api-key delete --help", - "Usage: lightning api-key delete", + "Usage: lightning api-key delete [OPTIONS] KEY_ID", "Delete an org-scoped API key.", + "--yes", + "-y", ) diff --git a/python/tests/cli/container/test_delete.py b/python/tests/cli/container/test_delete.py index 264dd0d9..32c31ea1 100644 --- a/python/tests/cli/container/test_delete.py +++ b/python/tests/cli/container/test_delete.py @@ -12,8 +12,10 @@ def test_container_delete_help() -> None: text = assert_help_contains( "lightning container delete --help", - "Usage: lightning container delete", + "Usage: lightning container delete [OPTIONS] NAME", "Delete the docker container NAME.", + "--yes", + "-y", ) normalized_text = " ".join(text.replace("│", " ").split()) assert "Defaults to the configured teamspace." in normalized_text @@ -23,7 +25,11 @@ def test_container_delete_help() -> None: @mock_command_logging def test_containers_delete_help() -> None: assert_help_contains( - "lightning containers delete --help", "Usage: lightning containers delete", "Delete the docker container NAME." + "lightning containers delete --help", + "Usage: lightning containers delete [OPTIONS] NAME", + "Delete the docker container NAME.", + "--yes", + "-y", ) @@ -34,6 +40,8 @@ def test_delete_container_legacy_help() -> None: "Deprecation warning:", "Use `lightning container delete` instead of `lightning delete container`.", "Usage: lightning delete container [OPTIONS] NAME", + "--yes", + "-y", ) @@ -47,9 +55,8 @@ def test_delete_container_uses_shared_command() -> None: with ( patch("lightning_sdk.cli.container.delete.LitContainer", return_value=api, create=True) as api_cls, patch( - "lightning_sdk.cli.container.delete.TeamspacesMenu", - return_value=MagicMock(return_value=resolved_teamspace), - create=True, + "lightning_sdk.cli.container.delete.resolve_teamspace", + return_value=resolved_teamspace, ), ): group = click.Group() diff --git a/python/tests/cli/deployment/test_delete.py b/python/tests/cli/deployment/test_delete.py index e3dff57f..51544c8f 100644 --- a/python/tests/cli/deployment/test_delete.py +++ b/python/tests/cli/deployment/test_delete.py @@ -5,6 +5,29 @@ from click.testing import CliRunner from lightning_sdk.cli.deployment import register_commands +from tests.cli.help import assert_help_contains, mock_command_logging + + +@mock_command_logging +def test_delete_deployment_help() -> None: + assert_help_contains( + "lightning deployment delete --help", + "Usage: lightning deployment delete [OPTIONS] NAME", + "Delete a deployment.", + "--yes", + "-y", + ) + + +@mock_command_logging +def test_delete_deployments_alias_help() -> None: + assert_help_contains( + "lightning deployments delete --help", + "Usage: lightning deployments delete [OPTIONS] NAME", + "Delete a deployment.", + "--yes", + "-y", + ) def test_delete_deployment_uses_shared_command() -> None: diff --git a/python/tests/cli/job/test_delete.py b/python/tests/cli/job/test_delete.py index 7655c704..fcb48977 100644 --- a/python/tests/cli/job/test_delete.py +++ b/python/tests/cli/job/test_delete.py @@ -31,7 +31,13 @@ def test_delete_job_uses_shared_command() -> None: @mock_command_logging def test_jobs_delete_help() -> None: - assert_help_contains("lightning jobs delete --help", "Usage: lightning jobs delete", "Delete a job.") + assert_help_contains( + "lightning jobs delete --help", + "Usage: lightning jobs delete [OPTIONS] NAME", + "Delete a job.", + "--yes", + "-y", + ) @mock_command_logging @@ -54,5 +60,7 @@ def test_delete_job_legacy_help() -> None: "Deprecation warning:", "Use `lightning job delete` instead of `lightning delete job`.", "Usage: lightning delete job [OPTIONS] NAME", + "--yes", + "-y", ) diff --git a/python/tests/cli/mmt/test_delete.py b/python/tests/cli/mmt/test_delete.py index f32c80d9..42614faf 100644 --- a/python/tests/cli/mmt/test_delete.py +++ b/python/tests/cli/mmt/test_delete.py @@ -31,7 +31,13 @@ def test_delete_mmt_uses_shared_command() -> None: @mock_command_logging def test_mmts_delete_help() -> None: - assert_help_contains("lightning mmts delete --help", "Usage: lightning mmts delete", "Delete a multi-machine job.") + assert_help_contains( + "lightning mmts delete --help", + "Usage: lightning mmts delete [OPTIONS] NAME", + "Delete a multi-machine job.", + "--yes", + "-y", + ) @mock_command_logging @@ -41,5 +47,7 @@ def test_delete_mmt_legacy_help() -> None: "Deprecation warning:", "Use `lightning mmt delete` instead of `lightning delete mmt`.", "Usage: lightning delete mmt [OPTIONS] NAME", + "--yes", + "-y", ) diff --git a/python/tests/cli/studio/test_delete.py b/python/tests/cli/studio/test_delete.py index 576ce946..0df37d8a 100644 --- a/python/tests/cli/studio/test_delete.py +++ b/python/tests/cli/studio/test_delete.py @@ -34,14 +34,24 @@ def test_delete_studio_uses_positional_name_and_yes_flag() -> None: @mock_command_logging def test_studios_delete_help() -> None: - assert_help_contains("lightning studios delete --help", "Usage: lightning studios delete", "Delete a Studio.") + text = assert_help_contains( + "lightning studios delete --help", + "Usage: lightning studios delete [OPTIONS] NAME", + "Delete a Studio.", + "--yes", + "-y", + ) + assert "--name" not in text @mock_command_logging def test_delete_studio_legacy_help() -> None: - assert_help_contains( + text = assert_help_contains( "lightning delete studio --help", "Deprecation warning:", "Use `lightning studio delete` instead of `lightning delete studio`.", - "Usage: lightning delete studio [OPTIONS]", + "Usage: lightning delete studio [OPTIONS] NAME", + "--yes", + "-y", ) + assert "--name" not in text diff --git a/python/tests/cli/utils/test_delete.py b/python/tests/cli/utils/test_delete.py index 867c1d57..accb7503 100644 --- a/python/tests/cli/utils/test_delete.py +++ b/python/tests/cli/utils/test_delete.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import ClassVar, Optional import rich_click as click from click.testing import CliRunner @@ -7,7 +7,7 @@ class FakeResource: - instances = [] + instances: ClassVar[list["FakeResource"]] = [] def __init__(self, name: str, teamspace: Optional[str] = None, marker: str = "") -> None: self.name = name From 830b52a15fb78efbc57e39e2603818e87bae041f Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 13:32:14 +0200 Subject: [PATCH 05/10] feat(cli): unify sandbox deletion --- python/lightning_sdk/cli/sandbox/__init__.py | 40 ++++++++++++++-- python/lightning_sdk/cli/sandbox/commands.py | 50 ++++++++------------ python/lightning_sdk/cli/utils/delete.py | 2 +- python/tests/cli/sandbox/test_sandbox.py | 29 +++++++++--- 4 files changed, 80 insertions(+), 41 deletions(-) diff --git a/python/lightning_sdk/cli/sandbox/__init__.py b/python/lightning_sdk/cli/sandbox/__init__.py index aa2b6a16..7e2888fb 100644 --- a/python/lightning_sdk/cli/sandbox/__init__.py +++ b/python/lightning_sdk/cli/sandbox/__init__.py @@ -27,23 +27,40 @@ def register_commands(group: click.Group) -> None: connect_sandbox, create_sandbox, create_snapshot, - delete_sandbox, - delete_snapshot, get_snapshot, list_sandbox_commands, list_sandboxes, list_snapshots, logs_sandbox_command, + resolve_sandbox_delete, + resolve_snapshot_delete, run_sandbox_command, start_sandbox, stop_sandbox, update_sandbox, ) + from lightning_sdk.cli.utils.delete import register_delete_command + from lightning_sdk.sandbox import Sandbox group.add_command(list_sandboxes, name="list") group.add_command(create_sandbox, name="create") group.add_command(update_sandbox, name="update") - group.add_command(delete_sandbox, name="delete") + register_delete_command( + group, + Sandbox, + label="Sandbox", + help="""Delete a sandbox. + + Example: + $ sandbox delete sbx-42 + + Sandbox deleted + """, + identifier="sandbox_id", + context_option="api_key", + context_help="Sandbox API key.", + resolve_delete=resolve_sandbox_delete, + ) group.add_command(stop_sandbox, name="stop") group.add_command(start_sandbox, name="start") group.add_command(connect_sandbox, name="connect") @@ -54,6 +71,21 @@ def register_commands(group: click.Group) -> None: snapshot.add_command(list_snapshots, name="list") snapshot.add_command(get_snapshot, name="get") snapshot.add_command(create_snapshot, name="create") - snapshot.add_command(delete_snapshot, name="delete") + register_delete_command( + snapshot, + Sandbox, + label="Snapshot", + help="""Delete a sandbox snapshot. + + Example: + $ sandbox snapshot delete snap-42 + + Snapshot deleted + """, + identifier="snapshot_id", + context_option="api_key", + context_help="Sandbox API key.", + resolve_delete=resolve_snapshot_delete, + ) group.add_command(snapshot, name="snapshot") group.add_command(list_sandbox_commands, name="commands") diff --git a/python/lightning_sdk/cli/sandbox/commands.py b/python/lightning_sdk/cli/sandbox/commands.py index 5f2c65e4..7494c7d7 100644 --- a/python/lightning_sdk/cli/sandbox/commands.py +++ b/python/lightning_sdk/cli/sandbox/commands.py @@ -6,6 +6,7 @@ import json from collections.abc import Sequence +from functools import partial from typing import Any import rich_click as click @@ -13,6 +14,7 @@ from lightning_sdk.api.logs_api import SEVERITIES from lightning_sdk.cli.job.run import _resolve_envs +from lightning_sdk.cli.utils.delete import DeleteAction from lightning_sdk.cli.utils.logging import LightningCommand from lightning_sdk.cli.utils.logs import LogSelection, read_logs, resolve_time from lightning_sdk.cli.utils.richt_print import rich_to_str @@ -36,6 +38,24 @@ def _sandbox_client( return Sandbox(_sandbox_config(api_key=api_key, base_url=base_url)) +def resolve_sandbox_delete( + _resource_cls: type[Sandbox], + sandbox_id: str, + api_key: str | None, +) -> DeleteAction: + """Resolve a sandbox and return its bound deletion action.""" + return _sandbox_client(api_key=api_key).get(sandbox_id).delete + + +def resolve_snapshot_delete( + _resource_cls: type[Sandbox], + snapshot_id: str, + api_key: str | None, +) -> DeleteAction: + """Resolve a sandbox snapshot and return its bound deletion action.""" + return partial(_sandbox_client(api_key=api_key).delete_snapshot, snapshot_id) + + def _json_default(value: object) -> str: if hasattr(value, "isoformat"): return value.isoformat() # type: ignore[attr-defined] @@ -325,22 +345,6 @@ def create_sandbox( _attach_interactive_shell(sandbox) -@click.command("delete", cls=LightningCommand) -@_with_common_options -@click.argument("sandbox_id") -def delete_sandbox(api_key: str | None, sandbox_id: str) -> None: - """Delete a sandbox. - - Example: - $ sandbox delete sbx-42 - - Deleted sandbox sbx-42 - """ - sandbox = _sandbox_client(api_key=api_key).get(sandbox_id) - sandbox.delete() - click.echo(f"Deleted sandbox {sandbox_id}") - - @click.command("stop", cls=LightningCommand) @_with_common_options @click.argument("sandbox_id") @@ -938,20 +942,6 @@ def create_snapshot( _echo_snapshot_summary(snapshot) -@click.command("delete", cls=LightningCommand) -@_with_common_options -@click.argument("snapshot_id") -def delete_snapshot(api_key: str | None, snapshot_id: str) -> None: - """Delete a sandbox snapshot. - - Example: - $ sandbox snapshot delete snap-42 - Deleted snapshot snap-42 - """ - _sandbox_client(api_key=api_key).delete_snapshot(snapshot_id) - click.echo(f"Deleted snapshot {snapshot_id}") - - @click.command("commands", cls=LightningCommand) @_with_common_options @click.argument("sandbox_id") diff --git a/python/lightning_sdk/cli/utils/delete.py b/python/lightning_sdk/cli/utils/delete.py index aea8fac2..7f72d13c 100644 --- a/python/lightning_sdk/cli/utils/delete.py +++ b/python/lightning_sdk/cli/utils/delete.py @@ -66,7 +66,7 @@ def callback(**params: Any) -> None: default=False, help="Delete without prompting for confirmation.", )(callback) - callback = click.option(f"--{context_option}", help=context_help)(callback) + callback = click.option(f"--{context_option.replace('_', '-')}", help=context_help)(callback) callback = click.argument(identifier)(callback) command = click.command("delete", cls=LightningCommand, help=help)(callback) group.add_command(command) diff --git a/python/tests/cli/sandbox/test_sandbox.py b/python/tests/cli/sandbox/test_sandbox.py index 4acc51ca..c29f9b64 100644 --- a/python/tests/cli/sandbox/test_sandbox.py +++ b/python/tests/cli/sandbox/test_sandbox.py @@ -145,9 +145,9 @@ def delete_snapshot(self, snapshot_id: str) -> None: self.deleted_snapshot_ids.append(snapshot_id) -def _invoke(args: list[str]) -> SimpleNamespace: +def _invoke(args: list[str], input: str | None = None) -> SimpleNamespace: runner = CliRunner() - result = runner.invoke(main_cli, args, catch_exceptions=False) + result = runner.invoke(main_cli, args, input=input, catch_exceptions=False) return SimpleNamespace(exit_code=result.exit_code, output=result.output) @@ -213,7 +213,7 @@ def test_sandbox_command_help_examples() -> None: ) assert_help_contains("lightning sandbox start --help", "$ sandbox start sbx-42", "devbox") assert_help_contains("lightning sandbox update --help", "$ sandbox update sbx-42 --resume", "devbox") - assert_help_contains("lightning sandbox delete --help", "$ sandbox delete sbx-42", "Deleted sandbox sbx-42") + assert_help_contains("lightning sandbox delete --help", "$ sandbox delete sbx-42", "Sandbox deleted") assert_help_contains( "lightning sandbox connect --help", "$ sandbox connect sbx-42", @@ -401,11 +401,28 @@ def test_sandbox_lifecycle_commands(_mock_log_command, monkeypatch) -> None: assert _invoke(["sandbox", "start", "sbx-1"]).exit_code == 0 assert instance.resumed is True - assert _invoke(["sandbox", "delete", "sbx-1"]).exit_code == 0 + result = _invoke(["sandbox", "delete", "sbx-1", "-y"]) + + assert result.exit_code == 0 + assert result.output == "Sandbox deleted\n" assert instance.deleted is True assert client.get_ids == ["sbx-1", "sbx-1", "sbx-1"] +@mock.patch("lightning_sdk.cli.utils.logging._log_command") +@mock_command_logging +def test_sandbox_delete_prompts_by_default(_mock_log_command, monkeypatch) -> None: + instance = FakeSandboxInstance() + client = FakeSandboxClient(instance) + monkeypatch.setattr(sandbox_commands, "_sandbox_client", lambda **_: client) + + result = _invoke(["sandbox", "delete", "sbx-1"], input="\n") + + assert result.exit_code == 0 + assert result.output == "Are you sure you want to delete? [Y/n]: \nSandbox deleted\n" + assert instance.deleted is True + + @mock.patch("lightning_sdk.cli.utils.logging._log_command") @mock_command_logging def test_sandbox_update_requires_resume(_mock_log_command, monkeypatch) -> None: @@ -600,8 +617,8 @@ def test_sandbox_snapshot_delete(_mock_log_command, monkeypatch) -> None: client = FakeSandboxClient() monkeypatch.setattr(sandbox_commands, "_sandbox_client", lambda **_: client) - result = _invoke(["sandbox", "snapshot", "delete", "snap-9"]) + result = _invoke(["sandbox", "snapshot", "delete", "snap-9", "-y"]) assert result.exit_code == 0 - assert "Deleted snapshot snap-9" in result.output + assert result.output == "Snapshot deleted\n" assert client.deleted_snapshot_ids == ["snap-9"] From 4ea160aa6ccbf4e1054bc466dc7af2f6cda6744f Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 13:57:20 +0200 Subject: [PATCH 06/10] feat(cli): preserve deletion JSON output --- python/lightning_sdk/cli/utils/delete.py | 11 ++++++++ .../tests/cli/deployment/test_deployment.py | 26 ------------------- python/tests/cli/job/test_delete.py | 17 +++++++++++- python/tests/cli/mmt/test_delete.py | 3 ++- .../cli/test_non_interactive_contract.py | 6 +++-- python/tests/cli/utils/test_delete.py | 10 +++++++ 6 files changed, 43 insertions(+), 30 deletions(-) diff --git a/python/lightning_sdk/cli/utils/delete.py b/python/lightning_sdk/cli/utils/delete.py index 7f72d13c..d299cb8f 100644 --- a/python/lightning_sdk/cli/utils/delete.py +++ b/python/lightning_sdk/cli/utils/delete.py @@ -7,6 +7,7 @@ import rich_click as click +from lightning_sdk.cli.utils.json_output import echo_json from lightning_sdk.cli.utils.logging import LightningCommand DeleteAction = Callable[[], None] @@ -57,8 +58,18 @@ def callback(**params: Any) -> None: abort=True, ) delete() + if params["as_json"]: + echo_json({identifier: params[identifier], "deleted": True}) + return click.echo(f"{label} deleted") + callback = click.option( + "--json", + "as_json", + is_flag=True, + default=False, + help="Output as JSON.", + )(callback) callback = click.option( "--yes", "-y", diff --git a/python/tests/cli/deployment/test_deployment.py b/python/tests/cli/deployment/test_deployment.py index 3d388e6b..b288529e 100644 --- a/python/tests/cli/deployment/test_deployment.py +++ b/python/tests/cli/deployment/test_deployment.py @@ -8,7 +8,6 @@ from lightning_sdk.api.logs_api import LogEntry from lightning_sdk.cli.deployment.create import create_deployment -from lightning_sdk.cli.deployment.delete import delete_deployment from lightning_sdk.cli.deployment.list import list_deployments from lightning_sdk.cli.deployment.logs import deployment_logs from lightning_sdk.cli.deployment.reload_weights import reload_weights @@ -54,31 +53,6 @@ def test_deployments_alias_help() -> None: ) -def test_deployment_delete_requires_yes_without_prompting_or_deleting(monkeypatch) -> None: - """Missing ``--yes`` must stop before teamspace resolution or API construction.""" - monkeypatch.setattr( - "lightning_sdk.cli.deployment.delete.resolve_teamspace", - MagicMock(side_effect=AssertionError("teamspace resolution must not start")), - ) - monkeypatch.setattr( - "lightning_sdk.cli.deployment.delete.DeploymentApi", - MagicMock(side_effect=AssertionError("deployment API construction must not start")), - ) - monkeypatch.setattr( - "lightning_sdk.cli.deployment.delete.resolve_deployment", - MagicMock(side_effect=AssertionError("deployment resolution must not start")), - ) - monkeypatch.setattr( - "lightning_sdk.cli.deployment.delete.click.confirm", - lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not prompt")), - ) - - result = CliRunner().invoke(delete_deployment, ["api"]) - - assert result.exit_code != 0 - assert "--yes" in result.output - - @mock_command_logging def test_list_deployments_includes_replicas(monkeypatch) -> None: teamspace = SimpleNamespace(id="project-id", name="test", owner=SimpleNamespace(name="ecorp")) diff --git a/python/tests/cli/job/test_delete.py b/python/tests/cli/job/test_delete.py index fcb48977..8a750b14 100644 --- a/python/tests/cli/job/test_delete.py +++ b/python/tests/cli/job/test_delete.py @@ -1,3 +1,6 @@ +import json +from unittest.mock import MagicMock, patch + import rich_click as click from click.testing import CliRunner @@ -13,6 +16,7 @@ def test_job_delete_help() -> None: "Delete a job.", "--yes", "-y", + "--json", ) @@ -29,6 +33,18 @@ def test_delete_job_uses_shared_command() -> None: resource.delete.assert_called_once_with() +def test_delete_job_json() -> None: + resource = MagicMock() + with patch("lightning_sdk.job.Job", return_value=resource): + group = click.Group() + register_commands(group) + result = CliRunner().invoke(group, ["delete", "my-job", "-y", "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"name": "my-job", "deleted": True} + resource.delete.assert_called_once_with() + + @mock_command_logging def test_jobs_delete_help() -> None: assert_help_contains( @@ -63,4 +79,3 @@ def test_delete_job_legacy_help() -> None: "--yes", "-y", ) - diff --git a/python/tests/cli/mmt/test_delete.py b/python/tests/cli/mmt/test_delete.py index 42614faf..61b748ec 100644 --- a/python/tests/cli/mmt/test_delete.py +++ b/python/tests/cli/mmt/test_delete.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock, patch + import rich_click as click from click.testing import CliRunner @@ -50,4 +52,3 @@ def test_delete_mmt_legacy_help() -> None: "--yes", "-y", ) - diff --git a/python/tests/cli/test_non_interactive_contract.py b/python/tests/cli/test_non_interactive_contract.py index 1d0992d4..0bf69e09 100644 --- a/python/tests/cli/test_non_interactive_contract.py +++ b/python/tests/cli/test_non_interactive_contract.py @@ -10,6 +10,7 @@ "LIGHTNING_NON_INTERACTIVE", "webbrowser.open", ) +ALLOWED = {("utils/delete.py", "click.confirm")} def test_cli_has_no_implicit_interaction() -> None: @@ -17,6 +18,7 @@ def test_cli_has_no_implicit_interaction() -> None: for path in CLI_ROOT.rglob("*.py"): text = path.read_text() for token in FORBIDDEN: - if token in text: - violations.append(f"{path.relative_to(CLI_ROOT)}: {token}") + relative_path = path.relative_to(CLI_ROOT).as_posix() + if token in text and (relative_path, token) not in ALLOWED: + violations.append(f"{relative_path}: {token}") assert violations == [] diff --git a/python/tests/cli/utils/test_delete.py b/python/tests/cli/utils/test_delete.py index accb7503..1c858522 100644 --- a/python/tests/cli/utils/test_delete.py +++ b/python/tests/cli/utils/test_delete.py @@ -1,3 +1,4 @@ +import json from typing import ClassVar, Optional import rich_click as click @@ -65,6 +66,15 @@ def test_delete_yes_flag_skips_prompt() -> None: assert FakeResource.instances[-1].deleted is True +def test_delete_json_preserves_machine_readable_output() -> None: + FakeResource.instances.clear() + result = CliRunner().invoke(_group(), ["delete", "demo", "-y", "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"name": "demo", "deleted": True} + assert FakeResource.instances[-1].deleted is True + + def test_delete_uses_registration_local_resolver() -> None: calls = [] From 757561a8e1f7d366b992d9e204482e05e61da654 Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 14:34:25 +0200 Subject: [PATCH 07/10] refactor(cli): simplify custom delete resolvers --- python/lightning_sdk/cli/api_key/__init__.py | 3 +-- python/lightning_sdk/cli/api_key/delete.py | 3 +-- .../lightning_sdk/cli/container/__init__.py | 3 +-- python/lightning_sdk/cli/container/delete.py | 3 +-- .../lightning_sdk/cli/deployment/__init__.py | 2 -- python/lightning_sdk/cli/deployment/delete.py | 3 +-- python/lightning_sdk/cli/sandbox/__init__.py | 3 --- python/lightning_sdk/cli/sandbox/commands.py | 2 -- python/lightning_sdk/cli/utils/delete.py | 22 ++++++++++++------- python/tests/cli/deployment/test_delete.py | 2 +- python/tests/cli/utils/test_delete.py | 7 +++--- 11 files changed, 23 insertions(+), 30 deletions(-) diff --git a/python/lightning_sdk/cli/api_key/__init__.py b/python/lightning_sdk/cli/api_key/__init__.py index fb8099c4..cce0ae93 100644 --- a/python/lightning_sdk/cli/api_key/__init__.py +++ b/python/lightning_sdk/cli/api_key/__init__.py @@ -7,7 +7,7 @@ def register_commands(group: click.Group) -> None: """Register API key commands with the given group.""" from lightning_sdk.cli.api_key.common import ORG_OPTION_HELP from lightning_sdk.cli.api_key.create import create_api_key - from lightning_sdk.cli.api_key.delete import ApiKeyApi, resolve_api_key_delete + from lightning_sdk.cli.api_key.delete import resolve_api_key_delete from lightning_sdk.cli.api_key.get import get_api_key from lightning_sdk.cli.api_key.list import list_api_keys from lightning_sdk.cli.utils.delete import register_delete_command @@ -17,7 +17,6 @@ def register_commands(group: click.Group) -> None: group.add_command(list_api_keys) register_delete_command( group, - ApiKeyApi, label="API key", help="Delete an org-scoped API key.", identifier="key_id", diff --git a/python/lightning_sdk/cli/api_key/delete.py b/python/lightning_sdk/cli/api_key/delete.py index 55311c23..8e9f7000 100644 --- a/python/lightning_sdk/cli/api_key/delete.py +++ b/python/lightning_sdk/cli/api_key/delete.py @@ -9,11 +9,10 @@ def resolve_api_key_delete( - resource_cls: type[ApiKeyApi], key_id: str, org_name: Optional[str], ) -> DeleteAction: """Resolve an API key and return its bound deletion action.""" organization = resolve_org(org_name) - api = resource_cls() + api = ApiKeyApi() return partial(api.delete, organization.id, key_id) diff --git a/python/lightning_sdk/cli/container/__init__.py b/python/lightning_sdk/cli/container/__init__.py index cee01ff5..1b142276 100644 --- a/python/lightning_sdk/cli/container/__init__.py +++ b/python/lightning_sdk/cli/container/__init__.py @@ -5,7 +5,7 @@ def register_commands(group: click.Group) -> None: """Register container commands with the given group.""" - from lightning_sdk.cli.container.delete import LitContainer, resolve_container_delete + from lightning_sdk.cli.container.delete import resolve_container_delete from lightning_sdk.cli.container.download import download_container from lightning_sdk.cli.container.list import list_containers from lightning_sdk.cli.container.upload import upload_container @@ -16,7 +16,6 @@ def register_commands(group: click.Group) -> None: group.add_command(download_container, name="download") register_delete_command( group, - LitContainer, label="Container", help="Delete the docker container NAME.", context_help=( diff --git a/python/lightning_sdk/cli/container/delete.py b/python/lightning_sdk/cli/container/delete.py index e29f171c..3f38dd2b 100644 --- a/python/lightning_sdk/cli/container/delete.py +++ b/python/lightning_sdk/cli/container/delete.py @@ -9,13 +9,12 @@ def resolve_container_delete( - resource_cls: type[LitContainer], name: str, teamspace: Optional[str], ) -> DeleteAction: """Resolve a container deletion and return its bound action.""" resolved_teamspace = resolve_teamspace(teamspace) - api = resource_cls() + api = LitContainer() def delete() -> None: try: diff --git a/python/lightning_sdk/cli/deployment/__init__.py b/python/lightning_sdk/cli/deployment/__init__.py index bea64a76..762cd470 100644 --- a/python/lightning_sdk/cli/deployment/__init__.py +++ b/python/lightning_sdk/cli/deployment/__init__.py @@ -5,7 +5,6 @@ def register_commands(group: click.Group) -> None: """Register deployment commands with the given group.""" - from lightning_sdk.api.deployment_api import DeploymentApi from lightning_sdk.cli.deployment.create import create_deployment from lightning_sdk.cli.deployment.delete import resolve_deployment_delete from lightning_sdk.cli.deployment.inspect import inspect_deployment @@ -21,7 +20,6 @@ def register_commands(group: click.Group) -> None: group.add_command(update_deployment, name="update") register_delete_command( group, - DeploymentApi, label="Deployment", help="Delete a deployment.", context_help="Override default teamspace (format: owner/teamspace).", diff --git a/python/lightning_sdk/cli/deployment/delete.py b/python/lightning_sdk/cli/deployment/delete.py index 9cc93dd9..ec312f91 100644 --- a/python/lightning_sdk/cli/deployment/delete.py +++ b/python/lightning_sdk/cli/deployment/delete.py @@ -10,12 +10,11 @@ def resolve_deployment_delete( - resource_cls: type[DeploymentApi], name: str, teamspace: Optional[str], ) -> DeleteAction: """Resolve a deployment and return its bound deletion action.""" resolved_teamspace = resolve_teamspace(teamspace) - api = resource_cls() + api = DeploymentApi() deployment = resolve_deployment(api, resolved_teamspace.id, name) return partial(api.delete_deployment, deployment) diff --git a/python/lightning_sdk/cli/sandbox/__init__.py b/python/lightning_sdk/cli/sandbox/__init__.py index 7e2888fb..fb1d8136 100644 --- a/python/lightning_sdk/cli/sandbox/__init__.py +++ b/python/lightning_sdk/cli/sandbox/__init__.py @@ -40,14 +40,12 @@ def register_commands(group: click.Group) -> None: update_sandbox, ) from lightning_sdk.cli.utils.delete import register_delete_command - from lightning_sdk.sandbox import Sandbox group.add_command(list_sandboxes, name="list") group.add_command(create_sandbox, name="create") group.add_command(update_sandbox, name="update") register_delete_command( group, - Sandbox, label="Sandbox", help="""Delete a sandbox. @@ -73,7 +71,6 @@ def register_commands(group: click.Group) -> None: snapshot.add_command(create_snapshot, name="create") register_delete_command( snapshot, - Sandbox, label="Snapshot", help="""Delete a sandbox snapshot. diff --git a/python/lightning_sdk/cli/sandbox/commands.py b/python/lightning_sdk/cli/sandbox/commands.py index 7494c7d7..804c40ff 100644 --- a/python/lightning_sdk/cli/sandbox/commands.py +++ b/python/lightning_sdk/cli/sandbox/commands.py @@ -39,7 +39,6 @@ def _sandbox_client( def resolve_sandbox_delete( - _resource_cls: type[Sandbox], sandbox_id: str, api_key: str | None, ) -> DeleteAction: @@ -48,7 +47,6 @@ def resolve_sandbox_delete( def resolve_snapshot_delete( - _resource_cls: type[Sandbox], snapshot_id: str, api_key: str | None, ) -> DeleteAction: diff --git a/python/lightning_sdk/cli/utils/delete.py b/python/lightning_sdk/cli/utils/delete.py index d299cb8f..0fde0869 100644 --- a/python/lightning_sdk/cli/utils/delete.py +++ b/python/lightning_sdk/cli/utils/delete.py @@ -11,7 +11,7 @@ from lightning_sdk.cli.utils.logging import LightningCommand DeleteAction = Callable[[], None] -DeleteResolver = Callable[[type[Any], str, str | None], DeleteAction] +DeleteResolver = Callable[[str, str | None], DeleteAction] def _default_delete_resolver( @@ -32,7 +32,7 @@ def _default_delete_resolver( def register_delete_command( group: click.Group, - resource_cls: type[Any], + resource_cls: type[Any] | None = None, *, label: str, help: str, # noqa: A002 @@ -43,14 +43,20 @@ def register_delete_command( resource_kwargs: dict[str, Any] | None = None, ) -> click.Command: """Create and directly attach a resource delete command.""" - resolver = resolve_delete or partial( - _default_delete_resolver, - context_option=context_option, - resource_kwargs=dict(resource_kwargs or {}), - ) + if resolve_delete is not None: + resolver = resolve_delete + elif resource_cls is not None: + resolver = partial( + _default_delete_resolver, + resource_cls, + context_option=context_option, + resource_kwargs=dict(resource_kwargs or {}), + ) + else: + raise TypeError("resource_cls is required without resolve_delete") def callback(**params: Any) -> None: - delete = resolver(resource_cls, params[identifier], params[context_option]) + delete = resolver(params[identifier], params[context_option]) if not params["yes"]: click.confirm( "Are you sure you want to delete?", diff --git a/python/tests/cli/deployment/test_delete.py b/python/tests/cli/deployment/test_delete.py index 51544c8f..4970b3d6 100644 --- a/python/tests/cli/deployment/test_delete.py +++ b/python/tests/cli/deployment/test_delete.py @@ -36,7 +36,7 @@ def test_delete_deployment_uses_shared_command() -> None: deployment = SimpleNamespace(name="demo") with ( - patch("lightning_sdk.api.deployment_api.DeploymentApi", return_value=api) as api_cls, + patch("lightning_sdk.cli.deployment.delete.DeploymentApi", return_value=api) as api_cls, patch("lightning_sdk.cli.deployment.delete.resolve_teamspace", return_value=teamspace), patch("lightning_sdk.cli.deployment.delete.resolve_deployment", return_value=deployment) as resolve_deployment, ): diff --git a/python/tests/cli/utils/test_delete.py b/python/tests/cli/utils/test_delete.py index 1c858522..18769041 100644 --- a/python/tests/cli/utils/test_delete.py +++ b/python/tests/cli/utils/test_delete.py @@ -78,14 +78,13 @@ def test_delete_json_preserves_machine_readable_output() -> None: def test_delete_uses_registration_local_resolver() -> None: calls = [] - def resolve_delete(resource_cls, identifier: str, context: Optional[str]): - calls.append((resource_cls, identifier, context)) + def resolve_delete(identifier: str, context: Optional[str]): + calls.append((identifier, context)) return lambda: calls.append("deleted") group = click.Group() register_delete_command( group, - FakeResource, label="API key", help="Delete an API key.", identifier="key_id", @@ -97,4 +96,4 @@ def resolve_delete(resource_cls, identifier: str, context: Optional[str]): assert result.exit_code == 0 assert result.output == "API key deleted\n" - assert calls == [(FakeResource, "key-123", "acme"), "deleted"] + assert calls == [("key-123", "acme"), "deleted"] From d468de078bef49ddfe4339c5d8da35b85b06dbf8 Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 17:54:33 +0200 Subject: [PATCH 08/10] refactor(cli): remove JSON deletion output --- python/lightning_sdk/cli/utils/delete.py | 11 ----------- python/tests/cli/job/test_delete.py | 17 ++--------------- python/tests/cli/utils/test_delete.py | 17 +++++++++++------ 3 files changed, 13 insertions(+), 32 deletions(-) diff --git a/python/lightning_sdk/cli/utils/delete.py b/python/lightning_sdk/cli/utils/delete.py index 0fde0869..c3bb2aac 100644 --- a/python/lightning_sdk/cli/utils/delete.py +++ b/python/lightning_sdk/cli/utils/delete.py @@ -7,7 +7,6 @@ import rich_click as click -from lightning_sdk.cli.utils.json_output import echo_json from lightning_sdk.cli.utils.logging import LightningCommand DeleteAction = Callable[[], None] @@ -64,18 +63,8 @@ def callback(**params: Any) -> None: abort=True, ) delete() - if params["as_json"]: - echo_json({identifier: params[identifier], "deleted": True}) - return click.echo(f"{label} deleted") - callback = click.option( - "--json", - "as_json", - is_flag=True, - default=False, - help="Output as JSON.", - )(callback) callback = click.option( "--yes", "-y", diff --git a/python/tests/cli/job/test_delete.py b/python/tests/cli/job/test_delete.py index 8a750b14..b73b63e1 100644 --- a/python/tests/cli/job/test_delete.py +++ b/python/tests/cli/job/test_delete.py @@ -1,4 +1,3 @@ -import json from unittest.mock import MagicMock, patch import rich_click as click @@ -10,14 +9,14 @@ @mock_command_logging def test_job_delete_help() -> None: - assert_help_contains( + text = assert_help_contains( "lightning job delete --help", "Usage: lightning job delete [OPTIONS] NAME", "Delete a job.", "--yes", "-y", - "--json", ) + assert "--json" not in text def test_delete_job_uses_shared_command() -> None: @@ -33,18 +32,6 @@ def test_delete_job_uses_shared_command() -> None: resource.delete.assert_called_once_with() -def test_delete_job_json() -> None: - resource = MagicMock() - with patch("lightning_sdk.job.Job", return_value=resource): - group = click.Group() - register_commands(group) - result = CliRunner().invoke(group, ["delete", "my-job", "-y", "--json"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"name": "my-job", "deleted": True} - resource.delete.assert_called_once_with() - - @mock_command_logging def test_jobs_delete_help() -> None: assert_help_contains( diff --git a/python/tests/cli/utils/test_delete.py b/python/tests/cli/utils/test_delete.py index 18769041..cc70d0cd 100644 --- a/python/tests/cli/utils/test_delete.py +++ b/python/tests/cli/utils/test_delete.py @@ -1,4 +1,3 @@ -import json from typing import ClassVar, Optional import rich_click as click @@ -66,13 +65,19 @@ def test_delete_yes_flag_skips_prompt() -> None: assert FakeResource.instances[-1].deleted is True -def test_delete_json_preserves_machine_readable_output() -> None: +def test_delete_does_not_offer_json_output() -> None: FakeResource.instances.clear() - result = CliRunner().invoke(_group(), ["delete", "demo", "-y", "--json"]) + group = _group() - assert result.exit_code == 0 - assert json.loads(result.output) == {"name": "demo", "deleted": True} - assert FakeResource.instances[-1].deleted is True + help_result = CliRunner().invoke(group, ["delete", "--help"]) + delete_result = CliRunner().invoke(group, ["delete", "demo", "-y", "--json"]) + + assert help_result.exit_code == 0 + assert "--json" not in help_result.output + assert delete_result.exit_code == 2 + assert "No such option" in delete_result.output + assert "--json" in delete_result.output + assert FakeResource.instances == [] def test_delete_uses_registration_local_resolver() -> None: From 4ec638c59c8e41ad6187679ffbbd93ce4fed32c3 Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Thu, 30 Jul 2026 19:03:04 +0200 Subject: [PATCH 09/10] style: format logs API --- python/lightning_sdk/api/logs_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/lightning_sdk/api/logs_api.py b/python/lightning_sdk/api/logs_api.py index e6f79164..cf206d00 100644 --- a/python/lightning_sdk/api/logs_api.py +++ b/python/lightning_sdk/api/logs_api.py @@ -82,7 +82,6 @@ def format(self, *, timestamps: bool = False, prefix: Optional[str] = None) -> s return " ".join(parts) - def to_json_dict(self, source: Optional[str] = None) -> "Dict[str, Optional[str]]": """Render the entry as a JSON-serializable object; ``source`` labels the replica/rank.""" return { From ca7d44c05501c9ddfbd055b4b047d9319ab25d52 Mon Sep 17 00:00:00 2001 From: Justus Perillieux Date: Fri, 31 Jul 2026 11:11:41 +0200 Subject: [PATCH 10/10] fix(cli): default delete confirmation to no --- python/lightning_sdk/cli/utils/delete.py | 2 +- python/tests/cli/sandbox/test_sandbox.py | 4 ++-- python/tests/cli/utils/test_delete.py | 23 ++++++++++++++--------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/python/lightning_sdk/cli/utils/delete.py b/python/lightning_sdk/cli/utils/delete.py index c3bb2aac..449a6bd4 100644 --- a/python/lightning_sdk/cli/utils/delete.py +++ b/python/lightning_sdk/cli/utils/delete.py @@ -59,7 +59,7 @@ def callback(**params: Any) -> None: if not params["yes"]: click.confirm( "Are you sure you want to delete?", - default=True, + default=False, abort=True, ) delete() diff --git a/python/tests/cli/sandbox/test_sandbox.py b/python/tests/cli/sandbox/test_sandbox.py index c29f9b64..805d2324 100644 --- a/python/tests/cli/sandbox/test_sandbox.py +++ b/python/tests/cli/sandbox/test_sandbox.py @@ -416,10 +416,10 @@ def test_sandbox_delete_prompts_by_default(_mock_log_command, monkeypatch) -> No client = FakeSandboxClient(instance) monkeypatch.setattr(sandbox_commands, "_sandbox_client", lambda **_: client) - result = _invoke(["sandbox", "delete", "sbx-1"], input="\n") + result = _invoke(["sandbox", "delete", "sbx-1"], input="y\n") assert result.exit_code == 0 - assert result.output == "Are you sure you want to delete? [Y/n]: \nSandbox deleted\n" + assert result.output == "Are you sure you want to delete? [y/N]: y\nSandbox deleted\n" assert instance.deleted is True diff --git a/python/tests/cli/utils/test_delete.py b/python/tests/cli/utils/test_delete.py index cc70d0cd..b5f2e420 100644 --- a/python/tests/cli/utils/test_delete.py +++ b/python/tests/cli/utils/test_delete.py @@ -32,19 +32,24 @@ def _group() -> click.Group: return group -def test_delete_prompts_with_default_yes_and_prints_success() -> None: +def test_delete_enter_aborts_without_deleting() -> None: FakeResource.instances.clear() result = CliRunner().invoke(_group(), ["delete", "demo"], input="\n") - assert result.exit_code == 0 - assert result.output == "Are you sure you want to delete? [Y/n]: \nWidget deleted\n" + assert result.exit_code == 1 + assert result.output == "Are you sure you want to delete? [y/N]: \nAborted!\n" resource = FakeResource.instances[-1] - assert (resource.name, resource.teamspace, resource.marker, resource.deleted) == ( - "demo", - None, - "registered", - True, - ) + assert (resource.name, resource.teamspace, resource.marker) == ("demo", None, "registered") + assert resource.deleted is False + + +def test_delete_yes_confirms_and_prints_success() -> None: + FakeResource.instances.clear() + result = CliRunner().invoke(_group(), ["delete", "demo"], input="y\n") + + assert result.exit_code == 0 + assert result.output == "Are you sure you want to delete? [y/N]: y\nWidget deleted\n" + assert FakeResource.instances[-1].deleted is True def test_delete_no_aborts_without_deleting() -> None: