Skip to content

Commit af3c8be

Browse files
authored
stubtest: improve handling of special dunders (#9626)
Reckon with the fact that __init_subclass__ and __class_getitem__ are special cased to be implicit classmethods. Fix some false negatives for other special dunders. Co-authored-by: hauntsaninja <>
1 parent db8de92 commit af3c8be

File tree

2 files changed

+50
-13
lines changed

2 files changed

+50
-13
lines changed

mypy/stubtest.py

+25-13
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,13 @@ def verify_typeinfo(
244244
yield Error(object_path, "is not a type", stub, runtime, stub_desc=repr(stub))
245245
return
246246

247+
# Check everything already defined in the stub
247248
to_check = set(stub.names)
248-
dunders_to_check = ("__init__", "__new__", "__call__", "__class_getitem__")
249-
# cast to workaround mypyc complaints
249+
# There's a reasonable case to be made that we should always check all dunders, but it's
250+
# currently quite noisy. We could turn this into a denylist instead of an allowlist.
250251
to_check.update(
251-
m for m in cast(Any, vars)(runtime) if m in dunders_to_check or not m.startswith("_")
252+
# cast to workaround mypyc complaints
253+
m for m in cast(Any, vars)(runtime) if not m.startswith("_") or m in SPECIAL_DUNDERS
252254
)
253255

254256
for entry in sorted(to_check):
@@ -265,8 +267,8 @@ def verify_typeinfo(
265267
def _verify_static_class_methods(
266268
stub: nodes.FuncItem, runtime: types.FunctionType, object_path: List[str]
267269
) -> Iterator[str]:
268-
if stub.name == "__new__":
269-
# Special cased by Python, so never declared as staticmethod
270+
if stub.name in ("__new__", "__init_subclass__", "__class_getitem__"):
271+
# Special cased by Python, so don't bother checking
270272
return
271273
if inspect.isbuiltin(runtime):
272274
# The isinstance checks don't work reliably for builtins, e.g. datetime.datetime.now, so do
@@ -303,8 +305,8 @@ def _verify_arg_name(
303305
stub_arg: nodes.Argument, runtime_arg: inspect.Parameter, function_name: str
304306
) -> Iterator[str]:
305307
"""Checks whether argument names match."""
306-
# Ignore exact names for all dunder methods other than __init__
307-
if is_dunder(function_name, exclude_init=True):
308+
# Ignore exact names for most dunder methods
309+
if is_dunder(function_name, exclude_special=True):
308310
return
309311

310312
def strip_prefix(s: str, prefix: str) -> str:
@@ -468,8 +470,8 @@ def from_overloadedfuncdef(stub: nodes.OverloadedFuncDef) -> "Signature[nodes.Ar
468470
lies it might try to tell.
469471
470472
"""
471-
# For all dunder methods other than __init__, just assume all args are positional-only
472-
assume_positional_only = is_dunder(stub.name, exclude_init=True)
473+
# For most dunder methods, just assume all args are positional-only
474+
assume_positional_only = is_dunder(stub.name, exclude_special=True)
473475

474476
all_args = {} # type: Dict[str, List[Tuple[nodes.Argument, int]]]
475477
for func in map(_resolve_funcitem_from_decorator, stub.items):
@@ -548,7 +550,7 @@ def _verify_signature(
548550
runtime_arg.kind == inspect.Parameter.POSITIONAL_ONLY
549551
and not stub_arg.variable.name.startswith("__")
550552
and not stub_arg.variable.name.strip("_") == "self"
551-
and not is_dunder(function_name) # noisy for dunder methods
553+
and not is_dunder(function_name, exclude_special=True) # noisy for dunder methods
552554
):
553555
yield (
554556
'stub argument "{}" should be positional-only '
@@ -656,6 +658,13 @@ def verify_funcitem(
656658
# catch RuntimeError because of https://bugs.python.org/issue39504
657659
return
658660

661+
if stub.name in ("__init_subclass__", "__class_getitem__"):
662+
# These are implicitly classmethods. If the stub chooses not to have @classmethod, we
663+
# should remove the cls argument
664+
if stub.arguments[0].variable.name == "cls":
665+
stub = copy.copy(stub)
666+
stub.arguments = stub.arguments[1:]
667+
659668
stub_sig = Signature.from_funcitem(stub)
660669
runtime_sig = Signature.from_inspect_signature(signature)
661670

@@ -846,13 +855,16 @@ def verify_typealias(
846855
yield None
847856

848857

849-
def is_dunder(name: str, exclude_init: bool = False) -> bool:
858+
SPECIAL_DUNDERS = ("__init__", "__new__", "__call__", "__init_subclass__", "__class_getitem__")
859+
860+
861+
def is_dunder(name: str, exclude_special: bool = False) -> bool:
850862
"""Returns whether name is a dunder name.
851863
852-
:param exclude_init: Whether to return False for __init__
864+
:param exclude_special: Whether to return False for a couple special dunder methods.
853865
854866
"""
855-
if exclude_init and name == "__init__":
867+
if exclude_special and name in SPECIAL_DUNDERS:
856868
return False
857869
return name.startswith("__") and name.endswith("__")
858870

mypy/test/teststubtest.py

+25
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,31 @@ def test_missing_no_runtime_all(self) -> Iterator[Case]:
628628
yield Case(stub="", runtime="import sys", error=None)
629629
yield Case(stub="", runtime="def g(): ...", error="g")
630630

631+
@collect_cases
632+
def test_special_dunders(self) -> Iterator[Case]:
633+
yield Case(
634+
stub="class A:\n def __init__(self, a: int, b: int) -> None: ...",
635+
runtime="class A:\n def __init__(self, a, bx): pass",
636+
error="A.__init__",
637+
)
638+
yield Case(
639+
stub="class B:\n def __call__(self, c: int, d: int) -> None: ...",
640+
runtime="class B:\n def __call__(self, c, dx): pass",
641+
error="B.__call__",
642+
)
643+
if sys.version_info >= (3, 6):
644+
yield Case(
645+
stub="class C:\n def __init_subclass__(cls, e: int, **kwargs: int) -> None: ...",
646+
runtime="class C:\n def __init_subclass__(cls, e, **kwargs): pass",
647+
error=None,
648+
)
649+
if sys.version_info >= (3, 9):
650+
yield Case(
651+
stub="class D:\n def __class_getitem__(cls, type: type) -> type: ...",
652+
runtime="class D:\n def __class_getitem__(cls, type): ...",
653+
error=None,
654+
)
655+
631656
@collect_cases
632657
def test_name_mangling(self) -> Iterator[Case]:
633658
yield Case(

0 commit comments

Comments
 (0)