summaryrefslogtreecommitdiff
path: root/test/lib
diff options
context:
space:
mode:
authorMatt Clay <matt@mystile.com>2023-04-14 15:13:58 -0700
committerGitHub <noreply@github.com>2023-04-14 15:13:58 -0700
commit676b731e6f7d60ce6fd48c0d1c883fc85f5c6537 (patch)
treed6dba970f075574f2b9fc24c816fcffb2f0a7153 /test/lib
parent6aac0e2460985daac132541f643cf1256430e572 (diff)
downloadansible-676b731e6f7d60ce6fd48c0d1c883fc85f5c6537.tar.gz
ansible-test - Replace pytest-forked (#80525)
- Unit tests now report warnings generated during test runs. - Python 3.12 warnings about `os.fork` usage with threads (due to `pytest-xdist`) are suppressed. - Added integration tests to verify forked test behavior.
Diffstat (limited to 'test/lib')
-rw-r--r--test/lib/ansible_test/_data/requirements/units.txt1
-rw-r--r--test/lib/ansible_test/_internal/commands/units/__init__.py3
-rw-r--r--test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test-target.cfg2
-rw-r--r--test/lib/ansible_test/_util/target/pytest/plugins/ansible_forked.py103
4 files changed, 107 insertions, 2 deletions
diff --git a/test/lib/ansible_test/_data/requirements/units.txt b/test/lib/ansible_test/_data/requirements/units.txt
index d2f56d35a9..d723a65fc6 100644
--- a/test/lib/ansible_test/_data/requirements/units.txt
+++ b/test/lib/ansible_test/_data/requirements/units.txt
@@ -2,5 +2,4 @@ mock
pytest
pytest-mock
pytest-xdist
-pytest-forked
pyyaml # required by the collection loader (only needed for collections)
diff --git a/test/lib/ansible_test/_internal/commands/units/__init__.py b/test/lib/ansible_test/_internal/commands/units/__init__.py
index 7d192e1be6..78dd849815 100644
--- a/test/lib/ansible_test/_internal/commands/units/__init__.py
+++ b/test/lib/ansible_test/_internal/commands/units/__init__.py
@@ -253,7 +253,6 @@ def command_units(args: UnitsConfig) -> None:
cmd = [
'pytest',
- '--forked',
'-r', 'a',
'-n', str(args.num_workers) if args.num_workers else 'auto',
'--color', 'yes' if args.color else 'no',
@@ -275,6 +274,8 @@ def command_units(args: UnitsConfig) -> None:
if data_context().content.collection:
plugins.append('ansible_pytest_collections')
+ plugins.append('ansible_forked')
+
if plugins:
env['PYTHONPATH'] += ':%s' % os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'pytest/plugins')
env['PYTEST_PLUGINS'] = ','.join(plugins)
diff --git a/test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test-target.cfg b/test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test-target.cfg
index e35301dd81..f8a0a8af3f 100644
--- a/test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test-target.cfg
+++ b/test/lib/ansible_test/_util/controller/sanity/pylint/config/ansible-test-target.cfg
@@ -57,3 +57,5 @@ preferred-modules =
# Listing them here makes it possible to enable the import-error check.
ignored-modules =
py,
+ pytest,
+ _pytest.runner,
diff --git a/test/lib/ansible_test/_util/target/pytest/plugins/ansible_forked.py b/test/lib/ansible_test/_util/target/pytest/plugins/ansible_forked.py
new file mode 100644
index 0000000000..d00d9e93d1
--- /dev/null
+++ b/test/lib/ansible_test/_util/target/pytest/plugins/ansible_forked.py
@@ -0,0 +1,103 @@
+"""Run each test in its own fork. PYTEST_DONT_REWRITE"""
+# MIT License (see licenses/MIT-license.txt or https://opensource.org/licenses/MIT)
+# Based on code originally from:
+# https://github.com/pytest-dev/pytest-forked
+# https://github.com/pytest-dev/py
+# TIP: Disable pytest-xdist when debugging internal errors in this plugin.
+from __future__ import absolute_import, division, print_function
+
+__metaclass__ = type
+
+import os
+import pickle
+import tempfile
+import warnings
+
+from pytest import Item, hookimpl
+
+try:
+ from pytest import TestReport
+except ImportError:
+ from _pytest.runner import TestReport # Backwards compatibility with pytest < 7. Remove once Python 2.7 is not supported.
+
+from _pytest.runner import runtestprotocol
+
+
+@hookimpl(tryfirst=True)
+def pytest_runtest_protocol(item, nextitem): # type: (Item, Item | None) -> object | None
+ """Entry point for enabling this plugin."""
+ # This is needed because pytest-xdist creates an OS thread (using execnet).
+ # See: https://github.com/pytest-dev/execnet/blob/d6aa1a56773c2e887515d63e50b1d08338cb78a7/execnet/gateway_base.py#L51
+ warnings.filterwarnings("ignore", "^This process .* is multi-threaded, use of .* may lead to deadlocks in the child.$", DeprecationWarning)
+
+ item_hook = item.ihook
+ item_hook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
+
+ reports = run_item(item, nextitem)
+
+ for report in reports:
+ item_hook.pytest_runtest_logreport(report=report)
+
+ item_hook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
+
+ return True
+
+
+def run_item(item, nextitem): # type: (Item, Item | None) -> list[TestReport]
+ """Run the item in a child process and return a list of reports."""
+ with tempfile.NamedTemporaryFile() as temp_file:
+ pid = os.fork()
+
+ if not pid:
+ temp_file.delete = False
+ run_child(item, nextitem, temp_file.name)
+
+ return run_parent(item, pid, temp_file.name)
+
+
+def run_child(item, nextitem, result_path): # type: (Item, Item | None, str) -> None
+ """Run the item, record the result and exit. Called in the child process."""
+ with warnings.catch_warnings(record=True) as captured_warnings:
+ reports = runtestprotocol(item, nextitem=nextitem, log=False)
+
+ with open(result_path, "wb") as result_file:
+ pickle.dump((reports, captured_warnings), result_file)
+
+ os._exit(0) # noqa
+
+
+def run_parent(item, pid, result_path): # type: (Item, int, str) -> list[TestReport]
+ """Wait for the child process to exit and return the test reports. Called in the parent process."""
+ exit_code = waitstatus_to_exitcode(os.waitpid(pid, 0)[1])
+
+ if exit_code:
+ reason = "Test CRASHED with exit code {}.".format(exit_code)
+ report = TestReport(item.nodeid, item.location, {x: 1 for x in item.keywords}, "failed", reason, "call", user_properties=item.user_properties)
+
+ if item.get_closest_marker("xfail"):
+ report.outcome = "skipped"
+ report.wasxfail = reason
+
+ reports = [report]
+ else:
+ with open(result_path, "rb") as result_file:
+ reports, captured_warnings = pickle.load(result_file) # type: list[TestReport], list[warnings.WarningMessage]
+
+ for warning in captured_warnings:
+ warnings.warn_explicit(warning.message, warning.category, warning.filename, warning.lineno)
+
+ return reports
+
+
+def waitstatus_to_exitcode(status): # type: (int) -> int
+ """Convert a wait status to an exit code."""
+ # This function was added in Python 3.9.
+ # See: https://docs.python.org/3/library/os.html#os.waitstatus_to_exitcode
+
+ if os.WIFEXITED(status):
+ return os.WEXITSTATUS(status)
+
+ if os.WIFSIGNALED(status):
+ return -os.WTERMSIG(status)
+
+ raise ValueError(status)