Skip to content

Commit 8600cc3

Browse files
committed
✨ feat: add mypy plugin to enforce typedict return type on serialize method
1 parent c9af920 commit 8600cc3

File tree

1 file changed

+58
-3
lines changed

1 file changed

+58
-3
lines changed

tools/mypy_helpers/plugin.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
from collections.abc import Callable
55

66
from mypy.build import PRI_MYPY
7-
from mypy.errorcodes import ATTR_DEFINED
7+
from mypy.errorcodes import ATTR_DEFINED, ErrorCode
88
from mypy.messages import format_type
9-
from mypy.nodes import ARG_POS, MDEF, MypyFile, SymbolTableNode, TypeInfo, Var
9+
from mypy.nodes import ARG_POS, MDEF, FuncDef, MypyFile, SymbolTableNode, TypeInfo, Var
1010
from mypy.plugin import (
1111
AttributeContext,
1212
ClassDefContext,
@@ -24,10 +24,25 @@
2424
Instance,
2525
NoneType,
2626
Type,
27+
TypedDictType,
2728
TypeOfAny,
2829
UnionType,
2930
)
3031

32+
SERIALIZE_TYPEDDICT_CODE = ErrorCode(
33+
"serialize-typeddict", "Serialize method must return a TypedDict", "attr-defined"
34+
)
35+
36+
SERIALIZER_BASE_CLASS = "sentry.api.serializers.base.Serializer"
37+
38+
39+
def _is_typed_dict(typ: Type) -> bool:
40+
"""Check if a type is a TypedDict"""
41+
if isinstance(typ, TypedDictType):
42+
return True
43+
44+
return False
45+
3146

3247
def _make_using_required_str(ctx: FunctionSigContext) -> CallableType:
3348
sig = ctx.default_signature
@@ -173,6 +188,44 @@ def _lazy_service_wrapper_attribute(ctx: AttributeContext, *, attr: str) -> Type
173188
return member
174189

175190

191+
def _check_serializer_class(ctx: ClassDefContext) -> None:
192+
"""
193+
Hook that checks if subclasses of Serializer have a serialize method that returns a TypedDict.
194+
"""
195+
if not ctx.cls.info.has_base(SERIALIZER_BASE_CLASS):
196+
return
197+
198+
# Look for the serialize method in the class
199+
for name, node in ctx.cls.info.names.items():
200+
if name == "serialize" and isinstance(node.node, FuncDef):
201+
# First check if the method has a return type annotation at all
202+
if not node.node.type:
203+
ctx.api.fail(
204+
"Method 'serialize' must have an explicit return type annotation in classes inheriting from Serializer",
205+
node.node,
206+
code=SERIALIZE_TYPEDDICT_CODE,
207+
)
208+
continue
209+
210+
# Then check if it's a callable with a proper return type
211+
if isinstance(node.node.type, CallableType):
212+
ret_type = node.node.type.ret_type
213+
if not _is_typed_dict(ret_type):
214+
print(ret_type, _is_typed_dict(ret_type))
215+
ctx.api.fail(
216+
"Method 'serialize' must return a TypedDict in classes inheriting from Serializer",
217+
node.node,
218+
code=SERIALIZE_TYPEDDICT_CODE,
219+
)
220+
else:
221+
# This case should be rare, but handle it for completeness
222+
ctx.api.fail(
223+
"Method 'serialize' has an invalid type annotation",
224+
node.node,
225+
code=SERIALIZE_TYPEDDICT_CODE,
226+
)
227+
228+
176229
class SentryMypyPlugin(Plugin):
177230
def get_function_signature_hook(
178231
self, fullname: str
@@ -181,7 +234,9 @@ def get_function_signature_hook(
181234

182235
def get_base_class_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
183236
# XXX: this is a hack -- I don't know if there's a better callback to modify a class
184-
if fullname == "_io.BytesIO":
237+
if fullname == SERIALIZER_BASE_CLASS:
238+
return _check_serializer_class
239+
elif fullname == "_io.BytesIO":
185240
return _adjust_http_request_members
186241
elif fullname == "django.http.request.HttpRequest":
187242
return _adjust_request_members

0 commit comments

Comments
 (0)