diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 6169fd3..5be48b4 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -10,9 +10,11 @@ cpart cpath crepository csource +delenv excinfo fileh fqcn +jsonschema levelname levelno netcommon @@ -23,5 +25,6 @@ setenv specp treemaker uninstallation +usefixtures vuuid xmltodict diff --git a/src/ansible_dev_environment/__main__.py b/src/ansible_dev_environment/__main__.py index fdf08a3..28dba09 100644 --- a/src/ansible_dev_environment/__main__.py +++ b/src/ansible_dev_environment/__main__.py @@ -4,7 +4,7 @@ via :command:`python -m ansible_dev_environment`. """ -from .cli import main +from ansible_dev_environment.cli import main if __name__ == "__main__": diff --git a/src/ansible_dev_environment/arg_parser.py b/src/ansible_dev_environment/arg_parser.py index 692e62a..06724cc 100644 --- a/src/ansible_dev_environment/arg_parser.py +++ b/src/ansible_dev_environment/arg_parser.py @@ -200,20 +200,25 @@ def parse() -> argparse.Namespace: help="Uninstall a collection.", ) - for grp in parser._action_groups: # noqa: SLF001 - if grp.title is None: - continue - grp.title = grp.title.capitalize() + _group_titles(parser) for subparser in subparsers.choices.values(): - for grp in subparser._action_groups: # noqa: SLF001 - if grp.title is None: - continue - grp.title = grp.title.capitalize() - # pylint: enable=protected-access + _group_titles(subparser) return parser.parse_args() +def _group_titles(parser: ArgumentParser) -> None: + """Set the group titles to be capitalized. + + Args: + parser: The parser to set the group titles for + """ + for group in parser._action_groups: # noqa: SLF001 + if group.title is None: + continue + group.title = group.title.capitalize() + + class ArgumentParser(argparse.ArgumentParser): """A custom argument parser.""" diff --git a/src/ansible_dev_environment/cli.py b/src/ansible_dev_environment/cli.py index 0c5eeb2..99448a8 100644 --- a/src/ansible_dev_environment/cli.py +++ b/src/ansible_dev_environment/cli.py @@ -170,7 +170,3 @@ def main() -> None: cli.args_sanity() cli.ensure_isolated() cli.run() - - -if __name__ == "__main__": - main() diff --git a/src/ansible_dev_environment/subcommands/treemaker.py b/src/ansible_dev_environment/subcommands/treemaker.py index 35970e8..a053f1b 100644 --- a/src/ansible_dev_environment/subcommands/treemaker.py +++ b/src/ansible_dev_environment/subcommands/treemaker.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from ansible_dev_environment.tree import Tree from ansible_dev_environment.utils import builder_introspect, collect_manifests @@ -44,10 +44,7 @@ def run(self: TreeMaker) -> None: # noqa: C901, PLR0912, PLR0915 target=self._config.site_pkg_collections_path, venv_cache_dir=self._config.venv_cache_dir, ) - tree_dict: JSONVal = {c: {} for c in collections} - if not isinstance(tree_dict, dict): - msg = "Tree dict is not a dict." - raise TypeError(msg) + tree_dict: dict[str, dict[str, JSONVal]] = {c: {} for c in collections} links: dict[str, str] = {} for collection_name, collection in collections.items(): @@ -65,10 +62,6 @@ def run(self: TreeMaker) -> None: # noqa: C901, PLR0912, PLR0915 self._output.error(err) continue target = tree_dict[collection_name] - if not isinstance(target, dict): - msg = "Tree dict is not a dict." - raise TypeError(msg) - target[dep] = tree_dict[dep] docs = collection["collection_info"].get("documentation") @@ -97,7 +90,8 @@ def run(self: TreeMaker) -> None: # noqa: C901, PLR0912, PLR0915 more_verbose = 2 if self._config.args.verbose >= more_verbose: - tree = Tree(obj=tree_dict, term_features=self._config.term_features) + j_tree_dict = cast(JSONVal, tree_dict) + tree = Tree(obj=j_tree_dict, term_features=self._config.term_features) tree.links = links tree.green.extend(green) rendered = tree.render() @@ -132,7 +126,7 @@ def run(self: TreeMaker) -> None: # noqa: C901, PLR0912, PLR0915 def add_python_reqs( - tree_dict: dict[str, JSONVal], + tree_dict: dict[str, dict[str, JSONVal]], collection_name: str, python_deps: list[str], ) -> None: diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py index 280d3ad..cdce183 100644 --- a/tests/integration/test_basic.py +++ b/tests/integration/test_basic.py @@ -137,16 +137,13 @@ def test_non_local( assert string in captured.out monkeypatch.setattr( "sys.argv", - ["ade", "tree", f"--venv={tmp_path / 'venv'}"], + ["ade", "tree", f"--venv={tmp_path / 'venv'}", "-v"], ) with pytest.raises(SystemExit): main() captured = capsys.readouterr() - with pytest.raises(SystemExit): - main() - captured = capsys.readouterr() - string = "ansible.scm\n└──ansible.utils\n\n" - assert string == captured.out + assert "ansible.scm\n├──ansible.utils" in captured.out + assert "├──jsonschema" in captured.out def test_requirements( diff --git a/tests/test_argparser.py b/tests/test_argparser.py new file mode 100644 index 0000000..a5ef043 --- /dev/null +++ b/tests/test_argparser.py @@ -0,0 +1,92 @@ +"""Tests for the arg_parser module.""" + +import pytest + +from ansible_dev_environment.arg_parser import ( + ArgumentParser, + CustomHelpFormatter, + _group_titles, +) + + +def test_no_option_string( + capsys: pytest.CaptureFixture[str], +) -> None: + """Test an argument without an option string. + + Args: + capsys: Pytest fixture. + """ + parser = ArgumentParser( + formatter_class=CustomHelpFormatter, + ) + parser.add_argument( + dest="test", + action="store_true", + help="Test this", + ) + parser.print_help() + captured = capsys.readouterr() + assert "Test this" in captured.out + + +def test_one_string( + capsys: pytest.CaptureFixture[str], +) -> None: + """Test an argument without an option string. + + Args: + capsys: Pytest fixture. + """ + parser = ArgumentParser( + formatter_class=CustomHelpFormatter, + ) + parser.add_argument( + "-t", + dest="test", + action="store_true", + help="Test this", + ) + parser.print_help() + captured = capsys.readouterr() + assert "-t Test this" in captured.out + + +def test_too_many_string( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test an argument with too many option strings. + + Args: + monkeypatch: Pytest fixture. + """ + monkeypatch.setattr("sys.argv", ["prog", "--help"]) + + parser = ArgumentParser( + formatter_class=CustomHelpFormatter, + ) + parser.add_argument( + "-t", + "-test", + "--test", + action="store_true", + help="Test this", + ) + with pytest.raises(ValueError, match="Too many option strings"): + parser.parse_args() + + +def test_group_no_title(capsys: pytest.CaptureFixture[str]) -> None: + """Test a group without a title. + + Args: + capsys: Pytest fixture. + """ + parser = ArgumentParser( + formatter_class=CustomHelpFormatter, + ) + parser.add_argument_group() + _group_titles(parser) + parser.print_help() + captured = capsys.readouterr() + assert "--help" in captured.out diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 194a550..98a870e 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -31,3 +31,25 @@ def output(tmp_path: Path) -> Output: term_features=TermFeatures(color=False, links=False), verbosity=0, ) + + +@pytest.fixture(name="_wide_console") +def _wide_console(monkeypatch: pytest.MonkeyPatch) -> None: + """Fixture to set the terminal width to 1000 to prevent wrapping. + + Args: + monkeypatch: Pytest fixture. + """ + + def _console_width() -> int: + """Return a large console width. + + Returns: + int: Console width. + """ + return 1000 + + monkeypatch.setattr( + "ansible_dev_environment.output.console_width", + _console_width, + ) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..d585774 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,317 @@ +"""Test cli functionality.""" + +from collections.abc import Generator +from pathlib import Path + +import pytest + +from ansible_dev_environment.cli import Cli + + +def main(cli: Cli) -> None: + """Stub main function for testing. + + Args: + cli: Cli object. + """ + cli.parse_args() + cli.init_output() + cli.args_sanity() + cli.ensure_isolated() + + +def test_cpi(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the cpi option. + + Args: + monkeypatch: Pytest fixture. + """ + monkeypatch.setattr("sys.argv", ["ansible-dev-environment", "install", "--cpi"]) + cli = Cli() + cli.parse_args() + assert cli.args.requirement.parts[-3:] == ( + "ansible-dev-environment", + ".config", + "source-requirements.yml", + ) + + +def test_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Test term features with tty. + + Args: + monkeypatch: Pytest fixture. + """ + monkeypatch.setattr("sys.stdout.isatty", (lambda: True)) + monkeypatch.setattr("os.environ", {"NO_COLOR": ""}) + monkeypatch.setattr("sys.argv", ["ansible-dev-environment", "install"]) + cli = Cli() + cli.parse_args() + cli.init_output() + assert cli.output.term_features.color + assert cli.output.term_features.links + + +@pytest.mark.usefixtures("_wide_console") +def test_missing_requirements( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test the missing requirements file. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + tmp_path: Pytest fixture. + """ + requirements_file = tmp_path / "requirements.yml" + monkeypatch.setattr( + "sys.argv", + ["ansible-dev-environment", "install", "-r", str(requirements_file)], + ) + match = f"Requirements file not found: {requirements_file}" + cli = Cli() + with pytest.raises(SystemExit): + main(cli) + captured = capsys.readouterr() + assert match in captured.err + + +def test_editable_many( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the editable option with too many arguments. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + """ + monkeypatch.setattr( + "sys.argv", + ["ansible-dev-environment", "install", "--venv", "venv", "-e", "one", "two"], + ) + cli = Cli() + cli.parse_args() + with pytest.raises(SystemExit): + main(cli) + captured = capsys.readouterr() + assert "Editable can only be used with a single collection specifier." in captured.err + + +def test_editable_requirements( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test the editable option with requirements file. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + tmp_path: Pytest fixture. + """ + requirements_file = tmp_path / "requirements.yml" + requirements_file.touch() + monkeypatch.setattr( + "sys.argv", + ["ansible-dev-environment", "install", "-r", str(requirements_file), "-e"], + ) + cli = Cli() + cli.parse_args() + with pytest.raises(SystemExit): + main(cli) + captured = capsys.readouterr() + assert "Editable can not be used with a requirements file." in captured.err + + +@pytest.mark.parametrize( + "env_var", + ( + "ANSIBLE_COLLECTIONS_PATHS", + "ANSIBLE_COLLECTION_PATH", + ), +) +def test_acp_env_var_set( + env_var: str, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the ansible collection path environment variable set. + + Args: + env_var: Environment variable name. + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + """ + monkeypatch.setenv(env_var, "test") + monkeypatch.setattr("sys.argv", ["ansible-dev-environment", "install"]) + cli = Cli() + cli.parse_args() + with pytest.raises(SystemExit): + main(cli) + captured = capsys.readouterr() + assert f"{env_var} is set" in captured.err + + +@pytest.mark.usefixtures("_wide_console") +def test_collections_in_home( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test the collections in home directory. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + tmp_path: Pytest fixture. + """ + monkeypatch.setattr( + "sys.argv", + ["ansible-dev-environment", "install", "--venv", "venv"], + ) + monkeypatch.setenv("HOME", str(tmp_path)) + collection_root = tmp_path / ".ansible" / "collections" / "ansible_collections" + (collection_root / "ansible" / "utils").mkdir(parents=True) + cli = Cli() + cli.parse_args() + with pytest.raises(SystemExit): + main(cli) + captured = capsys.readouterr() + msg = f"Collections found in {collection_root}" + assert msg in captured.err + + +def test_collections_in_user( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the collections in user directory. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + """ + usr_path = Path("/usr/share/ansible/collections") + exists = Path.exists + + def _exists(self: Path) -> bool: + """Patch the exists method. + + Args: + self: Path object. + + Returns: + bool: True if the path exists. + """ + if self == usr_path: + return True + return exists(self) + + monkeypatch.setattr(Path, "exists", _exists) + + iterdir = Path.iterdir + + def _iterdir(self: Path) -> list[Path] | Generator[Path, None, None]: + """Patch the iterdir method. + + Args: + self: Path object. + + Returns: + List of paths or generator. + """ + if self == usr_path: + return [usr_path / "ansible_collections"] + return iterdir(self) + + monkeypatch.setattr(Path, "iterdir", _iterdir) + + monkeypatch.setattr( + "sys.argv", + ["ansible-dev-environment", "install", "--venv", "venv"], + ) + cli = Cli() + cli.parse_args() + with pytest.raises(SystemExit): + main(cli) + captured = capsys.readouterr() + msg = f"Collections found in {usr_path}" + assert msg in captured.err + + +def test_no_venv_specified( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test no virtual environment specified. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + """ + monkeypatch.setattr( + "sys.argv", + ["ansible-dev-environment", "install"], + ) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + cli = Cli() + cli.parse_args() + with pytest.raises(SystemExit): + main(cli) + captured = capsys.readouterr() + assert "Unable to use user site packages directory" in captured.err + + +def test_exit_code_one( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test exit code one. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + """ + monkeypatch.setattr( + "sys.argv", + ["ansible-dev-environment", "install"], + ) + cli = Cli() + cli.parse_args() + cli.init_output() + cli.output.error("Test error") + with pytest.raises(SystemExit) as excinfo: + cli._exit() + expected = 1 + assert excinfo.value.code == expected + captured = capsys.readouterr() + assert "Test error" in captured.err + + +def test_exit_code_two( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test exit code two. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + """ + monkeypatch.setattr( + "sys.argv", + ["ansible-dev-environment", "install"], + ) + cli = Cli() + cli.parse_args() + cli.init_output() + cli.output.warning("Test warning") + with pytest.raises(SystemExit) as excinfo: + cli._exit() + expected = 2 + assert excinfo.value.code == expected + captured = capsys.readouterr() + assert "Test warning" in captured.out diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py new file mode 100644 index 0000000..a016b66 --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,18 @@ +"""Test the main file.""" + +import runpy + +import pytest + + +def test_main(capsys: pytest.CaptureFixture[str]) -> None: + """Test the main file. + + Args: + capsys: Capture stdout and stderr + """ + with pytest.raises(SystemExit): + runpy.run_module("ansible_dev_environment", run_name="__main__", alter_sys=True) + + captured = capsys.readouterr() + assert "the following arguments are required" in captured.err diff --git a/tests/unit/test_tree.py b/tests/unit/test_tree.py index fda3712..a02622a 100644 --- a/tests/unit/test_tree.py +++ b/tests/unit/test_tree.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING +import pytest + from ansible_dev_environment.tree import Tree from ansible_dev_environment.utils import TermFeatures @@ -150,3 +152,11 @@ def test_tree_color() -> None: tree.links = {"2": "http://red.ht"} rendered = tree.render().splitlines() assert rendered == expected + + +def test_tree_fail() -> None: + """Test a tree failure.""" + term_features = TermFeatures(color=False, links=False) + tree = Tree(obj=(1, 2, 3), term_features=term_features) # type: ignore[arg-type] + with pytest.raises(TypeError, match="Invalid type "): + tree.render() diff --git a/tests/unit/test_treemaker.py b/tests/unit/test_treemaker.py new file mode 100644 index 0000000..7f09f88 --- /dev/null +++ b/tests/unit/test_treemaker.py @@ -0,0 +1,276 @@ +"""Test the treemaker module.""" + +from argparse import Namespace +from pathlib import Path +from venv import EnvBuilder + +import pytest + +from ansible_dev_environment.config import Config +from ansible_dev_environment.output import Output +from ansible_dev_environment.subcommands.treemaker import TreeMaker +from ansible_dev_environment.utils import JSONVal + + +def test_tree_empty( + capsys: pytest.CaptureFixture[str], + output: Output, + tmp_path: Path, +) -> None: + """Test tree_not_dict. + + Args: + capsys: Pytest stdout capture fixture. + output: Output class object. + tmp_path: Pytest fixture. + """ + venv_path = tmp_path / "venv" + EnvBuilder().create(venv_path) + + args = Namespace( + venv=venv_path, + verbose=0, + ) + config = Config(args=args, output=output, term_features=output.term_features) + config.init() + treemaker = TreeMaker(config=config, output=output) + treemaker.run() + captured = capsys.readouterr() + assert captured.out == "\n\n" + assert not captured.err + + +def test_tree_malformed_info( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + output: Output, + tmp_path: Path, +) -> None: + """Test malformed collection info. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + output: Output class object. + tmp_path: Pytest fixture. + """ + venv_path = tmp_path / "venv" + EnvBuilder().create(venv_path) + + args = Namespace( + venv=venv_path, + verbose=0, + ) + + def collect_manifests( + target: Path, + venv_cache_dir: Path, + ) -> dict[str, dict[str, JSONVal]]: + """Return a malformed collection info. + + Args: + target: Target path. + venv_cache_dir: Venv cache directory. + <<<<<<< HEAD + + Returns: + Collection info. + ======= + >>>>>>> 556acba (Add some tests) + """ + assert target + assert venv_cache_dir + return { + "collection_one": { + "collection_info": "malformed", + }, + } + + monkeypatch.setattr( + "ansible_dev_environment.subcommands.treemaker.collect_manifests", + collect_manifests, + ) + config = Config(args=args, output=output, term_features=output.term_features) + config.init() + treemaker = TreeMaker(config=config, output=output) + treemaker.run() + captured = capsys.readouterr() + assert "Collection collection_one has malformed metadata." in captured.err + + +def test_tree_malformed_deps( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + output: Output, + tmp_path: Path, +) -> None: + """Test malformed collection deps. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + output: Output class object. + tmp_path: Pytest fixture. + """ + venv_path = tmp_path / "venv" + EnvBuilder().create(venv_path) + + args = Namespace( + venv=venv_path, + verbose=0, + ) + + def collect_manifests( + target: Path, + venv_cache_dir: Path, + ) -> dict[str, dict[str, JSONVal]]: + """Return a malformed collection info. + + Args: + target: Target path. + venv_cache_dir: Venv cache directory. + <<<<<<< HEAD + + Returns: + Collection info. + ======= + >>>>>>> 556acba (Add some tests) + """ + assert target + assert venv_cache_dir + return { + "collection_one": { + "collection_info": { + "dependencies": "malformed", + }, + }, + } + + monkeypatch.setattr( + "ansible_dev_environment.subcommands.treemaker.collect_manifests", + collect_manifests, + ) + config = Config(args=args, output=output, term_features=output.term_features) + config.init() + treemaker = TreeMaker(config=config, output=output) + treemaker.run() + captured = capsys.readouterr() + assert "Collection collection_one has malformed metadata." in captured.err + + +def test_tree_malformed_deps_not_string( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + output: Output, + tmp_path: Path, +) -> None: + """Test malformed collection deps. + + Args: + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture. + output: Output class object. + tmp_path: Pytest fixture. + """ + venv_path = tmp_path / "venv" + EnvBuilder().create(venv_path) + + args = Namespace( + venv=venv_path, + verbose=0, + ) + + def collect_manifests( + target: Path, + venv_cache_dir: Path, + ) -> dict[str, dict[str, dict[str, dict[int, int]]]]: + """Return a malformed collection info. + + Args: + target: Target path. + venv_cache_dir: Venv cache directory. + <<<<<<< HEAD + + Returns: + Collection info. + ======= + >>>>>>> 556acba (Add some tests) + """ + assert target + assert venv_cache_dir + return { + "collection_one": { + "collection_info": { + "dependencies": {1: 2}, + }, + }, + } + + monkeypatch.setattr( + "ansible_dev_environment.subcommands.treemaker.collect_manifests", + collect_manifests, + ) + config = Config(args=args, output=output, term_features=output.term_features) + config.init() + treemaker = TreeMaker(config=config, output=output) + treemaker.run() + captured = capsys.readouterr() + assert "Collection collection_one has malformed dependency." in captured.err + + +def test_tree_malformed_repo_not_string( + monkeypatch: pytest.MonkeyPatch, + output: Output, + tmp_path: Path, +) -> None: + """Test malformed collection repo. + + Args: + monkeypatch: Pytest fixture. + output: Output class object. + tmp_path: Pytest fixture. + """ + venv_path = tmp_path / "venv" + EnvBuilder().create(venv_path) + + args = Namespace( + venv=venv_path, + verbose=0, + ) + + def collect_manifests( + target: Path, + venv_cache_dir: Path, + ) -> dict[str, dict[str, JSONVal]]: + """Return a malformed collection info. + + Args: + target: Target path. + venv_cache_dir: Venv cache directory. + <<<<<<< HEAD + + Returns: + Collection info. + ======= + >>>>>>> 556acba (Add some tests) + """ + assert target + assert venv_cache_dir + return { + "collection_one": { + "collection_info": { + "dependencies": {}, + "repository": True, + }, + }, + } + + monkeypatch.setattr( + "ansible_dev_environment.subcommands.treemaker.collect_manifests", + collect_manifests, + ) + config = Config(args=args, output=output, term_features=output.term_features) + config.init() + treemaker = TreeMaker(config=config, output=output) + with pytest.raises(TypeError, match="Link is not a string."): + treemaker.run()