From 5c065f758ae659ae719e6a818bb11b5980641029 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Fri, 25 Jun 2021 16:21:13 +0100 Subject: [PATCH] Add runtime manager class Adds a Runtime() class that can be used to interact with a specific version of Ansible and ease access to various commands. --- .pre-commit-config.yaml | 1 + .pylintrc | 2 + README.md | 27 ++++++++++- pyproject.toml | 6 +++ src/ansible_compat/errors.py | 31 ++++++++++++ src/ansible_compat/prerun.py | 10 ++-- src/ansible_compat/runtime.py | 89 +++++++++++++++++++++++++++++++++++ test/test_runtime.py | 28 +++++++++++ tox.ini | 2 +- 9 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 src/ansible_compat/runtime.py create mode 100644 test/test_runtime.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3547fa05..f5cc2667 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,6 +82,7 @@ repos: - flaky - packaging - pytest + - pytest-mock - tenacity - types-PyYAML - repo: https://github.com/pre-commit/mirrors-pylint diff --git a/.pylintrc b/.pylintrc index a144fabe..8c370f5d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -19,3 +19,5 @@ disable = line-too-long, # local imports do not work well with pre-commit hook import-error, + # Temporary disable duplicate detection we remove old code from prerun + duplicate-code, diff --git a/README.md b/README.md index 909a1b6c..b04ca726 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,30 @@ # ansible-compat -A python package contains functions that facilitates working with various -versions of Ansible, 2.9 and newer. +A python package contains functions that facilitate working with various +versions of Ansible 2.9 and newer. + +## Using Ansible runtime + +```python +from ansible_compat.runtime import Runtime + +def test_runtime(): + + # instantiate the runtime using isolated mode, so installing new + # roles/collections do not pollute the default setup. + runtime = Runtime(isolated=True) + + # Print Ansible core version + print(runtime.version) # 2.9.10 (Version object) + # Get configuration info from runtime + print(runtime.config.collections_path) + + # Install a new collection + # runtime.install_collection("community.general") + + # Execute a command + result = runtime.exec(["ansible-doc", "--list"]) +``` ## Access to Ansible configuration diff --git a/pyproject.toml b/pyproject.toml index 8f680a27..771f5ad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,12 @@ build-backend = "setuptools.build_meta" source = ["src"] branch = true +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:" +] + [tool.black] skip-string-normalization = true diff --git a/src/ansible_compat/errors.py b/src/ansible_compat/errors.py index b5626e9a..2b1186b0 100644 --- a/src/ansible_compat/errors.py +++ b/src/ansible_compat/errors.py @@ -1,18 +1,49 @@ """Module to deal with errors.""" +from typing import TYPE_CHECKING, Any, Optional + from ansible_compat.constants import ANSIBLE_MISSING_RC, INVALID_PREREQUISITES_RC +if TYPE_CHECKING: + from subprocess import CompletedProcess + class AnsibleCompatError(RuntimeError): """Generic error originating from ansible_compat library.""" code = 1 # generic error + def __init__( + self, message: Optional[str] = None, proc: Optional[Any] = None + ) -> None: + """Construct generic library exception.""" + super().__init__(message) + self.proc = proc + + +class AnsibleCommandError(RuntimeError): + """Exception running an Ansible command.""" + + def __init__(self, proc: "CompletedProcess[Any]") -> None: + """Construct an exception given a completed process.""" + message = ( + f"Got {proc.returncode} exit code while running: {' '.join(proc.args)}" + ) + super().__init__(message) + self.proc = proc + class MissingAnsibleError(AnsibleCompatError): """Reports a missing or broken Ansible installation.""" code = ANSIBLE_MISSING_RC + def __init__( + self, message: Optional[str] = None, proc: Optional[Any] = None + ) -> None: + """.""" + super().__init__(message) + self.proc = proc + class InvalidPrerequisiteError(AnsibleCompatError): """Reports a missing requirement.""" diff --git a/src/ansible_compat/prerun.py b/src/ansible_compat/prerun.py index 4d267472..cbaabb4c 100644 --- a/src/ansible_compat/prerun.py +++ b/src/ansible_compat/prerun.py @@ -24,7 +24,11 @@ ANSIBLE_MISSING_RC, MSG_INVALID_FQRL, ) -from ansible_compat.errors import AnsibleCompatError, InvalidPrerequisiteError +from ansible_compat.errors import ( + AnsibleCommandError, + AnsibleCompatError, + InvalidPrerequisiteError, +) from ansible_compat.loaders import yaml_from_file _logger = logging.getLogger(__name__) @@ -155,7 +159,7 @@ def install_requirements(requirement: str, cache_dir) -> None: ) if run.returncode != 0: _logger.error(run.stdout) - raise AnsibleCompatError(run.returncode) + raise AnsibleCommandError(run) # Run galaxy collection install works on v2 requirements.yml if "collections" in yaml_from_file(requirement): @@ -180,7 +184,7 @@ def install_requirements(requirement: str, cache_dir) -> None: ) if run.returncode != 0: _logger.error(run.stdout) - raise AnsibleCompatError(run.returncode) + raise AnsibleCommandError(run) def get_cache_dir(project_dir: str) -> str: diff --git a/src/ansible_compat/runtime.py b/src/ansible_compat/runtime.py new file mode 100644 index 00000000..fc5da253 --- /dev/null +++ b/src/ansible_compat/runtime.py @@ -0,0 +1,89 @@ +"""Ansible runtime environment maanger.""" +import os +import subprocess +from typing import TYPE_CHECKING, Any, List, Optional, Union + +from packaging.version import Version + +from ansible_compat.config import AnsibleConfig, parse_ansible_version +from ansible_compat.errors import MissingAnsibleError +from ansible_compat.prerun import get_cache_dir + +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3240 + # pylint: disable=unsubscriptable-object + CompletedProcess = subprocess.CompletedProcess[Any] +else: + CompletedProcess = subprocess.CompletedProcess + + +class Runtime: + """Ansible Runtime manager.""" + + _version: Optional[Version] = None + cache_dir: Optional[str] = None + + def __init__( + self, project_dir: Optional[str] = None, isolated: bool = False + ) -> None: + """Initialize Ansible runtime environment. + + Isolated mode assures that installation of collections or roles + does not affect Ansible installation, an unique cache directory + being used instead. + """ + self.project_dir = project_dir or os.getcwd() + if isolated: + self.cache_dir = get_cache_dir(self.project_dir) + self.config = AnsibleConfig() + + # pylint: disable=no-self-use + def exec(self, args: Union[str, List[str]]) -> CompletedProcess: + """Execute a command inside an Ansible environment.""" + return subprocess.run( + args, + universal_newlines=True, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + @property + def version(self) -> Version: + """Return current Version object for Ansible. + + If version is not mentioned, it returns current version as detected. + When version argument is mentioned, it return converts the version string + to Version object in order to make it usable in comparisons. + """ + if self._version: + return self._version + + proc = self.exec(["ansible", "--version"]) + if proc.returncode == 0: + version, error = parse_ansible_version(proc.stdout) + if error is not None: + raise MissingAnsibleError(error) + else: + msg = "Unable to find a working copy of ansible executable." + raise MissingAnsibleError(msg, proc=proc) + + self._version = Version(version) + return self._version + + # def install_collection(self, collection: str) -> None: + # """...""" + # ... + + # def install_requirements(self, requirement: str) -> None: + # """...""" + # ... + + # def prepare_environment( + # self, + # project_dir: Optional[str] = None, + # offline: bool = False, + # required_collections: Optional[Dict[str, str]] = None, + # ) -> None: + # """...""" + # ... diff --git a/test/test_runtime.py b/test/test_runtime.py new file mode 100644 index 00000000..aa7aaca1 --- /dev/null +++ b/test/test_runtime.py @@ -0,0 +1,28 @@ +"""Tests for Runtime class.""" +import pytest +from packaging.version import Version +from pytest_mock import MockerFixture + +from ansible_compat.runtime import Runtime + + +def test_runtime_version() -> None: + """Tests version property.""" + runtime = Runtime() + version = runtime.version + assert isinstance(version, Version) + # tests that caching property value worked (coverage) + assert version == runtime.version + + +def test_runtime_version_fail(mocker: MockerFixture) -> None: + """Tests for failure to detect Ansible version.""" + mocker.patch( + "ansible_compat.runtime.parse_ansible_version", + return_value=("", "some error"), + autospec=True, + ) + runtime = Runtime() + with pytest.raises(RuntimeError) as exc: + _ = runtime.version + assert exc.value.args[0] == "some error" diff --git a/tox.ini b/tox.ini index daa32a8e..69570e7e 100644 --- a/tox.ini +++ b/tox.ini @@ -44,7 +44,7 @@ setenv = PIP_DISABLE_PIP_VERSION_CHECK = 1 PIP_CONSTRAINT = {toxinidir}/constraints.txt PRE_COMMIT_COLOR = always - PYTEST_REQPASS = 28 + PYTEST_REQPASS = 31 FORCE_COLOR = 1 allowlist_externals = sh