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
pull/83736/head
Matt Clay 4 months ago committed by GitHub
parent 717f1092e3
commit 70be017f02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -6,6 +6,6 @@ include licenses/*.txt
include requirements.txt include requirements.txt
recursive-include packaging *.py *.j2 recursive-include packaging *.py *.j2
recursive-include test/integration * 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/support *.py *.ps1 *.psm1 *.cs *.md
recursive-include test/units * 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.' \ result.reason = f'Skipping sanity test "{test.name}" on Python {version} because it is unsupported.' \
f' Supported Python versions: {", ".join(test.supported_python_versions)}' f' Supported Python versions: {", ".join(test.supported_python_versions)}'
else: else:
if isinstance(test, SanityCodeSmellTest): if isinstance(test, SanityMultipleVersion):
settings = test.load_processor(args)
elif isinstance(test, SanityMultipleVersion):
settings = test.load_processor(args, version) settings = test.load_processor(args, version)
elif isinstance(test, SanitySingleVersion): elif isinstance(test, SanitySingleVersion):
settings = test.load_processor(args) 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) 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) 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 return tests
@ -829,21 +827,34 @@ class SanitySingleVersion(SanityTest, metaclass=abc.ABCMeta):
return SanityIgnoreProcessor(args, self, None) return SanityIgnoreProcessor(args, self, None)
class SanityCodeSmellTest(SanitySingleVersion): class SanityScript(SanityTest, metaclass=abc.ABCMeta):
"""Sanity test script.""" """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] name = os.path.splitext(os.path.basename(path))[0]
config_path = os.path.splitext(path)[0] + '.json' 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) super().__init__(name=name)
self.path = path self.path = path
self.config_path = config_path if os.path.exists(config_path) else None self.config = config
self.config = None
if self.config_path:
self.config = read_json_file(self.config_path)
if self.config: if self.config:
self.enabled = not self.config.get('disabled') self.enabled = not self.config.get('disabled')
@ -854,6 +865,8 @@ class SanityCodeSmellTest(SanitySingleVersion):
self.files: list[str] = self.config.get('files') self.files: list[str] = self.config.get('files')
self.text: t.Optional[bool] = self.config.get('text') self.text: t.Optional[bool] = self.config.get('text')
self.ignore_self: bool = self.config.get('ignore_self') 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.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') self.maximum_python_version: t.Optional[str] = self.config.get('maximum_python_version')
@ -869,6 +882,8 @@ class SanityCodeSmellTest(SanitySingleVersion):
self.files = [] self.files = []
self.text = None self.text = None
self.ignore_self = False self.ignore_self = False
self.controller_only = False
self.min_max_python_only = False
self.minimum_python_version = None self.minimum_python_version = None
self.maximum_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.""" """A tuple of supported Python versions or None if the test does not depend on specific Python versions."""
versions = super().supported_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: if self.minimum_python_version:
versions = tuple(version for version in versions if str_to_version(version) >= str_to_version(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: if self.maximum_python_version:
versions = tuple(version for version in versions if str_to_version(version) <= str_to_version(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 return versions
def filter_targets(self, targets: list[TestTarget]) -> list[TestTarget]: def filter_targets(self, targets: list[TestTarget]) -> list[TestTarget]:
@ -960,17 +981,29 @@ class SanityCodeSmellTest(SanitySingleVersion):
return targets 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.""" """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 = 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 pattern = None
data = None data = None
settings = self.load_processor(args) settings = self.conditionally_load_processor(args, python.version)
paths = [target.path for target in targets.include] paths = [target.path for target in targets.include]
@ -991,7 +1024,7 @@ class SanityCodeSmellTest(SanitySingleVersion):
display.info(data, verbosity=4) display.info(data, verbosity=4)
try: 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 status = 0
except SubprocessError as ex: except SubprocessError as ex:
stdout = ex.stdout stdout = ex.stdout
@ -1031,9 +1064,9 @@ class SanityCodeSmellTest(SanitySingleVersion):
return SanitySuccess(self.name) 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.""" """Load the ignore processor for this sanity test."""
return SanityIgnoreProcessor(args, self, None)
class SanityVersionNeutral(SanityTest, metaclass=abc.ABCMeta): class SanityVersionNeutral(SanityTest, metaclass=abc.ABCMeta):
@ -1094,6 +1127,50 @@ class SanityMultipleVersion(SanityTest, metaclass=abc.ABCMeta):
return targets 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 @cache
def sanity_get_tests() -> tuple[SanityTest, ...]: def sanity_get_tests() -> tuple[SanityTest, ...]:
"""Return a tuple of the available sanity tests.""" """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, ...]

@ -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"
}

@ -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 cffi==1.17.0
cryptography==43.0.0 cryptography==43.0.0
Jinja2==3.1.4 Jinja2==3.1.4

@ -2,5 +2,8 @@
"disabled": true, "disabled": true,
"all_targets": true, "all_targets": true,
"include_symlinks": true, "include_symlinks": true,
"multi_version": "target",
"controller_only": true,
"min_max_python_only": true,
"output": "path-message" "output": "path-message"
} }

@ -5,6 +5,7 @@ import contextlib
import fnmatch import fnmatch
import os import os
import pathlib import pathlib
import re
import shutil import shutil
import subprocess import subprocess
import sys 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]: def build(source_dir: str, tmp_dir: str) -> tuple[pathlib.Path, pathlib.Path]:
"""Create a sdist and wheel.""" """Create a sdist and wheel."""
create = subprocess.run( create = subprocess.run( # pylint: disable=subprocess-run-check
[sys.executable, '-m', 'build', '--outdir', tmp_dir], [sys.executable, '-m', 'build', '--outdir', tmp_dir],
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
capture_output=True, capture_output=True,
text=True, text=True,
check=False,
cwd=source_dir, cwd=source_dir,
) )
@ -152,11 +152,57 @@ def main() -> None:
"""Main program entry point.""" """Main program entry point."""
complete_file_list = sys.argv[1:] or sys.stdin.read().splitlines() 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. # Limit visible files to those reported by ansible-test.
# This avoids including files which are not committed to git. # This avoids including files which are not committed to git.
with clean_repository(complete_file_list) as clean_repo_dir: 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'): if __version__.endswith('.dev0'):
# Make sure a changelog exists for this version when testing from devel. # Make sure a changelog exists for this version when testing from devel.
# When testing from a stable branch the changelog will already exist. # 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('sdist', expected_sdist_files, actual_sdist_files))
errors.extend(check_files('wheel', expected_wheel_files, actual_wheel_files)) errors.extend(check_files('wheel', expected_wheel_files, actual_wheel_files))
for error in errors: errors = [f'{msg} ({setuptools_version})' for msg in errors]
print(error)
return errors
if __name__ == '__main__': 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) unmatched.append(line)
if unmatched: 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 return matched

Loading…
Cancel
Save