Skip to content

Commit

Permalink
Add runtime manager class
Browse files Browse the repository at this point in the history
Adds a Runtime() class that can be used to interact with a specific
version of Ansible and ease access to various commands.
  • Loading branch information
ssbarnea committed Jul 1, 2021
1 parent fd17dbf commit 5c065f7
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 6 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ repos:
- flaky
- packaging
- pytest
- pytest-mock
- tenacity
- types-PyYAML
- repo: https://github.com/pre-commit/mirrors-pylint
Expand Down
2 changes: 2 additions & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions src/ansible_compat/errors.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down
10 changes: 7 additions & 3 deletions src/ansible_compat/prerun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions src/ansible_compat/runtime.py
Original file line number Diff line number Diff line change
@@ -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:
# """..."""
# ...
28 changes: 28 additions & 0 deletions test/test_runtime.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 5c065f7

Please sign in to comment.