test_typing.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import importlib.util
  2. import os
  3. import re
  4. import shutil
  5. import textwrap
  6. from collections import defaultdict
  7. from typing import TYPE_CHECKING
  8. import pytest
  9. # Only trigger a full `mypy` run if this environment variable is set
  10. # Note that these tests tend to take over a minute even on a macOS M1 CPU,
  11. # and more than that in CI.
  12. RUN_MYPY = "NPY_RUN_MYPY_IN_TESTSUITE" in os.environ
  13. if RUN_MYPY and RUN_MYPY not in ('0', '', 'false'):
  14. RUN_MYPY = True
  15. # Skips all functions in this file
  16. pytestmark = pytest.mark.skipif(
  17. not RUN_MYPY,
  18. reason="`NPY_RUN_MYPY_IN_TESTSUITE` not set"
  19. )
  20. try:
  21. from mypy import api
  22. except ImportError:
  23. NO_MYPY = True
  24. else:
  25. NO_MYPY = False
  26. if TYPE_CHECKING:
  27. from collections.abc import Iterator
  28. # We need this as annotation, but it's located in a private namespace.
  29. # As a compromise, do *not* import it during runtime
  30. from _pytest.mark.structures import ParameterSet
  31. DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
  32. PASS_DIR = os.path.join(DATA_DIR, "pass")
  33. FAIL_DIR = os.path.join(DATA_DIR, "fail")
  34. REVEAL_DIR = os.path.join(DATA_DIR, "reveal")
  35. MISC_DIR = os.path.join(DATA_DIR, "misc")
  36. MYPY_INI = os.path.join(DATA_DIR, "mypy.ini")
  37. CACHE_DIR = os.path.join(DATA_DIR, ".mypy_cache")
  38. #: A dictionary with file names as keys and lists of the mypy stdout as values.
  39. #: To-be populated by `run_mypy`.
  40. OUTPUT_MYPY: defaultdict[str, list[str]] = defaultdict(list)
  41. def _key_func(key: str) -> str:
  42. """Split at the first occurrence of the ``:`` character.
  43. Windows drive-letters (*e.g.* ``C:``) are ignored herein.
  44. """
  45. drive, tail = os.path.splitdrive(key)
  46. return os.path.join(drive, tail.split(":", 1)[0])
  47. def _strip_filename(msg: str) -> tuple[int, str]:
  48. """Strip the filename and line number from a mypy message."""
  49. _, tail = os.path.splitdrive(msg)
  50. _, lineno, msg = tail.split(":", 2)
  51. return int(lineno), msg.strip()
  52. def strip_func(match: re.Match[str]) -> str:
  53. """`re.sub` helper function for stripping module names."""
  54. return match.groups()[1]
  55. @pytest.fixture(scope="module", autouse=True)
  56. def run_mypy() -> None:
  57. """Clears the cache and run mypy before running any of the typing tests.
  58. The mypy results are cached in `OUTPUT_MYPY` for further use.
  59. The cache refresh can be skipped using
  60. NUMPY_TYPING_TEST_CLEAR_CACHE=0 pytest numpy/typing/tests
  61. """
  62. if (
  63. os.path.isdir(CACHE_DIR)
  64. and bool(os.environ.get("NUMPY_TYPING_TEST_CLEAR_CACHE", True)) # noqa: PLW1508
  65. ):
  66. shutil.rmtree(CACHE_DIR)
  67. split_pattern = re.compile(r"(\s+)?\^(\~+)?")
  68. for directory in (PASS_DIR, REVEAL_DIR, FAIL_DIR, MISC_DIR):
  69. # Run mypy
  70. stdout, stderr, exit_code = api.run([
  71. "--config-file",
  72. MYPY_INI,
  73. "--cache-dir",
  74. CACHE_DIR,
  75. directory,
  76. ])
  77. if stderr:
  78. pytest.fail(f"Unexpected mypy standard error\n\n{stderr}", False)
  79. elif exit_code not in {0, 1}:
  80. pytest.fail(f"Unexpected mypy exit code: {exit_code}\n\n{stdout}", False)
  81. str_concat = ""
  82. filename: str | None = None
  83. for i in stdout.split("\n"):
  84. if "note:" in i:
  85. continue
  86. if filename is None:
  87. filename = _key_func(i)
  88. str_concat += f"{i}\n"
  89. if split_pattern.match(i) is not None:
  90. OUTPUT_MYPY[filename].append(str_concat)
  91. str_concat = ""
  92. filename = None
  93. def get_test_cases(*directories: str) -> "Iterator[ParameterSet]":
  94. for directory in directories:
  95. for root, _, files in os.walk(directory):
  96. for fname in files:
  97. short_fname, ext = os.path.splitext(fname)
  98. if ext not in (".pyi", ".py"):
  99. continue
  100. fullpath = os.path.join(root, fname)
  101. yield pytest.param(fullpath, id=short_fname)
  102. _FAIL_INDENT = " " * 4
  103. _FAIL_SEP = "\n" + "_" * 79 + "\n\n"
  104. _FAIL_MSG_REVEAL = """{}:{} - reveal mismatch:
  105. {}"""
  106. @pytest.mark.slow
  107. @pytest.mark.skipif(NO_MYPY, reason="Mypy is not installed")
  108. @pytest.mark.parametrize("path", get_test_cases(PASS_DIR, FAIL_DIR))
  109. def test_pass(path) -> None:
  110. # Alias `OUTPUT_MYPY` so that it appears in the local namespace
  111. output_mypy = OUTPUT_MYPY
  112. if path not in output_mypy:
  113. return
  114. relpath = os.path.relpath(path)
  115. # collect any reported errors, and clean up the output
  116. messages = []
  117. for message in output_mypy[path]:
  118. lineno, content = _strip_filename(message)
  119. content = content.removeprefix("error:").lstrip()
  120. messages.append(f"{relpath}:{lineno} - {content}")
  121. if messages:
  122. pytest.fail("\n".join(messages), pytrace=False)
  123. @pytest.mark.slow
  124. @pytest.mark.skipif(NO_MYPY, reason="Mypy is not installed")
  125. @pytest.mark.parametrize("path", get_test_cases(REVEAL_DIR))
  126. def test_reveal(path: str) -> None:
  127. """Validate that mypy correctly infers the return-types of
  128. the expressions in `path`.
  129. """
  130. __tracebackhide__ = True
  131. output_mypy = OUTPUT_MYPY
  132. if path not in output_mypy:
  133. return
  134. relpath = os.path.relpath(path)
  135. # collect any reported errors, and clean up the output
  136. failures = []
  137. for error_line in output_mypy[path]:
  138. lineno, error_msg = _strip_filename(error_line)
  139. error_msg = textwrap.indent(error_msg, _FAIL_INDENT)
  140. reason = _FAIL_MSG_REVEAL.format(relpath, lineno, error_msg)
  141. failures.append(reason)
  142. if failures:
  143. reasons = _FAIL_SEP.join(failures)
  144. pytest.fail(reasons, pytrace=False)
  145. @pytest.mark.slow
  146. @pytest.mark.skipif(NO_MYPY, reason="Mypy is not installed")
  147. @pytest.mark.parametrize("path", get_test_cases(PASS_DIR))
  148. def test_code_runs(path: str) -> None:
  149. """Validate that the code in `path` properly during runtime."""
  150. path_without_extension, _ = os.path.splitext(path)
  151. dirname, filename = path.split(os.sep)[-2:]
  152. spec = importlib.util.spec_from_file_location(
  153. f"{dirname}.{filename}", path
  154. )
  155. assert spec is not None
  156. assert spec.loader is not None
  157. test_module = importlib.util.module_from_spec(spec)
  158. spec.loader.exec_module(test_module)