Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: integrate pytest-molecule plugin #124

Closed
wants to merge 35 commits into from
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a1bf2a1
integrate pytest-molecule plugin WIP
Ruchip16 May 10, 2023
2f68be2
Add python 3.8 back (#119)
cidrblock May 10, 2023
d4c7c3c
Remove password (#120)
cidrblock May 10, 2023
d35539d
Add token write for pypi (#122)
cidrblock May 10, 2023
67dd05b
REmove ansible as dep (#127)
cidrblock May 11, 2023
0de974d
Restore python 3.7 support (#128)
cidrblock May 15, 2023
e97022e
WIP
Ruchip16 May 18, 2023
8665f9a
Merge branch 'main' into integrate-molecule
Ruchip16 May 18, 2023
1648519
need review
Ruchip16 May 22, 2023
70657eb
molecule integration
Ruchip16 May 29, 2023
21b0de7
checks testing
Ruchip16 May 29, 2023
3a0adc3
fixes
Ruchip16 Jun 7, 2023
e07b0b2
tests for molecule
Ruchip16 Jun 13, 2023
373ef00
broken tests & fixes
Ruchip16 Jun 16, 2023
5c6418d
tox failing tests
Ruchip16 Jun 19, 2023
79802d9
constraint file fixture
Ruchip16 Jun 20, 2023
6e8325e
fixtures
Ruchip16 Jun 20, 2023
aff79e0
Merge branch 'main' into integrate-molecule
Ruchip16 Jun 21, 2023
ddf74b3
REmove ansible as dep (#127)
cidrblock May 11, 2023
2dd8d4f
Restore python 3.7 support (#128)
cidrblock May 15, 2023
e7386d1
molecule integration
Ruchip16 May 29, 2023
5d1677a
naming conflicts fix
Ruchip16 Jul 17, 2023
699e7d2
chore: auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2023
e8652c8
Merge branch 'main' into integrate-molecule
audgirka Jul 18, 2023
e085bc2
Switch to ANSIBLE_COLLECTION_PATH (#139)
cidrblock Aug 1, 2023
94a0af0
Fix for ansible 2.9 (#141)
cidrblock Aug 1, 2023
b0f07f5
integrate pytest-molecule plugin WIP
Ruchip16 May 10, 2023
6202eda
REmove ansible as dep (#127)
cidrblock May 11, 2023
f051d4a
WIP
Ruchip16 May 18, 2023
2aeb3a8
need review
Ruchip16 May 22, 2023
f53d18a
molecule integration
Ruchip16 May 29, 2023
97582b2
broken tests & fixes
Ruchip16 Jun 16, 2023
55baec0
REmove ansible as dep (#127)
cidrblock May 11, 2023
6f7deb0
molecule integration
Ruchip16 May 29, 2023
8e2fe4d
ModuleNotFoundError in tests
Ruchip16 Aug 2, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .config/requirements.in
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
coverage
pytest>=6,<8.0.0

14 changes: 10 additions & 4 deletions .config/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
#
# This file is autogenerated by pip-compile with Python 3.7
# This file is autogenerated by pip-compile with Python 3.9
# by the following command:
#
# pip-compile --extra=docs --extra=test --no-annotate --output-file=.config/requirements.txt --resolver=backtracking --strip-extras --unsafe-package=ruamel-yaml-clib pyproject.toml
#
ansible-core==2.15.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is why py3.7 and 3.8 are failing, core is pinned here, but to a version that isn't compatible with 3.7 or 3.8

attrs==22.2.0
cffi==1.15.1
coverage==7.2.2
cryptography==40.0.2
exceptiongroup==1.1.1
importlib-metadata==6.6.0
importlib-resources==5.0.7
iniconfig==2.0.0
jinja2==3.1.2
markupsafe==2.1.2
packaging==23.0
pluggy==1.0.0
pycparser==2.21
pytest==7.2.2
pyyaml==6.0
resolvelib==1.0.1
tomli==2.0.1
typing-extensions==4.5.0
zipp==3.15.0
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
"[python]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
},
"editor.defaultFormatter": "ms-python.black-formatter"
},
"editor.formatOnSave": true,
"isort.check": false,
"prettier.enable": false,
"python.formatting.provider": "black",
"python.formatting.provider": "none",
Ruchip16 marked this conversation as resolved.
Show resolved Hide resolved
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"python.linting.pylintEnabled": true,
Expand Down
236 changes: 236 additions & 0 deletions src/pytest_ansible/molecule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
"""pytest-molecule plugin implementation."""
# pylint: disable=protected-access
from __future__ import annotations

import logging
import os
import shlex
import subprocess
import sys
import warnings
from shlex import quote

import pkg_resources
import pytest
import yaml
from molecule.api import drivers
from molecule.config import ansible_version

logger = logging.getLogger(__name__)


def molecule_pytest_configure(config):
"""Pytest hook for loading our specific configuration."""
interesting_env_vars = [
"ANSIBLE",
"MOLECULE",
"DOCKER",
"PODMAN",
"VAGRANT",
"VIRSH",
"ZUUL",
]

# Add extra information that may be key for debugging failures
if hasattr(config, "_metadata"):
for package in ["molecule"]:
config._metadata["Packages"][package] = pkg_resources.get_distribution(
package,
).version

if "Tools" not in config._metadata:
config._metadata["Tools"] = {}
config._metadata["Tools"]["ansible"] = str(ansible_version())

# Adds interesting env vars
env = ""
for key, value in sorted(os.environ.items()):
for var_name in interesting_env_vars:
if key.startswith(var_name):
env += f"{key}={value} "
config._metadata["env"] = env

# We hide DeprecationWarnings thrown by driver loading because these are
# outside our control and worse: they are displayed even on projects that
# have no molecule tests at all as pytest_configure() is called during
# collection, causing spam.
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)

config.option.molecule = {}
for driver in map(str, drivers()):
config.addinivalue_line(
"markers",
f"{driver}: mark test to run only when {driver} is available",
)
config.option.molecule[driver] = {"available": True}

config.addinivalue_line(
"markers",
"no_driver: mark used for scenarios that do not contain driver info",
)

config.addinivalue_line(
"markers",
"molecule: mark used by all molecule scenarios",
)

# validate selinux availability
if sys.platform == "linux" and os.path.isfile("/etc/selinux/config"):
try:
import selinux # noqa pylint: disable=unused-import,import-error,import-outside-toplevel
except ImportError:
logging.error(
"It appears that you are trying to use "
"molecule with a Python interpreter that does not have the "
"libselinux python bindings installed. These can only be "
"installed using your distro package manager and are specific "
"to each python version. Common package names: "
"libselinux-python python2-libselinux python3-libselinux",
)
# we do not re-raise this exception because missing or broken
# selinux bindings are not guaranteed to fail molecule execution.


class MoleculeFile(pytest.File):
"""Wrapper class for molecule files."""

def collect(self):
"""Test generator."""
if hasattr(MoleculeItem, "from_parent"):
yield MoleculeItem.from_parent(name="test", parent=self)
else:
yield MoleculeItem("test", self)

def __str__(self):
"""Return test name string representation."""
return str(self.path.relative_to(os.getcwd()))


class MoleculeItem(pytest.Item):
"""A molecule test.

Pytest supports multiple tests per file, molecule only one "test".
"""

def __init__(self, name, parent):
"""Construct MoleculeItem."""
self.funcargs = {}
super().__init__(name, parent)
moleculeyml = self.path
with open(str(moleculeyml), encoding="utf-8") as stream:
# If the molecule.yml file is empty, YAML loader returns None. To
# simplify things down the road, we replace None with an empty
# dict.
data = yaml.load(stream, Loader=yaml.SafeLoader) or {}

# we add the driver as mark
self.molecule_driver = data.get("driver", {}).get("name", "no_driver")
self.add_marker(self.molecule_driver)

# check for known markers and add them
markers = data.get("markers", [])
if "xfail" in markers:
self.add_marker(
pytest.mark.xfail(
reason="Marked as broken by scenario configuration.",
),
)
if "skip" in markers:
self.add_marker(
pytest.mark.skip(reason="Disabled by scenario configuration."),
)

# we also add platforms as marks
for platform in data.get("platforms", []):
platform_name = platform["name"]
self.config.addinivalue_line(
"markers",
f"{platform_name}: molecule platform name is {platform_name}",
)
self.add_marker(platform_name)
self.add_marker("molecule")
if (
self.config.option.molecule_unavailable_driver
and not self.config.option.molecule[self.molecule_driver]["available"]
):
self.add_marker(self.config.option.molecule_unavailable_driver)

def runtest(self):
"""Perform effective test run."""
folder = self.path.parent
folders = folder.parts
cwd = os.path.abspath(os.path.join(folder, "../.."))
scenario = folders[-1]

cmd = [sys.executable, "-m", "molecule"]
if self.config.option.molecule_base_config:
cmd.extend(("--base-config", self.config.option.molecule_base_config))
if self.config.option.skip_no_git_change:
try:
with subprocess.Popen(
["git", "diff", self.config.option.skip_no_git_change, "--", "./"],
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
) as proc:
proc.wait()
if len(proc.stdout.readlines()) == 0:
pytest.skip("No change in role")
except subprocess.CalledProcessError as exc:
pytest.fail(
"Error checking git diff. Error code was: "
+ str(exc.returncode)
+ "\nError output was: "
+ exc.output,
)

cmd.extend((self.name, "-s", scenario))
# We append the additional options to molecule call, allowing user to
# control how molecule is called by pytest-molecule
opts = os.environ.get("MOLECULE_OPTS")
if opts:
cmd.extend(shlex.split(opts))

print(f"running: {' '.join(quote(arg) for arg in cmd)} (from {cwd})")
if self.config.getoption("--molecule"): # Check if --molecule option is enabled
try:
# Workaround for STDOUT/STDERR line ordering issue:
# https://github.com/pytest-dev/pytest/issues/5449
with subprocess.Popen(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
) as proc:
for line in proc.stdout:
print(line, end="")
proc.wait()
if proc.returncode != 0:
pytest.fail(
f"Error code {proc.returncode} returned by: {' '.join(cmd)}",
pytrace=False,
)
except subprocess.CalledProcessError as exc:
pytest.fail(
f"Exception {exc} returned by: {' '.join(cmd)}",
pytrace=False,
)
else:
pytest.skip(
"Molecule tests are disabled",
) # Skip the test if --molecule option is not enabled

def reportinfo(self):
"""Return representation of test location when in verbose mode."""
return self.fspath, 0, f"usecase: {self.name}"

def __str__(self):
"""Return name of the test."""
return f"{self.name}[{self.molecule_driver}]"


class MoleculeExceptionError(Exception):
"""Custom exception for error reporting."""
38 changes: 38 additions & 0 deletions src/pytest_ansible/plugin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""PyTest Ansible Plugin."""
from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING

import ansible
import ansible.constants
Expand All @@ -17,8 +20,12 @@
)
from pytest_ansible.host_manager import get_host_manager

from .molecule import MoleculeFile
Ruchip16 marked this conversation as resolved.
Show resolved Hide resolved
from .units import inject, inject_only

if TYPE_CHECKING:
from _pytest.nodes import Node

logger = logging.getLogger(__name__)

# Silence linters for imported fixtures
Expand Down Expand Up @@ -146,6 +153,25 @@ def pytest_addoption(parser):
default=False,
help="Enable support for ansible collection unit tests by only injecting exisiting ANSIBLE_COLLECTIONS_PATHS.",
)

group.addoption(
Ruchip16 marked this conversation as resolved.
Show resolved Hide resolved
"--molecule_unavailable_driver",
action="store",
default=None,
help="What marker to add to molecule scenarios when driver is ",
)
group.addoption(
"--molecule_base_config",
action="store",
default=None,
help="Path to the molecule base config file. The value of this option is ",
)
group.addoption(
"--skip_no_git_change",
action="store",
default=None,
help="Commit to use as a reference for this test. If the role wasn't",
)
# Add github marker to --help
parser.addini("ansible", "Ansible integration", "args")

Expand Down Expand Up @@ -175,6 +201,18 @@ def pytest_configure(config):
inject(start_path)


def pytest_collect_file(
parent: pytest.Collector,
file_path: Path | None,
) -> Node | None:
"""Transform each found molecule.yml into a pytest test."""
Ruchip16 marked this conversation as resolved.
Show resolved Hide resolved
if file_path and file_path.is_symlink():
return None
if file_path and file_path.name == "molecule.yml":
return MoleculeFile.from_parent(path=file_path, parent=parent)
return None


def pytest_generate_tests(metafunc):
"""Generate tests when specific `ansible_*` fixtures are used by tests."""
if "ansible_host" in metafunc.fixturenames:
Expand Down
41 changes: 41 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,44 @@ def hosts():
from pytest_ansible.host_manager import get_host_manager

return get_host_manager(inventory=",".join(ALL_HOSTS), connection="local")


@pytest.fixture(scope="session")
Ruchip16 marked this conversation as resolved.
Show resolved Hide resolved
def molecule_scenario(request):
"""
Fixture that returns the value of the "--molecule" option from the pytest configuration.

:param request: pytest request object
:return: Value of the "--molecule" option
"""
return request.config.getoption("--molecule")


@pytest.fixture(scope="session")
def molecule_ansible(ansible_playbook):
"""
Fixture that returns the `ansible_playbook` fixture.

:param ansible_playbook: The `ansible_playbook` fixture
:return: The `ansible_playbook` fixture
"""
return ansible_playbook


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""
Pytest hook that modifies the test item by setting the `molecule_ansible` fixture
for specific fixtures ("ansible_module", "ansible_inventory") when the report
matches the call.

:param item: Pytest test item
:param call: Test execution call (setup, call, teardown)
"""
outcome = yield
rep = outcome.get_result()
if rep.when == call:
for fixture in ("ansible_module", "ansible_inventory"):
if fixture in item.fixturenames:
setattr(item, fixture, molecule_ansible)
break