import importlib.util import os import re import shutil import textwrap from collections import defaultdict from typing import TYPE_CHECKING import pytest # Only trigger a full `mypy` run if this environment variable is set # Note that these tests tend to take over a minute even on a macOS M1 CPU, # and more than that in CI. RUN_MYPY = "NPY_RUN_MYPY_IN_TESTSUITE" in os.environ if RUN_MYPY and RUN_MYPY not in ('0', '', 'false'): RUN_MYPY = True # Skips all functions in this file pytestmark = pytest.mark.skipif( not RUN_MYPY, reason="`NPY_RUN_MYPY_IN_TESTSUITE` not set" ) try: from mypy import api except ImportError: NO_MYPY = True else: NO_MYPY = False if TYPE_CHECKING: from collections.abc import Iterator # We need this as annotation, but it's located in a private namespace. # As a compromise, do *not* import it during runtime from _pytest.mark.structures import ParameterSet DATA_DIR = os.path.join(os.path.dirname(__file__), "data") PASS_DIR = os.path.join(DATA_DIR, "pass") FAIL_DIR = os.path.join(DATA_DIR, "fail") REVEAL_DIR = os.path.join(DATA_DIR, "reveal") MISC_DIR = os.path.join(DATA_DIR, "misc") MYPY_INI = os.path.join(DATA_DIR, "mypy.ini") CACHE_DIR = os.path.join(DATA_DIR, ".mypy_cache") #: A dictionary with file names as keys and lists of the mypy stdout as values. #: To-be populated by `run_mypy`. OUTPUT_MYPY: defaultdict[str, list[str]] = defaultdict(list) def _key_func(key: str) -> str: """Split at the first occurrence of the ``:`` character. Windows drive-letters (*e.g.* ``C:``) are ignored herein. """ drive, tail = os.path.splitdrive(key) return os.path.join(drive, tail.split(":", 1)[0]) def _strip_filename(msg: str) -> tuple[int, str]: """Strip the filename and line number from a mypy message.""" _, tail = os.path.splitdrive(msg) _, lineno, msg = tail.split(":", 2) return int(lineno), msg.strip() def strip_func(match: re.Match[str]) -> str: """`re.sub` helper function for stripping module names.""" return match.groups()[1] @pytest.fixture(scope="module", autouse=True) def run_mypy() -> None: """Clears the cache and run mypy before running any of the typing tests. The mypy results are cached in `OUTPUT_MYPY` for further use. The cache refresh can be skipped using NUMPY_TYPING_TEST_CLEAR_CACHE=0 pytest numpy/typing/tests """ if ( os.path.isdir(CACHE_DIR) and bool(os.environ.get("NUMPY_TYPING_TEST_CLEAR_CACHE", True)) # noqa: PLW1508 ): shutil.rmtree(CACHE_DIR) split_pattern = re.compile(r"(\s+)?\^(\~+)?") for directory in (PASS_DIR, REVEAL_DIR, FAIL_DIR, MISC_DIR): # Run mypy stdout, stderr, exit_code = api.run([ "--config-file", MYPY_INI, "--cache-dir", CACHE_DIR, directory, ]) if stderr: pytest.fail(f"Unexpected mypy standard error\n\n{stderr}", False) elif exit_code not in {0, 1}: pytest.fail(f"Unexpected mypy exit code: {exit_code}\n\n{stdout}", False) str_concat = "" filename: str | None = None for i in stdout.split("\n"): if "note:" in i: continue if filename is None: filename = _key_func(i) str_concat += f"{i}\n" if split_pattern.match(i) is not None: OUTPUT_MYPY[filename].append(str_concat) str_concat = "" filename = None def get_test_cases(*directories: str) -> "Iterator[ParameterSet]": for directory in directories: for root, _, files in os.walk(directory): for fname in files: short_fname, ext = os.path.splitext(fname) if ext not in (".pyi", ".py"): continue fullpath = os.path.join(root, fname) yield pytest.param(fullpath, id=short_fname) _FAIL_INDENT = " " * 4 _FAIL_SEP = "\n" + "_" * 79 + "\n\n" _FAIL_MSG_REVEAL = """{}:{} - reveal mismatch: {}""" @pytest.mark.slow @pytest.mark.skipif(NO_MYPY, reason="Mypy is not installed") @pytest.mark.parametrize("path", get_test_cases(PASS_DIR, FAIL_DIR)) def test_pass(path) -> None: # Alias `OUTPUT_MYPY` so that it appears in the local namespace output_mypy = OUTPUT_MYPY if path not in output_mypy: return relpath = os.path.relpath(path) # collect any reported errors, and clean up the output messages = [] for message in output_mypy[path]: lineno, content = _strip_filename(message) content = content.removeprefix("error:").lstrip() messages.append(f"{relpath}:{lineno} - {content}") if messages: pytest.fail("\n".join(messages), pytrace=False) @pytest.mark.slow @pytest.mark.skipif(NO_MYPY, reason="Mypy is not installed") @pytest.mark.parametrize("path", get_test_cases(REVEAL_DIR)) def test_reveal(path: str) -> None: """Validate that mypy correctly infers the return-types of the expressions in `path`. """ __tracebackhide__ = True output_mypy = OUTPUT_MYPY if path not in output_mypy: return relpath = os.path.relpath(path) # collect any reported errors, and clean up the output failures = [] for error_line in output_mypy[path]: lineno, error_msg = _strip_filename(error_line) error_msg = textwrap.indent(error_msg, _FAIL_INDENT) reason = _FAIL_MSG_REVEAL.format(relpath, lineno, error_msg) failures.append(reason) if failures: reasons = _FAIL_SEP.join(failures) pytest.fail(reasons, pytrace=False) @pytest.mark.slow @pytest.mark.skipif(NO_MYPY, reason="Mypy is not installed") @pytest.mark.parametrize("path", get_test_cases(PASS_DIR)) def test_code_runs(path: str) -> None: """Validate that the code in `path` properly during runtime.""" path_without_extension, _ = os.path.splitext(path) dirname, filename = path.split(os.sep)[-2:] spec = importlib.util.spec_from_file_location( f"{dirname}.{filename}", path ) assert spec is not None assert spec.loader is not None test_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(test_module)