summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--MANIFEST.in2
-rw-r--r--test/lib/ansible_test/_internal/commands/sanity/__init__.py115
-rw-r--r--test/lib/ansible_test/_internal/commands/sanity/mypy.py265
-rw-r--r--test/sanity/code-smell/mypy.json13
-rw-r--r--test/sanity/code-smell/mypy.py228
-rw-r--r--test/sanity/code-smell/mypy.requirements.in (renamed from test/lib/ansible_test/_data/requirements/sanity.mypy.in)0
-rw-r--r--test/sanity/code-smell/mypy.requirements.txt (renamed from test/lib/ansible_test/_data/requirements/sanity.mypy.txt)2
-rw-r--r--test/sanity/code-smell/mypy/ansible-core.ini (renamed from test/lib/ansible_test/_util/controller/sanity/mypy/ansible-core.ini)0
-rw-r--r--test/sanity/code-smell/mypy/ansible-test.ini (renamed from test/lib/ansible_test/_util/controller/sanity/mypy/ansible-test.ini)0
-rw-r--r--test/sanity/code-smell/mypy/modules.ini (renamed from test/lib/ansible_test/_util/controller/sanity/mypy/modules.ini)0
-rw-r--r--test/sanity/code-smell/mypy/packaging.ini (renamed from test/lib/ansible_test/_util/controller/sanity/mypy/packaging.ini)0
-rw-r--r--test/sanity/code-smell/package-data.json3
-rw-r--r--test/sanity/code-smell/package-data.py57
-rw-r--r--test/sanity/code-smell/pymarkdown.py2
14 files changed, 395 insertions, 292 deletions
diff --git a/MANIFEST.in b/MANIFEST.in
index bf7a6a047e..cc03ebcbe9 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -6,6 +6,6 @@ include licenses/*.txt
include requirements.txt
recursive-include packaging *.py *.j2
recursive-include test/integration *
-recursive-include test/sanity *.in *.json *.py *.txt
+recursive-include test/sanity *.in *.json *.py *.txt *.ini
recursive-include test/support *.py *.ps1 *.psm1 *.cs *.md
recursive-include test/units *
diff --git a/test/lib/ansible_test/_internal/commands/sanity/__init__.py b/test/lib/ansible_test/_internal/commands/sanity/__init__.py
index 143fe338ca..50da7c040d 100644
--- a/test/lib/ansible_test/_internal/commands/sanity/__init__.py
+++ b/test/lib/ansible_test/_internal/commands/sanity/__init__.py
@@ -209,9 +209,7 @@ def command_sanity(args: SanityConfig) -> None:
result.reason = f'Skipping sanity test "{test.name}" on Python {version} because it is unsupported.' \
f' Supported Python versions: {", ".join(test.supported_python_versions)}'
else:
- if isinstance(test, SanityCodeSmellTest):
- settings = test.load_processor(args)
- elif isinstance(test, SanityMultipleVersion):
+ if isinstance(test, SanityMultipleVersion):
settings = test.load_processor(args, version)
elif isinstance(test, SanitySingleVersion):
settings = test.load_processor(args)
@@ -327,7 +325,7 @@ def collect_code_smell_tests() -> tuple[SanityTest, ...]:
skip_tests = read_lines_without_comments(os.path.join(ansible_code_smell_root, 'skip.txt'), remove_blank_lines=True, optional=True)
paths.extend(path for path in glob.glob(os.path.join(ansible_code_smell_root, '*.py')) if os.path.basename(path) not in skip_tests)
- tests = tuple(SanityCodeSmellTest(p) for p in paths)
+ tests = tuple(SanityScript.create(p) for p in paths)
return tests
@@ -829,21 +827,34 @@ class SanitySingleVersion(SanityTest, metaclass=abc.ABCMeta):
return SanityIgnoreProcessor(args, self, None)
-class SanityCodeSmellTest(SanitySingleVersion):
- """Sanity test script."""
+class SanityScript(SanityTest, metaclass=abc.ABCMeta):
+ """Base class for sanity test scripts."""
- def __init__(self, path) -> None:
+ @classmethod
+ def create(cls, path: str) -> SanityScript:
+ """Create and return a SanityScript instance from the given path."""
name = os.path.splitext(os.path.basename(path))[0]
config_path = os.path.splitext(path)[0] + '.json'
+ if os.path.exists(config_path):
+ config = read_json_file(config_path)
+ else:
+ config = None
+
+ instance: SanityScript
+
+ if config.get('multi_version'):
+ instance = SanityScriptMultipleVersion(name=name, path=path, config=config)
+ else:
+ instance = SanityScriptSingleVersion(name=name, path=path, config=config)
+
+ return instance
+
+ def __init__(self, name: str, path: str, config: dict[str, t.Any] | None) -> None:
super().__init__(name=name)
self.path = path
- self.config_path = config_path if os.path.exists(config_path) else None
- self.config = None
-
- if self.config_path:
- self.config = read_json_file(self.config_path)
+ self.config = config
if self.config:
self.enabled = not self.config.get('disabled')
@@ -854,6 +865,8 @@ class SanityCodeSmellTest(SanitySingleVersion):
self.files: list[str] = self.config.get('files')
self.text: t.Optional[bool] = self.config.get('text')
self.ignore_self: bool = self.config.get('ignore_self')
+ self.controller_only: bool = self.config.get('controller_only')
+ self.min_max_python_only: bool = self.config.get('min_max_python_only')
self.minimum_python_version: t.Optional[str] = self.config.get('minimum_python_version')
self.maximum_python_version: t.Optional[str] = self.config.get('maximum_python_version')
@@ -869,6 +882,8 @@ class SanityCodeSmellTest(SanitySingleVersion):
self.files = []
self.text = None
self.ignore_self = False
+ self.controller_only = False
+ self.min_max_python_only = False
self.minimum_python_version = None
self.maximum_python_version = None
@@ -925,12 +940,18 @@ class SanityCodeSmellTest(SanitySingleVersion):
"""A tuple of supported Python versions or None if the test does not depend on specific Python versions."""
versions = super().supported_python_versions
+ if self.controller_only:
+ versions = tuple(version for version in versions if version in CONTROLLER_PYTHON_VERSIONS)
+
if self.minimum_python_version:
versions = tuple(version for version in versions if str_to_version(version) >= str_to_version(self.minimum_python_version))
if self.maximum_python_version:
versions = tuple(version for version in versions if str_to_version(version) <= str_to_version(self.maximum_python_version))
+ if self.min_max_python_only:
+ versions = versions[0], versions[-1]
+
return versions
def filter_targets(self, targets: list[TestTarget]) -> list[TestTarget]:
@@ -960,17 +981,29 @@ class SanityCodeSmellTest(SanitySingleVersion):
return targets
- def test(self, args: SanityConfig, targets: SanityTargets, python: PythonConfig) -> TestResult:
+ def test_script(self, args: SanityConfig, targets: SanityTargets, virtualenv_python: PythonConfig, python: PythonConfig) -> TestResult:
"""Run the sanity test and return the result."""
- cmd = [python.path, self.path]
+ cmd = [virtualenv_python.path, self.path]
env = ansible_environment(args, color=False)
- env.update(PYTHONUTF8='1') # force all code-smell sanity tests to run with Python UTF-8 Mode enabled
+
+ env.update(
+ PYTHONUTF8='1', # force all code-smell sanity tests to run with Python UTF-8 Mode enabled
+ ANSIBLE_TEST_TARGET_PYTHON_VERSION=python.version,
+ ANSIBLE_TEST_CONTROLLER_PYTHON_VERSIONS=','.join(CONTROLLER_PYTHON_VERSIONS),
+ ANSIBLE_TEST_REMOTE_ONLY_PYTHON_VERSIONS=','.join(REMOTE_ONLY_PYTHON_VERSIONS),
+ )
+
+ if self.min_max_python_only:
+ min_python, max_python = self.supported_python_versions
+
+ env.update(ANSIBLE_TEST_MIN_PYTHON=min_python)
+ env.update(ANSIBLE_TEST_MAX_PYTHON=max_python)
pattern = None
data = None
- settings = self.load_processor(args)
+ settings = self.conditionally_load_processor(args, python.version)
paths = [target.path for target in targets.include]
@@ -991,7 +1024,7 @@ class SanityCodeSmellTest(SanitySingleVersion):
display.info(data, verbosity=4)
try:
- stdout, stderr = intercept_python(args, python, cmd, data=data, env=env, capture=True)
+ stdout, stderr = intercept_python(args, virtualenv_python, cmd, data=data, env=env, capture=True)
status = 0
except SubprocessError as ex:
stdout = ex.stdout
@@ -1031,9 +1064,9 @@ class SanityCodeSmellTest(SanitySingleVersion):
return SanitySuccess(self.name)
- def load_processor(self, args: SanityConfig) -> SanityIgnoreProcessor:
+ @abc.abstractmethod
+ def conditionally_load_processor(self, args: SanityConfig, python_version: str) -> SanityIgnoreProcessor:
"""Load the ignore processor for this sanity test."""
- return SanityIgnoreProcessor(args, self, None)
class SanityVersionNeutral(SanityTest, metaclass=abc.ABCMeta):
@@ -1094,6 +1127,50 @@ class SanityMultipleVersion(SanityTest, metaclass=abc.ABCMeta):
return targets
+class SanityScriptSingleVersion(SanityScript, SanitySingleVersion):
+ """External sanity test script which should run on a single python version."""
+
+ def test(self, args: SanityConfig, targets: SanityTargets, python: PythonConfig) -> TestResult:
+ """Run the sanity test and return the result."""
+ return super().test_script(args, targets, python, python)
+
+ def conditionally_load_processor(self, args: SanityConfig, python_version: str) -> SanityIgnoreProcessor:
+ """Load the ignore processor for this sanity test."""
+ return SanityIgnoreProcessor(args, self, None)
+
+
+class SanityScriptMultipleVersion(SanityScript, SanityMultipleVersion):
+ """External sanity test script which should run on multiple python versions."""
+
+ def test(self, args: SanityConfig, targets: SanityTargets, python: PythonConfig) -> TestResult:
+ """Run the sanity test and return the result."""
+ multi_version = self.config['multi_version']
+
+ if multi_version == 'controller':
+ virtualenv_python_config = args.controller_python
+ elif multi_version == 'target':
+ virtualenv_python_config = python
+ else:
+ raise NotImplementedError(f'{multi_version=}')
+
+ virtualenv_python = create_sanity_virtualenv(args, virtualenv_python_config, self.name)
+
+ if not virtualenv_python:
+ result = SanitySkipped(self.name, python.version)
+ result.reason = f'Skipping sanity test "{self.name}" due to missing virtual environment support on Python {virtualenv_python_config.version}.'
+
+ return result
+
+ if args.prime_venvs:
+ return SanitySkipped(self.name, python.version)
+
+ return super().test_script(args, targets, virtualenv_python, python)
+
+ def conditionally_load_processor(self, args: SanityConfig, python_version: str) -> SanityIgnoreProcessor:
+ """Load the ignore processor for this sanity test."""
+ return SanityIgnoreProcessor(args, self, python_version)
+
+
@cache
def sanity_get_tests() -> tuple[SanityTest, ...]:
"""Return a tuple of the available sanity tests."""
diff --git a/test/lib/ansible_test/_internal/commands/sanity/mypy.py b/test/lib/ansible_test/_internal/commands/sanity/mypy.py
deleted file mode 100644
index 4d580e933e..0000000000
--- a/test/lib/ansible_test/_internal/commands/sanity/mypy.py
+++ /dev/null
@@ -1,265 +0,0 @@
-"""Sanity test which executes mypy."""
-from __future__ import annotations
-
-import dataclasses
-import os
-import re
-import typing as t
-
-from . import (
- SanityMultipleVersion,
- SanityMessage,
- SanityFailure,
- SanitySuccess,
- SanitySkipped,
- SanityTargets,
- create_sanity_virtualenv,
-)
-
-from ...constants import (
- CONTROLLER_PYTHON_VERSIONS,
- REMOTE_ONLY_PYTHON_VERSIONS,
-)
-
-from ...test import (
- TestResult,
-)
-
-from ...target import (
- TestTarget,
-)
-
-from ...util import (
- SubprocessError,
- display,
- parse_to_list_of_dict,
- ANSIBLE_TEST_CONTROLLER_ROOT,
- ApplicationError,
- is_subdir,
-)
-
-from ...util_common import (
- intercept_python,
-)
-
-from ...ansible_util import (
- ansible_environment,
-)
-
-from ...config import (
- SanityConfig,
-)
-
-from ...host_configs import (
- PythonConfig,
- VirtualPythonConfig,
-)
-
-
-class MypyTest(SanityMultipleVersion):
- """Sanity test which executes mypy."""
-
- ansible_only = True
-
- vendored_paths = (
- 'lib/ansible/module_utils/six/__init__.py',
- 'lib/ansible/module_utils/distro/_distro.py',
- )
-
- def filter_targets(self, targets: list[TestTarget]) -> list[TestTarget]:
- """Return the given list of test targets, filtered to include only those relevant for the test."""
- return [target for target in targets if os.path.splitext(target.path)[1] == '.py' and target.path not in self.vendored_paths and (
- target.path.startswith('lib/ansible/') or target.path.startswith('test/lib/ansible_test/_internal/')
- or target.path.startswith('packaging/')
- or target.path.startswith('test/lib/ansible_test/_util/target/sanity/import/'))]
-
- @property
- def error_code(self) -> t.Optional[str]:
- """Error code for ansible-test matching the format used by the underlying test program, or None if the program does not use error codes."""
- return 'ansible-test'
-
- @property
- def needs_pypi(self) -> bool:
- """True if the test requires PyPI, otherwise False."""
- return True
-
- def test(self, args: SanityConfig, targets: SanityTargets, python: PythonConfig) -> TestResult:
- settings = self.load_processor(args, python.version)
-
- paths = [target.path for target in targets.include]
-
- virtualenv_python = create_sanity_virtualenv(args, args.controller_python, self.name)
-
- if args.prime_venvs:
- return SanitySkipped(self.name, python_version=python.version)
-
- if not virtualenv_python:
- display.warning(f'Skipping sanity test "{self.name}" due to missing virtual environment support on Python {args.controller_python.version}.')
- return SanitySkipped(self.name, python.version)
-
- controller_python_versions = CONTROLLER_PYTHON_VERSIONS
- remote_only_python_versions = REMOTE_ONLY_PYTHON_VERSIONS
-
- contexts = (
- MyPyContext('ansible-test', ['test/lib/ansible_test/_util/target/sanity/import/'], controller_python_versions),
- MyPyContext('ansible-test', ['test/lib/ansible_test/_internal/'], controller_python_versions),
- MyPyContext('ansible-core', ['lib/ansible/'], controller_python_versions),
- MyPyContext('modules', ['lib/ansible/modules/', 'lib/ansible/module_utils/'], remote_only_python_versions),
- MyPyContext('packaging', ['packaging/'], controller_python_versions),
- )
-
- unfiltered_messages: list[SanityMessage] = []
-
- for context in contexts:
- if python.version not in context.python_versions:
- continue
-
- unfiltered_messages.extend(self.test_context(args, virtualenv_python, python, context, paths))
-
- notices = []
- messages = []
-
- for message in unfiltered_messages:
- if message.level != 'error':
- notices.append(message)
- continue
-
- match = re.search(r'^(?P<message>.*) {2}\[(?P<code>.*)]$', message.message)
-
- messages.append(SanityMessage(
- message=match.group('message'),
- path=message.path,
- line=message.line,
- column=message.column,
- level=message.level,
- code=match.group('code'),
- ))
-
- for notice in notices:
- display.info(notice.format(), verbosity=3)
-
- # The following error codes from mypy indicate that results are incomplete.
- # That prevents the test from completing successfully, just as if mypy were to traceback or generate unexpected output.
- fatal_error_codes = {
- 'import',
- 'syntax',
- }
-
- fatal_errors = [message for message in messages if message.code in fatal_error_codes]
-
- if fatal_errors:
- error_message = '\n'.join(error.format() for error in fatal_errors)
- raise ApplicationError(f'Encountered {len(fatal_errors)} fatal errors reported by mypy:\n{error_message}')
-
- paths_set = set(paths)
-
- # Only report messages for paths that were specified as targets.
- # Imports in our code are followed by mypy in order to perform its analysis, which is important for accurate results.
- # However, it will also report issues on those files, which is not the desired behavior.
- messages = [message for message in messages if message.path in paths_set]
-
- if args.explain:
- return SanitySuccess(self.name, python_version=python.version)
-
- results = settings.process_errors(messages, paths)
-
- if results:
- return SanityFailure(self.name, messages=results, python_version=python.version)
-
- return SanitySuccess(self.name, python_version=python.version)
-
- @staticmethod
- def test_context(
- args: SanityConfig,
- virtualenv_python: VirtualPythonConfig,
- python: PythonConfig,
- context: MyPyContext,
- paths: list[str],
- ) -> list[SanityMessage]:
- """Run mypy tests for the specified context."""
- context_paths = [path for path in paths if any(is_subdir(path, match_path) for match_path in context.paths)]
-
- if not context_paths:
- return []
-
- config_path = os.path.join(ANSIBLE_TEST_CONTROLLER_ROOT, 'sanity', 'mypy', f'{context.name}.ini')
-
- display.info(f'Checking context "{context.name}"', verbosity=1)
-
- env = ansible_environment(args, color=False)
- env['MYPYPATH'] = env['PYTHONPATH']
-
- # The --no-site-packages option should not be used, as it will prevent loading of type stubs from the sanity test virtual environment.
-
- # Enabling the --warn-unused-configs option would help keep the config files clean.
- # However, the option can only be used when all files in tested contexts are evaluated.
- # Unfortunately sanity tests have no way of making that determination currently.
- # The option is also incompatible with incremental mode and caching.
-
- cmd = [
- # Below are arguments common to all contexts.
- # They are kept here to avoid repetition in each config file.
- virtualenv_python.path,
- '-m', 'mypy',
- '--show-column-numbers',
- '--show-error-codes',
- '--no-error-summary',
- # This is a fairly common pattern in our code, so we'll allow it.
- '--allow-redefinition',
- # Since we specify the path(s) to test, it's important that mypy is configured to use the default behavior of following imports.
- '--follow-imports', 'normal',
- # Incremental results and caching do not provide significant performance benefits.
- # It also prevents the use of the --warn-unused-configs option.
- '--no-incremental',
- '--cache-dir', '/dev/null',
- # The platform is specified here so that results are consistent regardless of what platform the tests are run from.
- # In the future, if testing of other platforms is desired, the platform should become part of the test specification, just like the Python version.
- '--platform', 'linux',
- # Despite what the documentation [1] states, the --python-version option does not cause mypy to search for a corresponding Python executable.
- # It will instead use the Python executable that is used to run mypy itself.
- # The --python-executable option can be used to specify the Python executable, with the default being the executable used to run mypy.
- # As a precaution, that option is used in case the behavior of mypy is updated in the future to match the documentation.
- # That should help guarantee that the Python executable providing type hints is the one used to run mypy.
- # [1] https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-python-version
- '--python-executable', virtualenv_python.path,
- '--python-version', python.version,
- # Below are context specific arguments.
- # They are primarily useful for listing individual 'ignore_missing_imports' entries instead of using a global ignore.
- '--config-file', config_path,
- ] # fmt: skip
-
- cmd.extend(context_paths)
-
- try:
- stdout, stderr = intercept_python(args, virtualenv_python, cmd, env, capture=True)
-
- if stdout or stderr:
- raise SubprocessError(cmd, stdout=stdout, stderr=stderr)
- except SubprocessError as ex:
- if ex.status != 1 or ex.stderr or not ex.stdout:
- raise
-
- stdout = ex.stdout
-
- pattern = r'^(?P<path>[^:]*):(?P<line>[0-9]+):((?P<column>[0-9]+):)? (?P<level>[^:]+): (?P<message>.*)$'
-
- parsed = parse_to_list_of_dict(pattern, stdout or '')
-
- messages = [SanityMessage(
- level=r['level'],
- message=r['message'],
- path=r['path'],
- line=int(r['line']),
- column=int(r.get('column') or '0'),
- ) for r in parsed]
-
- return messages
-
-
-@dataclasses.dataclass(frozen=True)
-class MyPyContext:
- """Context details for a single run of mypy."""
-
- name: str
- paths: list[str]
- python_versions: tuple[str, ...]
diff --git a/test/sanity/code-smell/mypy.json b/test/sanity/code-smell/mypy.json
new file mode 100644
index 0000000000..57a6ad6c15
--- /dev/null
+++ b/test/sanity/code-smell/mypy.json
@@ -0,0 +1,13 @@
+{
+ "prefixes": [
+ "lib/ansible/",
+ "test/lib/ansible_test/_internal/",
+ "packaging/",
+ "test/lib/ansible_test/_util/target/sanity/import/"
+ ],
+ "extensions": [
+ ".py"
+ ],
+ "multi_version": "controller",
+ "output": "path-line-column-code-message"
+}
diff --git a/test/sanity/code-smell/mypy.py b/test/sanity/code-smell/mypy.py
new file mode 100644
index 0000000000..fda83e8b0d
--- /dev/null
+++ b/test/sanity/code-smell/mypy.py
@@ -0,0 +1,228 @@
+"""Sanity test which executes mypy."""
+
+from __future__ import annotations
+
+import dataclasses
+import os
+import pathlib
+import re
+import subprocess
+import sys
+import typing as t
+
+vendored_paths = (
+ 'lib/ansible/module_utils/six/__init__.py',
+ 'lib/ansible/module_utils/distro/_distro.py',
+)
+
+config_dir = pathlib.Path(__file__).parent / 'mypy'
+
+
+def main() -> None:
+ """Main program entry point."""
+ paths = sys.argv[1:] or sys.stdin.read().splitlines()
+ paths = [path for path in paths if path not in vendored_paths] # FUTURE: define the exclusions in config so the paths can be skipped earlier
+
+ if not paths:
+ return
+
+ python_version = os.environ['ANSIBLE_TEST_TARGET_PYTHON_VERSION']
+ controller_python_versions = os.environ['ANSIBLE_TEST_CONTROLLER_PYTHON_VERSIONS'].split(',')
+ remote_only_python_versions = os.environ['ANSIBLE_TEST_REMOTE_ONLY_PYTHON_VERSIONS'].split(',')
+
+ contexts = (
+ MyPyContext('ansible-test', ['test/lib/ansible_test/_util/target/sanity/import/'], controller_python_versions),
+ MyPyContext('ansible-test', ['test/lib/ansible_test/_internal/'], controller_python_versions),
+ MyPyContext('ansible-core', ['lib/ansible/'], controller_python_versions),
+ MyPyContext('modules', ['lib/ansible/modules/', 'lib/ansible/module_utils/'], remote_only_python_versions),
+ MyPyContext('packaging', ['packaging/'], controller_python_versions),
+ )
+
+ unfiltered_messages: list[SanityMessage] = []
+
+ for context in contexts:
+ if python_version not in context.python_versions:
+ continue
+
+ unfiltered_messages.extend(test_context(python_version, context, paths))
+
+ notices = []
+ messages = []
+
+ for message in unfiltered_messages:
+ if message.level != 'error':
+ notices.append(message)
+ continue
+
+ match = re.search(r'^(?P<message>.*) {2}\[(?P<code>.*)]$', message.message)
+
+ messages.append(SanityMessage(
+ message=match.group('message'),
+ path=message.path,
+ line=message.line,
+ column=message.column,
+ level=message.level,
+ code=match.group('code'),
+ ))
+
+ # FUTURE: provide a way for script based tests to report non-error messages (in this case, notices)
+
+ # The following error codes from mypy indicate that results are incomplete.
+ # That prevents the test from completing successfully, just as if mypy were to traceback or generate unexpected output.
+ fatal_error_codes = {
+ 'import',
+ 'syntax',
+ }
+
+ fatal_errors = [message for message in messages if message.code in fatal_error_codes]
+
+ if fatal_errors:
+ error_message = '\n'.join(error.format() for error in fatal_errors)
+ raise Exception(f'Encountered {len(fatal_errors)} fatal errors reported by mypy:\n{error_message}')
+
+ paths_set = set(paths)
+
+ # Only report messages for paths that were specified as targets.
+ # Imports in our code are followed by mypy in order to perform its analysis, which is important for accurate results.
+ # However, it will also report issues on those files, which is not the desired behavior.
+ messages = [message for message in messages if message.path in paths_set]
+
+ for message in messages:
+ print(message.format())
+
+
+def test_context(
+ python_version: str,
+ context: MyPyContext,
+ paths: list[str],
+) -> list[SanityMessage]:
+ """Run mypy tests for the specified context."""
+ context_paths = [path for path in paths if any(path.startswith(match_path) for match_path in context.paths)]
+
+ if not context_paths:
+ return []
+
+ config_path = config_dir / f'{context.name}.ini'
+
+ # FUTURE: provide a way for script based tests to report progress and other diagnostic information
+ # display.info(f'Checking context "{context.name}"', verbosity=1)
+
+ env = os.environ.copy()
+ env['MYPYPATH'] = env['PYTHONPATH']
+
+ # The --no-site-packages option should not be used, as it will prevent loading of type stubs from the sanity test virtual environment.
+
+ # Enabling the --warn-unused-configs option would help keep the config files clean.
+ # However, the option can only be used when all files in tested contexts are evaluated.
+ # Unfortunately sanity tests have no way of making that determination currently.
+ # The option is also incompatible with incremental mode and caching.
+
+ cmd = [
+ # Below are arguments common to all contexts.
+ # They are kept here to avoid repetition in each config file.
+ sys.executable,
+ '-m', 'mypy',
+ '--show-column-numbers',
+ '--show-error-codes',
+ '--no-error-summary',
+ # This is a fairly common pattern in our code, so we'll allow it.
+ '--allow-redefinition',
+ # Since we specify the path(s) to test, it's important that mypy is configured to use the default behavior of following imports.
+ '--follow-imports', 'normal',
+ # Incremental results and caching do not provide significant performance benefits.
+ # It also prevents the use of the --warn-unused-configs option.
+ '--no-incremental',
+ '--cache-dir', '/dev/null',
+ # The platform is specified here so that results are consistent regardless of what platform the tests are run from.
+ # In the future, if testing of other platforms is desired, the platform should become part of the test specification, just like the Python version.
+ '--platform', 'linux',
+ # Despite what the documentation [1] states, the --python-version option does not cause mypy to search for a corresponding Python executable.
+ # It will instead use the Python executable that is used to run mypy itself.
+ # The --python-executable option can be used to specify the Python executable, with the default being the executable used to run mypy.
+ # As a precaution, that option is used in case the behavior of mypy is updated in the future to match the documentation.
+ # That should help guarantee that the Python executable providing type hints is the one used to run mypy.
+ # [1] https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-python-version
+ '--python-executable', sys.executable,
+ '--python-version', python_version,
+ # Below are context specific arguments.
+ # They are primarily useful for listing individual 'ignore_missing_imports' entries instead of using a global ignore.
+ '--config-file', config_path,
+ ] # fmt: skip
+
+ cmd.extend(context_paths)
+
+ try:
+ completed_process = subprocess.run(cmd, env=env, capture_output=True, check=True, text=True)
+ stdout, stderr = completed_process.stdout, completed_process.stderr
+
+ if stdout or stderr:
+ raise Exception(f'{stdout=} {stderr=}')
+ except subprocess.CalledProcessError as ex:
+ if ex.returncode != 1 or ex.stderr or not ex.stdout:
+ raise
+
+ stdout = ex.stdout
+
+ pattern = re.compile(r'^(?P<path>[^:]*):(?P<line>[0-9]+):((?P<column>[0-9]+):)? (?P<level>[^:]+): (?P<message>.*)$')
+
+ parsed = parse_to_list_of_dict(pattern, stdout or '')
+
+ messages = [SanityMessage(
+ level=r['level'],
+ message=r['message'],
+ path=r['path'],
+ line=int(r['line']),
+ column=int(r.get('column') or '0'),
+ code='', # extracted from error level messages later
+ ) for r in parsed]
+
+ return messages
+
+
+@dataclasses.dataclass(frozen=True)
+class MyPyContext:
+ """Context details for a single run of mypy."""
+
+ name: str
+ paths: list[str]
+ python_versions: list[str]
+
+
+@dataclasses.dataclass(frozen=True)
+class SanityMessage:
+ message: str
+ path: str
+ line: int
+ column: int
+ level: str
+ code: str
+
+ def format(self) -> str:
+ if self.code:
+ msg = f'{self.code}: {self.message}'
+ else:
+ msg = self.message
+
+ return f'{self.path}:{self.line}:{self.column}: {msg}'
+
+
+def parse_to_list_of_dict(pattern: re.Pattern, value: str) -> list[dict[str, t.Any]]:
+ matched = []
+ unmatched = []
+
+ for line in value.splitlines():
+ match = re.search(pattern, line)
+
+ if match:
+ matched.append(match.groupdict())
+ else:
+ unmatched.append(line)
+
+ if unmatched:
+ raise Exception(f'Pattern {pattern!r} did not match values:\n' + '\n'.join(unmatched))
+
+ return matched
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/lib/ansible_test/_data/requirements/sanity.mypy.in b/test/sanity/code-smell/mypy.requirements.in
index 073513bdf8..073513bdf8 100644
--- a/test/lib/ansible_test/_data/requirements/sanity.mypy.in
+++ b/test/sanity/code-smell/mypy.requirements.in
diff --git a/test/lib/ansible_test/_data/requirements/sanity.mypy.txt b/test/sanity/code-smell/mypy.requirements.txt
index a1a1bb08cf..27d69d2575 100644
--- a/test/lib/ansible_test/_data/requirements/sanity.mypy.txt
+++ b/test/sanity/code-smell/mypy.requirements.txt
@@ -1,4 +1,4 @@
-# edit "sanity.mypy.in" and generate with: hacking/update-sanity-requirements.py --test mypy
+# edit "mypy.requirements.in" and generate with: hacking/update-sanity-requirements.py --test mypy
cffi==1.17.0
cryptography==43.0.0
Jinja2==3.1.4
diff --git a/test/lib/ansible_test/_util/controller/sanity/mypy/ansible-core.ini b/test/sanity/code-smell/mypy/ansible-core.ini
index 0d2208be2d..0d2208be2d 100644
--- a/test/lib/ansible_test/_util/controller/sanity/mypy/ansible-core.ini
+++ b/test/sanity/code-smell/mypy/ansible-core.ini
diff --git a/test/lib/ansible_test/_util/controller/sanity/mypy/ansible-test.ini b/test/sanity/code-smell/mypy/ansible-test.ini
index 8b7a8ab8c5..8b7a8ab8c5 100644
--- a/test/lib/ansible_test/_util/controller/sanity/mypy/ansible-test.ini
+++ b/test/sanity/code-smell/mypy/ansible-test.ini
diff --git a/test/lib/ansible_test/_util/controller/sanity/mypy/modules.ini b/test/sanity/code-smell/mypy/modules.ini
index b4e7b05eb9..b4e7b05eb9 100644
--- a/test/lib/ansible_test/_util/controller/sanity/mypy/modules.ini
+++ b/test/sanity/code-smell/mypy/modules.ini
diff --git a/test/lib/ansible_test/_util/controller/sanity/mypy/packaging.ini b/test/sanity/code-smell/mypy/packaging.ini
index 70b0983c62..70b0983c62 100644
--- a/test/lib/ansible_test/_util/controller/sanity/mypy/packaging.ini
+++ b/test/sanity/code-smell/mypy/packaging.ini
diff --git a/test/sanity/code-smell/package-data.json b/test/sanity/code-smell/package-data.json
index f7ecd010a5..055e568a10 100644
--- a/test/sanity/code-smell/package-data.json
+++ b/test/sanity/code-smell/package-data.json
@@ -2,5 +2,8 @@
"disabled": true,
"all_targets": true,
"include_symlinks": true,
+ "multi_version": "target",
+ "controller_only": true,
+ "min_max_python_only": true,
"output": "path-message"
}
diff --git a/test/sanity/code-smell/package-data.py b/test/sanity/code-smell/package-data.py
index 4dc242a057..1a5ff3d379 100644
--- a/test/sanity/code-smell/package-data.py
+++ b/test/sanity/code-smell/package-data.py
@@ -5,6 +5,7 @@ import contextlib
import fnmatch
import os
import pathlib
+import re
import shutil
import subprocess
import sys
@@ -94,12 +95,11 @@ def clean_repository(complete_file_list: list[str]) -> t.Generator[str, None, No
def build(source_dir: str, tmp_dir: str) -> tuple[pathlib.Path, pathlib.Path]:
"""Create a sdist and wheel."""
- create = subprocess.run(
+ create = subprocess.run( # pylint: disable=subprocess-run-check
[sys.executable, '-m', 'build', '--outdir', tmp_dir],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
- check=False,
cwd=source_dir,
)
@@ -152,11 +152,57 @@ def main() -> None:
"""Main program entry point."""
complete_file_list = sys.argv[1:] or sys.stdin.read().splitlines()
- errors = []
+ python_version = '.'.join(map(str, sys.version_info[:2]))
+ python_min = os.environ['ANSIBLE_TEST_MIN_PYTHON']
+ python_max = os.environ['ANSIBLE_TEST_MAX_PYTHON']
+
+ if python_version == python_min:
+ use_upper_setuptools_version = False
+ elif python_version == python_max:
+ use_upper_setuptools_version = True
+ else:
+ raise RuntimeError(f'Python version {python_version} is neither the minimum {python_min} or the maximum {python_max}.')
+
+ errors = check_build(complete_file_list, use_upper_setuptools_version)
+
+ for error in errors:
+ print(error)
+
+
+def set_setuptools_version(repo_dir: str, use_upper_version: bool) -> str:
+ pyproject_toml = pathlib.Path(repo_dir) / 'pyproject.toml'
+
+ current = pyproject_toml.read_text()
+ pattern = re.compile(r'^(?P<begin>requires = \["setuptools >= )(?P<lower>[^,]+)(?P<middle>, <= )(?P<upper>[^"]+)(?P<end>".*)$', re.MULTILINE)
+ match = pattern.search(current)
+
+ if not match:
+ raise RuntimeError(f"Unable to find the 'requires' entry in: {pyproject_toml}")
+
+ lower_version = match.group('lower')
+ upper_version = match.group('upper')
+
+ requested_version = upper_version if use_upper_version else lower_version
+
+ updated = pattern.sub(fr'\g<begin>{requested_version}\g<middle>{requested_version}\g<end>', current)
+
+ if current == updated:
+ raise RuntimeError("Failed to set the setuptools version.")
+
+ pyproject_toml.write_text(updated)
+
+ return requested_version
+
+
+def check_build(complete_file_list: list[str], use_upper_setuptools_version: bool) -> list[str]:
+ errors: list[str] = []
+ complete_file_list = list(complete_file_list) # avoid mutation of input
# Limit visible files to those reported by ansible-test.
# This avoids including files which are not committed to git.
with clean_repository(complete_file_list) as clean_repo_dir:
+ setuptools_version = set_setuptools_version(clean_repo_dir, use_upper_setuptools_version)
+
if __version__.endswith('.dev0'):
# Make sure a changelog exists for this version when testing from devel.
# When testing from a stable branch the changelog will already exist.
@@ -177,8 +223,9 @@ def main() -> None:
errors.extend(check_files('sdist', expected_sdist_files, actual_sdist_files))
errors.extend(check_files('wheel', expected_wheel_files, actual_wheel_files))
- for error in errors:
- print(error)
+ errors = [f'{msg} ({setuptools_version})' for msg in errors]
+
+ return errors
if __name__ == '__main__':
diff --git a/test/sanity/code-smell/pymarkdown.py b/test/sanity/code-smell/pymarkdown.py
index 721c8937ef..0d788c9771 100644
--- a/test/sanity/code-smell/pymarkdown.py
+++ b/test/sanity/code-smell/pymarkdown.py
@@ -55,7 +55,7 @@ def parse_to_list_of_dict(pattern: re.Pattern, value: str) -> list[dict[str, t.A
unmatched.append(line)
if unmatched:
- raise Exception('Pattern {pattern!r} did not match values:\n' + '\n'.join(unmatched))
+ raise Exception(f'Pattern {pattern!r} did not match values:\n' + '\n'.join(unmatched))
return matched