test_plugin.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. from __future__ import annotations
  2. import os.path
  3. import pathlib
  4. import shutil
  5. import subprocess
  6. import sys
  7. import tempfile
  8. import pytest
  9. def call_mypy(src: str, *, plugins: list[str] | None = None) -> tuple[int, str]:
  10. if plugins is None:
  11. plugins = ["tools.mypy_helpers.plugin"]
  12. with tempfile.TemporaryDirectory() as tmpdir:
  13. cfg = os.path.join(tmpdir, "mypy.toml")
  14. with open(cfg, "w") as f:
  15. f.write(f"[tool.mypy]\nplugins = {plugins!r}\n")
  16. ret = subprocess.run(
  17. (
  18. *(sys.executable, "-m", "mypy"),
  19. *("--config", cfg),
  20. *("-c", src),
  21. ),
  22. capture_output=True,
  23. encoding="UTF-8",
  24. )
  25. assert not ret.stderr
  26. return ret.returncode, ret.stdout
  27. def test_invalid_get_connection_call():
  28. code = """
  29. from django.db.transaction import get_connection
  30. with get_connection() as cursor:
  31. cursor.execute("SELECT 1")
  32. """
  33. expected = """\
  34. <string>:4: error: Missing positional argument "using" in call to "get_connection" [call-arg]
  35. Found 1 error in 1 file (checked 1 source file)
  36. """
  37. ret, out = call_mypy(code)
  38. assert ret
  39. assert out == expected
  40. def test_ok_get_connection():
  41. code = """
  42. from django.db.transaction import get_connection
  43. with get_connection("default") as cursor:
  44. cursor.execute("SELECT 1")
  45. """
  46. ret, out = call_mypy(code)
  47. assert ret == 0
  48. def test_invalid_transaction_atomic():
  49. code = """
  50. from django.db import transaction
  51. with transaction.atomic():
  52. value = 10 / 2
  53. """
  54. expected = """\
  55. <string>:4: error: All overload variants of "atomic" require at least one argument [call-overload]
  56. <string>:4: note: Possible overload variants:
  57. <string>:4: note: def [_C] atomic(using: _C) -> _C
  58. <string>:4: note: def atomic(using: str, savepoint: bool = ..., durable: bool = ...) -> Atomic
  59. Found 1 error in 1 file (checked 1 source file)
  60. """
  61. ret, out = call_mypy(code)
  62. assert ret
  63. assert out == expected
  64. def test_ok_transaction_atomic():
  65. code = """
  66. from django.db import transaction
  67. with transaction.atomic("default"):
  68. value = 10 / 2
  69. """
  70. ret, _ = call_mypy(code)
  71. assert ret == 0
  72. def test_ok_transaction_on_commit():
  73. code = """
  74. from django.db import transaction
  75. def completed():
  76. pass
  77. transaction.on_commit(completed, "default")
  78. """
  79. ret, _ = call_mypy(code)
  80. assert ret == 0
  81. def test_invalid_transaction_on_commit():
  82. code = """
  83. from django.db import transaction
  84. def completed():
  85. pass
  86. transaction.on_commit(completed)
  87. """
  88. expected = """\
  89. <string>:7: error: Missing positional argument "using" in call to "on_commit" [call-arg]
  90. Found 1 error in 1 file (checked 1 source file)
  91. """
  92. ret, out = call_mypy(code)
  93. assert ret
  94. assert out == expected
  95. def test_invalid_transaction_set_rollback():
  96. code = """
  97. from django.db import transaction
  98. transaction.set_rollback(True)
  99. """
  100. expected = """\
  101. <string>:4: error: Missing positional argument "using" in call to "set_rollback" [call-arg]
  102. Found 1 error in 1 file (checked 1 source file)
  103. """
  104. ret, out = call_mypy(code)
  105. assert ret
  106. assert out == expected
  107. def test_ok_transaction_set_rollback():
  108. code = """
  109. from django.db import transaction
  110. transaction.set_rollback(True, "default")
  111. """
  112. ret, _ = call_mypy(code)
  113. assert ret == 0
  114. @pytest.mark.parametrize(
  115. "attr",
  116. (
  117. pytest.param("access", id="access from sentry.api.base"),
  118. pytest.param("auth", id="auth from sentry.middleware.auth"),
  119. pytest.param("csp_nonce", id="csp_nonce from csp.middleware"),
  120. pytest.param("is_sudo", id="is_sudo from sudo.middleware"),
  121. pytest.param("subdomain", id="subdomain from sentry.middleware.subdomain"),
  122. pytest.param("superuser", id="superuser from sentry.middleware.superuser"),
  123. ),
  124. )
  125. def test_added_http_request_attribute(attr: str) -> None:
  126. src = f"""\
  127. from django.http.request import HttpRequest
  128. x: HttpRequest
  129. x.{attr}
  130. """
  131. ret, out = call_mypy(src, plugins=[])
  132. assert ret
  133. ret, out = call_mypy(src)
  134. assert ret == 0, (ret, out)
  135. def test_lazy_service_wrapper(tmp_path: pathlib.Path) -> None:
  136. src = """\
  137. from typing import assert_type, Literal
  138. from sentry.utils.lazy_service_wrapper import LazyServiceWrapper, Service, _EmptyType
  139. class MyService(Service):
  140. X = "hello world"
  141. def f(self) -> int:
  142. return 5
  143. backend = LazyServiceWrapper(MyService, "some.path", {})
  144. # should proxy attributes properly
  145. assert_type(backend.X, str)
  146. assert_type(backend.f(), int)
  147. # should represent self types properly
  148. assert_type(backend._backend, str)
  149. assert_type(backend._wrapped, _EmptyType | MyService)
  150. """
  151. expected = """\
  152. <string>:12: error: Expression is of type "Any", not "str" [assert-type]
  153. <string>:13: error: Expression is of type "Any", not "int" [assert-type]
  154. Found 2 errors in 1 file (checked 1 source file)
  155. """
  156. # tools tests aren't allowed to import from `sentry` so we fixture
  157. # the particular source file we are testing
  158. utils_dir = tmp_path.joinpath("sentry/utils")
  159. utils_dir.mkdir(parents=True)
  160. here = os.path.dirname(__file__)
  161. sentry_src = os.path.join(here, "../../../src/sentry/utils/lazy_service_wrapper.py")
  162. shutil.copy(sentry_src, utils_dir)
  163. init_pyi = "from typing import Any\ndef __getattr__(self) -> Any: ...\n"
  164. utils_dir.joinpath("__init__.pyi").write_text(init_pyi)
  165. cfg = tmp_path.joinpath("mypy.toml")
  166. cfg.write_text("[tool.mypy]\nplugins = []\n")
  167. # can't use our helper above because we're fixturing sentry src, so mimic it here
  168. def _mypy() -> tuple[int, str]:
  169. ret = subprocess.run(
  170. (
  171. *(sys.executable, "-m", "mypy"),
  172. *("--config", cfg),
  173. # we only stub out limited parts of the sentry source tree
  174. "--ignore-missing-imports",
  175. *("-c", src),
  176. ),
  177. env={**os.environ, "MYPYPATH": str(tmp_path)},
  178. capture_output=True,
  179. encoding="UTF-8",
  180. )
  181. assert not ret.stderr
  182. return ret.returncode, ret.stdout
  183. ret, out = _mypy()
  184. assert ret
  185. assert out == expected
  186. cfg.write_text('[tool.mypy]\nplugins = ["tools.mypy_helpers.plugin"]\n')
  187. ret, out = _mypy()
  188. assert ret == 0
  189. def test_resolution_of_objects_across_typevar(tmp_path: pathlib.Path) -> None:
  190. src = """\
  191. from typing import assert_type, TypeVar
  192. from sentry.db.models.base import Model
  193. M = TypeVar("M", bound=Model, covariant=True)
  194. def f(m: type[M]) -> M:
  195. return m.objects.get()
  196. class C(Model): pass
  197. assert_type(f(C), C)
  198. """
  199. expected = """\
  200. <string>:8: error: Incompatible return value type (got "Model", expected "M") [return-value]
  201. Found 1 error in 1 file (checked 1 source file)
  202. """
  203. # tools tests aren't allowed to import from `sentry` so we fixture
  204. # the particular source file we are testing
  205. models_dir = tmp_path.joinpath("sentry/db/models")
  206. models_dir.mkdir(parents=True)
  207. models_base_src = """\
  208. from typing import ClassVar, Self
  209. from .manager.base import BaseManager
  210. class Model:
  211. objects: ClassVar[BaseManager[Self]]
  212. """
  213. models_dir.joinpath("base.pyi").write_text(models_base_src)
  214. manager_dir = models_dir.joinpath("manager")
  215. manager_dir.mkdir(parents=True)
  216. manager_base_src = """\
  217. from typing import Generic, TypeVar
  218. M = TypeVar("M")
  219. class BaseManager(Generic[M]):
  220. def get(self) -> M: ...
  221. """
  222. manager_dir.joinpath("base.pyi").write_text(manager_base_src)
  223. cfg = tmp_path.joinpath("mypy.toml")
  224. cfg.write_text("[tool.mypy]\nplugins = []\n")
  225. # can't use our helper above because we're fixturing sentry src, so mimic it here
  226. def _mypy() -> tuple[int, str]:
  227. ret = subprocess.run(
  228. (
  229. *(sys.executable, "-m", "mypy"),
  230. *("--config", cfg),
  231. *("-c", src),
  232. ),
  233. env={**os.environ, "MYPYPATH": str(tmp_path)},
  234. capture_output=True,
  235. encoding="UTF-8",
  236. )
  237. assert not ret.stderr
  238. return ret.returncode, ret.stdout
  239. ret, out = _mypy()
  240. assert ret
  241. assert out == expected
  242. cfg.write_text('[tool.mypy]\nplugins = ["tools.mypy_helpers.plugin"]\n')
  243. ret, out = _mypy()
  244. assert ret == 0