Skip to content

Commit

Permalink
Revise site packages collection search test (#325)
Browse files Browse the repository at this point in the history
* Revise site packages collection search test
* Fix linting errors
* Set cpath
* REturn resolved path
* Set CWD
* Resolve site pkg dirs
* Print stdout, stderr
* Force the installation
* Force install first time
* Install if needed
  • Loading branch information
cidrblock authored Aug 29, 2023
1 parent d736af6 commit 63e7fbf
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 73 deletions.
22 changes: 16 additions & 6 deletions src/ansible_compat/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
else:
CompletedProcess = subprocess.CompletedProcess


_logger = logging.getLogger(__name__)
# regex to extract the first version from a collection range specifier
version_re = re.compile(":[>=<]*([^,]*)")
Expand Down Expand Up @@ -679,11 +680,16 @@ def require_collection(
version: str | None = None,
*,
install: bool = True,
) -> None:
) -> tuple[CollectionVersion, Path]:
"""Check if a minimal collection version is present or exits.
In the future this method may attempt to install a missing or outdated
collection before failing.
:param name: collection name
:param version: minimal version required
:param install: if True, attempt to install a missing collection
:returns: tuple of (found_version, collection_path)
"""
try:
ns, coll = name.split(".", 1)
Expand Down Expand Up @@ -728,15 +734,19 @@ def require_collection(
msg = f"Found {name} collection {found_version} but {version} or newer is required."
_logger.fatal(msg)
raise InvalidPrerequisiteError(msg)
return found_version, collpath.resolve()
break
else:
if install:
self.install_collection(f"{name}:>={version}" if version else name)
self.require_collection(name=name, version=version, install=False)
else:
msg = f"Collection '{name}' not found in '{paths}'"
_logger.fatal(msg)
raise InvalidPrerequisiteError(msg)
return self.require_collection(
name=name,
version=version,
install=False,
)
msg = f"Collection '{name}' not found in '{paths}'"
_logger.fatal(msg)
raise InvalidPrerequisiteError(msg)

def _prepare_ansible_paths(self) -> None:
"""Configure Ansible environment variables."""
Expand Down
99 changes: 99 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"""Pytest fixtures."""
import importlib.metadata
import json
import pathlib
import subprocess
import sys
from collections.abc import Generator
from pathlib import Path
from typing import Callable

import pytest

Expand All @@ -26,3 +32,96 @@ def runtime_tmp(
instance = Runtime(project_dir=tmp_path, isolated=True)
yield instance
instance.clean()


def query_pkg_version(pkg: str) -> str:
"""Get the version of a current installed package.
:param pkg: Package name
:return: Package version
"""
return importlib.metadata.version(pkg)


@pytest.fixture()
def pkg_version() -> Callable[[str], str]:
"""Get the version of a current installed package.
:return: Callable function to get package version
"""
return query_pkg_version


class VirtualEnvironment:
"""Virtualenv wrapper."""

def __init__(self, path: Path) -> None:
"""Initialize.
:param path: Path to virtualenv
"""
self.project = path
self.venv_path = self.project / "venv"
self.venv_bin_path = self.venv_path / "bin"
self.venv_python_path = self.venv_bin_path / "python"

def create(self) -> None:
"""Create virtualenv."""
cmd = [str(sys.executable), "-m", "venv", str(self.venv_path)]
subprocess.check_call(args=cmd)
# Install this package into the virtual environment
self.install(f"{__file__}/../..")

def install(self, *packages: str) -> None:
"""Install packages in virtualenv.
:param packages: Packages to install
"""
cmd = [str(self.venv_python_path), "-m", "pip", "install", *packages]
subprocess.check_call(args=cmd)

def python_script_run(self, script: str) -> subprocess.CompletedProcess[str]:
"""Run command in project dir using venv.
:param args: Command to run
"""
proc = subprocess.run(
args=[self.venv_python_path, "-c", script],
capture_output=True,
cwd=self.project,
check=False,
text=True,
)
return proc

def site_package_dirs(self) -> list[Path]:
"""Get site packages.
:return: List of site packages dirs
"""
script = "import json, site; print(json.dumps(site.getsitepackages()))"
proc = subprocess.run(
args=[self.venv_python_path, "-c", script],
capture_output=True,
check=False,
text=True,
)
dirs = json.loads(proc.stdout)
if not isinstance(dirs, list):
msg = "Expected list of site packages"
raise TypeError(msg)
sanitized = list({Path(d).resolve() for d in dirs})
return sanitized


@pytest.fixture(scope="module")
def venv_module(tmp_path_factory: pytest.TempPathFactory) -> VirtualEnvironment:
"""Create a virtualenv in a temporary directory.
:param tmp_path: pytest fixture for temp path
:return: VirtualEnvironment instance
"""
test_project = tmp_path_factory.mktemp(basename="test_project-", numbered=True)
_venv = VirtualEnvironment(test_project)
_venv.create()
return _venv
67 changes: 0 additions & 67 deletions test/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import pathlib
import subprocess
from contextlib import contextmanager
from dataclasses import dataclass, fields
from pathlib import Path
from shutil import rmtree
from typing import TYPE_CHECKING, Any
Expand Down Expand Up @@ -35,8 +34,6 @@
from _pytest.monkeypatch import MonkeyPatch
from pytest_mock import MockerFixture

V2_COLLECTION_TARBALL = Path("examples/reqs_v2/community-molecule-0.1.0.tar.gz")


def test_runtime_version(runtime: Runtime) -> None:
"""Tests version property."""
Expand Down Expand Up @@ -452,70 +449,6 @@ def test_require_collection(runtime_tmp: Runtime) -> None:
runtime_tmp.require_collection("community.molecule", "0.1.0")


@dataclass
class ScanSysPath:
"""Parameters for scan tests."""

isolated: bool
scan: bool
expected: bool

def __str__(self) -> str:
"""Return a string representation of the object."""
parts = [
f"{field.name}{str(getattr(self, field.name))[0]}" for field in fields(self)
]
return "-".join(parts)


@pytest.mark.parametrize(
("param"),
(
ScanSysPath(isolated=True, scan=True, expected=False),
ScanSysPath(isolated=True, scan=False, expected=False),
ScanSysPath(isolated=False, scan=True, expected=True),
ScanSysPath(isolated=False, scan=False, expected=False),
),
ids=str,
)
def test_scan_sys_path(
monkeypatch: MonkeyPatch,
tmp_path: Path,
runtime_tmp: Runtime,
param: ScanSysPath,
) -> None:
"""Confirm sys path is scanned for collections.
:param monkeypatch: Fixture for monkeypatching
:param tmp_path: Fixture for a temp directory
:param runtime_tmp: Fixture for a Runtime object
:param param: The parameters for the test
"""
runtime_tmp.install_collection(
V2_COLLECTION_TARBALL,
destination=tmp_path,
)
runtime_tmp.config.collections_paths.remove(str(tmp_path))

# Set the runtime to the test parameters
runtime_tmp.isolated = param.isolated
runtime_tmp.config.collections_scan_sys_path = param.scan
monkeypatch.syspath_prepend(str(tmp_path))
runtime_tmp._add_sys_path_to_collection_paths()

try:
runtime_tmp.require_collection(
name="community.molecule",
version="0.1.0",
install=False,
)
raised_missing = False
except InvalidPrerequisiteError:
raised_missing = True

assert param.expected != raised_missing


@pytest.mark.parametrize(
("name", "version", "install"),
(
Expand Down
105 changes: 105 additions & 0 deletions test/test_runtime_scan_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Test the scan path functionality of the runtime."""

import json
import textwrap
from dataclasses import dataclass, fields
from pathlib import Path

import pytest
from _pytest.monkeypatch import MonkeyPatch

from ansible_compat.runtime import Runtime

from .conftest import VirtualEnvironment

V2_COLLECTION_TARBALL = Path("examples/reqs_v2/community-molecule-0.1.0.tar.gz")
V2_COLLECTION_NAMESPACE = "community"
V2_COLLECTION_NAME = "molecule"
V2_COLLECTION_VERSION = "0.1.0"
V2_COLLECTION_FULL_NAME = f"{V2_COLLECTION_NAMESPACE}.{V2_COLLECTION_NAME}"


@dataclass
class ScanSysPath:
"""Parameters for scan tests."""

isolated: bool
scan: bool
raises_not_found: bool

def __str__(self) -> str:
"""Return a string representation of the object."""
parts = [
f"{field.name}{str(getattr(self, field.name))[0]}" for field in fields(self)
]
return "-".join(parts)


@pytest.mark.parametrize(
("param"),
(
ScanSysPath(isolated=True, scan=True, raises_not_found=True),
ScanSysPath(isolated=True, scan=False, raises_not_found=True),
ScanSysPath(isolated=False, scan=True, raises_not_found=False),
ScanSysPath(isolated=False, scan=False, raises_not_found=True),
),
ids=str,
)
def test_scan_sys_path(
venv_module: VirtualEnvironment,
monkeypatch: MonkeyPatch,
runtime_tmp: Runtime,
tmp_path: Path,
param: ScanSysPath,
) -> None:
"""Confirm sys path is scanned for collections.
:param venv_module: Fixture for a virtual environment
:param monkeypatch: Fixture for monkeypatching
:param runtime_tmp: Fixture for a Runtime object
:param tmp_dir: Fixture for a temporary directory
:param param: The parameters for the test
"""
first_site_package_dir = venv_module.site_package_dirs()[0]

installed_to = (
first_site_package_dir
/ "ansible_collections"
/ V2_COLLECTION_NAMESPACE
/ V2_COLLECTION_NAME
)
if not installed_to.exists():
# Install the collection into the venv site packages directory, force
# as of yet this test is not isolated from the rest of the system
runtime_tmp.install_collection(
collection=V2_COLLECTION_TARBALL,
destination=first_site_package_dir,
force=True,
)
# Confirm the collection is installed
assert installed_to.exists()
# Set the sys scan path environment variable
monkeypatch.setenv("ANSIBLE_COLLECTIONS_SCAN_SYS_PATH", str(param.scan))
# Set the ansible collections paths to avoid bleed from other tests
monkeypatch.setenv("ANSIBLE_COLLECTIONS_PATH", str(tmp_path))

script = textwrap.dedent(
f"""
import json;
from ansible_compat.runtime import Runtime;
r = Runtime(isolated={param.isolated});
fv, cp = r.require_collection(name="{V2_COLLECTION_FULL_NAME}", version="{V2_COLLECTION_VERSION}", install=False);
print(json.dumps({{"found_version": str(fv), "collection_path": str(cp)}}));
""",
)

proc = venv_module.python_script_run(script)
if param.raises_not_found:
assert proc.returncode != 0, (proc.stdout, proc.stderr)
assert "InvalidPrerequisiteError" in proc.stderr
assert "'community.molecule' not found" in proc.stderr
else:
assert proc.returncode == 0, (proc.stdout, proc.stderr)
result = json.loads(proc.stdout)
assert result["found_version"] == V2_COLLECTION_VERSION
assert result["collection_path"] == str(installed_to)

0 comments on commit 63e7fbf

Please sign in to comment.