diff --git a/.config/dictionary.txt b/.config/dictionary.txt index cff4571..898f4f6 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -21,3 +21,4 @@ reqs setenv treemaker usefixtures +xmltodict diff --git a/pyproject.toml b/pyproject.toml index f0431e7..add54d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ line-length = 100 [tool.coverage.report] exclude_also = ["if TYPE_CHECKING:", "pragma: no cover"] -fail_under = 72 +fail_under = 81 ignore_errors = true show_missing = true skip_covered = true diff --git a/src/ansible_dev_environment/collection.py b/src/ansible_dev_environment/collection.py index af7f9e8..f84deca 100644 --- a/src/ansible_dev_environment/collection.py +++ b/src/ansible_dev_environment/collection.py @@ -3,7 +3,6 @@ from __future__ import annotations import re -import sys from dataclasses import dataclass from pathlib import Path @@ -34,14 +33,14 @@ class Collection: # pylint: disable=too-many-instance-attributes """ config: Config - path: Path | None = None - opt_deps: str | None = None - local: bool | None = None - cnamespace: str | None = None - cname: str | None = None - csource: list[str] | None = None - specifier: str | None = None - original: str | None = None + path: Path + opt_deps: str + local: bool + cnamespace: str + cname: str + csource: list[str] + specifier: str + original: str @property def name(self: Collection) -> str: @@ -70,12 +69,7 @@ def site_pkg_path(self: Collection) -> Path: Returns: The site packages collection path - Raises: - RuntimeError: If the collection namespace or name is not set """ - if not self.cnamespace or not self.cname: - msg = "Collection namespace or name not set." - raise RuntimeError(msg) return self.config.site_pkg_collections_path / self.cnamespace / self.cname @@ -90,10 +84,12 @@ def parse_collection_request( # noqa: PLR0915 string: The collection request string config: The configuration object output: The output object + + Raises: + SystemExit: If the collection request is invalid Returns: A collection object """ - collection = Collection(config=config, original=string) # spec with dep, local if "[" in string and "]" in string: msg = f"Found optional dependencies in collection request: {string}" @@ -106,15 +102,25 @@ def parse_collection_request( # noqa: PLR0915 output.critical(msg) msg = f"Found local collection request with dependencies: {string}" output.debug(msg) - collection.path = path - msg = f"Setting collection path: {collection.path}" + msg = f"Setting collection path: {path}" output.debug(msg) - collection.opt_deps = string.split("[")[1].split("]")[0] - msg = f"Setting optional dependencies: {collection.opt_deps}" + opt_deps = string.split("[")[1].split("]")[0] + msg = f"Setting optional dependencies: {opt_deps}" output.debug(msg) - collection.local = True + local = True msg = "Setting request as local" output.debug(msg) + collection = Collection( + config=config, + path=path, + opt_deps=opt_deps, + local=local, + cnamespace="", + cname="", + csource=[], + specifier="", + original=string, + ) get_galaxy(collection=collection, output=output) return collection # spec without dep, local @@ -124,10 +130,20 @@ def parse_collection_request( # noqa: PLR0915 output.debug(msg) msg = f"Setting collection path: {path}" output.debug(msg) - collection.path = path msg = "Setting request as local" output.debug(msg) - collection.local = True + local = True + collection = Collection( + config=config, + path=path, + opt_deps="", + local=local, + cnamespace="", + cname="", + csource=[], + specifier="", + original=string, + ) get_galaxy(collection=collection, output=output) return collection non_local_re = re.compile( @@ -145,28 +161,42 @@ def parse_collection_request( # noqa: PLR0915 output.hint(msg) msg = f"Failed to parse collection request: {string}" output.critical(msg) - sys.exit(1) + raise SystemExit(1) # pragma: no cover # (critical is a sys.exit) msg = f"Found non-local collection request: {string}" output.debug(msg) - collection.cnamespace = matched.group("cnamespace") - msg = f"Setting collection namespace: {collection.cnamespace}" + cnamespace = matched.group("cnamespace") + msg = f"Setting collection namespace: {cnamespace}" output.debug(msg) - collection.cname = matched.group("cname") - msg = f"Setting collection name: {collection.cname}" + cname = matched.group("cname") + msg = f"Setting collection name: {cname}" output.debug(msg) if matched.group("specifier"): - collection.specifier = matched.group("specifier") - msg = f"Setting collection specifier: {collection.specifier}" + specifier = matched.group("specifier") + msg = f"Setting collection specifier: {specifier}" + output.debug(msg) + else: + specifier = "" + msg = "Setting collection specifier as empty" output.debug(msg) - collection.local = False + local = False msg = "Setting request as non-local" output.debug(msg) - return collection + return Collection( + config=config, + path=Path(), + opt_deps="", + local=local, + cnamespace=cnamespace, + cname=cname, + csource=[], + specifier=specifier, + original=string, + ) def get_galaxy(collection: Collection, output: Output) -> None: @@ -178,9 +208,6 @@ def get_galaxy(collection: Collection, output: Output) -> None: Raises: SystemExit: If the collection name is not found """ - if collection is None or collection.path is None: - msg = "get_galaxy called without a collection or path" - raise RuntimeError(msg) file_name = collection.path / "galaxy.yml" if not file_name.exists(): err = f"Failed to find {file_name} in {collection.path}" @@ -203,4 +230,4 @@ def get_galaxy(collection: Collection, output: Output) -> None: output.critical(err) else: return - raise SystemExit(1) # We shouldn't be here + raise SystemExit(1) # pragma: no cover # (critical is a sys.exit) diff --git a/src/ansible_dev_environment/subcommands/treemaker.py b/src/ansible_dev_environment/subcommands/treemaker.py index 43fb3b3..b7d3b35 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, cast +from typing import TYPE_CHECKING, Union, cast from ansible_dev_environment.tree import Tree from ansible_dev_environment.utils import builder_introspect, collect_manifests @@ -15,7 +15,8 @@ ScalarVal = bool | str | float | int | None JSONVal = ScalarVal | list["JSONVal"] | dict[str, "JSONVal"] -NOT_A_DICT = "Tree dict is not a dict." +TreeWithReqs = dict[str, Union[list[str], "TreeWithReqs"]] +TreeWithoutReqs = dict[str, "TreeWithoutReqs"] class TreeMaker: @@ -32,11 +33,7 @@ def __init__(self: TreeMaker, config: Config, output: Output) -> None: self._output = output def run(self: TreeMaker) -> None: # noqa: C901, PLR0912, PLR0915 - """Run the command. - - Raises: - TypeError: If the tree dict is not a dict. - """ + """Run the command.""" builder_introspect(self._config, self._output) with self._config.discovered_python_reqs.open("r") as reqs_file: @@ -46,7 +43,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: dict[str, dict[str, JSONVal]] = {c: {} for c in collections} + tree_dict: TreeWithoutReqs = {c: {} for c in collections} links: dict[str, str] = {} for collection_name, collection in collections.items(): @@ -70,15 +67,17 @@ def run(self: TreeMaker) -> None: # noqa: C901, PLR0912, PLR0915 homepage = collection["collection_info"].get("homepage") repository = collection["collection_info"].get("repository") issues = collection["collection_info"].get("issues") - link = repository or homepage or docs or issues or "http://ansible.com" + fallback = "https://ansible.com" + link = repository or homepage or docs or issues or fallback if not isinstance(link, str): - msg = "Link is not a string." - raise TypeError(msg) + err = f"Collection {collection_name} has malformed repository metadata." + self._output.error(err) + link = fallback links[collection_name] = link if self._config.args.verbose >= 1: add_python_reqs( - tree_dict=tree_dict, + tree_dict=cast(TreeWithReqs, tree_dict), collection_name=collection_name, python_deps=python_deps, ) @@ -92,27 +91,25 @@ def run(self: TreeMaker) -> None: # noqa: C901, PLR0912, PLR0915 more_verbose = 2 if self._config.args.verbose >= more_verbose: - j_tree_dict = cast(JSONVal, tree_dict) - tree = Tree(obj=j_tree_dict, term_features=self._config.term_features) + tree = Tree(obj=cast(JSONVal, tree_dict), term_features=self._config.term_features) tree.links = links tree.green.extend(green) rendered = tree.render() print(rendered) # noqa: T201 else: - pruned_tree_dict: JSONVal = {} - if not isinstance(pruned_tree_dict, dict): - raise TypeError(NOT_A_DICT) - for collection_name in list(tree_dict.keys()): + pruned_tree_dict: TreeWithoutReqs = {} + for collection_name in tree_dict: found = False for value in tree_dict.values(): - if not isinstance(value, dict): - raise TypeError(NOT_A_DICT) if collection_name in value: found = True if not found: pruned_tree_dict[collection_name] = tree_dict[collection_name] - tree = Tree(obj=pruned_tree_dict, term_features=self._config.term_features) + tree = Tree( + obj=cast(JSONVal, pruned_tree_dict), + term_features=self._config.term_features, + ) tree.links = links tree.green.extend(green) rendered = tree.render() @@ -126,7 +123,7 @@ def run(self: TreeMaker) -> None: # noqa: C901, PLR0912, PLR0915 def add_python_reqs( - tree_dict: dict[str, dict[str, JSONVal]], + tree_dict: TreeWithReqs, collection_name: str, python_deps: list[str], ) -> None: @@ -140,17 +137,19 @@ def add_python_reqs( Raises: TypeError: If the tree dict is not a dict. """ - if not isinstance(tree_dict, dict): - raise TypeError(NOT_A_DICT) collection = tree_dict[collection_name] if not isinstance(collection, dict): - raise TypeError(NOT_A_DICT) - collection["python requirements"] = [] + msg = "Did you really name a collection 'python requirements'?" + raise TypeError(msg) + deps = [] for dep in sorted(python_deps): - name, comment = dep.split("#", 1) + if "#" in dep: + name, comment = dep.split("#", 1) + else: + name = dep + comment = "" if collection_name in comment: - if not isinstance(collection["python requirements"], list): - msg = "Python requirements is not a list." - raise TypeError(msg) - collection["python requirements"].append(name.strip()) + deps.append(name.strip()) + + collection["python requirements"] = deps diff --git a/tests/conftest.py b/tests/conftest.py index 0feff89..66ab754 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,4 +16,85 @@ Tracing '/**/src//__init__.py' """ +import shutil +import tempfile + +from collections.abc import Generator +from pathlib import Path + +import pytest + import ansible_dev_environment # noqa: F401 + +from ansible_dev_environment.cli import Cli +from ansible_dev_environment.config import Config + + +@pytest.fixture(name="monkey_session", scope="session") +def fixture_monkey_session() -> Generator[pytest.MonkeyPatch, None, None]: + """Session scoped monkeypatch fixture. + + Yields: + pytest.MonkeyPatch: The monkeypatch fixture. + """ + monkey_patch = pytest.MonkeyPatch() + yield monkey_patch + monkey_patch.undo() + + +@pytest.fixture(name="session_dir", scope="session") +def fixture_session_dir() -> Generator[Path, None, None]: + """A session scoped temporary directory. + + Yields: + Path: Temporary directory. + """ + temp_dir = Path(tempfile.mkdtemp()) + yield temp_dir + shutil.rmtree(temp_dir) + + +@pytest.fixture(scope="session") +def session_venv(session_dir: Path, monkey_session: pytest.MonkeyPatch) -> Config: + """Create a temporary venv for the session. + + Add some common collections to the venv. + + Since this is a session level fixture, care should be taken to not manipulate it + or the resulting config in a way that would affect other tests. + + Args: + session_dir: Temporary directory. + monkey_session: Pytest monkeypatch fixture. + + Returns: + The configuration object for the venv. + """ + venv_path = session_dir / "venv" + monkey_session.setattr( + "sys.argv", + [ + "ade", + "install", + "ansible.utils", + "ansible.scm", + "ansible.posix", + "--venv", + str(venv_path), + "--ll", + "debug", + "--la", + "true", + "--lf", + str(session_dir / "ade.log"), + "-vvv", + ], + ) + cli = Cli() + cli.parse_args() + cli.init_output() + cli.args_sanity() + cli.ensure_isolated() + with pytest.raises(SystemExit): + cli.run() + return cli.config diff --git a/tests/unit/test_collection.py b/tests/unit/test_collection.py new file mode 100644 index 0000000..19f35f6 --- /dev/null +++ b/tests/unit/test_collection.py @@ -0,0 +1,131 @@ +"""Tests for the collection module.""" + +from __future__ import annotations + +from argparse import Namespace +from typing import TYPE_CHECKING + +import pytest + +from ansible_dev_environment.collection import Collection, get_galaxy +from ansible_dev_environment.config import Config +from ansible_dev_environment.utils import TermFeatures + + +if TYPE_CHECKING: + from pathlib import Path + + from ansible_dev_environment.output import Output + + +@pytest.mark.usefixtures("_wide_console") +def test_get_galaxy_missing( + tmp_path: Path, + output: Output, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test when the galaxy.yml file is missing. + + Args: + tmp_path: Temporary directory. + output: Output class object. + capsys: Pytest fixture + """ + config = Config( + args=Namespace(), + term_features=TermFeatures(color=False, links=False), + output=output, + ) + collection = Collection( + config=config, + path=tmp_path, + cname="utils", + cnamespace="ansible", + local=False, + original="ansible.utils", + specifier="", + opt_deps="", + csource=[], + ) + with pytest.raises(SystemExit): + get_galaxy(collection, output) + + captured = capsys.readouterr() + assert f"Failed to find {tmp_path / 'galaxy.yml'} in {tmp_path}\n" in captured.err + + +@pytest.mark.usefixtures("_wide_console") +def test_get_galaxy_no_meta( + tmp_path: Path, + output: Output, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test when the galaxy.yml file is name/namespace. + + Args: + tmp_path: Temporary directory. + output: Output class object. + capsys: Pytest fixture + """ + (tmp_path / "galaxy.yml").write_text("corrupt: yaml\n") + config = Config( + args=Namespace(), + term_features=TermFeatures(color=False, links=False), + output=output, + ) + collection = Collection( + config=config, + path=tmp_path, + cname="utils", + cnamespace="ansible", + local=False, + original="ansible.utils", + specifier="", + opt_deps="", + csource=[], + ) + with pytest.raises(SystemExit): + get_galaxy(collection, output) + + captured = capsys.readouterr() + assert ( + f"Failed to find collection name in {tmp_path / 'galaxy.yml'}: 'namespace'\n" + in captured.err + ) + + +@pytest.mark.usefixtures("_wide_console") +def test_get_galaxy_corrupt( + tmp_path: Path, + output: Output, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test when the galaxy.yml file is missing. + + Args: + tmp_path: Temporary directory. + output: Output class object. + capsys: Pytest fixture + """ + (tmp_path / "galaxy.yml").write_text(",") + config = Config( + args=Namespace(), + term_features=TermFeatures(color=False, links=False), + output=output, + ) + collection = Collection( + config=config, + path=tmp_path, + cname="utils", + cnamespace="ansible", + local=False, + original="ansible.utils", + specifier="", + opt_deps="", + csource=[], + ) + with pytest.raises(SystemExit): + get_galaxy(collection, output) + + captured = capsys.readouterr() + assert "Failed to load yaml file:" in captured.err diff --git a/tests/unit/test_inspector.py b/tests/unit/test_inspector.py new file mode 100644 index 0000000..f0314b5 --- /dev/null +++ b/tests/unit/test_inspector.py @@ -0,0 +1,84 @@ +"""Tests for the inspector module.""" + +import copy +import importlib +import json +import re +import sys + +import pytest + +from ansible_dev_environment.config import Config +from ansible_dev_environment.subcommands import inspector + + +def test_output_no_color(session_venv: Config, capsys: pytest.CaptureFixture) -> None: + """Test the inspector output. + + Args: + session_venv: The configuration object for the venv. + capsys: Pytest capture fixture. + """ + _inspector = inspector.Inspector(config=session_venv, output=session_venv._output) + _inspector.run() + captured = capsys.readouterr() + data = json.loads(captured.out) + assert "ansible.posix" in data + assert "ansible.scm" in data + assert "ansible.utils" in data + + +def test_output_color( + session_venv: Config, + capsys: pytest.CaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the inspector output. + + Args: + session_venv: The configuration object for the venv. + capsys: Pytest capture fixture. + monkeypatch: Pytest monkeypatch fixture. + """ + monkeypatch.setenv("FORCE_COLOR", "1") + config = copy.deepcopy(session_venv) + config.term_features.color = True + _inspector = inspector.Inspector(config=config, output=session_venv._output) + _inspector.run() + captured = capsys.readouterr() + assert captured.out.startswith("\x1b") + ansi_escape = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]") + no_ansi = ansi_escape.sub("", captured.out) + data = json.loads(no_ansi) + assert "ansible.posix" in data + assert "ansible.scm" in data + assert "ansible.utils" in data + + +def test_no_rich( + session_venv: Config, + capsys: pytest.CaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the inspector output when rich is not available. + + Args: + session_venv: The configuration object for the venv. + capsys: Pytest capture fixture. + monkeypatch: Pytest monkeypatch fixture. + """ + with monkeypatch.context() as monkey_rich: + monkey_rich.setitem(sys.modules, "pip._vendor.rich", None) + importlib.reload(inspector) + assert inspector.HAS_RICH is False + + _inspector = inspector.Inspector(config=session_venv, output=session_venv._output) + _inspector.run() + + importlib.reload(inspector) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert "ansible.posix" in data + assert "ansible.scm" in data + assert "ansible.utils" in data diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index a016b66..0370022 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5,12 +5,14 @@ import pytest -def test_main(capsys: pytest.CaptureFixture[str]) -> None: +def test_main(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: """Test the main file. Args: capsys: Capture stdout and stderr + monkeypatch: Pytest monkeypatch fixture. """ + monkeypatch.setattr("sys.argv", ["ansible-dev-environment"]) with pytest.raises(SystemExit): runpy.run_module("ansible_dev_environment", run_name="__main__", alter_sys=True) diff --git a/tests/unit/test_treemaker.py b/tests/unit/test_treemaker.py index 7f09f88..5641d2b 100644 --- a/tests/unit/test_treemaker.py +++ b/tests/unit/test_treemaker.py @@ -8,7 +8,7 @@ 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.subcommands.treemaker import TreeMaker, TreeWithReqs, add_python_reqs from ansible_dev_environment.utils import JSONVal @@ -71,12 +71,9 @@ def collect_manifests( Args: target: Target path. venv_cache_dir: Venv cache directory. - <<<<<<< HEAD Returns: Collection info. - ======= - >>>>>>> 556acba (Add some tests) """ assert target assert venv_cache_dir @@ -129,12 +126,9 @@ def collect_manifests( Args: target: Target path. venv_cache_dir: Venv cache directory. - <<<<<<< HEAD Returns: Collection info. - ======= - >>>>>>> 556acba (Add some tests) """ assert target assert venv_cache_dir @@ -189,12 +183,9 @@ def collect_manifests( Args: target: Target path. venv_cache_dir: Venv cache directory. - <<<<<<< HEAD Returns: Collection info. - ======= - >>>>>>> 556acba (Add some tests) """ assert target assert venv_cache_dir @@ -222,6 +213,7 @@ def test_tree_malformed_repo_not_string( monkeypatch: pytest.MonkeyPatch, output: Output, tmp_path: Path, + capsys: pytest.CaptureFixture[str], ) -> None: """Test malformed collection repo. @@ -229,6 +221,7 @@ def test_tree_malformed_repo_not_string( monkeypatch: Pytest fixture. output: Output class object. tmp_path: Pytest fixture. + capsys: Pytest stdout capture fixture. """ venv_path = tmp_path / "venv" EnvBuilder().create(venv_path) @@ -247,12 +240,9 @@ def collect_manifests( Args: target: Target path. venv_cache_dir: Venv cache directory. - <<<<<<< HEAD Returns: Collection info. - ======= - >>>>>>> 556acba (Add some tests) """ assert target assert venv_cache_dir @@ -272,5 +262,62 @@ def 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() + treemaker.run() + captured = capsys.readouterr() + assert "Collection collection_one has malformed repository metadata." in captured.err + + +def test_tree_verbose(session_venv: Config, capsys: pytest.CaptureFixture[str]) -> None: + """Test tree verbose, the session_venv has v=3. + + Args: + session_venv: Pytest fixture. + capsys: Pytest stdout capture fixture. + """ + treemaker = TreeMaker(config=session_venv, output=session_venv._output) + treemaker.run() + captured = capsys.readouterr() + assert "└──python requirements" in captured.out + assert "xmltodict" in captured.out + + +def test_reqs_no_pound( + session_venv: Config, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test python deps with no pound signs in the line, cannot be attributed to a collection. + + Args: + session_venv: Pytest fixture. + capsys: Pytest stdout capture fixture. + monkeypatch: Pytest fixture for patching. + """ + + def builder_introspect(config: Config, output: Output) -> None: + """Mock builder introspect. + + Args: + config: The application configuration. + output: The application output object. + """ + assert output + config.discovered_python_reqs.write_text("xmltodict\n") + + monkeypatch.setattr( + "ansible_dev_environment.subcommands.treemaker.builder_introspect", + builder_introspect, + ) + + treemaker = TreeMaker(config=session_venv, output=session_venv._output) + treemaker.run() + captured = capsys.readouterr() + assert "└──python requirements" in captured.out + assert "xmltodict" not in captured.out + + +def test_collection_is_a_list() -> None: + """Confirm a TypeError is the collection isn't a dict.""" + tree_dict: TreeWithReqs = {"test_collection": []} + with pytest.raises(TypeError): + add_python_reqs(tree_dict, "test_collection", ["xmltodict"]) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index bf171b2..a51e8d2 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -41,6 +41,10 @@ cnamespace="ansible", local=False, original="ansible.utils", + specifier="", + path=Path(), + opt_deps="", + csource=[], ), ), ( @@ -52,6 +56,9 @@ specifier=":1.0.0", local=False, original="ansible.utils:1.0.0", + path=Path(), + opt_deps="", + csource=[], ), ), ( @@ -63,6 +70,9 @@ specifier=">=1.0.0", local=False, original="ansible.utils>=1.0.0", + path=Path(), + opt_deps="", + csource=[], ), ), ( @@ -73,8 +83,10 @@ config=config, local=True, path=FIXTURE_DIR, - specifier=None, + specifier="", original=str(FIXTURE_DIR), + opt_deps="", + csource=[], ), ), ( @@ -86,18 +98,15 @@ local=True, opt_deps="test", path=FIXTURE_DIR, - specifier=None, + specifier="", original=str(FIXTURE_DIR) + "/[test]", + csource=[], ), ), - ( - "/foo/bar", - None, - ), - ( - "abcdefg", - None, - ), + ("/foo/bar", None), + ("abcdefg", None), + ("/12345678901234567890[test]", None), + ("not_a_collection_name", None), )