Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(purge): Adding state clear functionality to purge #125

Merged
merged 6 commits into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion devservices/commands/purge.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from argparse import Namespace

from devservices.constants import DEVSERVICES_CACHE_DIR
from devservices.exceptions import DockerDaemonNotRunningError
from devservices.utils.console import Console
from devservices.utils.console import Status
from devservices.utils.docker import stop_all_running_containers
from devservices.utils.state import State


def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
Expand All @@ -18,10 +22,29 @@
def purge(args: Namespace) -> None:
"""Purge the local devservices cache."""
console = Console()
# Prompt the user to stop all running containers
should_stop_containers = console.confirm(
"Warning: Purging stops all running containers and clears devservices state. Would you like to continue?"
)
if not should_stop_containers:
console.warning("Purge canceled")
return

if os.path.exists(DEVSERVICES_CACHE_DIR):
try:
shutil.rmtree(DEVSERVICES_CACHE_DIR)
except PermissionError as e:
console.failure(f"Failed to purge cache: {e}")
exit(1)
console.success("The local devservices cache has been purged")
state = State()
state.clear_state()
with Status(
lambda: console.warning("Stopping all running containers"),
lambda: console.success("All running containers have been stopped"),
):
try:
stop_all_running_containers()
except DockerDaemonNotRunningError:
console.warning("The docker daemon not running, no containers to stop")

Check warning on line 48 in devservices/commands/purge.py

View check run for this annotation

Codecov / codecov/patch

devservices/commands/purge.py#L47-L48

Added lines #L47 - L48 were not covered by tests

console.success("The local devservices cache and state has been purged")
4 changes: 3 additions & 1 deletion devservices/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ class DevservicesUpdateError(BinaryInstallError):
class DockerDaemonNotRunningError(Exception):
"""Raised when the Docker daemon is not running."""

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


class DockerComposeInstallationError(BinaryInstallError):
Expand Down
6 changes: 6 additions & 0 deletions devservices/utils/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Color:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[0;33m"
BLUE = "\033[0;34m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
NEGATIVE = "\033[7m"
Expand Down Expand Up @@ -46,6 +47,11 @@ def warning(self, message: str, bold: bool = False) -> None:
def info(self, message: str, bold: bool = False) -> None:
self.print(message=message, color="", bold=bold)

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


class Status:
"""Shows loading status in the terminal."""
Expand Down
23 changes: 20 additions & 3 deletions devservices/utils/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@


def check_docker_daemon_running() -> None:
"""Checks if the Docker daemon is running. Raises DockerDaemonNotRunningError if not."""
try:
subprocess.run(
["docker", "info"],
Expand All @@ -14,6 +15,22 @@ def check_docker_daemon_running() -> None:
check=True,
)
except subprocess.CalledProcessError as e:
raise DockerDaemonNotRunningError(
"Unable to connect to the docker daemon. Is the docker daemon running?"
) from e
raise DockerDaemonNotRunningError from e


def stop_all_running_containers() -> None:
check_docker_daemon_running()
running_containers = (
subprocess.check_output(["docker", "ps", "-q"], stderr=subprocess.DEVNULL)
.decode()
.strip()
.splitlines()
)
if len(running_containers) == 0:
return
subprocess.run(
["docker", "stop"] + running_containers,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
9 changes: 9 additions & 0 deletions devservices/utils/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,12 @@ def get_mode_for_service(self, service_name: str) -> str | None:
if result is None:
return None
return str(result[0])

def clear_state(self) -> None:
cursor = self.conn.cursor()
cursor.execute(
"""
DELETE FROM started_services
"""
)
self.conn.commit()
113 changes: 105 additions & 8 deletions tests/commands/test_purge.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,132 @@
from __future__ import annotations

import builtins
from argparse import Namespace
from pathlib import Path
from unittest import mock

from devservices.commands.purge import purge
from devservices.utils.state import State


def test_purge_no_cache(tmp_path: Path) -> None:
with mock.patch(
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
str(tmp_path / ".devservices-cache"),
@mock.patch("devservices.commands.purge.stop_all_running_containers")
def test_purge_not_confirmed(
mock_stop_all_running_containers: mock.Mock, tmp_path: Path
) -> None:
with (
mock.patch(
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
str(tmp_path / ".devservices-cache"),
),
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
mock.patch.object(builtins, "input", lambda _: "no"),
):
args = Namespace()
purge(args)

mock_stop_all_running_containers.assert_not_called()

def test_purge_with_cache(tmp_path: Path) -> None:
with mock.patch(
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
str(tmp_path / ".devservices-cache"),

@mock.patch("devservices.commands.purge.stop_all_running_containers")
def test_purge_with_cache_and_state_and_no_running_containers_confirmed(
mock_stop_all_running_containers: mock.Mock, tmp_path: Path
) -> None:
with (
mock.patch(
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
str(tmp_path / ".devservices-cache"),
),
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
mock.patch.object(builtins, "input", lambda _: "yes"),
mock.patch(
"devservices.utils.docker.check_docker_daemon_running", return_value=None
),
):
# Create a cache file to test purging
cache_dir = tmp_path / ".devservices-cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = tmp_path / ".devservices-cache" / "test.txt"
cache_file.write_text("This is a test cache file.")

state = State()
state.add_started_service("test-service", "test-mode")

assert cache_file.exists()
assert state.get_started_services() == ["test-service"]

args = Namespace()
purge(args)

assert not cache_file.exists()
assert state.get_started_services() == []

mock_stop_all_running_containers.assert_called_once()


@mock.patch("devservices.commands.purge.stop_all_running_containers")
def test_purge_with_cache_and_state_and_running_containers_confirmed(
mock_stop_all_running_containers: mock.Mock, tmp_path: Path
) -> None:
with (
mock.patch(
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
str(tmp_path / ".devservices-cache"),
),
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
mock.patch.object(builtins, "input", lambda _: "yes"),
mock.patch(
"devservices.utils.docker.check_docker_daemon_running", return_value=None
),
):
# Create a cache file to test purging
cache_dir = tmp_path / ".devservices-cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = tmp_path / ".devservices-cache" / "test.txt"
cache_file.write_text("This is a test cache file.")

state = State()
state.add_started_service("test-service", "test-mode")

assert cache_file.exists()
assert state.get_started_services() == ["test-service"]

args = Namespace()
purge(args)

assert not cache_file.exists()
assert state.get_started_services() == []

mock_stop_all_running_containers.assert_called_once()


@mock.patch("devservices.commands.purge.stop_all_running_containers")
def test_purge_with_cache_and_state_and_running_containers_not_confirmed(
mock_stop_all_running_containers: mock.Mock, tmp_path: Path
) -> None:
with (
mock.patch(
"devservices.commands.purge.DEVSERVICES_CACHE_DIR",
str(tmp_path / ".devservices-cache"),
),
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
mock.patch.object(builtins, "input", lambda _: "no"),
mock.patch(
"devservices.utils.docker.check_docker_daemon_running", return_value=None
),
):
# Create a cache file to test purging
cache_dir = tmp_path / ".devservices-cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = tmp_path / ".devservices-cache" / "test.txt"
cache_file.write_text("This is a test cache file.")

state = State()
state.add_started_service("test-service", "test-mode")

args = Namespace()
purge(args)

assert cache_file.exists()
assert state.get_started_services() == ["test-service"]

mock_stop_all_running_containers.assert_not_called()
47 changes: 47 additions & 0 deletions tests/utils/test_docker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

import subprocess
from unittest import mock

from devservices.utils.docker import stop_all_running_containers


@mock.patch("subprocess.check_output")
@mock.patch("subprocess.run")
@mock.patch("devservices.utils.docker.check_docker_daemon_running")
def test_stop_all_running_containers_none_running(
mock_check_docker_daemon_running: mock.Mock,
mock_run: mock.Mock,
mock_check_output: mock.Mock,
) -> None:
mock_check_docker_daemon_running.return_value = None
mock_check_output.return_value = b""
stop_all_running_containers()
mock_check_docker_daemon_running.assert_called_once()
mock_check_output.assert_called_once_with(
["docker", "ps", "-q"], stderr=subprocess.DEVNULL
)
mock_run.assert_not_called()


@mock.patch("subprocess.check_output")
@mock.patch("subprocess.run")
@mock.patch("devservices.utils.docker.check_docker_daemon_running")
def test_stop_all_running_containers(
mock_check_docker_daemon_running: mock.Mock,
mock_run: mock.Mock,
mock_check_output: mock.Mock,
) -> None:
mock_check_docker_daemon_running.return_value = None
mock_check_output.return_value = b"container1\ncontainer2\n"
stop_all_running_containers()
mock_check_docker_daemon_running.assert_called_once()
mock_check_output.assert_called_once_with(
["docker", "ps", "-q"], stderr=subprocess.DEVNULL
)
mock_run.assert_called_once_with(
["docker", "stop", "container1", "container2"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)