Skip to content

Commit 06b683a

Browse files
authored
feat(purge): Adding state clear functionality to purge (#125)
* feat(purge): Adding state clear functionality to purge * Adding docker daemon check for purge * Adding docker tests * Adding a test with no running containers * Addressing review feedback * Addressing review feedback
1 parent 5c7343c commit 06b683a

File tree

7 files changed

+214
-13
lines changed

7 files changed

+214
-13
lines changed

devservices/commands/purge.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from argparse import Namespace
88

99
from devservices.constants import DEVSERVICES_CACHE_DIR
10+
from devservices.exceptions import DockerDaemonNotRunningError
1011
from devservices.utils.console import Console
12+
from devservices.utils.console import Status
13+
from devservices.utils.docker import stop_all_running_containers
14+
from devservices.utils.state import State
1115

1216

1317
def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
@@ -18,10 +22,29 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
1822
def purge(args: Namespace) -> None:
1923
"""Purge the local devservices cache."""
2024
console = Console()
25+
# Prompt the user to stop all running containers
26+
should_stop_containers = console.confirm(
27+
"Warning: Purging stops all running containers and clears devservices state. Would you like to continue?"
28+
)
29+
if not should_stop_containers:
30+
console.warning("Purge canceled")
31+
return
32+
2133
if os.path.exists(DEVSERVICES_CACHE_DIR):
2234
try:
2335
shutil.rmtree(DEVSERVICES_CACHE_DIR)
2436
except PermissionError as e:
2537
console.failure(f"Failed to purge cache: {e}")
2638
exit(1)
27-
console.success("The local devservices cache has been purged")
39+
state = State()
40+
state.clear_state()
41+
with Status(
42+
lambda: console.warning("Stopping all running containers"),
43+
lambda: console.success("All running containers have been stopped"),
44+
):
45+
try:
46+
stop_all_running_containers()
47+
except DockerDaemonNotRunningError:
48+
console.warning("The docker daemon not running, no containers to stop")
49+
50+
console.success("The local devservices cache and state has been purged")

devservices/exceptions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ class DevservicesUpdateError(BinaryInstallError):
4646
class DockerDaemonNotRunningError(Exception):
4747
"""Raised when the Docker daemon is not running."""
4848

49-
pass
49+
def __str__(self) -> str:
50+
# TODO: Provide explicit instructions on what to do
51+
return "Unable to connect to the docker daemon. Is the docker daemon running?"
5052

5153

5254
class DockerComposeInstallationError(BinaryInstallError):

devservices/utils/console.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class Color:
1414
RED = "\033[0;31m"
1515
GREEN = "\033[0;32m"
1616
YELLOW = "\033[0;33m"
17+
BLUE = "\033[0;34m"
1718
BOLD = "\033[1m"
1819
UNDERLINE = "\033[4m"
1920
NEGATIVE = "\033[7m"
@@ -46,6 +47,11 @@ def warning(self, message: str, bold: bool = False) -> None:
4647
def info(self, message: str, bold: bool = False) -> None:
4748
self.print(message=message, color="", bold=bold)
4849

50+
def confirm(self, message: str) -> bool:
51+
self.warning(message=message, bold=True)
52+
response = input("(Y/n): ").strip().lower()
53+
return response in ("y", "yes", "")
54+
4955

5056
class Status:
5157
"""Shows loading status in the terminal."""

devservices/utils/docker.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77

88
def check_docker_daemon_running() -> None:
9+
"""Checks if the Docker daemon is running. Raises DockerDaemonNotRunningError if not."""
910
try:
1011
subprocess.run(
1112
["docker", "info"],
@@ -14,6 +15,22 @@ def check_docker_daemon_running() -> None:
1415
check=True,
1516
)
1617
except subprocess.CalledProcessError as e:
17-
raise DockerDaemonNotRunningError(
18-
"Unable to connect to the docker daemon. Is the docker daemon running?"
19-
) from e
18+
raise DockerDaemonNotRunningError from e
19+
20+
21+
def stop_all_running_containers() -> None:
22+
check_docker_daemon_running()
23+
running_containers = (
24+
subprocess.check_output(["docker", "ps", "-q"], stderr=subprocess.DEVNULL)
25+
.decode()
26+
.strip()
27+
.splitlines()
28+
)
29+
if len(running_containers) == 0:
30+
return
31+
subprocess.run(
32+
["docker", "stop"] + running_containers,
33+
check=True,
34+
stdout=subprocess.DEVNULL,
35+
stderr=subprocess.DEVNULL,
36+
)

devservices/utils/state.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,12 @@ def get_mode_for_service(self, service_name: str) -> str | None:
7979
if result is None:
8080
return None
8181
return str(result[0])
82+
83+
def clear_state(self) -> None:
84+
cursor = self.conn.cursor()
85+
cursor.execute(
86+
"""
87+
DELETE FROM started_services
88+
"""
89+
)
90+
self.conn.commit()

tests/commands/test_purge.py

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,132 @@
11
from __future__ import annotations
22

3+
import builtins
34
from argparse import Namespace
45
from pathlib import Path
56
from unittest import mock
67

78
from devservices.commands.purge import purge
9+
from devservices.utils.state import State
810

911

10-
def test_purge_no_cache(tmp_path: Path) -> None:
11-
with mock.patch(
12-
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
13-
str(tmp_path / ".devservices-cache"),
12+
@mock.patch("devservices.commands.purge.stop_all_running_containers")
13+
def test_purge_not_confirmed(
14+
mock_stop_all_running_containers: mock.Mock, tmp_path: Path
15+
) -> None:
16+
with (
17+
mock.patch(
18+
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
19+
str(tmp_path / ".devservices-cache"),
20+
),
21+
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
22+
mock.patch.object(builtins, "input", lambda _: "no"),
1423
):
1524
args = Namespace()
1625
purge(args)
1726

27+
mock_stop_all_running_containers.assert_not_called()
1828

19-
def test_purge_with_cache(tmp_path: Path) -> None:
20-
with mock.patch(
21-
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
22-
str(tmp_path / ".devservices-cache"),
29+
30+
@mock.patch("devservices.commands.purge.stop_all_running_containers")
31+
def test_purge_with_cache_and_state_and_no_running_containers_confirmed(
32+
mock_stop_all_running_containers: mock.Mock, tmp_path: Path
33+
) -> None:
34+
with (
35+
mock.patch(
36+
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
37+
str(tmp_path / ".devservices-cache"),
38+
),
39+
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
40+
mock.patch.object(builtins, "input", lambda _: "yes"),
41+
mock.patch(
42+
"devservices.utils.docker.check_docker_daemon_running", return_value=None
43+
),
2344
):
2445
# Create a cache file to test purging
2546
cache_dir = tmp_path / ".devservices-cache"
2647
cache_dir.mkdir(parents=True, exist_ok=True)
2748
cache_file = tmp_path / ".devservices-cache" / "test.txt"
2849
cache_file.write_text("This is a test cache file.")
2950

51+
state = State()
52+
state.add_started_service("test-service", "test-mode")
53+
3054
assert cache_file.exists()
55+
assert state.get_started_services() == ["test-service"]
3156

3257
args = Namespace()
3358
purge(args)
3459

3560
assert not cache_file.exists()
61+
assert state.get_started_services() == []
62+
63+
mock_stop_all_running_containers.assert_called_once()
64+
65+
66+
@mock.patch("devservices.commands.purge.stop_all_running_containers")
67+
def test_purge_with_cache_and_state_and_running_containers_confirmed(
68+
mock_stop_all_running_containers: mock.Mock, tmp_path: Path
69+
) -> None:
70+
with (
71+
mock.patch(
72+
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
73+
str(tmp_path / ".devservices-cache"),
74+
),
75+
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
76+
mock.patch.object(builtins, "input", lambda _: "yes"),
77+
mock.patch(
78+
"devservices.utils.docker.check_docker_daemon_running", return_value=None
79+
),
80+
):
81+
# Create a cache file to test purging
82+
cache_dir = tmp_path / ".devservices-cache"
83+
cache_dir.mkdir(parents=True, exist_ok=True)
84+
cache_file = tmp_path / ".devservices-cache" / "test.txt"
85+
cache_file.write_text("This is a test cache file.")
86+
87+
state = State()
88+
state.add_started_service("test-service", "test-mode")
89+
90+
assert cache_file.exists()
91+
assert state.get_started_services() == ["test-service"]
92+
93+
args = Namespace()
94+
purge(args)
95+
96+
assert not cache_file.exists()
97+
assert state.get_started_services() == []
98+
99+
mock_stop_all_running_containers.assert_called_once()
100+
101+
102+
@mock.patch("devservices.commands.purge.stop_all_running_containers")
103+
def test_purge_with_cache_and_state_and_running_containers_not_confirmed(
104+
mock_stop_all_running_containers: mock.Mock, tmp_path: Path
105+
) -> None:
106+
with (
107+
mock.patch(
108+
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
109+
str(tmp_path / ".devservices-cache"),
110+
),
111+
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
112+
mock.patch.object(builtins, "input", lambda _: "no"),
113+
mock.patch(
114+
"devservices.utils.docker.check_docker_daemon_running", return_value=None
115+
),
116+
):
117+
# Create a cache file to test purging
118+
cache_dir = tmp_path / ".devservices-cache"
119+
cache_dir.mkdir(parents=True, exist_ok=True)
120+
cache_file = tmp_path / ".devservices-cache" / "test.txt"
121+
cache_file.write_text("This is a test cache file.")
122+
123+
state = State()
124+
state.add_started_service("test-service", "test-mode")
125+
126+
args = Namespace()
127+
purge(args)
128+
129+
assert cache_file.exists()
130+
assert state.get_started_services() == ["test-service"]
131+
132+
mock_stop_all_running_containers.assert_not_called()

tests/utils/test_docker.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
import subprocess
4+
from unittest import mock
5+
6+
from devservices.utils.docker import stop_all_running_containers
7+
8+
9+
@mock.patch("subprocess.check_output")
10+
@mock.patch("subprocess.run")
11+
@mock.patch("devservices.utils.docker.check_docker_daemon_running")
12+
def test_stop_all_running_containers_none_running(
13+
mock_check_docker_daemon_running: mock.Mock,
14+
mock_run: mock.Mock,
15+
mock_check_output: mock.Mock,
16+
) -> None:
17+
mock_check_docker_daemon_running.return_value = None
18+
mock_check_output.return_value = b""
19+
stop_all_running_containers()
20+
mock_check_docker_daemon_running.assert_called_once()
21+
mock_check_output.assert_called_once_with(
22+
["docker", "ps", "-q"], stderr=subprocess.DEVNULL
23+
)
24+
mock_run.assert_not_called()
25+
26+
27+
@mock.patch("subprocess.check_output")
28+
@mock.patch("subprocess.run")
29+
@mock.patch("devservices.utils.docker.check_docker_daemon_running")
30+
def test_stop_all_running_containers(
31+
mock_check_docker_daemon_running: mock.Mock,
32+
mock_run: mock.Mock,
33+
mock_check_output: mock.Mock,
34+
) -> None:
35+
mock_check_docker_daemon_running.return_value = None
36+
mock_check_output.return_value = b"container1\ncontainer2\n"
37+
stop_all_running_containers()
38+
mock_check_docker_daemon_running.assert_called_once()
39+
mock_check_output.assert_called_once_with(
40+
["docker", "ps", "-q"], stderr=subprocess.DEVNULL
41+
)
42+
mock_run.assert_called_once_with(
43+
["docker", "stop", "container1", "container2"],
44+
check=True,
45+
stdout=subprocess.DEVNULL,
46+
stderr=subprocess.DEVNULL,
47+
)

0 commit comments

Comments
 (0)