You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ri...@apache.org on 2026/09/14 22:11:35 UTC
(superset) branch master updated: chore(cli): direct tests for the deletion-retention click entry points; non-zero exit on blocked/not-found force-purge (#44252)
This is an automated email from the ASF dual-hosted git repository.
richardfogaca pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c83c95faf63 chore(cli): direct tests for the deletion-retention click entry points; non-zero exit on blocked/not-found force-purge (#44252)
c83c95faf63 is described below
commit c83c95faf636d3f0855804c3cc36fbb9de1d34f5
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Mon Sep 14 16:11:28 2026 -0600
chore(cli): direct tests for the deletion-retention click entry points; non-zero exit on blocked/not-found force-purge (#44252)
Co-authored-by: Mike Bridge <mi...@ext.preset.io>
Co-authored-by: Claude Fable 5 <no...@anthropic.com>
---
UPDATING.md | 5 +
superset/cli/deletion_retention.py | 6 +-
tests/unit_tests/cli/deletion_retention_test.py | 389 ++++++++++++++++++++++++
3 files changed, 399 insertions(+), 1 deletion(-)
diff --git a/UPDATING.md b/UPDATING.md
index 951f2e0cf33..48f03c93a5a 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,11 @@ assists people when migrating to a new version.
## Next
+- `superset deletion-retention force-purge` now exits **1** when the target is
+ blocked by a deletion rule or is not found (the messages are unchanged), so a
+ scripted compliance erasure cannot mistake a refusal for a completed purge.
+ Only a completed purge exits 0; a usage error still exits 2.
+
### Default Docker image is now batteries-included; the minimal image moves to `-lean`
The default `apache/superset` Docker image (the plain tags: `latest`, `master`,
diff --git a/superset/cli/deletion_retention.py b/superset/cli/deletion_retention.py
index 79453dbb2b3..e294ad23755 100644
--- a/superset/cli/deletion_retention.py
+++ b/superset/cli/deletion_retention.py
@@ -155,7 +155,11 @@ def force_purge(uuid: UUID, entity_type: str | None) -> None:
)
else:
click.echo(f"No entity found for uuid={uuid} (nothing to purge).")
- return
+ # A refused or absent target is not a completed purge: exit non-zero
+ # so an operator's script (a compliance-erasure runbook) cannot mistake
+ # it for success. The message above is the contract; only the exit
+ # status changed.
+ raise click.exceptions.Exit(1)
click.echo(
f"Purged {result['entity_type']} uuid={uuid}. "
f"Dangling charts: {len(result.get('dangling_chart_uuids') or [])}; "
diff --git a/tests/unit_tests/cli/deletion_retention_test.py b/tests/unit_tests/cli/deletion_retention_test.py
new file mode 100644
index 00000000000..9bff7a5a6bf
--- /dev/null
+++ b/tests/unit_tests/cli/deletion_retention_test.py
@@ -0,0 +1,389 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Direct tests for the ``superset deletion-retention`` click entry points.
+
+The command classes underneath (``ForcePurgeCommand``,
+``resolve_retention_window``) have their own coverage; what was untested was
+the CLI surface itself — argument and option parsing, the irreversible
+confirmation prompt on ``force-purge`` (an operator-facing destructive
+command, so a regression that skips or mis-wires the prompt is the serious
+case), the operator-visible output the CLI is the only consumer of, and exit
+codes. Everything is driven through ``CliRunner`` with the underlying command
+objects mocked at their import source, so these stay unit-scoped.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+from uuid import UUID, uuid4
+
+import click
+import pytest
+from click.testing import CliRunner
+from pytest_mock import MockerFixture
+
+from superset.cli.deletion_retention import (
+ _resolve_model,
+ deletion_retention,
+ force_purge,
+ set_window,
+ show_window,
+)
+from superset.commands.deletion_retention.force_purge import (
+ AmbiguousPurgeTargetError,
+)
+
+_UUID: UUID = uuid4()
+_PROMPT: str = "Force-purge is irreversible"
+
+
+# ---- group ----------------------------------------------------------------
+
+
+def test_group_registers_the_three_commands(app_context: None) -> None:
+ """The operator surface is exactly set-window, show-window, force-purge."""
+ assert set(deletion_retention.commands) == {
+ "set-window",
+ "show-window",
+ "force-purge",
+ }
+
+
+# ---- set-window -----------------------------------------------------------
+
+
+@pytest.mark.parametrize("days", [30, 0, 3650])
+def test_set_window_upserts_the_shared_value_and_reports_it(
+ days: int, mocker: MockerFixture, app_context: None
+) -> None:
+ """``--days N`` is written through the shared-value upsert (0 disables)."""
+ from superset.key_value.types import SharedKey
+
+ upsert = mocker.patch("superset.key_value.shared_entries.upsert_shared_value")
+
+ result = CliRunner().invoke(set_window, ["--days", str(days)])
+
+ assert result.exit_code == 0, result.output
+ upsert.assert_called_once_with(SharedKey.SOFT_DELETE_RETENTION_DAYS, days)
+ assert f"set to {days} day(s)" in result.output
+
+
+def test_set_window_short_option(mocker: MockerFixture, app_context: None) -> None:
+ upsert = mocker.patch("superset.key_value.shared_entries.upsert_shared_value")
+
+ result = CliRunner().invoke(set_window, ["-d", "7"])
+
+ assert result.exit_code == 0, result.output
+ assert upsert.call_args.args[1] == 7
+
+
+def test_set_window_rejects_a_negative_window_before_writing(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """A negative value is a usage error (exit 2) and nothing is written."""
+ upsert = mocker.patch("superset.key_value.shared_entries.upsert_shared_value")
+
+ result = CliRunner().invoke(set_window, ["--days", "-1"])
+
+ assert result.exit_code == 2
+ assert "--days must be >= 0" in result.output
+ upsert.assert_not_called()
+
+
+@pytest.mark.parametrize("argv", [[], ["--days", "thirty"], ["--days", "1.5"]])
+def test_set_window_rejects_missing_or_non_integer_days(
+ argv: list[str], mocker: MockerFixture, app_context: None
+) -> None:
+ """Missing or non-integer ``--days`` never reaches the upsert."""
+ upsert = mocker.patch("superset.key_value.shared_entries.upsert_shared_value")
+
+ result = CliRunner().invoke(set_window, argv)
+
+ assert result.exit_code == 2
+ upsert.assert_not_called()
+
+
+# ---- show-window ----------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "days, expected",
+ [(30, "30 day(s)"), (1, "1 day(s)"), (0, "disabled")],
+)
+def test_show_window_prints_the_effective_window(
+ days: int, expected: str, mocker: MockerFixture, app_context: None
+) -> None:
+ """The effective window is reported as days, or ``disabled`` for zero."""
+ mocker.patch(
+ "superset.commands.deletion_retention.window.resolve_retention_window",
+ return_value=days,
+ )
+
+ result = CliRunner().invoke(show_window)
+
+ assert result.exit_code == 0, result.output
+ assert f"Effective soft-delete retention window: {expected}." in result.output
+
+
+# ---- force-purge: parsing --------------------------------------------------
+
+
+def _purge_command(mocker: MockerFixture, result: object) -> MagicMock:
+ """Patch ``ForcePurgeCommand`` at its import source; ``run()`` -> *result*."""
+ command_cls = mocker.patch(
+ "superset.commands.deletion_retention.force_purge.ForcePurgeCommand"
+ )
+ if isinstance(result, BaseException):
+ command_cls.return_value.run.side_effect = result
+ else:
+ command_cls.return_value.run.return_value = result
+ return command_cls
+
+
+def test_force_purge_rejects_a_malformed_uuid_before_the_prompt(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """A bad ``--uuid`` fails as a clean usage error up front: the operator
+ is never asked to confirm an irreversible action for an invalid target,
+ and the command is never constructed."""
+ command_cls = _purge_command(mocker, {"purged": True})
+
+ result = CliRunner().invoke(force_purge, ["--uuid", "not-a-uuid", "--yes"])
+
+ assert result.exit_code == 2
+ assert "not-a-uuid" in result.output
+ assert _PROMPT not in result.output
+ command_cls.assert_not_called()
+
+
+def test_force_purge_requires_a_uuid(mocker: MockerFixture, app_context: None) -> None:
+ command_cls = _purge_command(mocker, {"purged": True})
+
+ result = CliRunner().invoke(force_purge, ["--yes"])
+
+ assert result.exit_code == 2
+ assert "Missing option" in result.output
+ command_cls.assert_not_called()
+
+
+def test_force_purge_rejects_an_unknown_type(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ command_cls = _purge_command(mocker, {"purged": True})
+
+ result = CliRunner().invoke(
+ force_purge, ["--uuid", str(_UUID), "--type", "widget", "--yes"]
+ )
+
+ assert result.exit_code == 2
+ assert "widget" in result.output
+ command_cls.assert_not_called()
+
+
+# ---- force-purge: the confirmation prompt -----------------------------------
+
+
+def test_force_purge_prompts_and_aborts_on_no_answer(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """Without ``--yes`` the irreversible prompt is shown, and an empty answer
+ aborts without constructing the command."""
+ command_cls = _purge_command(mocker, {"purged": True})
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID)], input="\n")
+
+ assert result.exit_code == 1
+ assert _PROMPT in result.output
+ assert "Aborted" in result.output
+ command_cls.assert_not_called()
+
+
+def test_force_purge_prompt_declined_does_not_purge(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ command_cls = _purge_command(mocker, {"purged": True})
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID)], input="n\n")
+
+ assert result.exit_code == 1
+ assert "Aborted" in result.output
+ command_cls.assert_not_called()
+
+
+def test_force_purge_prompt_accepted_runs_the_purge(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ command_cls = _purge_command(mocker, {"purged": True, "entity_type": "slices"})
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID)], input="y\n")
+
+ assert result.exit_code == 0, result.output
+ assert _PROMPT in result.output
+ command_cls.assert_called_once_with(str(_UUID), model_cls=None)
+ command_cls.return_value.run.assert_called_once_with()
+
+
+def test_force_purge_yes_bypasses_the_prompt(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """``--yes`` is the scripted bypass: no prompt text, the purge runs."""
+ command_cls = _purge_command(mocker, {"purged": True, "entity_type": "slices"})
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID), "--yes"])
+
+ assert result.exit_code == 0, result.output
+ assert _PROMPT not in result.output
+ command_cls.return_value.run.assert_called_once_with()
+
+
+# ---- force-purge: --type resolution ----------------------------------------
+
+
+@pytest.mark.parametrize(
+ "entity_type, tablename",
+ [("chart", "slices"), ("dashboard", "dashboards"), ("dataset", "tables")],
+)
+def test_resolve_model_maps_each_type_to_its_soft_delete_model(
+ entity_type: str, tablename: str, app_context: None
+) -> None:
+ model = _resolve_model(entity_type)
+ assert model is not None
+ assert getattr(model, "__tablename__", None) == tablename
+
+
+def test_resolve_model_none_means_search_every_model(app_context: None) -> None:
+ assert _resolve_model(None) is None
+
+
+def test_resolve_model_is_case_insensitive(app_context: None) -> None:
+ assert getattr(_resolve_model("Dashboard"), "__tablename__", None) == "dashboards"
+
+
+def test_resolve_model_reports_a_map_drift_as_an_operator_error(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """If the type map names a table no registered model carries, the CLI
+ reports it cleanly rather than returning ``None`` (which would silently
+ widen the purge to every model)."""
+ mocker.patch.dict(
+ "superset.cli.deletion_retention._PURGE_TYPES", {"chart": "renamed_table"}
+ )
+ with pytest.raises(click.ClickException, match="No soft-delete model"):
+ _resolve_model("chart")
+
+
+def test_force_purge_passes_the_resolved_model_to_the_command(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ from superset.models.slice import Slice
+
+ command_cls = _purge_command(mocker, {"purged": True, "entity_type": "slices"})
+
+ result = CliRunner().invoke(
+ force_purge, ["--uuid", str(_UUID), "--type", "CHART", "--yes"]
+ )
+
+ assert result.exit_code == 0, result.output
+ command_cls.assert_called_once_with(str(_UUID), model_cls=Slice)
+
+
+# ---- force-purge: outcomes and exit codes ----------------------------------
+
+
+def test_force_purge_reports_a_successful_purge_with_its_counts(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ _purge_command(
+ mocker,
+ {
+ "purged": True,
+ "entity_type": "tables",
+ "dangling_chart_uuids": ["a", "b", "c"],
+ "removed_dashboard_slices": 4,
+ "version_rows_removed": 12,
+ },
+ )
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID), "--yes"])
+
+ assert result.exit_code == 0, result.output
+ assert f"Purged tables uuid={_UUID}." in result.output
+ assert "Dangling charts: 3;" in result.output
+ assert "dashboard_slices removed: 4;" in result.output
+ assert "version rows removed: 12." in result.output
+
+
+def test_force_purge_success_output_tolerates_missing_counts(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """A result carrying only ``purged``/``entity_type`` still renders."""
+ _purge_command(mocker, {"purged": True, "entity_type": "slices"})
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID), "--yes"])
+
+ assert result.exit_code == 0, result.output
+ assert "Dangling charts: 0;" in result.output
+ assert "dashboard_slices removed: 0;" in result.output
+ assert "version rows removed: 0." in result.output
+
+
+def test_force_purge_reports_a_blocked_purge(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """A purge refused by the deletion rules names the reason and exits 1.
+
+ A blocked compliance purge is not a success: the message stays on stdout
+ unchanged, and the non-zero exit lets an operator's script tell it apart
+ from a completed purge.
+ """
+ _purge_command(
+ mocker,
+ {"purged": False, "reason": "blocked", "blocked_reason": "report_schedule"},
+ )
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID), "--yes"])
+
+ assert result.exit_code == 1, result.output
+ assert f"Entity uuid={_UUID} was not purged" in result.output
+ assert "report_schedule" in result.output
+
+
+def test_force_purge_reports_nothing_to_purge(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """Nothing to purge is reported and, like a refusal, exits 1."""
+ _purge_command(mocker, {"purged": False, "reason": "not_found"})
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID), "--yes"])
+
+ assert result.exit_code == 1, result.output
+ assert f"No entity found for uuid={_UUID} (nothing to purge)." in result.output
+
+
+def test_force_purge_reports_ambiguity_as_an_operator_error(
+ mocker: MockerFixture, app_context: None
+) -> None:
+ """An ambiguous bare UUID surfaces as a clean error naming ``--type``
+ (exit 1), not a traceback — it lands after the prompt was answered."""
+ _purge_command(mocker, AmbiguousPurgeTargetError("Ambiguous purge target."))
+
+ result = CliRunner().invoke(force_purge, ["--uuid", str(_UUID), "--yes"])
+
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "Ambiguous purge target." in result.output
+ assert "Re-run with --type, e.g. --type chart." in result.output