Skip to content

Commit

Permalink
Add doctor subcommand plugin (conda#12124)
Browse files Browse the repository at this point in the history
Introduce new `conda doctor` subcommand using the pluggy plugin framework.
The `conda doctor` command checks for all expected files given the installed
packages.

---------

Co-authored-by: Travis Hathaway <[email protected]>
Co-authored-by: Daniel Holth <[email protected]>
Co-authored-by: Jannis Leidel <[email protected]>
Co-authored-by: Bianca Henderson <[email protected]>
Co-authored-by: Ken Odegard <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
7 people authored Apr 17, 2023
1 parent d371c98 commit 330fa91
Show file tree
Hide file tree
Showing 8 changed files with 310 additions and 5 deletions.
9 changes: 6 additions & 3 deletions conda/cli/conda_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,13 @@ def parse_args(self, args=None, namespace=None):
subcommand. We instead return a ``Namespace`` object with ``plugin_subcommand`` defined,
which is a ``conda.plugins.CondaSubcommand`` object.
"""
plugin_subcommand = None
# args default to the system args
if args is None:
args = sys.argv[1:]

if len(sys.argv) > 1:
name = sys.argv[1]
plugin_subcommand = None
if args:
name = args[0]
for subcommand in self._subcommands:
if subcommand.name == name:
if name.lower() in BUILTIN_COMMANDS:
Expand Down
8 changes: 6 additions & 2 deletions conda/plugins/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ..base.context import context
from ..core.solve import Solver
from ..exceptions import CondaValueError, PluginError
from . import solvers, virtual_packages
from . import solvers, subcommands, virtual_packages
from .hookspec import CondaSpecs, spec_name

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -168,6 +168,10 @@ def get_plugin_manager() -> CondaPluginManager:
"""
plugin_manager = CondaPluginManager()
plugin_manager.add_hookspecs(CondaSpecs)
plugin_manager.load_plugins(solvers, *virtual_packages.plugins)
plugin_manager.load_plugins(
solvers,
*virtual_packages.plugins,
*subcommands.plugins,
)
plugin_manager.load_entrypoints(spec_name)
return plugin_manager
5 changes: 5 additions & 0 deletions conda/plugins/subcommands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
from .doctor import cli as doctor

plugins = [doctor]
6 changes: 6 additions & 0 deletions conda/plugins/subcommands/doctor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
"""
Provide 'conda doctor' environment check feature.
"""
# This file is here for packaging, not for importing submodules.
69 changes: 69 additions & 0 deletions conda/plugins/subcommands/doctor/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations

import argparse

from ....base.context import context, locate_prefix_by_name
from ....cli.common import validate_prefix
from ....cli.conda_argparse import add_parser_prefix
from ....exceptions import CondaEnvException
from ... import CondaSubcommand, hookimpl


def get_parsed_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
"conda doctor",
description="Display a health report for your environment.",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="generate a detailed environment health report",
)
add_parser_prefix(parser)
args = parser.parse_args(argv)

return args


def get_prefix(args: argparse.Namespace) -> str:
"""
Determine the correct prefix to use provided the CLI arguments and the context object.
When not specified via CLI options, the default is the currently active prefix
"""
if args.name:
return locate_prefix_by_name(args.name)

if args.prefix:
return validate_prefix(args.prefix)

if context.active_prefix:
return context.active_prefix

raise CondaEnvException(
"No environment specified. Activate an environment or specify the "
"environment via `--name` or `--prefix`."
)


def execute(argv: list[str]) -> None:
"""
Run conda doctor subcommand.
"""
from .health_checks import display_health_checks

args = get_parsed_args(argv)
prefix = get_prefix(args)
display_health_checks(prefix, verbose=args.verbose)


@hookimpl
def conda_subcommands():
yield CondaSubcommand(
name="doctor",
summary="A subcommand that displays environment health report",
action=execute,
)
54 changes: 54 additions & 0 deletions conda/plugins/subcommands/doctor/health_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations

import json
from pathlib import Path

OK_MARK = "✅"
REPORT_TITLE = "\nENVIRONMENT HEALTH REPORT\n"
DETAILED_REPORT_TITLE = "\nDETAILED ENVIRONMENT HEALTH REPORT\n"
MISSING_FILES_SUCCESS_MESSAGE = f"{OK_MARK} There are no packages with missing files.\n"


def display_report_heading(prefix: str) -> None:
"""
Displays our report heading
"""
print("-" * 20)
print(REPORT_TITLE)
print(f"Environment Name: {Path(prefix).name}\n")


def find_packages_with_missing_files(prefix: str | Path) -> dict[str, list[str]]:
"""
Finds packages listed in conda-meta which have missing files
"""
packages_with_missing_files = {}
prefix = Path(prefix)
for file in (prefix / "conda-meta").glob("*.json"):
for file_name in json.loads(file.read_text()).get("files", []):
# Add warnings if json file has missing "files"
if not (prefix / file_name).exists():
packages_with_missing_files.setdefault(file.stem, []).append(file_name)
return packages_with_missing_files


def display_health_checks(prefix: str, verbose: bool) -> None:
"""
Prints health report
"""
display_report_heading(prefix)
missing_files = find_packages_with_missing_files(prefix)
if missing_files:
print("Missing Files\n")
for file, files in missing_files.items():
if verbose:
delimiter = "\n "
print(f"{file}:{delimiter}{delimiter.join(files)}\n")
else:
print(f"{file}: {len(files)}")

print("\n")
else:
print(MISSING_FILES_SUCCESS_MESSAGE)
92 changes: 92 additions & 0 deletions tests/plugins/subcommands/doctor/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
from argparse import Namespace
from pathlib import Path
from typing import Iterable

import pytest
from pytest import MonkeyPatch

from conda.base.context import conda_tests_ctxt_mgmt_def_pol, context
from conda.common.io import env_vars
from conda.exceptions import (
CondaEnvException,
DirectoryNotACondaEnvironmentError,
EnvironmentNameNotFound,
)
from conda.plugins.subcommands.doctor.cli import get_prefix
from conda.plugins.subcommands.doctor.health_checks import MISSING_FILES_SUCCESS_MESSAGE
from conda.testing.helpers import run_inprocess_conda_command as run
from conda.testing.integration import make_temp_env


def test_conda_doctor_happy_path():
"""Make sure that we are able to call the ``conda doctor`` command"""

out, err, code = run(f"conda doctor")

assert not err # no error message
assert not code # successful exit code


def test_conda_doctor_happy_path_verbose():
"""Make sure that we are able to run ``conda doctor`` command with the --verbose flag"""

out, err, code = run(f"conda doctor --verbose")

assert not err # no error message
assert not code # successful exit code


def test_conda_doctor_happy_path_show_help():
"""Make sure that we are able to run ``conda doctor`` command with the --help flag"""

out, err, code = run(f"conda doctor --help")

assert "Display a health report for your environment." in out
assert not err # no error message
assert not code # successful exit code


def test_conda_doctor_with_test_environment():
"""Make sure that we are able to call ``conda doctor`` command for a specific environment"""

with make_temp_env() as prefix:
out, err, code = run(f"conda doctor --prefix '{prefix}'")

assert MISSING_FILES_SUCCESS_MESSAGE in out
assert not err # no error message
assert not code # successful exit code


def test_get_prefix_name():
assert get_prefix(Namespace(name="base", prefix=None)) == context.root_prefix


def test_get_prefix_bad_name():
with pytest.raises(EnvironmentNameNotFound):
get_prefix(Namespace(name="invalid", prefix=None))


def test_get_prefix_prefix():
with make_temp_env() as prefix:
assert get_prefix(Namespace(name=None, prefix=prefix)) == prefix


def test_get_prefix_bad_prefix(tmp_path: Path):
with pytest.raises(DirectoryNotACondaEnvironmentError):
assert get_prefix(Namespace(name=None, prefix=tmp_path))


def test_get_prefix_active():
with make_temp_env() as prefix, env_vars(
{"CONDA_PREFIX": prefix},
stack_callback=conda_tests_ctxt_mgmt_def_pol,
):
assert get_prefix(Namespace(name=None, prefix=None)) == prefix


def test_get_prefix_not_active(monkeypatch: MonkeyPatch):
monkeypatch.delenv("CONDA_PREFIX")
with pytest.raises(CondaEnvException):
get_prefix(Namespace(name=None, prefix=None))
72 changes: 72 additions & 0 deletions tests/plugins/subcommands/doctor/test_health_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations

import json
import uuid
from pathlib import Path
from typing import Iterable

import pytest

from conda.base.context import conda_tests_ctxt_mgmt_def_pol
from conda.common.io import env_vars
from conda.plugins.subcommands.doctor.health_checks import (
display_health_checks,
find_packages_with_missing_files,
)
from conda.testing.integration import make_temp_env


@pytest.fixture
def env_ok(tmp_path: Path) -> Iterable[tuple[Path, str, str, str]]:
"""Fixture that returns a testing environment with no missing files"""
package = uuid.uuid4().hex

(tmp_path / "bin").mkdir(parents=True, exist_ok=True)
(tmp_path / "lib").mkdir(parents=True, exist_ok=True)
(tmp_path / "conda-meta").mkdir(parents=True, exist_ok=True)

bin_doctor = f"bin/{package}"
(tmp_path / bin_doctor).touch()

lib_doctor = f"lib/{package}.py"
(tmp_path / lib_doctor).touch()

(tmp_path / "conda-meta" / f"{package}.json").write_text(
json.dumps({"files": [bin_doctor, lib_doctor]})
)

yield tmp_path, bin_doctor, lib_doctor, package


@pytest.fixture
def env_broken(env_ok: tuple[Path, str, str, str]) -> tuple[Path, str, str, str]:
"""Fixture that returns a testing environment with missing files"""
prefix, bin_doctor, _, _ = env_ok
(prefix / bin_doctor).unlink()
return env_ok


def test_no_missing_files(env_ok: tuple[Path, str, str, str]):
"""Test that runs for the case with no missing files"""
prefix, _, _, _ = env_ok
assert find_packages_with_missing_files(prefix) == {}


def test_missing_files(env_broken: tuple[Path, str, str, str]):
prefix, bin_doctor, _, package = env_broken
assert find_packages_with_missing_files(prefix) == {package: [bin_doctor]}


@pytest.mark.parametrize("verbose", [True, False])
def test_display_health_checks(env_ok: tuple[Path, str, str, str], verbose: bool):
"""Run display_health_checks without and with missing files."""
prefix, bin_doctor, lib_doctor, package = env_ok
with env_vars(
{"CONDA_PREFIX": prefix},
stack_callback=conda_tests_ctxt_mgmt_def_pol,
):
display_health_checks(prefix, verbose=verbose)
(prefix / bin_doctor).unlink()
display_health_checks(prefix, verbose=verbose)

0 comments on commit 330fa91

Please sign in to comment.