From 4d4d6193b81f136f72480da3bfef45c116accc2d Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Thu, 30 Oct 2025 11:01:03 -0700 Subject: [PATCH 1/5] feat(purge): Add ability to purge single service --- devservices/commands/purge.py | 118 +++++++++++++++++++++++++++++++++- tests/commands/test_purge.py | 101 +++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) diff --git a/devservices/commands/purge.py b/devservices/commands/purge.py index 733e1598..b4e05e62 100644 --- a/devservices/commands/purge.py +++ b/devservices/commands/purge.py @@ -6,7 +6,9 @@ from argparse import ArgumentParser from argparse import Namespace +from devservices.constants import DEPENDENCY_CONFIG_VERSION from devservices.constants import DEVSERVICES_CACHE_DIR +from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL from devservices.constants import DOCKER_NETWORK_NAME from devservices.exceptions import DockerDaemonNotRunningError @@ -19,17 +21,131 @@ from devservices.utils.docker import remove_docker_resources from devservices.utils.docker import stop_containers from devservices.utils.state import State +from devservices.utils.state import StateTables +from devservices.configs.service_config import load_service_config_from_file +from devservices.exceptions import ConfigNotFoundError +from devservices.exceptions import ConfigParseError +from devservices.exceptions import ConfigValidationError def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: parser = subparsers.add_parser("purge", help="Purge the local devservices cache") + parser.add_argument( + "service_name", + nargs="?", + help="Service name to purge (optional, purges all if not specified)", + default=None, + ) parser.set_defaults(func=purge) -def purge(_args: Namespace) -> None: +def _get_service_cache_paths(service_name: str) -> list[str]: + """Find cache directory paths for a given service name.""" + + cache_paths = [] + dependencies_cache_dir = os.path.join( + DEVSERVICES_DEPENDENCIES_CACHE_DIR, DEPENDENCY_CONFIG_VERSION + ) + + if not os.path.exists(dependencies_cache_dir): + return cache_paths + + for repo_name in os.listdir(dependencies_cache_dir): + repo_path = os.path.join(dependencies_cache_dir, repo_name) + if not os.path.isdir(repo_path): + continue + + try: + service_config = load_service_config_from_file(repo_path) + if service_config.service_name == service_name: + cache_paths.append(repo_path) + except (ConfigNotFoundError, ConfigParseError, ConfigValidationError): + # Skip invalid configs + continue + + return cache_paths + + +def purge(args: Namespace) -> None: """Purge the local devservices state and cache and remove all devservices containers and volumes.""" console = Console() + service_name = getattr(args, "service_name", None) + + if service_name: + _purge_service(service_name, console) + else: + _purge_all(console) + + +def _purge_service(service_name: str, console: Console) -> None: + """Purge a specific service.""" + state = State() + + state.remove_service_entry(service_name, StateTables.STARTED_SERVICES) + state.remove_service_entry(service_name, StateTables.STARTING_SERVICES) + state.remove_service_entry(service_name, StateTables.SERVICE_RUNTIME) + + try: + service_containers = get_matching_containers( + [ + DEVSERVICES_ORCHESTRATOR_LABEL, + f"com.docker.compose.service={service_name}", + ] + ) + except DockerDaemonNotRunningError as e: + console.warning(str(e)) + service_containers = [] + except DockerError as de: + console.failure(f"Failed to get containers for {service_name}: {de.stderr}") + exit(1) + + if len(service_containers) == 0: + console.warning(f"No containers found for {service_name}") + else: + try: + service_volumes = get_volumes_for_containers(service_containers) + except DockerError as e: + console.failure(f"Failed to get volumes for {service_name}: {e.stderr}") + exit(1) + + with Status( + lambda: console.warning(f"Stopping {service_name} containers"), + lambda: console.success(f"{service_name} containers have been stopped"), + ): + try: + stop_containers(service_containers, should_remove=True) + except DockerError as e: + console.failure(f"Failed to stop {service_name} containers: {e.stderr}") + exit(1) + + console.warning(f"Removing {service_name} docker volumes") + if len(service_volumes) == 0: + console.success(f"No volumes found for {service_name}") + else: + try: + remove_docker_resources("volume", list(service_volumes)) + console.success(f"{service_name} volumes removed") + except DockerError as e: + console.failure(f"Failed to remove {service_name} volumes: {e.stderr}") + + cache_paths = _get_service_cache_paths(service_name) + if cache_paths: + console.warning(f"Removing cache for {service_name}") + for cache_path in cache_paths: + try: + shutil.rmtree(cache_path) + except PermissionError as e: + console.failure(f"Failed to remove cache at {cache_path}: {e}") + exit(1) + console.success(f"Cache for {service_name} has been removed") + else: + console.success(f"No cache found for {service_name}") + + console.success(f"{service_name} has been purged") + +def _purge_all(console: Console) -> None: + """Purge all devservices state, cache, containers, volumes, and networks.""" if os.path.exists(DEVSERVICES_CACHE_DIR): try: shutil.rmtree(DEVSERVICES_CACHE_DIR) diff --git a/tests/commands/test_purge.py b/tests/commands/test_purge.py index e2f4d5c0..f4cd5251 100644 --- a/tests/commands/test_purge.py +++ b/tests/commands/test_purge.py @@ -6,6 +6,7 @@ import pytest +from devservices.commands.purge import _get_service_cache_paths from devservices.commands.purge import purge from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL from devservices.constants import DOCKER_NETWORK_NAME @@ -552,3 +553,103 @@ def test_purge_with_cache_and_state_and_containers_with_networks_and_volumes( mock.call("network", ["abc", "def", "ghe"]), ] ) + + +@mock.patch("devservices.commands.purge.get_matching_containers") +@mock.patch("devservices.commands.purge.get_volumes_for_containers") +@mock.patch("devservices.commands.purge.stop_containers") +@mock.patch("devservices.commands.purge.remove_docker_resources") +@mock.patch("devservices.commands.purge._get_service_cache_paths") +def test_purge_specific_service( + mock_get_service_cache_paths: mock.Mock, + mock_remove_docker_resources: mock.Mock, + mock_stop_containers: mock.Mock, + mock_get_volumes_for_containers: mock.Mock, + mock_get_matching_containers: mock.Mock, + tmp_path: Path, +) -> None: + """Test that purging a specific service removes only that service's containers, volumes, and cache.""" + mock_get_matching_containers.return_value = ["kafka-container-1", "kafka-container-2"] + mock_get_volumes_for_containers.return_value = ["kafka-volume-1", "kafka-volume-2"] + cache_path = tmp_path / "dependencies" / "v1" / "kafka-repo" + cache_path.mkdir(parents=True, exist_ok=True) + mock_get_service_cache_paths.return_value = [str(cache_path)] + + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.docker.check_docker_daemon_running", return_value=None + ), + ): + state = State() + state.update_service_entry( + "kafka", "default", StateTables.STARTED_SERVICES + ) + state.update_service_entry( + "redis", "default", StateTables.STARTED_SERVICES + ) + + assert state.get_service_entries(StateTables.STARTED_SERVICES) == [ + "kafka", "redis" + ] + assert cache_path.exists() + + purge(Namespace(service_name="kafka")) + + # Only kafka should be removed from state + assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["redis"] + # Cache path should be removed + assert not cache_path.exists() + + # Should filter containers by service name + mock_get_matching_containers.assert_called_once_with( + [ + DEVSERVICES_ORCHESTRATOR_LABEL, + "com.docker.compose.service=kafka", + ] + ) + mock_get_volumes_for_containers.assert_called_once_with( + ["kafka-container-1", "kafka-container-2"] + ) + mock_stop_containers.assert_called_once_with( + ["kafka-container-1", "kafka-container-2"], should_remove=True + ) + mock_remove_docker_resources.assert_called_once_with( + "volume", ["kafka-volume-1", "kafka-volume-2"] + ) + mock_get_service_cache_paths.assert_called_once_with("kafka") + + +@mock.patch("devservices.commands.purge.get_matching_containers") +@mock.patch("devservices.commands.purge._get_service_cache_paths") +def test_purge_specific_service_no_containers( + mock_get_service_cache_paths: mock.Mock, + mock_get_matching_containers: mock.Mock, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + """Test that purging a service with no containers or cache still removes it from state.""" + mock_get_matching_containers.return_value = [] + mock_get_service_cache_paths.return_value = [] + + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.docker.check_docker_daemon_running", return_value=None + ), + ): + state = State() + state.update_service_entry( + "kafka", "default", StateTables.STARTED_SERVICES + ) + + args = Namespace(service_name="kafka") + purge(args) + + # Service should be removed from state even if no containers found + assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] + + captured = capsys.readouterr() + assert "No containers found for kafka" in captured.out + assert "No cache found for kafka" in captured.out + assert "kafka has been purged" in captured.out From 0f329d026bbeab8504a53b0c3806222e53b82fb7 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Thu, 30 Oct 2025 11:10:26 -0700 Subject: [PATCH 2/5] lint --- devservices/commands/purge.py | 20 ++++++++++---------- tests/commands/test_purge.py | 27 ++++++++++++--------------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/devservices/commands/purge.py b/devservices/commands/purge.py index b4e05e62..7a8fcbb8 100644 --- a/devservices/commands/purge.py +++ b/devservices/commands/purge.py @@ -6,11 +6,15 @@ from argparse import ArgumentParser from argparse import Namespace +from devservices.configs.service_config import load_service_config_from_file from devservices.constants import DEPENDENCY_CONFIG_VERSION from devservices.constants import DEVSERVICES_CACHE_DIR from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL from devservices.constants import DOCKER_NETWORK_NAME +from devservices.exceptions import ConfigNotFoundError +from devservices.exceptions import ConfigParseError +from devservices.exceptions import ConfigValidationError from devservices.exceptions import DockerDaemonNotRunningError from devservices.exceptions import DockerError from devservices.utils.console import Console @@ -22,10 +26,6 @@ from devservices.utils.docker import stop_containers from devservices.utils.state import State from devservices.utils.state import StateTables -from devservices.configs.service_config import load_service_config_from_file -from devservices.exceptions import ConfigNotFoundError -from devservices.exceptions import ConfigParseError -from devservices.exceptions import ConfigValidationError def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: @@ -42,19 +42,19 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def _get_service_cache_paths(service_name: str) -> list[str]: """Find cache directory paths for a given service name.""" - cache_paths = [] + cache_paths: list[str] = [] dependencies_cache_dir = os.path.join( DEVSERVICES_DEPENDENCIES_CACHE_DIR, DEPENDENCY_CONFIG_VERSION ) - + if not os.path.exists(dependencies_cache_dir): return cache_paths - + for repo_name in os.listdir(dependencies_cache_dir): repo_path = os.path.join(dependencies_cache_dir, repo_name) if not os.path.isdir(repo_path): continue - + try: service_config = load_service_config_from_file(repo_path) if service_config.service_name == service_name: @@ -62,7 +62,7 @@ def _get_service_cache_paths(service_name: str) -> list[str]: except (ConfigNotFoundError, ConfigParseError, ConfigValidationError): # Skip invalid configs continue - + return cache_paths @@ -80,7 +80,7 @@ def purge(args: Namespace) -> None: def _purge_service(service_name: str, console: Console) -> None: """Purge a specific service.""" state = State() - + state.remove_service_entry(service_name, StateTables.STARTED_SERVICES) state.remove_service_entry(service_name, StateTables.STARTING_SERVICES) state.remove_service_entry(service_name, StateTables.SERVICE_RUNTIME) diff --git a/tests/commands/test_purge.py b/tests/commands/test_purge.py index f4cd5251..7e59d0d6 100644 --- a/tests/commands/test_purge.py +++ b/tests/commands/test_purge.py @@ -6,7 +6,6 @@ import pytest -from devservices.commands.purge import _get_service_cache_paths from devservices.commands.purge import purge from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL from devservices.constants import DOCKER_NETWORK_NAME @@ -569,12 +568,15 @@ def test_purge_specific_service( tmp_path: Path, ) -> None: """Test that purging a specific service removes only that service's containers, volumes, and cache.""" - mock_get_matching_containers.return_value = ["kafka-container-1", "kafka-container-2"] + mock_get_matching_containers.return_value = [ + "kafka-container-1", + "kafka-container-2", + ] mock_get_volumes_for_containers.return_value = ["kafka-volume-1", "kafka-volume-2"] cache_path = tmp_path / "dependencies" / "v1" / "kafka-repo" cache_path.mkdir(parents=True, exist_ok=True) mock_get_service_cache_paths.return_value = [str(cache_path)] - + with ( mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), mock.patch( @@ -582,15 +584,12 @@ def test_purge_specific_service( ), ): state = State() - state.update_service_entry( - "kafka", "default", StateTables.STARTED_SERVICES - ) - state.update_service_entry( - "redis", "default", StateTables.STARTED_SERVICES - ) + state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES) + state.update_service_entry("redis", "default", StateTables.STARTED_SERVICES) assert state.get_service_entries(StateTables.STARTED_SERVICES) == [ - "kafka", "redis" + "kafka", + "redis", ] assert cache_path.exists() @@ -631,7 +630,7 @@ def test_purge_specific_service_no_containers( """Test that purging a service with no containers or cache still removes it from state.""" mock_get_matching_containers.return_value = [] mock_get_service_cache_paths.return_value = [] - + with ( mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), mock.patch( @@ -639,16 +638,14 @@ def test_purge_specific_service_no_containers( ), ): state = State() - state.update_service_entry( - "kafka", "default", StateTables.STARTED_SERVICES - ) + state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES) args = Namespace(service_name="kafka") purge(args) # Service should be removed from state even if no containers found assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] - + captured = capsys.readouterr() assert "No containers found for kafka" in captured.out assert "No cache found for kafka" in captured.out From d3d5e5013ad56fa540097066b4c3592dc9570575 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 4 Nov 2025 10:18:00 -0800 Subject: [PATCH 3/5] feedback --- devservices/commands/purge.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/devservices/commands/purge.py b/devservices/commands/purge.py index 7a8fcbb8..32672108 100644 --- a/devservices/commands/purge.py +++ b/devservices/commands/purge.py @@ -81,8 +81,17 @@ def _purge_service(service_name: str, console: Console) -> None: """Purge a specific service.""" state = State() - state.remove_service_entry(service_name, StateTables.STARTED_SERVICES) - state.remove_service_entry(service_name, StateTables.STARTING_SERVICES) + # Check if service is currently running or starting + started_services = state.get_service_entries(StateTables.STARTED_SERVICES) + starting_services = state.get_service_entries(StateTables.STARTING_SERVICES) + + if service_name in started_services or service_name in starting_services: + console.failure( + f"Cannot purge {service_name} while it is running or starting. " + f"Please stop the service first using 'devservices down {service_name}'" + ) + exit(1) + state.remove_service_entry(service_name, StateTables.SERVICE_RUNTIME) try: From 656f9ba8d515ab0720d04a09f514264ee779186a Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 4 Nov 2025 14:37:26 -0800 Subject: [PATCH 4/5] update tests --- devservices/commands/purge.py | 2 +- tests/commands/test_purge.py | 60 ++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/devservices/commands/purge.py b/devservices/commands/purge.py index 32672108..c967ed05 100644 --- a/devservices/commands/purge.py +++ b/devservices/commands/purge.py @@ -84,7 +84,7 @@ def _purge_service(service_name: str, console: Console) -> None: # Check if service is currently running or starting started_services = state.get_service_entries(StateTables.STARTED_SERVICES) starting_services = state.get_service_entries(StateTables.STARTING_SERVICES) - + if service_name in started_services or service_name in starting_services: console.failure( f"Cannot purge {service_name} while it is running or starting. " diff --git a/tests/commands/test_purge.py b/tests/commands/test_purge.py index 7e59d0d6..93d1271f 100644 --- a/tests/commands/test_purge.py +++ b/tests/commands/test_purge.py @@ -584,18 +584,16 @@ def test_purge_specific_service( ), ): state = State() - state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES) + # Don't add kafka to STARTED_SERVICES - it should be stopped before purging + # Add redis to verify it's not affected by kafka purge state.update_service_entry("redis", "default", StateTables.STARTED_SERVICES) - assert state.get_service_entries(StateTables.STARTED_SERVICES) == [ - "kafka", - "redis", - ] + assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["redis"] assert cache_path.exists() purge(Namespace(service_name="kafka")) - # Only kafka should be removed from state + # redis should still be in state (unaffected) assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["redis"] # Cache path should be removed assert not cache_path.exists() @@ -638,15 +636,61 @@ def test_purge_specific_service_no_containers( ), ): state = State() - state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES) + # Don't add kafka to STARTED_SERVICES - it should be stopped before purging args = Namespace(service_name="kafka") purge(args) - # Service should be removed from state even if no containers found + # State should remain empty (kafka was never added) assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] captured = capsys.readouterr() assert "No containers found for kafka" in captured.out assert "No cache found for kafka" in captured.out assert "kafka has been purged" in captured.out + + +def test_purge_specific_service_fails_if_started( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + """Test that purging a service that is in STARTED_SERVICES fails with an error.""" + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES) + + args = Namespace(service_name="kafka") + with pytest.raises(SystemExit) as exc_info: + purge(args) + + assert exc_info.value.code == 1 + + # Service should still be in state (purge was blocked) + assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["kafka"] + + captured = capsys.readouterr() + assert "Cannot purge kafka while it is running or starting" in captured.out + assert "Please stop the service first" in captured.out + + +def test_purge_specific_service_fails_if_starting( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + """Test that purging a service that is in STARTING_SERVICES fails with an error.""" + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry("kafka", "default", StateTables.STARTING_SERVICES) + + args = Namespace(service_name="kafka") + with pytest.raises(SystemExit) as exc_info: + purge(args) + + assert exc_info.value.code == 1 + + # Service should still be in state (purge was blocked) + assert state.get_service_entries(StateTables.STARTING_SERVICES) == ["kafka"] + + captured = capsys.readouterr() + assert "Cannot purge kafka while it is running or starting" in captured.out + assert "Please stop the service first" in captured.out From cfb1536310361c461fd8442b413653dbf8ad83ff Mon Sep 17 00:00:00 2001 From: Hubert Deng Date: Fri, 7 Nov 2025 16:21:04 -0800 Subject: [PATCH 5/5] purge specific service warning prompt --- devservices/commands/purge.py | 18 +++++----- tests/commands/test_purge.py | 64 ++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/devservices/commands/purge.py b/devservices/commands/purge.py index c967ed05..712d4427 100644 --- a/devservices/commands/purge.py +++ b/devservices/commands/purge.py @@ -81,16 +81,14 @@ def _purge_service(service_name: str, console: Console) -> None: """Purge a specific service.""" state = State() - # Check if service is currently running or starting - started_services = state.get_service_entries(StateTables.STARTED_SERVICES) - starting_services = state.get_service_entries(StateTables.STARTING_SERVICES) - - if service_name in started_services or service_name in starting_services: - console.failure( - f"Cannot purge {service_name} while it is running or starting. " - f"Please stop the service first using 'devservices down {service_name}'" - ) - exit(1) + # Warn user about potential dependency issues + if not console.confirm( + f"WARNING: Purging {service_name} may introduce issues with the dependency tree.\n" + "Other services that depend on this service may stop working correctly.\n" + "Do you want to continue?" + ): + console.info("Purge cancelled.") + return state.remove_service_entry(service_name, StateTables.SERVICE_RUNTIME) diff --git a/tests/commands/test_purge.py b/tests/commands/test_purge.py index 93d1271f..22bf0ac7 100644 --- a/tests/commands/test_purge.py +++ b/tests/commands/test_purge.py @@ -554,6 +554,7 @@ def test_purge_with_cache_and_state_and_containers_with_networks_and_volumes( ) +@mock.patch("devservices.commands.purge.Console.confirm") @mock.patch("devservices.commands.purge.get_matching_containers") @mock.patch("devservices.commands.purge.get_volumes_for_containers") @mock.patch("devservices.commands.purge.stop_containers") @@ -565,9 +566,11 @@ def test_purge_specific_service( mock_stop_containers: mock.Mock, mock_get_volumes_for_containers: mock.Mock, mock_get_matching_containers: mock.Mock, + mock_console_confirm: mock.Mock, tmp_path: Path, ) -> None: """Test that purging a specific service removes only that service's containers, volumes, and cache.""" + mock_console_confirm.return_value = True mock_get_matching_containers.return_value = [ "kafka-container-1", "kafka-container-2", @@ -617,15 +620,18 @@ def test_purge_specific_service( mock_get_service_cache_paths.assert_called_once_with("kafka") +@mock.patch("devservices.commands.purge.Console.confirm") @mock.patch("devservices.commands.purge.get_matching_containers") @mock.patch("devservices.commands.purge._get_service_cache_paths") def test_purge_specific_service_no_containers( mock_get_service_cache_paths: mock.Mock, mock_get_matching_containers: mock.Mock, + mock_console_confirm: mock.Mock, capsys: pytest.CaptureFixture[str], tmp_path: Path, ) -> None: """Test that purging a service with no containers or cache still removes it from state.""" + mock_console_confirm.return_value = True mock_get_matching_containers.return_value = [] mock_get_service_cache_paths.return_value = [] @@ -650,47 +656,43 @@ def test_purge_specific_service_no_containers( assert "kafka has been purged" in captured.out -def test_purge_specific_service_fails_if_started( +@mock.patch("devservices.commands.purge.Console.confirm") +@mock.patch("devservices.commands.purge.get_matching_containers") +@mock.patch("devservices.commands.purge._get_service_cache_paths") +def test_purge_specific_service_cancelled_by_user( + mock_get_service_cache_paths: mock.Mock, + mock_get_matching_containers: mock.Mock, + mock_console_confirm: mock.Mock, capsys: pytest.CaptureFixture[str], tmp_path: Path, ) -> None: - """Test that purging a service that is in STARTED_SERVICES fails with an error.""" - with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + """Test that purging a service can be cancelled by the user.""" + mock_console_confirm.return_value = False + mock_get_matching_containers.return_value = [] + mock_get_service_cache_paths.return_value = [] + + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.docker.check_docker_daemon_running", return_value=None + ), + ): state = State() + # Add kafka to state state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES) args = Namespace(service_name="kafka") - with pytest.raises(SystemExit) as exc_info: - purge(args) - - assert exc_info.value.code == 1 + purge(args) - # Service should still be in state (purge was blocked) + # Service should still be in state (purge was cancelled) assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["kafka"] - captured = capsys.readouterr() - assert "Cannot purge kafka while it is running or starting" in captured.out - assert "Please stop the service first" in captured.out - - -def test_purge_specific_service_fails_if_starting( - capsys: pytest.CaptureFixture[str], - tmp_path: Path, -) -> None: - """Test that purging a service that is in STARTING_SERVICES fails with an error.""" - with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): - state = State() - state.update_service_entry("kafka", "default", StateTables.STARTING_SERVICES) - - args = Namespace(service_name="kafka") - with pytest.raises(SystemExit) as exc_info: - purge(args) - - assert exc_info.value.code == 1 + # Should have prompted user + mock_console_confirm.assert_called_once() - # Service should still be in state (purge was blocked) - assert state.get_service_entries(StateTables.STARTING_SERVICES) == ["kafka"] + # Should not have attempted to get containers + mock_get_matching_containers.assert_not_called() + mock_get_service_cache_paths.assert_not_called() captured = capsys.readouterr() - assert "Cannot purge kafka while it is running or starting" in captured.out - assert "Please stop the service first" in captured.out + assert "Purge cancelled." in captured.out