Skip to content

Commit 3231cb3

Browse files
asottile-sentryarmenzg
authored andcommitted
ref: hack around mypy's mistreatment of descriptors in unions (#53308)
working around python/mypy#5570 this is especially important as more things are being moved to unions of `HC | Model`
1 parent c037c27 commit 3231cb3

File tree

8 files changed

+107
-40
lines changed

8 files changed

+107
-40
lines changed

pyproject.toml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ module = [
156156
"sentry.api.bases.user",
157157
"sentry.api.decorators",
158158
"sentry.api.endpoints.accept_organization_invite",
159-
"sentry.api.endpoints.accept_project_transfer",
160159
"sentry.api.endpoints.artifact_lookup",
161160
"sentry.api.endpoints.auth_config",
162161
"sentry.api.endpoints.auth_login",
@@ -633,7 +632,6 @@ module = [
633632
"sentry.models.authprovider",
634633
"sentry.models.commit",
635634
"sentry.models.group",
636-
"sentry.models.groupassignee",
637635
"sentry.models.grouphistory",
638636
"sentry.models.groupowner",
639637
"sentry.models.groupsnooze",
@@ -718,7 +716,6 @@ module = [
718716
"sentry.projectoptions.defaults",
719717
"sentry.queue.command",
720718
"sentry.quotas.redis",
721-
"sentry.ratelimits.utils",
722719
"sentry.receivers.outbox",
723720
"sentry.receivers.outbox.control",
724721
"sentry.receivers.releases",
@@ -795,7 +792,6 @@ module = [
795792
"sentry.services.hybrid_cloud.actor",
796793
"sentry.services.hybrid_cloud.auth.impl",
797794
"sentry.services.hybrid_cloud.integration.impl",
798-
"sentry.services.hybrid_cloud.integration.service",
799795
"sentry.services.hybrid_cloud.log.impl",
800796
"sentry.services.hybrid_cloud.notifications.impl",
801797
"sentry.services.hybrid_cloud.organizationmember_mapping.impl",
@@ -961,7 +957,6 @@ module = [
961957
"sentry.web.frontend.project_event",
962958
"sentry.web.frontend.react_page",
963959
"sentry.web.frontend.reactivate_account",
964-
"sentry.web.frontend.restore_organization",
965960
"sentry.web.frontend.setup_wizard",
966961
"sentry.web.frontend.shared_group_details",
967962
"sentry.web.frontend.twofactor",

src/sentry/api/bases/organization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ def convert_args(
215215
raise ResourceDoesNotExist
216216

217217
organization_context = organization_service.get_organization_by_slug(
218-
slug=organization_slug, only_visible=False, user_id=request.user.id # type: ignore
218+
slug=organization_slug, only_visible=False, user_id=request.user.id
219219
)
220220
if organization_context is None:
221221
raise ResourceDoesNotExist

src/sentry/integrations/discord/integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def get_client(self) -> DiscordClient:
4747
org_integration_id = self.org_integration.id if self.org_integration else None
4848

4949
return DiscordClient(
50-
integration_id=self.model.id, # type:ignore
50+
integration_id=self.model.id,
5151
org_integration_id=org_integration_id,
5252
)
5353

src/sentry/models/activity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def create_group_activity(
6868
send_notification: bool = True,
6969
) -> Activity:
7070
if user:
71-
user_id = user.id # type: ignore[assignment]
71+
user_id = user.id
7272
activity_args = {
7373
"project_id": group.project_id,
7474
"group": group,

src/sentry/relay/config/metric_extraction.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from dataclasses import dataclass
3-
from typing import Any, Dict, List, Optional, Sequence, Tuple, TypedDict, Union, cast
3+
from typing import Any, Dict, List, Optional, Sequence, Tuple, TypedDict, Union
44

55
from sentry import features
66
from sentry.api.endpoints.project_transaction_threshold import DEFAULT_THRESHOLD
@@ -198,10 +198,10 @@ def _threshold_to_rules(
198198
"inner": [
199199
{
200200
"op": "gt",
201-
"name": _TRANSACTION_METRICS_TO_RULE_FIELD[cast(int, threshold.metric)],
201+
"name": _TRANSACTION_METRICS_TO_RULE_FIELD[threshold.metric],
202202
# The frustration threshold is always four times the threshold
203203
# (see https://docs.sentry.io/product/performance/metrics/#apdex)
204-
"value": cast(int, threshold.threshold) * 4,
204+
"value": threshold.threshold * 4,
205205
},
206206
*extra_conditions,
207207
],
@@ -216,7 +216,7 @@ def _threshold_to_rules(
216216
"inner": [
217217
{
218218
"op": "gt",
219-
"name": _TRANSACTION_METRICS_TO_RULE_FIELD[cast(int, threshold.metric)],
219+
"name": _TRANSACTION_METRICS_TO_RULE_FIELD[threshold.metric],
220220
"value": threshold.threshold,
221221
},
222222
*extra_conditions,

tests/tools/mypy_helpers/test_plugin.py

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,32 @@
11
from __future__ import annotations
22

3-
import pathlib
3+
import os.path
44
import subprocess
55
import sys
6-
from typing import Callable
6+
import tempfile
77

8-
import pytest
98

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")
1016

11-
@pytest.fixture
12-
def call_mypy(tmp_path: pathlib.Path) -> Callable[[str], tuple[int, str]]:
13-
cfg = """\
14-
[tool.mypy]
15-
plugins = ["tools.mypy_helpers.plugin"]
16-
"""
17-
cfg_path = tmp_path.joinpath("mypy.toml")
18-
cfg_path.write_text(cfg)
19-
20-
def _call_mypy(contents: str) -> tuple[int, str]:
2117
ret = subprocess.run(
2218
(
2319
*(sys.executable, "-m", "mypy"),
24-
*("--config", str(cfg_path)),
25-
*("-c", contents),
20+
*("--config", cfg),
21+
*("-c", src),
2622
),
2723
capture_output=True,
2824
encoding="UTF-8",
2925
)
3026
return ret.returncode, ret.stdout
3127

32-
return _call_mypy
33-
3428

35-
def test_invalid_get_connection_call(call_mypy):
29+
def test_invalid_get_connection_call():
3630
code = """
3731
from django.db.transaction import get_connection
3832
@@ -48,7 +42,7 @@ def test_invalid_get_connection_call(call_mypy):
4842
assert out == expected
4943

5044

51-
def test_ok_get_connection(call_mypy):
45+
def test_ok_get_connection():
5246
code = """
5347
from django.db.transaction import get_connection
5448
@@ -59,7 +53,7 @@ def test_ok_get_connection(call_mypy):
5953
assert ret == 0
6054

6155

62-
def test_invalid_transaction_atomic(call_mypy):
56+
def test_invalid_transaction_atomic():
6357
code = """
6458
from django.db import transaction
6559
@@ -78,7 +72,7 @@ def test_invalid_transaction_atomic(call_mypy):
7872
assert out == expected
7973

8074

81-
def test_ok_transaction_atomic(call_mypy):
75+
def test_ok_transaction_atomic():
8276
code = """
8377
from django.db import transaction
8478
@@ -89,7 +83,7 @@ def test_ok_transaction_atomic(call_mypy):
8983
assert ret == 0
9084

9185

92-
def test_ok_transaction_on_commit(call_mypy):
86+
def test_ok_transaction_on_commit():
9387
code = """
9488
from django.db import transaction
9589
@@ -102,7 +96,7 @@ def completed():
10296
assert ret == 0
10397

10498

105-
def test_invalid_transaction_on_commit(call_mypy):
99+
def test_invalid_transaction_on_commit():
106100
code = """
107101
from django.db import transaction
108102
@@ -120,7 +114,7 @@ def completed():
120114
assert out == expected
121115

122116

123-
def test_invalid_transaction_set_rollback(call_mypy):
117+
def test_invalid_transaction_set_rollback():
124118
code = """
125119
from django.db import transaction
126120
@@ -135,11 +129,55 @@ def test_invalid_transaction_set_rollback(call_mypy):
135129
assert out == expected
136130

137131

138-
def test_ok_transaction_set_rollback(call_mypy):
132+
def test_ok_transaction_set_rollback():
139133
code = """
140134
from django.db import transaction
141135
142136
transaction.set_rollback(True, "default")
143137
"""
144138
ret, _ = call_mypy(code)
145139
assert ret == 0
140+
141+
142+
def test_field_descriptor_hack():
143+
code = """\
144+
from __future__ import annotations
145+
146+
from django.db import models
147+
148+
class M1(models.Model):
149+
f: models.Field[int, int] = models.IntegerField()
150+
151+
class C:
152+
f: int
153+
154+
def f(inst: C | M1 | M2) -> int:
155+
return inst.f
156+
157+
# should also work with field subclasses
158+
class F(models.Field[int, int]):
159+
pass
160+
161+
class M2(models.Model):
162+
f = F()
163+
164+
def g(inst: C | M2) -> int:
165+
return inst.f
166+
"""
167+
168+
# should be an error with default plugins
169+
# mypy may fix this at some point hopefully: python/mypy#5570
170+
ret, out = call_mypy(code, plugins=[])
171+
assert ret
172+
assert (
173+
out
174+
== """\
175+
<string>:12: error: Incompatible return value type (got "Union[int, Field[int, int]]", expected "int") [return-value]
176+
<string>:22: error: Incompatible return value type (got "Union[int, F]", expected "int") [return-value]
177+
Found 2 errors in 1 file (checked 1 source file)
178+
"""
179+
)
180+
181+
# should be fixed with our special plugin
182+
ret, _ = call_mypy(code)
183+
assert ret == 0

tools/mypy_helpers/plugin.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
from typing import Callable
44

5-
from mypy.nodes import ARG_POS
6-
from mypy.plugin import FunctionSigContext, Plugin
5+
from mypy.nodes import ARG_POS, TypeInfo
6+
from mypy.plugin import FunctionSigContext, MethodSigContext, Plugin
77
from mypy.types import CallableType, FunctionLike, Instance
88

99

@@ -48,12 +48,46 @@ def replace_transaction_atomic_sig_callback(ctx: FunctionSigContext) -> Callable
4848
}
4949

5050

51+
def field_descriptor_no_overloads(ctx: MethodSigContext) -> FunctionLike:
52+
# ignore the class / non-model instance descriptor overloads
53+
signature = ctx.default_signature
54+
# replace `def __get__(self, inst: Model, owner: Any) -> _GT:`
55+
# with `def __get__(self, inst: Any, owner: Any) -> _GT:`
56+
if str(signature.arg_types[0]) == "django.db.models.base.Model":
57+
return signature.copy_modified(arg_types=[signature.arg_types[1]] * 2)
58+
else:
59+
return signature
60+
61+
5162
class SentryMypyPlugin(Plugin):
5263
def get_function_signature_hook(
5364
self, fullname: str
5465
) -> Callable[[FunctionSigContext], FunctionLike] | None:
5566
return _FUNCTION_SIGNATURE_HOOKS.get(fullname)
5667

68+
def get_method_signature_hook(
69+
self, fullname: str
70+
) -> Callable[[MethodSigContext], FunctionLike] | None:
71+
if fullname == "django.db.models.fields.Field":
72+
return field_descriptor_no_overloads
73+
74+
clsname, _, methodname = fullname.rpartition(".")
75+
if methodname != "__get__":
76+
return None
77+
78+
clsinfo = self.lookup_fully_qualified(clsname)
79+
if clsinfo is None or not isinstance(clsinfo.node, TypeInfo):
80+
return None
81+
82+
fieldinfo = self.lookup_fully_qualified("django.db.models.fields.Field")
83+
if fieldinfo is None:
84+
return None
85+
86+
if fieldinfo.node in clsinfo.node.mro:
87+
return field_descriptor_no_overloads
88+
else:
89+
return None
90+
5791

5892
def plugin(version: str) -> type[SentryMypyPlugin]:
5993
return SentryMypyPlugin

tools/mypy_helpers/remove_unneeded_type_ignores.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ def main() -> int:
88
cmd = (sys.executable, "-m", "tools.mypy_helpers.mypy_without_ignores", *sys.argv[1:])
99
out = subprocess.run(cmd, stdout=subprocess.PIPE)
1010
for line in out.stdout.decode().splitlines():
11-
if line.endswith('Unused "type: ignore" comment'):
11+
if line.endswith("[unused-ignore]"):
1212
fname, n, *_ = line.split(":")
1313

1414
subprocess.check_call(

0 commit comments

Comments
 (0)