Repository: AgnostiqHQ/covalent (language: python)

Issue:
Not clearing DB on purge
## Acceptance Criteria

- [ ] Running `covalent purge` should warn users about deleting database, and user should confirm prior to continuing


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 623832f8f..c4dd577bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [UNRELEASED]
 
+### Added
+
+- `--hard` and `--yes` flags added to `covalent purge` for hard purging (also deletes the databse) and autoapproving respectively.
+
+### Changed
+
+- `covalent purge` now shows the user a prompt informing them what dirs and files will be deleted.
+- Improved shown messages in some commands.
+
+### Tests
+
+- Updated tests to reflect above changes.
+
 ## [0.172.0] - 2022-08-10
 
 ### Authors
diff --git a/covalent_dispatcher/_cli/service.py b/covalent_dispatcher/_cli/service.py
index bd8b44e06..00f3a541c 100644
--- a/covalent_dispatcher/_cli/service.py
+++ b/covalent_dispatcher/_cli/service.py
@@ -20,7 +20,9 @@
 
 """Covalent CLI Tool - Service Management."""
 
+
 import asyncio
+import contextlib
 import json
 import os
 import shutil
@@ -44,8 +46,8 @@
 UI_LOGFILE = get_config("user_interface.log_dir") + "/covalent_ui.log"
 UI_SRVDIR = os.path.dirname(os.path.abspath(__file__)) + "/../../covalent_ui"
 
-MIGRATION_COMMAND_MSG = '   (use "covalent db migrate" to run database migrations)'
 MIGRATION_WARNING_MSG = "There have been changes applied to the database."
+MIGRATION_COMMAND_MSG = '   (use "covalent db migrate" to run database migrations)'
 
 
 def _read_pid(filename: str) -> int:
@@ -387,20 +389,62 @@ def status() -> None:
 
 
 @click.command()
-def purge() -> None:
+@click.option(
+    "-H",
+    "--hard",
+    is_flag=True,
+    help="Perform a hard purge, deleting the DB as well. [default: False]",
+)
+@click.option(
+    "-y", "--yes", is_flag=True, help="Approve without showing the warning. [default: False]"
+)
+def purge(hard: bool, yes: bool) -> None:
     """
-    Shutdown server and delete the cache and config settings.
+    Purge Covalent from this system. This command is for developers.
     """
 
-    # Shutdown server.
+    removal_list = {
+        get_config("sdk.log_dir"),
+        get_config("dispatcher.cache_dir"),
+        get_config("dispatcher.log_dir"),
+        get_config("user_interface.log_dir"),
+        os.path.dirname(cm.config_file),
+    }
+
+    if hard:
+        removal_list.add(get_config("dispatcher.db_path"))
+
+    if not yes:
+
+        click.secho(f"{''.join(['*'] * 21)} WARNING {''.join(['*'] * 21)}", fg="yellow")
+
+        click.echo("Purging will perform the following operations: ")
+
+        click.echo("1. Stop the covalent server if running.")
+
+        for i, rem_path in enumerate(removal_list, start=2):
+            if os.path.isdir(rem_path):
+                click.echo(f"{i}. {rem_path} directory will be deleted.")
+            else:
+                click.echo(f"{i}. {rem_path} file will be deleted.")
+
+        if hard:
+            click.secho("WARNING: All user data will be deleted.", fg="red")
+
+        click.confirm("\nWould you like to proceed?", abort=True)
+
+    # Shutdown covalent server
     _graceful_shutdown(UI_PIDFILE)
 
-    shutil.rmtree(get_config("sdk.log_dir"), ignore_errors=True)
-    shutil.rmtree(get_config("dispatcher.cache_dir"), ignore_errors=True)
-    shutil.rmtree(get_config("dispatcher.log_dir"), ignore_errors=True)
-    shutil.rmtree(get_config("user_interface.log_dir"), ignore_errors=True)
+    # Remove all directories and files
+    for rem_path in removal_list:
+        if os.path.isdir(rem_path):
+            shutil.rmtree(rem_path, ignore_errors=True)
+        else:
+            with contextlib.suppress(FileNotFoundError):
+                os.remove(rem_path)
 
-    cm.purge_config()
+        click.echo(f"Removed {rem_path}.")
 
     click.echo("Covalent server files have been purged.")
 
@@ -410,15 +454,16 @@ def logs() -> None:
     """
     Show Covalent server logs.
     """
-    if os.path.exists(UI_LOGFILE):
-        f = open(UI_LOGFILE, "r")
-        line = f.readline()
-        while line:
-            click.echo(line.rstrip("\n"))
+    from pathlib import Path
+
+    if Path(UI_LOGFILE).is_file():
+        with open(UI_LOGFILE, "r") as f:
             line = f.readline()
-        f.close()
+            while line:
+                click.echo(line.rstrip("\n"))
+                line = f.readline()
     else:
-        click.echo(f"{UI_LOGFILE} not found!. Server possibly purged!")
+        click.echo(f"{UI_LOGFILE} not found. Restart the server to create a new log file.")
 
 
 @click.command()
diff --git a/requirements.txt b/requirements.txt
index cbe906378..7e1ae4774 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,7 +8,7 @@ networkx==2.5
 psutil==5.9.0
 python-socketio==5.7.1
 PyYAML==6.0
-requests==2.24.0
+requests>=2.24.0
 simplejson==3.17.6
 sqlalchemy==1.4.37
 tailer==0.4.1


Test patch:
diff --git a/tests/covalent_dispatcher_tests/_cli/service_test.py b/tests/covalent_dispatcher_tests/_cli/service_test.py
index e268d0a8c..01f165a18 100644
--- a/tests/covalent_dispatcher_tests/_cli/service_test.py
+++ b/tests/covalent_dispatcher_tests/_cli/service_test.py
@@ -41,6 +41,7 @@
     _rm_pid_file,
     cluster,
     config,
+    logs,
     migrate_legacy_result_object,
     purge,
     restart,
@@ -296,27 +297,137 @@ def test_is_server_running(mocker):
     assert not _is_server_running()
 
 
-def test_purge(mocker):
+@pytest.mark.parametrize("hard", [False, True])
+def test_purge_proceed(hard, mocker):
     """Test the 'covalent purge' CLI command."""
 
-    from covalent_dispatcher._cli.service import UI_PIDFILE, get_config
+    from covalent_dispatcher._cli.service import UI_PIDFILE, cm
 
     runner = CliRunner()
+
+    dir_list = [
+        mock.call(dirs)
+        for dirs in [
+            "sdk.log_dir",
+            "dispatcher.cache_dir",
+            "dispatcher.log_dir",
+            "user_interface.log_dir",
+        ]
+    ]
+
+    def get_config_side_effect(conf_name):
+        return "file" if conf_name == "dispatcher.db_path" else "dir"
+
+    get_config_mock = mocker.patch(
+        "covalent_dispatcher._cli.service.get_config", side_effect=get_config_side_effect
+    )
+    os_path_dirname_mock = mocker.patch(
+        "covalent_dispatcher._cli.service.os.path.dirname", return_value="dir"
+    )
+
+    def isdir_side_effect(path):
+        return path == "dir"
+
+    os_path_isdir_mock = mocker.patch(
+        "covalent_dispatcher._cli.service.os.path.isdir", side_effect=isdir_side_effect
+    )
+
     graceful_shutdown_mock = mocker.patch("covalent_dispatcher._cli.service._graceful_shutdown")
     shutil_rmtree_mock = mocker.patch("covalent_dispatcher._cli.service.shutil.rmtree")
-    purge_config_mock = mocker.patch("covalent_dispatcher._cli.service.cm.purge_config")
-    result = runner.invoke(purge)
-    graceful_shutdown_mock.assert_has_calls([mock.call(UI_PIDFILE)])
-    shutil_rmtree_mock.assert_has_calls(
-        [
-            mock.call(get_config("sdk.log_dir"), ignore_errors=True),
-            mock.call(get_config("dispatcher.cache_dir"), ignore_errors=True),
-            mock.call(get_config("dispatcher.log_dir"), ignore_errors=True),
-            mock.call(get_config("user_interface.log_dir"), ignore_errors=True),
+    os_remove_mock = mocker.patch("covalent_dispatcher._cli.service.os.remove")
+
+    if hard:
+        result = runner.invoke(purge, args="--hard", input="y")
+
+        dir_list.append(mock.call("dispatcher.db_path"))
+        os_path_isdir_mock.assert_has_calls([mock.call("file"), mock.call("file")], any_order=True)
+        os_remove_mock.assert_called_with("file")
+    else:
+        result = runner.invoke(purge, input="y")
+
+    get_config_mock.assert_has_calls(dir_list, any_order=True)
+    os_path_dirname_mock.assert_called_with(cm.config_file)
+    os_path_isdir_mock.assert_has_calls([mock.call("dir"), mock.call("dir")], any_order=True)
+
+    graceful_shutdown_mock.assert_called_with(UI_PIDFILE)
+
+    shutil_rmtree_mock.assert_has_calls([mock.call("dir", ignore_errors=True)])
+
+    assert "Covalent server files have been purged.\n" in result.output
+
+
+@pytest.mark.parametrize("hard", [False, True])
+def test_purge_abort(hard, mocker):
+    """Test the 'covalent purge' CLI command."""
+
+    from covalent_dispatcher._cli.service import cm
+
+    runner = CliRunner()
+
+    dir_list = [
+        mock.call(dirs)
+        for dirs in [
+            "sdk.log_dir",
+            "dispatcher.cache_dir",
+            "dispatcher.log_dir",
+            "user_interface.log_dir",
         ]
+    ]
+
+    def get_config_side_effect(conf_name):
+        return "file" if conf_name == "dispatcher.db_path" else "dir"
+
+    get_config_mock = mocker.patch(
+        "covalent_dispatcher._cli.service.get_config", side_effect=get_config_side_effect
+    )
+    os_path_dirname_mock = mocker.patch(
+        "covalent_dispatcher._cli.service.os.path.dirname", return_value="dir"
     )
-    purge_config_mock.assert_called_once()
-    assert result.output == "Covalent server files have been purged.\n"
+
+    def isdir_side_effect(path):
+        return path == "dir"
+
+    os_path_isdir_mock = mocker.patch(
+        "covalent_dispatcher._cli.service.os.path.isdir", side_effect=isdir_side_effect
+    )
+
+    if hard:
+        result = runner.invoke(purge, input="n", args="--hard")
+        dir_list.append(mock.call("dispatcher.db_path"))
+        os_path_isdir_mock.assert_has_calls([mock.call("file")])
+    else:
+        result = runner.invoke(purge, input="n")
+
+    get_config_mock.assert_has_calls(dir_list, any_order=True)
+    os_path_dirname_mock.assert_called_with(cm.config_file)
+    os_path_isdir_mock.assert_has_calls([mock.call("dir")])
+
+    assert "Aborted!\n" in result.output
+
+
+@pytest.mark.parametrize("exists", [False, True])
+def test_logs(exists, mocker):
+    """Test covalent logs command"""
+
+    from covalent_dispatcher._cli.service import UI_LOGFILE
+
+    runner = CliRunner()
+
+    mocker.patch("pathlib.Path.is_file", return_value=exists)
+
+    if not exists:
+        result = runner.invoke(logs)
+        assert (
+            result.output
+            == f"{UI_LOGFILE} not found. Restart the server to create a new log file.\n"
+        )
+    else:
+        m_open = mock.mock_open(read_data="testing")
+        with mock.patch("covalent_dispatcher._cli.service.open", m_open):
+            result = runner.invoke(logs)
+
+        m_open.assert_called_once_with(UI_LOGFILE, "r")
+        assert result.output == "testing\n"
 
 
 def test_config(mocker):
diff --git a/tests/functional_tests/dask_cluster_cli_test.py b/tests/functional_tests/dask_cluster_cli_test.py
index 5d8cf4147..a79064c7c 100644
--- a/tests/functional_tests/dask_cluster_cli_test.py
+++ b/tests/functional_tests/dask_cluster_cli_test.py
@@ -21,7 +21,7 @@
 import asyncio
 import contextlib
 
-import dask.system
+import dask
 import pytest
 from dask.distributed import LocalCluster
 from distributed.comm import parse_address, unparse_address