Skip to content

Commit de3e607

Browse files
ref: add mypy plugin to fix referencing .objects through a TypeVar (#72877)
fortunately there's enough plugin hooks that we can work around python/mypy#17395 <!-- Describe your PR here. -->
1 parent 4cdd6f9 commit de3e607

File tree

3 files changed

+102
-2
lines changed

3 files changed

+102
-2
lines changed

src/sentry/hybridcloud/models/webhookpayload.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import datetime
4-
from typing import Any
4+
from typing import Any, Self
55

66
from django.db import models
77
from django.http import HttpRequest
@@ -79,7 +79,7 @@ def create_from_request(
7979
identifier: int | str,
8080
request: HttpRequest,
8181
integration_id: int | None = None,
82-
) -> WebhookPayload:
82+
) -> Self:
8383
metrics.incr("hybridcloud.deliver_webhooks.saved")
8484
return cls.objects.create(
8585
mailbox_name=f"{provider}:{identifier}",

tests/tools/mypy_helpers/test_plugin.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,78 @@ def _mypy() -> tuple[int, str]:
234234
cfg.write_text('[tool.mypy]\nplugins = ["tools.mypy_helpers.plugin"]\n')
235235
ret, out = _mypy()
236236
assert ret == 0
237+
238+
239+
def test_resolution_of_objects_across_typevar(tmp_path: pathlib.Path) -> None:
240+
src = """\
241+
from typing import assert_type, TypeVar
242+
243+
from sentry.db.models.base import Model
244+
245+
M = TypeVar("M", bound=Model, covariant=True)
246+
247+
def f(m: type[M]) -> M:
248+
return m.objects.get()
249+
250+
class C(Model): pass
251+
252+
assert_type(f(C), C)
253+
"""
254+
expected = """\
255+
<string>:8: error: Incompatible return value type (got "Model", expected "M") [return-value]
256+
Found 1 error in 1 file (checked 1 source file)
257+
"""
258+
259+
# tools tests aren't allowed to import from `sentry` so we fixture
260+
# the particular source file we are testing
261+
models_dir = tmp_path.joinpath("sentry/db/models")
262+
models_dir.mkdir(parents=True)
263+
264+
models_base_src = """\
265+
from typing import ClassVar, Self
266+
267+
from .manager.base import BaseManager
268+
269+
class Model:
270+
objects: ClassVar[BaseManager[Self]]
271+
"""
272+
models_dir.joinpath("base.pyi").write_text(models_base_src)
273+
274+
manager_dir = models_dir.joinpath("manager")
275+
manager_dir.mkdir(parents=True)
276+
277+
manager_base_src = """\
278+
from typing import Generic, TypeVar
279+
280+
M = TypeVar("M")
281+
282+
class BaseManager(Generic[M]):
283+
def get(self) -> M: ...
284+
"""
285+
manager_dir.joinpath("base.pyi").write_text(manager_base_src)
286+
287+
cfg = tmp_path.joinpath("mypy.toml")
288+
cfg.write_text("[tool.mypy]\nplugins = []\n")
289+
290+
# can't use our helper above because we're fixturing sentry src, so mimic it here
291+
def _mypy() -> tuple[int, str]:
292+
ret = subprocess.run(
293+
(
294+
*(sys.executable, "-m", "mypy"),
295+
*("--config", cfg),
296+
*("-c", src),
297+
),
298+
env={**os.environ, "MYPYPATH": str(tmp_path)},
299+
capture_output=True,
300+
encoding="UTF-8",
301+
)
302+
assert not ret.stderr
303+
return ret.returncode, ret.stdout
304+
305+
ret, out = _mypy()
306+
assert ret
307+
assert out == expected
308+
309+
cfg.write_text('[tool.mypy]\nplugins = ["tools.mypy_helpers.plugin"]\n')
310+
ret, out = _mypy()
311+
assert ret == 0

tools/mypy_helpers/plugin.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
NoneType,
1818
Type,
1919
TypeOfAny,
20+
TypeType,
21+
TypeVarType,
2022
UnionType,
2123
)
2224

@@ -114,6 +116,23 @@ def _lazy_service_wrapper_attribute(ctx: AttributeContext, *, attr: str) -> Type
114116
return member
115117

116118

119+
def _resolve_objects_for_typevars(ctx: AttributeContext) -> Type:
120+
# XXX: hack around python/mypy#17395
121+
122+
# self: type[<TypeVar>]
123+
# default_attr_type: BaseManager[ConcreteTypeVarBound]
124+
if (
125+
isinstance(ctx.type, TypeType)
126+
and isinstance(ctx.type.item, TypeVarType)
127+
and isinstance(ctx.default_attr_type, Instance)
128+
and ctx.default_attr_type.type.fullname == "sentry.db.models.manager.base.BaseManager"
129+
):
130+
tvar = ctx.type.item
131+
return ctx.default_attr_type.copy_modified(args=(tvar,))
132+
else:
133+
return ctx.default_attr_type
134+
135+
117136
class SentryMypyPlugin(Plugin):
118137
def get_function_signature_hook(
119138
self, fullname: str
@@ -127,6 +146,12 @@ def get_base_class_hook(self, fullname: str) -> Callable[[ClassDefContext], None
127146
else:
128147
return None
129148

149+
def get_class_attribute_hook(self, fullname: str) -> Callable[[AttributeContext], Type] | None:
150+
if fullname.startswith("sentry.") and fullname.endswith(".objects"):
151+
return _resolve_objects_for_typevars
152+
else:
153+
return None
154+
130155
def get_attribute_hook(self, fullname: str) -> Callable[[AttributeContext], Type] | None:
131156
if fullname.startswith("sentry.utils.lazy_service_wrapper.LazyServiceWrapper."):
132157
_, attr = fullname.rsplit(".", 1)

0 commit comments

Comments
 (0)