mypy.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. # testing/fixtures/mypy.py
  2. # Copyright (C) 2005-2024 the SQLAlchemy authors and contributors
  3. # <see AUTHORS file>
  4. #
  5. # This module is part of SQLAlchemy and is released under
  6. # the MIT License: https://www.opensource.org/licenses/mit-license.php
  7. # mypy: ignore-errors
  8. from __future__ import annotations
  9. import inspect
  10. import os
  11. from pathlib import Path
  12. import re
  13. import shutil
  14. import sys
  15. import tempfile
  16. from .base import TestBase
  17. from .. import config
  18. from ..assertions import eq_
  19. from ... import util
  20. @config.add_to_marker.mypy
  21. class MypyTest(TestBase):
  22. __requires__ = ("no_sqlalchemy2_stubs",)
  23. @config.fixture(scope="function")
  24. def per_func_cachedir(self):
  25. yield from self._cachedir()
  26. @config.fixture(scope="class")
  27. def cachedir(self):
  28. yield from self._cachedir()
  29. def _cachedir(self):
  30. # as of mypy 0.971 i think we need to keep mypy_path empty
  31. mypy_path = ""
  32. with tempfile.TemporaryDirectory() as cachedir:
  33. with open(
  34. Path(cachedir) / "sqla_mypy_config.cfg", "w"
  35. ) as config_file:
  36. config_file.write(
  37. f"""
  38. [mypy]\n
  39. plugins = sqlalchemy.ext.mypy.plugin\n
  40. show_error_codes = True\n
  41. {mypy_path}
  42. disable_error_code = no-untyped-call
  43. [mypy-sqlalchemy.*]
  44. ignore_errors = True
  45. """
  46. )
  47. with open(
  48. Path(cachedir) / "plain_mypy_config.cfg", "w"
  49. ) as config_file:
  50. config_file.write(
  51. f"""
  52. [mypy]\n
  53. show_error_codes = True\n
  54. {mypy_path}
  55. disable_error_code = var-annotated,no-untyped-call
  56. [mypy-sqlalchemy.*]
  57. ignore_errors = True
  58. """
  59. )
  60. yield cachedir
  61. @config.fixture()
  62. def mypy_runner(self, cachedir):
  63. from mypy import api
  64. def run(path, use_plugin=False, use_cachedir=None):
  65. if use_cachedir is None:
  66. use_cachedir = cachedir
  67. args = [
  68. "--strict",
  69. "--raise-exceptions",
  70. "--cache-dir",
  71. use_cachedir,
  72. "--config-file",
  73. os.path.join(
  74. use_cachedir,
  75. "sqla_mypy_config.cfg"
  76. if use_plugin
  77. else "plain_mypy_config.cfg",
  78. ),
  79. ]
  80. # mypy as of 0.990 is more aggressively blocking messaging
  81. # for paths that are in sys.path, and as pytest puts currdir,
  82. # test/ etc in sys.path, just copy the source file to the
  83. # tempdir we are working in so that we don't have to try to
  84. # manipulate sys.path and/or guess what mypy is doing
  85. filename = os.path.basename(path)
  86. test_program = os.path.join(use_cachedir, filename)
  87. if path != test_program:
  88. shutil.copyfile(path, test_program)
  89. args.append(test_program)
  90. # I set this locally but for the suite here needs to be
  91. # disabled
  92. os.environ.pop("MYPY_FORCE_COLOR", None)
  93. stdout, stderr, exitcode = api.run(args)
  94. return stdout, stderr, exitcode
  95. return run
  96. @config.fixture
  97. def mypy_typecheck_file(self, mypy_runner):
  98. def run(path, use_plugin=False):
  99. expected_messages = self._collect_messages(path)
  100. stdout, stderr, exitcode = mypy_runner(path, use_plugin=use_plugin)
  101. self._check_output(
  102. path, expected_messages, stdout, stderr, exitcode
  103. )
  104. return run
  105. @staticmethod
  106. def file_combinations(dirname):
  107. if os.path.isabs(dirname):
  108. path = dirname
  109. else:
  110. caller_path = inspect.stack()[1].filename
  111. path = os.path.join(os.path.dirname(caller_path), dirname)
  112. files = list(Path(path).glob("**/*.py"))
  113. for extra_dir in config.options.mypy_extra_test_paths:
  114. if extra_dir and os.path.isdir(extra_dir):
  115. files.extend((Path(extra_dir) / dirname).glob("**/*.py"))
  116. return files
  117. def _collect_messages(self, path):
  118. from sqlalchemy.ext.mypy.util import mypy_14
  119. expected_messages = []
  120. expected_re = re.compile(r"\s*# EXPECTED(_MYPY)?(_RE)?(_TYPE)?: (.+)")
  121. py_ver_re = re.compile(r"^#\s*PYTHON_VERSION\s?>=\s?(\d+\.\d+)")
  122. with open(path) as file_:
  123. current_assert_messages = []
  124. for num, line in enumerate(file_, 1):
  125. m = py_ver_re.match(line)
  126. if m:
  127. major, _, minor = m.group(1).partition(".")
  128. if sys.version_info < (int(major), int(minor)):
  129. config.skip_test(
  130. "Requires python >= %s" % (m.group(1))
  131. )
  132. continue
  133. m = expected_re.match(line)
  134. if m:
  135. is_mypy = bool(m.group(1))
  136. is_re = bool(m.group(2))
  137. is_type = bool(m.group(3))
  138. expected_msg = re.sub(r"# noqa[:]? ?.*", "", m.group(4))
  139. if is_type:
  140. if not is_re:
  141. # the goal here is that we can cut-and-paste
  142. # from vscode -> pylance into the
  143. # EXPECTED_TYPE: line, then the test suite will
  144. # validate that line against what mypy produces
  145. expected_msg = re.sub(
  146. r"([\[\]])",
  147. lambda m: rf"\{m.group(0)}",
  148. expected_msg,
  149. )
  150. # note making sure preceding text matches
  151. # with a dot, so that an expect for "Select"
  152. # does not match "TypedSelect"
  153. expected_msg = re.sub(
  154. r"([\w_]+)",
  155. lambda m: rf"(?:.*\.)?{m.group(1)}\*?",
  156. expected_msg,
  157. )
  158. expected_msg = re.sub(
  159. "List", "builtins.list", expected_msg
  160. )
  161. expected_msg = re.sub(
  162. r"\b(int|str|float|bool)\b",
  163. lambda m: rf"builtins.{m.group(0)}\*?",
  164. expected_msg,
  165. )
  166. # expected_msg = re.sub(
  167. # r"(Sequence|Tuple|List|Union)",
  168. # lambda m: fr"typing.{m.group(0)}\*?",
  169. # expected_msg,
  170. # )
  171. is_mypy = is_re = True
  172. expected_msg = f'Revealed type is "{expected_msg}"'
  173. if mypy_14 and util.py39:
  174. # use_lowercase_names, py39 and above
  175. # https://github.com/python/mypy/blob/304997bfb85200fb521ac727ee0ce3e6085e5278/mypy/options.py#L363 # noqa: E501
  176. # skip first character which could be capitalized
  177. # "List item x not found" type of message
  178. expected_msg = expected_msg[0] + re.sub(
  179. r"\b(List|Tuple|Dict|Set)\b"
  180. if is_type
  181. else r"\b(List|Tuple|Dict|Set|Type)\b",
  182. lambda m: m.group(1).lower(),
  183. expected_msg[1:],
  184. )
  185. if mypy_14 and util.py310:
  186. # use_or_syntax, py310 and above
  187. # https://github.com/python/mypy/blob/304997bfb85200fb521ac727ee0ce3e6085e5278/mypy/options.py#L368 # noqa: E501
  188. expected_msg = re.sub(
  189. r"Optional\[(.*?)\]",
  190. lambda m: f"{m.group(1)} | None",
  191. expected_msg,
  192. )
  193. current_assert_messages.append(
  194. (is_mypy, is_re, expected_msg.strip())
  195. )
  196. elif current_assert_messages:
  197. expected_messages.extend(
  198. (num, is_mypy, is_re, expected_msg)
  199. for (
  200. is_mypy,
  201. is_re,
  202. expected_msg,
  203. ) in current_assert_messages
  204. )
  205. current_assert_messages[:] = []
  206. return expected_messages
  207. def _check_output(self, path, expected_messages, stdout, stderr, exitcode):
  208. not_located = []
  209. filename = os.path.basename(path)
  210. if expected_messages:
  211. # mypy 0.990 changed how return codes work, so don't assume a
  212. # 1 or a 0 return code here, could be either depending on if
  213. # errors were generated or not
  214. output = []
  215. raw_lines = stdout.split("\n")
  216. while raw_lines:
  217. e = raw_lines.pop(0)
  218. if re.match(r".+\.py:\d+: error: .*", e):
  219. output.append(("error", e))
  220. elif re.match(
  221. r".+\.py:\d+: note: +(?:Possible overload|def ).*", e
  222. ):
  223. while raw_lines:
  224. ol = raw_lines.pop(0)
  225. if not re.match(r".+\.py:\d+: note: +def \[.*", ol):
  226. break
  227. elif re.match(
  228. r".+\.py:\d+: note: .*(?:perhaps|suggestion)", e, re.I
  229. ):
  230. pass
  231. elif re.match(r".+\.py:\d+: note: .*", e):
  232. output.append(("note", e))
  233. for num, is_mypy, is_re, msg in expected_messages:
  234. msg = msg.replace("'", '"')
  235. prefix = "[SQLAlchemy Mypy plugin] " if not is_mypy else ""
  236. for idx, (typ, errmsg) in enumerate(output):
  237. if is_re:
  238. if re.match(
  239. rf".*{filename}\:{num}\: {typ}\: {prefix}{msg}",
  240. errmsg,
  241. ):
  242. break
  243. elif (
  244. f"{filename}:{num}: {typ}: {prefix}{msg}"
  245. in errmsg.replace("'", '"')
  246. ):
  247. break
  248. else:
  249. not_located.append(msg)
  250. continue
  251. del output[idx]
  252. if not_located:
  253. missing = "\n".join(not_located)
  254. print("Couldn't locate expected messages:", missing, sep="\n")
  255. if output:
  256. extra = "\n".join(msg for _, msg in output)
  257. print("Remaining messages:", extra, sep="\n")
  258. assert False, "expected messages not found, see stdout"
  259. if output:
  260. print(f"{len(output)} messages from mypy were not consumed:")
  261. print("\n".join(msg for _, msg in output))
  262. assert False, "errors and/or notes remain, see stdout"
  263. else:
  264. if exitcode != 0:
  265. print(stdout, stderr, sep="\n")
  266. eq_(exitcode, 0, msg=stdout)