diff --git a/MANIFEST.in b/MANIFEST.in index bf7a6a047e2..cc03ebcbe9e 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 143fe338caf..50da7c040df 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 4d580e933e3..00000000000 --- 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.*) {2}\[(?P.*)]$', 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[^:]*):(?P[0-9]+):((?P[0-9]+):)? (?P[^:]+): (?P.*)$' - - 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 00000000000..57a6ad6c15b --- /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 00000000000..fda83e8b0da --- /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.*) {2}\[(?P.*)]$', 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[^:]*):(?P[0-9]+):((?P[0-9]+):)? (?P[^:]+): (?P.*)$') + + 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 similarity index 100% rename from test/lib/ansible_test/_data/requirements/sanity.mypy.in rename to 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 similarity index 78% rename from test/lib/ansible_test/_data/requirements/sanity.mypy.txt rename to test/sanity/code-smell/mypy.requirements.txt index a1a1bb08cf9..27d69d2575a 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 similarity index 100% rename from test/lib/ansible_test/_util/controller/sanity/mypy/ansible-core.ini rename to 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 similarity index 100% rename from test/lib/ansible_test/_util/controller/sanity/mypy/ansible-test.ini rename to 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 similarity index 100% rename from test/lib/ansible_test/_util/controller/sanity/mypy/modules.ini rename to 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 similarity index 100% rename from test/lib/ansible_test/_util/controller/sanity/mypy/packaging.ini rename to 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 f7ecd010a50..055e568a108 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 4dc242a057a..1a5ff3d3796 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'^(?Prequires = \["setuptools >= )(?P[^,]+)(?P, <= )(?P[^"]+)(?P".*)$', 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{requested_version}\g{requested_version}\g', 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 721c8937ef9..0d788c97714 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