mirror of
https://github.com/zebrajr/ansible.git
synced 2025-12-06 00:19:48 +01:00
ansible-test - Update mypy and package-data tests (#83734)
* package-data - Test min/max setuptools version * Fix multi-version abstraction * Convert mypy test to script based test * Fix f-string in pymarkdown test * Sanity test fixes
This commit is contained in:
parent
717f1092e3
commit
70be017f02
|
|
@ -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 *
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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, ...]
|
||||
13
test/sanity/code-smell/mypy.json
Normal file
13
test/sanity/code-smell/mypy.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
228
test/sanity/code-smell/mypy.py
Normal file
228
test/sanity/code-smell/mypy.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user