Initial mypy sanity test support for core.

pull/77237/head
Matt Clay 3 years ago
parent 27923aad7e
commit 3d5637beec

@ -30,7 +30,7 @@ recursive-include test/ansible_test *.py Makefile
recursive-include test/integration *
recursive-include test/lib/ansible_test/config *.yml *.template
recursive-include test/lib/ansible_test/_data *.cfg *.in *.ini *.ps1 *.txt *.yml coveragerc
recursive-include test/lib/ansible_test/_util *.cfg *.json *.ps1 *.psd1 *.py *.sh *.txt *.yml
recursive-include test/lib/ansible_test/_util *.cfg *.ini *.json *.ps1 *.psd1 *.py *.sh *.txt *.yml
recursive-include test/lib/ansible_test/_util/controller/sanity/validate-modules validate-modules
recursive-include test/sanity *.in *.json *.py *.txt
recursive-include test/support *.py *.ps1 *.psm1 *.cs

@ -0,0 +1,14 @@
mypy
====
The ``mypy`` static type checker is used to check the following code against each Python version supported by the controller:
* ``lib/ansible/``
* ``test/lib/ansible_test/_internal/``
Additionally, the following code is checked against Python versions supported only on managed nodes:
* ``lib/ansible/modules/``
* ``lib/ansible/module_utils/``
See https://mypy.readthedocs.io/en/stable/ for additional details.

@ -0,0 +1,9 @@
mypy[python2]
packaging # type stubs not published separately
types-backports
types-jinja2
types-paramiko
types-pyyaml < 6 # PyYAML 6+ stubs do not support Python 2.7
types-requests
types-setuptools
types-toml

@ -0,0 +1,20 @@
# edit "sanity.mypy.in" and generate with: hacking/update-sanity-requirements.py --test mypy
mypy==0.931
mypy-extensions==0.4.3
packaging==21.2
pyparsing==2.4.7
tomli==2.0.1
typed-ast==1.5.2
types-backports==0.1.3
types-cryptography==3.3.15
types-enum34==1.1.8
types-ipaddress==1.0.8
types-Jinja2==2.11.9
types-MarkupSafe==1.1.10
types-paramiko==2.8.13
types-PyYAML==5.4.12
types-requests==2.27.10
types-setuptools==57.4.9
types-toml==0.10.4
types-urllib3==1.26.9
typing-extensions==3.10.0.2

@ -0,0 +1,247 @@
"""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
def filter_targets(self, targets): # type: (t.List[TestTarget]) -> t.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.startswith('lib/ansible/') or target.path.startswith('test/lib/ansible_test/_internal/'))]
@property
def error_code(self): # type: () -> 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): # type: () -> bool
"""True if the test requires PyPI, otherwise False."""
return True
def test(self, args, targets, python): # type: (SanityConfig, SanityTargets, 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)
contexts = (
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),
)
unfiltered_messages = [] # type: t.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]
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, # type: SanityConfig
virtualenv_python, # type: VirtualPythonConfig
python, # type: PythonConfig
context, # type: MyPyContext
paths, # type: t.List[str]
): # type: (...) -> t.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)
# 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,
]
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)
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: t.List[str]
python_versions: t.Tuple[str, ...]

@ -0,0 +1,119 @@
# IMPORTANT
# Set "ignore_missing_imports" per package below, rather than globally.
# That will help identify missing type stubs that should be added to the sanity test environment.
[mypy]
# There are ~20 errors reported in ansible-core when strict optional checking is enabled.
# Until the number of occurrences are reduced, it's better to disable strict checking.
strict_optional = False
# There are ~70 errors reported in ansible-core when checking attributes.
# Until the number of occurrences are reduced, it's better to disable the check.
disable_error_code = attr-defined
[mypy-ansible.module_utils.six.moves.*]
ignore_missing_imports = True
[mypy-passlib.*]
ignore_missing_imports = True
[mypy-pexpect.*]
ignore_missing_imports = True
[mypy-pypsrp.*]
ignore_missing_imports = True
[mypy-winrm.*]
ignore_missing_imports = True
[mypy-kerberos.*]
ignore_missing_imports = True
[mypy-xmltodict.*]
ignore_missing_imports = True
[mypy-md5.*]
ignore_missing_imports = True
[mypy-scp.*]
ignore_missing_imports = True
[mypy-ncclient.*]
ignore_missing_imports = True
[mypy-lxml.*]
ignore_missing_imports = True
[mypy-yum.*]
ignore_missing_imports = True
[mypy-rpmUtils.*]
ignore_missing_imports = True
[mypy-rpm.*]
ignore_missing_imports = True
[mypy-psutil.*]
ignore_missing_imports = True
[mypy-dnf.*]
ignore_missing_imports = True
[mypy-apt.*]
ignore_missing_imports = True
[mypy-apt_pkg.*]
ignore_missing_imports = True
[mypy-gssapi.*]
ignore_missing_imports = True
[mypy-_ssl.*]
ignore_missing_imports = True
[mypy-urllib_gssapi.*]
ignore_missing_imports = True
[mypy-systemd.*]
ignore_missing_imports = True
[mypy-sha.*]
ignore_missing_imports = True
[mypy-distro.*]
ignore_missing_imports = True
[mypy-selectors2.*]
ignore_missing_imports = True
[mypy-resolvelib.*]
ignore_missing_imports = True
[mypy-urlparse.*]
ignore_missing_imports = True
[mypy-argcomplete.*]
ignore_missing_imports = True
[mypy-selinux.*]
ignore_missing_imports = True
[mypy-urllib2.*]
ignore_missing_imports = True
[mypy-httplib.*]
ignore_missing_imports = True
[mypy-compiler.*]
ignore_missing_imports = True
[mypy-aptsources.*]
ignore_missing_imports = True
[mypy-urllib3.*]
ignore_missing_imports = True
[mypy-requests.*]
ignore_missing_imports = True
[mypy-jinja2.nativetypes]
ignore_missing_imports = True

@ -0,0 +1,21 @@
# IMPORTANT
# Set "ignore_missing_imports" per package below, rather than globally.
# That will help identify missing type stubs that should be added to the sanity test environment.
[mypy]
# There are ~350 errors reported in ansible-test when strict optional checking is enabled.
# Until the number of occurrences are greatly reduced, it's better to disable strict checking.
strict_optional = False
# There are ~25 errors reported in ansible-test under the 'misc' code.
# The majority of those errors are "Only concrete class can be given", which is due to a limitation of mypy.
# See: https://github.com/python/mypy/issues/5374
disable_error_code = misc
[mypy-argcomplete]
ignore_missing_imports = True
[mypy-coverage]
ignore_missing_imports = True
[mypy-ansible_release]
ignore_missing_imports = True

@ -0,0 +1,98 @@
# IMPORTANT
# Set "ignore_missing_imports" per package below, rather than globally.
# That will help identify missing type stubs that should be added to the sanity test environment.
[mypy]
[mypy-ansible.module_utils.six.moves.*]
ignore_missing_imports = True
[mypy-pexpect.*]
ignore_missing_imports = True
[mypy-md5.*]
ignore_missing_imports = True
[mypy-yum.*]
ignore_missing_imports = True
[mypy-rpmUtils.*]
ignore_missing_imports = True
[mypy-rpm.*]
ignore_missing_imports = True
[mypy-psutil.*]
ignore_missing_imports = True
[mypy-dnf.*]
ignore_missing_imports = True
[mypy-apt.*]
ignore_missing_imports = True
[mypy-apt_pkg.*]
ignore_missing_imports = True
[mypy-gssapi.*]
ignore_missing_imports = True
[mypy-_ssl.*]
ignore_missing_imports = True
[mypy-urllib_gssapi.*]
ignore_missing_imports = True
[mypy-systemd.*]
ignore_missing_imports = True
[mypy-sha.*]
ignore_missing_imports = True
[mypy-distro.*]
ignore_missing_imports = True
[mypy-selectors2.*]
ignore_missing_imports = True
[mypy-selinux.*]
ignore_missing_imports = True
[mypy-urllib2.*]
ignore_missing_imports = True
[mypy-httplib.*]
ignore_missing_imports = True
[mypy-compiler.*]
ignore_missing_imports = True
[mypy-aptsources.*]
ignore_missing_imports = True
[mypy-urllib3.*]
ignore_missing_imports = True
[mypy-requests.*]
ignore_missing_imports = True
[mypy-pkg_resources.*]
ignore_missing_imports = True
[mypy-urllib.*]
ignore_missing_imports = True
[mypy-email.*]
ignore_missing_imports = True
[mypy-selectors.*]
ignore_missing_imports = True
[mypy-importlib.*]
ignore_missing_imports = True
[mypy-collections.*]
ignore_missing_imports = True
[mypy-http.*]
ignore_missing_imports = True

@ -271,3 +271,75 @@ test/units/utils/collection_loader/fixtures/collections_masked/ansible_collectio
test/units/utils/collection_loader/fixtures/collections_masked/ansible_collections/testns/__init__.py empty-init # testing that collections don't need inits
test/units/utils/collection_loader/fixtures/collections_masked/ansible_collections/testns/testcoll/__init__.py empty-init # testing that collections don't need inits
test/units/utils/collection_loader/test_collection_loader.py pylint:undefined-variable # magic runtime local var splatting
lib/ansible/module_utils/six/__init__.py mypy-2.7:has-type # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.5:has-type # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.6:has-type # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.7:has-type # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.8:has-type # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.9:has-type # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.10:has-type # vendored code
lib/ansible/module_utils/six/__init__.py mypy-2.7:name-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.5:name-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.6:name-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.7:name-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.8:name-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.9:name-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.10:name-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-2.7:assignment # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.5:assignment # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.6:assignment # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.7:assignment # vendored code
lib/ansible/module_utils/six/__init__.py mypy-2.7:misc # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.5:misc # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.6:misc # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.7:misc # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.8:misc # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.9:misc # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.10:misc # vendored code
lib/ansible/module_utils/six/__init__.py mypy-2.7:var-annotated # vendored code
lib/ansible/module_utils/six/__init__.py mypy-2.7:attr-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.5:var-annotated # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.5:attr-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.6:var-annotated # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.6:attr-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.7:var-annotated # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.7:attr-defined # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.8:var-annotated # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.9:var-annotated # vendored code
lib/ansible/module_utils/six/__init__.py mypy-3.10:var-annotated # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-2.7:arg-type # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-3.5:valid-type # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-3.6:valid-type # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-3.7:valid-type # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-2.7:assignment # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-2.7:attr-defined # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-3.5:attr-defined # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-3.6:attr-defined # vendored code
lib/ansible/module_utils/distro/_distro.py mypy-3.7:attr-defined # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-2.7:misc # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.5:misc # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.6:misc # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.7:misc # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.8:misc # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.9:misc # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.10:misc # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-2.7:assignment # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.5:assignment # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.6:assignment # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.7:assignment # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.8:assignment # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.9:assignment # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-3.10:assignment # vendored code
lib/ansible/module_utils/compat/_selectors2.py mypy-2.7:attr-defined # vendored code
lib/ansible/parsing/yaml/dumper.py mypy-3.8:misc
lib/ansible/parsing/yaml/dumper.py mypy-3.9:misc
lib/ansible/parsing/yaml/dumper.py mypy-3.10:misc
lib/ansible/parsing/yaml/dumper.py mypy-3.8:valid-type
lib/ansible/parsing/yaml/dumper.py mypy-3.9:valid-type
lib/ansible/parsing/yaml/dumper.py mypy-3.10:valid-type
lib/ansible/parsing/yaml/loader.py mypy-3.8:misc
lib/ansible/parsing/yaml/loader.py mypy-3.9:misc
lib/ansible/parsing/yaml/loader.py mypy-3.10:misc
lib/ansible/parsing/yaml/loader.py mypy-3.8:valid-type
lib/ansible/parsing/yaml/loader.py mypy-3.9:valid-type
lib/ansible/parsing/yaml/loader.py mypy-3.10:valid-type

Loading…
Cancel
Save