summaryrefslogtreecommitdiff
path: root/test/lib/ansible_test/_internal/commands/sanity/mypy.py
blob: cb8ed12c1ae44357f5669214d33ab44ae6cca883 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""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',
        'lib/ansible/module_utils/compat/_selectors2.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('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),
        )

        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]

        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,
        ]

        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: list[str]
    python_versions: tuple[str, ...]