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 { diff --git a/python/lightning_sdk/cli/api_key/__init__.py b/python/lightning_sdk/cli/api_key/__init__.py index 96c84f2f..cce0ae93 100644 --- a/python/lightning_sdk/cli/api_key/__init__.py +++ b/python/lightning_sdk/cli/api_key/__init__.py @@ -5,12 +5,22 @@ 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 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, + 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..8e9f7000 100644 --- a/python/lightning_sdk/cli/api_key/delete.py +++ b/python/lightning_sdk/cli/api_key/delete.py @@ -1,20 +1,18 @@ -"""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) +def resolve_api_key_delete( + key_id: str, + org_name: Optional[str], +) -> DeleteAction: + """Resolve an API key and return its bound deletion action.""" + organization = resolve_org(org_name) api = ApiKeyApi() - api.delete(organization.id, key_id) - click.echo(f"Deleted API key {key_id}.") + 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..1b142276 100644 --- a/python/lightning_sdk/cli/container/__init__.py +++ b/python/lightning_sdk/cli/container/__init__.py @@ -5,12 +5,23 @@ 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 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, + label="Container", + help="Delete the docker container NAME.", + 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/container/delete.py b/python/lightning_sdk/cli/container/delete.py index 399ec1a6..3f38dd2b 100644 --- a/python/lightning_sdk/cli/container/delete.py +++ b/python/lightning_sdk/cli/container/delete.py @@ -1,25 +1,31 @@ -"""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( + name: str, + teamspace: Optional[str], +) -> DeleteAction: + """Resolve a container deletion and return its bound action.""" + resolved_teamspace = resolve_teamspace(teamspace) + api = LitContainer() -@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..762cd470 100644 --- a/python/lightning_sdk/cli/deployment/__init__.py +++ b/python/lightning_sdk/cli/deployment/__init__.py @@ -6,17 +6,24 @@ def register_commands(group: click.Group) -> None: """Register deployment commands with the given group.""" 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, + 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..ec312f91 100644 --- a/python/lightning_sdk/cli/deployment/delete.py +++ b/python/lightning_sdk/cli/deployment/delete.py @@ -1,32 +1,20 @@ -"""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( + name: str, + teamspace: Optional[str], +) -> DeleteAction: + """Resolve a deployment and return its bound deletion action.""" resolved_teamspace = resolve_teamspace(teamspace) api = DeploymentApi() 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/lightning_sdk/cli/job/__init__.py b/python/lightning_sdk/cli/job/__init__.py index f3fb8202..a4e7cfc9 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.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") 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..1d88f413 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.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") 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/sandbox/__init__.py b/python/lightning_sdk/cli/sandbox/__init__.py index aa2b6a16..fb1d8136 100644 --- a/python/lightning_sdk/cli/sandbox/__init__.py +++ b/python/lightning_sdk/cli/sandbox/__init__.py @@ -27,23 +27,38 @@ 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 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, + 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 +69,20 @@ 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, + 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..804c40ff 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,22 @@ def _sandbox_client( return Sandbox(_sandbox_config(api_key=api_key, base_url=base_url)) +def resolve_sandbox_delete( + 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( + 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 +343,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 +940,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/studio/__init__.py b/python/lightning_sdk/cli/studio/__init__.py index 74beab8a..0f083970 100644 --- a/python/lightning_sdk/cli/studio/__init__.py +++ b/python/lightning_sdk/cli/studio/__init__.py @@ -8,7 +8,6 @@ def register_commands(group: click.Group) -> None: 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 +16,17 @@ 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 - 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/lightning_sdk/cli/utils/delete.py b/python/lightning_sdk/cli/utils/delete.py new file mode 100644 index 00000000..449a6bd4 --- /dev/null +++ b/python/lightning_sdk/cli/utils/delete.py @@ -0,0 +1,79 @@ +"""Shared registration for resource deletion commands.""" + +from __future__ import annotations + +from functools import partial +from typing import Any, Callable + +import rich_click as click + +from lightning_sdk.cli.utils.logging import LightningCommand + +DeleteAction = Callable[[], None] +DeleteResolver = Callable[[str, str | None], DeleteAction] + + +def _default_delete_resolver( + resource_cls: type[Any], + identifier: str, + context: str | None, + *, + 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] | None = None, + *, + label: str, + help: str, # noqa: A002 + identifier: str = "name", + context_option: str = "teamspace", + 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.""" + 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(params[identifier], params[context_option]) + if not params["yes"]: + click.confirm( + "Are you sure you want to delete?", + default=False, + 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.replace('_', '-')}", 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/api_key/test_api_key.py b/python/tests/cli/api_key/test_api_key.py index b575ada0..9b1c8df2 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 @@ -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", ) @@ -213,7 +215,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 +223,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..32c31ea1 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 @@ -5,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 @@ -16,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", ) @@ -27,4 +40,30 @@ 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", + ) + + +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.resolve_teamspace", + return_value=resolved_teamspace, + ), + ): + 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..4970b3d6 --- /dev/null +++ b/python/tests/cli/deployment/test_delete.py @@ -0,0 +1,51 @@ +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 +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: + api = MagicMock() + teamspace = SimpleNamespace(id="teamspace-1") + deployment = SimpleNamespace(name="demo") + + with ( + 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, + ): + 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) 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 1af2bd8d..b73b63e1 100644 --- a/python/tests/cli/job/test_delete.py +++ b/python/tests/cli/job/test_delete.py @@ -1,20 +1,46 @@ 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" + text = assert_help_contains( + "lightning job delete --help", + "Usage: lightning job delete [OPTIONS] NAME", + "Delete a job.", + "--yes", + "-y", ) + assert "--json" not in text + + +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.") + assert_help_contains( + "lightning jobs delete --help", + "Usage: lightning jobs delete [OPTIONS] NAME", + "Delete a job.", + "--yes", + "-y", + ) @mock_command_logging @@ -37,40 +63,6 @@ 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", ) - - -@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..61b748ec 100644 --- a/python/tests/cli/mmt/test_delete.py +++ b/python/tests/cli/mmt/test_delete.py @@ -1,7 +1,9 @@ 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,15 +11,35 @@ 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.") + assert_help_contains( + "lightning mmts delete --help", + "Usage: lightning mmts delete [OPTIONS] NAME", + "Delete a multi-machine job.", + "--yes", + "-y", + ) @mock_command_logging @@ -27,22 +49,6 @@ 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", ) - - -@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/sandbox/test_sandbox.py b/python/tests/cli/sandbox/test_sandbox.py index 4acc51ca..805d2324 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="y\n") + + assert result.exit_code == 0 + assert result.output == "Are you sure you want to delete? [y/N]: y\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"] diff --git a/python/tests/cli/studio/test_delete.py b/python/tests/cli/studio/test_delete.py index a48eeeab..0df37d8a 100644 --- a/python/tests/cli/studio/test_delete.py +++ b/python/tests/cli/studio/test_delete.py @@ -1,93 +1,57 @@ -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 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/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 new file mode 100644 index 00000000..b5f2e420 --- /dev/null +++ b/python/tests/cli/utils/test_delete.py @@ -0,0 +1,109 @@ +from typing import ClassVar, Optional + +import rich_click as click +from click.testing import CliRunner + +from lightning_sdk.cli.utils.delete import register_delete_command + + +class FakeResource: + instances: ClassVar[list["FakeResource"]] = [] + + 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_enter_aborts_without_deleting() -> None: + FakeResource.instances.clear() + result = CliRunner().invoke(_group(), ["delete", "demo"], input="\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) == ("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: + 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_does_not_offer_json_output() -> None: + FakeResource.instances.clear() + group = _group() + + 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: + calls = [] + + def resolve_delete(identifier: str, context: Optional[str]): + calls.append((identifier, context)) + return lambda: calls.append("deleted") + + group = click.Group() + register_delete_command( + group, + 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 == [("key-123", "acme"), "deleted"]