From 8e39f14f985116ab3ba7681f39a6b8527478c9aa Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Mon, 5 Aug 2024 12:25:01 -0700 Subject: [PATCH] Add tests for list and uninstall (#239) * Start cache * Tests for uninstall and list --- .config/dictionary.txt | 3 + .../subcommands/lister.py | 12 +- .../subcommands/uninstaller.py | 35 ++- tests/conftest.py | 133 ++++++++++- tests/unit/test_lister.py | 225 ++++++++++++++++++ tests/unit/test_uninstaller.py | 119 +++++++++ 6 files changed, 498 insertions(+), 29 deletions(-) create mode 100644 tests/unit/test_lister.py create mode 100644 tests/unit/test_uninstaller.py diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 0d4f4ca..ab7c011 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -7,6 +7,7 @@ capsys cauthor cdescription cnamespace +collectonly cpath crepository csource @@ -21,7 +22,9 @@ platlib platstdlib purelib reqs +sessionstart setenv treemaker usefixtures +XDIST xmltodict diff --git a/src/ansible_dev_environment/subcommands/lister.py b/src/ansible_dev_environment/subcommands/lister.py index b8b6aff..70cf93c 100644 --- a/src/ansible_dev_environment/subcommands/lister.py +++ b/src/ansible_dev_environment/subcommands/lister.py @@ -26,11 +26,7 @@ def __init__(self: Lister, config: Config, output: Output) -> None: self._output = output def run(self: Lister) -> None: - """Run the Lister. - - Raises: - TypeError: If the link is not a string. - """ + """Run the Lister.""" collections = collect_manifests( target=self._config.site_pkg_collections_path, venv_cache_dir=self._config.venv_cache_dir, @@ -80,10 +76,10 @@ def run(self: Lister) -> None: homepage = ci.get("homepage") repository = ci.get("repository") issues = ci.get("issues") - link = repository or homepage or docs or issues or "http://ansible.com" + link = repository or homepage or docs or issues or "https://ansible.com" if not isinstance(link, str): - msg = "Link is not a string." - raise TypeError(msg) + self._output.error(err) + link = "https://ansible.com" fqcn_linked = term_link( uri=link, label=fqcn, diff --git a/src/ansible_dev_environment/subcommands/uninstaller.py b/src/ansible_dev_environment/subcommands/uninstaller.py index 16176fe..9d27dba 100644 --- a/src/ansible_dev_environment/subcommands/uninstaller.py +++ b/src/ansible_dev_environment/subcommands/uninstaller.py @@ -92,22 +92,21 @@ def _remove_collection(self: UnInstaller) -> None: self._output.debug(msg) collection_namespace_root = self._collection.site_pkg_path.parent - try: - collection_namespace_root.rmdir() - msg = f"Removed collection namespace root: {collection_namespace_root}" - self._output.debug(msg) - except FileNotFoundError: - pass - except OSError as exc: - msg = f"Failed to remove collection namespace root: {exc}" - self._output.debug(msg) - try: - self._config.site_pkg_collections_path.rmdir() - msg = f"Removed collection root: {self._config.site_pkg_collections_path}" - self._output.debug(msg) - except FileNotFoundError: - pass - except OSError as exc: - msg = f"Failed to remove collection root: {exc}" - self._output.debug(msg) + if collection_namespace_root.exists(): + try: + collection_namespace_root.rmdir() + msg = f"Removed collection namespace root: {collection_namespace_root}" + self._output.debug(msg) + except OSError as exc: + msg = f"Failed to remove collection namespace root: {exc}" + self._output.debug(msg) + + if self._config.site_pkg_collections_path.exists(): + try: + self._config.site_pkg_collections_path.rmdir() + msg = f"Removed collection root: {self._config.site_pkg_collections_path}" + self._output.debug(msg) + except OSError as exc: + msg = f"Failed to remove collection root: {exc}" + self._output.debug(msg) diff --git a/tests/conftest.py b/tests/conftest.py index 66ab754..947ab6e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,13 +16,18 @@ Tracing '/**/src//__init__.py' """ +import json +import os import shutil import tempfile +import warnings from collections.abc import Generator from pathlib import Path +from urllib.request import HTTPError, urlopen import pytest +import yaml import ansible_dev_environment # noqa: F401 @@ -30,6 +35,84 @@ from ansible_dev_environment.config import Config +GALAXY_CACHE = Path(__file__).parent.parent / ".cache" / ".galaxy_cache" +REQS_FILE_NAME = "requirements.yml" + + +@pytest.fixture() +def galaxy_cache() -> Path: + """Return the galaxy cache directory. + + Returns: + The galaxy cache directory. + """ + return GALAXY_CACHE + + +def check_download_collection(name: str, dest: Path) -> None: + """Download a collection if necessary. + + Args: + name: The collection name. + dest: The destination directory. + """ + namespace, name = name.split(".") + base_url = "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections" + + url = f"{base_url}/index/{namespace}/{name}/versions/?is_highest=true" + try: + with urlopen(url) as response: # noqa: S310 + body = response.read() + except HTTPError: + err = f"Failed to check collection version: {name}" + pytest.fail(err) + with urlopen(url) as response: # noqa: S310 + body = response.read() + json_response = json.loads(body) + version = json_response["data"][0]["version"] + file_name = f"{namespace}-{name}-{version}.tar.gz" + file_path = dest / file_name + if file_path.exists(): + return + for found_file in dest.glob(f"{namespace}-{name}-*"): + found_file.unlink() + url = f"{base_url}/artifacts/{file_name}" + warnings.warn(f"Downloading collection: {file_name}", stacklevel=0) + try: + with urlopen(url) as response, file_path.open(mode="wb") as file: # noqa: S310 + file.write(response.read()) + except HTTPError: + err = f"Failed to download collection: {name}" + pytest.fail(err) + + +def pytest_sessionstart(session: pytest.Session) -> None: + """Start the server. + + Args: + session: The pytest session. + """ + if session.config.option.collectonly: + return + + if os.environ.get("PYTEST_XDIST_WORKER"): + return + + if not GALAXY_CACHE.exists(): + GALAXY_CACHE.mkdir(parents=True, exist_ok=True) + + for collection in ("ansible.utils", "ansible.scm", "ansible.posix"): + check_download_collection(collection, GALAXY_CACHE) + + reqs: dict[str, list[dict[str, str]]] = {"collections": []} + + for found_file in GALAXY_CACHE.glob("*.tar.gz"): + reqs["collections"].append({"name": str(found_file)}) + + requirements = GALAXY_CACHE / REQS_FILE_NAME + requirements.write_text(yaml.dump(reqs)) + + @pytest.fixture(name="monkey_session", scope="session") def fixture_monkey_session() -> Generator[pytest.MonkeyPatch, None, None]: """Session scoped monkeypatch fixture. @@ -76,9 +159,8 @@ def session_venv(session_dir: Path, monkey_session: pytest.MonkeyPatch) -> Confi [ "ade", "install", - "ansible.utils", - "ansible.scm", - "ansible.posix", + "-r", + str(GALAXY_CACHE / REQS_FILE_NAME), "--venv", str(venv_path), "--ll", @@ -98,3 +180,48 @@ def session_venv(session_dir: Path, monkey_session: pytest.MonkeyPatch) -> Confi with pytest.raises(SystemExit): cli.run() return cli.config + + +@pytest.fixture() +def function_venv(tmp_path: Path, monkeypatch: 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: + tmp_path: Temporary directory. + monkeypatch: Pytest monkeypatch fixture. + + Returns: + The configuration object for the venv. + """ + venv_path = tmp_path / "venv" + monkeypatch.setattr( + "sys.argv", + [ + "ade", + "install", + "-r", + str(GALAXY_CACHE / REQS_FILE_NAME), + "--venv", + str(venv_path), + "--ll", + "debug", + "--la", + "true", + "--lf", + str(tmp_path / "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_lister.py b/tests/unit/test_lister.py new file mode 100644 index 0000000..3277444 --- /dev/null +++ b/tests/unit/test_lister.py @@ -0,0 +1,225 @@ +"""Test the lister module.""" + +import copy +import tarfile + +from pathlib import Path + +import pytest +import yaml + +from ansible_dev_environment.arg_parser import parse +from ansible_dev_environment.config import Config +from ansible_dev_environment.output import Output +from ansible_dev_environment.subcommands.installer import Installer +from ansible_dev_environment.subcommands.lister import Lister +from ansible_dev_environment.utils import JSONVal, collect_manifests + + +def test_success(session_venv: Config, capsys: pytest.CaptureFixture[str]) -> None: + """Test the lister. + + Args: + session_venv: The venv configuration. + capsys: The capsys fixture. + + """ + lister = Lister(config=session_venv, output=session_venv._output) + lister.run() + captured = capsys.readouterr() + assert "ansible.scm" in captured.out + assert "ansible.utils" in captured.out + assert "ansible.posix" in captured.out + + +def test_collection_info_corrupt( + session_venv: Config, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test the lister with corrupt collection info. + + Args: + session_venv: The venv configuration. + monkeypatch: The monkeypatch fixture. + capsys: The capsys fixture. + """ + orig_collect_manifests = collect_manifests + + def mock_collect_manifests(target: Path, venv_cache_dir: Path) -> dict[str, dict[str, JSONVal]]: + """Mock the manifest collection. + + Args: + target: The target directory. + venv_cache_dir: The venv cache directory. + + Returns: + dict: The collection manifests. + + """ + collections = orig_collect_manifests(target=target, venv_cache_dir=venv_cache_dir) + collections["ansible.utils"]["collection_info"] = "This is not a valid dict." + return collections + + monkeypatch.setattr( + "ansible_dev_environment.subcommands.lister.collect_manifests", + mock_collect_manifests, + ) + + lister = Lister(config=session_venv, output=session_venv._output) + lister.run() + captured = capsys.readouterr() + assert "Collection ansible.utils has malformed metadata." in captured.err + + +def test_collection_info_collection_corrupt( + session_venv: Config, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test the lister with corrupt collection info for collections. + + Args: + session_venv: The venv configuration. + monkeypatch: The monkeypatch fixture. + capsys: The capsys fixture. + """ + orig_collect_manifests = collect_manifests + + def mock_collect_manifests(target: Path, venv_cache_dir: Path) -> dict[str, dict[str, JSONVal]]: + """Mock the manifest collection. + + Args: + target: The target directory. + venv_cache_dir: The venv cache directory. + + Returns: + dict: The collection manifests. + + """ + collections = orig_collect_manifests(target=target, venv_cache_dir=venv_cache_dir) + assert isinstance(collections["ansible.utils"]["collection_info"], dict) + assert isinstance(collections["ansible.scm"]["collection_info"], dict) + assert isinstance(collections["ansible.posix"]["collection_info"], dict) + collections["ansible.utils"]["collection_info"]["name"] = True + collections["ansible.scm"]["collection_info"]["namespace"] = True + collections["ansible.posix"]["collection_info"]["version"] = True + return collections + + monkeypatch.setattr( + "ansible_dev_environment.subcommands.lister.collect_manifests", + mock_collect_manifests, + ) + + lister = Lister(config=session_venv, output=session_venv._output) + lister.run() + captured = capsys.readouterr() + assert "Collection ansible.utils has malformed metadata." in captured.err + assert "Collection ansible.utils has malformed metadata." in captured.err + assert "Collection ansible.scm has malformed metadata." in captured.err + + +def test_broken_link( + session_venv: Config, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test the lister with corrupt repository URL. + + Args: + session_venv: The venv configuration. + monkeypatch: The monkeypatch fixture. + capsys: The capsys fixture. + """ + config = copy.deepcopy(session_venv) + config._output.term_features.links = True + orig_collect_manifests = collect_manifests + + def mock_collect_manifests(target: Path, venv_cache_dir: Path) -> dict[str, dict[str, JSONVal]]: + """Mock the manifest collection. + + Args: + target: The target directory. + venv_cache_dir: The venv cache directory. + + Returns: + dict: The collection manifests. + + """ + collections = orig_collect_manifests(target=target, venv_cache_dir=venv_cache_dir) + assert isinstance(collections["ansible.utils"]["collection_info"], dict) + collections["ansible.utils"]["collection_info"]["repository"] = True + return collections + + monkeypatch.setattr( + "ansible_dev_environment.subcommands.lister.collect_manifests", + mock_collect_manifests, + ) + + lister = Lister(config=session_venv, output=session_venv._output) + lister.run() + captured = capsys.readouterr() + assert "Collection ansible.utils has malformed metadata." in captured.err + + +def test_editable( + tmp_path: Path, + output: Output, + galaxy_cache: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the uninstaller against an editable collection. + + Because the galaxy tar doesn't have a galaxy.yml file, construct one. + Uninstall twice to catch them not found error. Use the ansible.posix + collection since it has no deps. + + Args: + tmp_path: The tmp_path fixture. + output: The output fixture. + galaxy_cache: The galaxy_cache fixture. + capsys: The capsys fixture. + monkeypatch: The monkeypatch fixture. + + """ + src_dir = tmp_path / "ansible.posix" + tar_file_path = next(galaxy_cache.glob("ansible-posix*")) + with tarfile.open(tar_file_path, "r") as tar: + try: + tar.extractall(src_dir, filter="data") + except TypeError: + tar.extractall(src_dir) # noqa: S202 + galaxy_contents = { + "authors": "author", + "name": "posix", + "namespace": "ansible", + "readme": "readme", + "version": "1.0.0", + } + yaml.dump(galaxy_contents, (src_dir / "galaxy.yml").open("w")) + + monkeypatch.setattr( + "sys.argv", + [ + "ade", + "install", + "--editable", + str(src_dir), + "--lf", + str(tmp_path / "ade.log"), + "--venv", + str(tmp_path / "venv"), + ], + ) + args = parse() + config = Config(args=args, output=output, term_features=output.term_features) + config.init() + installer = Installer(config=config, output=config._output) + installer.run() + + lister = Lister(config=config, output=config._output) + lister.run() + captured = capsys.readouterr() + assert "ansible.posix" in captured.out + assert str(tmp_path / "ansible.posix") in captured.out diff --git a/tests/unit/test_uninstaller.py b/tests/unit/test_uninstaller.py new file mode 100644 index 0000000..4736e97 --- /dev/null +++ b/tests/unit/test_uninstaller.py @@ -0,0 +1,119 @@ +"""Test the uninstaller module.""" + +import copy +import tarfile + +from pathlib import Path + +import pytest +import yaml + +from ansible_dev_environment.arg_parser import parse +from ansible_dev_environment.config import Config +from ansible_dev_environment.output import Output +from ansible_dev_environment.subcommands.installer import Installer +from ansible_dev_environment.subcommands.uninstaller import UnInstaller + + +def test_many(session_venv: Config, capsys: pytest.CaptureFixture[str]) -> None: + """Test the uninstaller with many collections. + + Args: + session_venv: The session_venv fixture. + capsys: The capsys fixture. + """ + config = copy.deepcopy(session_venv) + config.args.collection_specifier = ["community.general", "ansible.utils"] + uninstaller = UnInstaller(config=config, output=config._output) + with pytest.raises(SystemExit) as exc: + uninstaller.run() + assert exc.value.code == 1 + captured = capsys.readouterr() + assert "Only one collection can be uninstalled at a time." in captured.err + + +def test_missing_reqs( + session_venv: Config, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test the uninstaller against a missing requirements file. + + Args: + session_venv: The session_venv fixture. + tmp_path: The tmp_path fixture. + capsys: The capsys fixture. + """ + config = copy.deepcopy(session_venv) + config.args.requirement = str(tmp_path / "requirements.yml") + uninstaller = UnInstaller(config=config, output=config._output) + with pytest.raises(SystemExit) as exc: + uninstaller.run() + assert exc.value.code == 1 + captured = capsys.readouterr() + assert "Failed to find requirements file" in captured.err + + +def test_editable_uninstall( + tmp_path: Path, + output: Output, + galaxy_cache: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the uninstaller against an editable collection. + + Because the galaxy tar doesn't have a galaxy.yml file, construct one. + Uninstall twice to catch them not found error. Use the ansible.posix + collection since it has no deps. + + Args: + tmp_path: The tmp_path fixture. + output: The output fixture. + galaxy_cache: The galaxy_cache fixture. + capsys: The capsys fixture. + monkeypatch: The monkeypatch fixture. + + """ + src_dir = tmp_path / "ansible.posix" + tar_file_path = next(galaxy_cache.glob("ansible-posix*")) + with tarfile.open(tar_file_path, "r") as tar: + try: + tar.extractall(src_dir, filter="data") + except TypeError: + tar.extractall(src_dir) # noqa: S202 + galaxy_contents = { + "authors": "author", + "name": "posix", + "namespace": "ansible", + "readme": "readme", + "version": "1.0.0", + } + yaml.dump(galaxy_contents, (src_dir / "galaxy.yml").open("w")) + + monkeypatch.setattr( + "sys.argv", + [ + "ade", + "install", + "--editable", + str(src_dir), + "--lf", + str(tmp_path / "ade.log"), + "--venv", + str(tmp_path / "venv"), + "-vvv", + ], + ) + args = parse() + config = Config(args=args, output=output, term_features=output.term_features) + config.init() + installer = Installer(config=config, output=config._output) + installer.run() + args.collection_specifier = ["ansible.posix"] + uninstaller = UnInstaller(config=config, output=config._output) + uninstaller.run() + uninstaller.run() + captured = capsys.readouterr() + assert "Removed ansible.posix" in captured.out + assert "Failed to find ansible.posix" in captured.out