From d4e9f90ecebb3dd58ac37b92255edf6b9f005e7a Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Tue, 21 Jul 2026 15:29:02 +0100 Subject: [PATCH 1/2] Improve stubtest handling of expected dunder methods --- mypy/stubtest.py | 65 +++++++++++++++++++++-- mypy/test/teststubtest.py | 109 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/mypy/stubtest.py b/mypy/stubtest.py index 63b584e8652f4..23c9de412ca28 100644 --- a/mypy/stubtest.py +++ b/mypy/stubtest.py @@ -685,7 +685,8 @@ def verify_typeinfo( # Filter out non-identifier names, as these are (hopefully always?) whacky/fictional things # (like __mypy-replace or __mypy-post_init, etc.) that don't exist at runtime, # and exist purely for internal mypy reasons - to_check = {name for name in stub.names if name.isidentifier()} + existing_stub_names = {name for name in stub.names if name.isidentifier()} + to_check = existing_stub_names.copy() # Check all public things on the runtime class to_check.update( m for m in vars(runtime) if not is_probably_private(m) and m not in IGNORABLE_CLASS_DUNDERS @@ -740,11 +741,15 @@ def verify_typeinfo( # Do not error for an object missing from the stub # If the runtime object is a types.WrapperDescriptorType object # and has a non-special dunder name. - # The vast majority of these are false positives. + # The vast majority of these are false positives, unless the stub gives us + # a reason to expect the method. if not ( isinstance(stub_to_verify, Missing) and isinstance(runtime_attr, types.WrapperDescriptorType) and is_dunder(mangled_entry, exclude_special=True) + and not is_expected_dunder( + mangled_entry, stub=stub, existing_stub_names=existing_stub_names + ) ): yield from verify(stub_to_verify, runtime_attr, object_path + [entry]) @@ -1844,6 +1849,60 @@ def verify_typealias( ) +PAIRED_DUNDERS: Final = ( + ("__add__", "__radd__"), + ("__sub__", "__rsub__"), + ("__mul__", "__rmul__"), + ("__matmul__", "__rmatmul__"), + ("__truediv__", "__rtruediv__"), + ("__floordiv__", "__rfloordiv__"), + ("__mod__", "__rmod__"), + ("__divmod__", "__rdivmod__"), + ("__pow__", "__rpow__"), + ("__lshift__", "__rlshift__"), + ("__rshift__", "__rrshift__"), + ("__and__", "__rand__"), + ("__xor__", "__rxor__"), + ("__or__", "__ror__"), + ("__lt__", "__gt__"), + ("__le__", "__ge__"), + ("__enter__", "__exit__"), +) + + +def is_expected_dunder(name: str, *, stub: nodes.TypeInfo, existing_stub_names: set[str]) -> bool: + """ + Return `True` if we would reasonably "expect" this dunder to be present in the stub, + given the presence of other dunders that are already in the stub. + + For example, if the stub has `__add__`, we would expect it to also have `__radd__`, + and vice versa. + + We use this to inform our heuristics regarding whether a diagnostic complaining about + a missing dunder method is likely to be a false positive or not. In many cases where + a runtime dunder is an instance of `WrapperDesriptorType`, the runtime dunder will + not actually be callable at runtime, so it's too noisy to complain about them in + general. If we would reasonably *expect* the dunder to be present in the stub, however, + it may be worth complaining about the missing dunder even if the dunder at runtime is + a `WrapperDescriptorType`. + """ + if any( + (name == left and right in existing_stub_names) + or (name == right and left in existing_stub_names) + for left, right in PAIRED_DUNDERS + ): + return True + + if name == "__le__" and {"__lt__", "__eq__"}.issubset(existing_stub_names): + return True + if name == "__ge__" and {"__gt__", "__eq__"}.issubset(existing_stub_names): + return True + + return (name in ("__or__", "__ror__", "__ior__") and stub.has_base("typing.Mapping")) or ( + name in ("__mul__", "__rmul__", "__imul__") and stub.has_base("typing.Sequence") + ) + + def is_probably_private(name: str) -> bool: return name.startswith("_") and not is_dunder(name) @@ -1967,7 +2026,7 @@ def safe_inspect_signature(runtime: Any) -> inspect.Signature | None: def describe_runtime_callable(signature: inspect.Signature, *, is_async: bool) -> str: - return f'{"async " if is_async else ""}def {signature}' + return f"{'async ' if is_async else ''}def {signature}" class _TypeCheckOnlyBaseMapper(mypy.types.TypeTranslator): diff --git a/mypy/test/teststubtest.py b/mypy/test/teststubtest.py index 858fd860fd01d..55d510355ae0f 100644 --- a/mypy/test/teststubtest.py +++ b/mypy/test/teststubtest.py @@ -1893,6 +1893,115 @@ def test_dunders(self) -> Iterator[Case]: error=None, ) + @collect_cases + def test_missing_wrapper_descriptors(self) -> Iterator[Case]: + """ + Dunders present at runtime but missing in the stub are usually ignored + if the runtime dunder is an instance of `WrapperDescriptorType` -- these + are often not actually callable at runtime. However, in certain specific + situations we still report a diagnostic when the stub is missing a dunder + that is present as a `WrapperDescriptorType` at runtime. + """ + + # If the stub has `__add__` and `__radd__` exists at runtime, + # it's likely that the missing `__radd__` is a true positive, + # so we report it. + yield Case( + stub=""" + class Forward: + def __add__(self, other: object) -> object: ... + """, + runtime=""" + class Forward: + def __add__(self, other): pass + __radd__ = int.__radd__ + """, + error="Forward.__radd__", + ) + yield Case( + stub=""" + class Reflected: + def __radd__(self, other: object) -> object: ... + """, + runtime=""" + class Reflected: + def __radd__(self, other): pass + __add__ = int.__add__ + """, + error="Reflected.__add__", + ) + yield Case( + stub=""" + class Ordering: + def __lt__(self, other: object) -> bool: ... + """, + runtime=""" + class Ordering: + def __lt__(self, other): pass + __gt__ = int.__gt__ + """, + error="Ordering.__gt__", + ) + yield Case( + stub=""" + class ContextManager: + def __enter__(self) -> object: ... + """, + runtime=""" + class ContextManager: + def __enter__(self): pass + __exit__ = int.__add__ + """, + error="ContextManager.__exit__", + ) + yield Case( + stub=""" + class LessThanOrEqual: + def __lt__(self, other: object) -> bool: ... + def __eq__(self, other: object) -> bool: ... + """, + runtime=""" + class LessThanOrEqual: + def __lt__(self, other): pass + def __eq__(self, other): pass + __le__ = int.__le__ + """, + error="LessThanOrEqual.__le__", + ) + yield Case( + stub=""" + from typing import Mapping + class MappingOr(Mapping[str, int]): ... + """, + runtime=""" + class MappingOr: + __or__ = dict.__or__ + """, + error="MappingOr.__or__", + ) + yield Case( + stub=""" + from typing import Mapping + class MappingIor(Mapping[str, int]): ... + """, + runtime=""" + class MappingIor: + __ior__ = dict.__ior__ + """, + error="MappingIor.__ior__", + ) + yield Case( + stub=""" + from typing import Sequence + class SequenceRmul(Sequence[int]): ... + """, + runtime=""" + class SequenceRmul: + __rmul__ = list.__rmul__ + """, + error="SequenceRmul.__rmul__", + ) + @collect_cases def test_not_subclassable(self) -> Iterator[Case]: yield Case( From f519d92c8b9e3da5509d2c21670724586010756b Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Tue, 21 Jul 2026 16:04:23 +0100 Subject: [PATCH 2/2] refine --- mypy/stubtest.py | 15 ++++++++++++--- mypy/test/teststubtest.py | 9 +++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/mypy/stubtest.py b/mypy/stubtest.py index 23c9de412ca28..4a38939a03907 100644 --- a/mypy/stubtest.py +++ b/mypy/stubtest.py @@ -1898,8 +1898,17 @@ def is_expected_dunder(name: str, *, stub: nodes.TypeInfo, existing_stub_names: if name == "__ge__" and {"__gt__", "__eq__"}.issubset(existing_stub_names): return True - return (name in ("__or__", "__ror__", "__ior__") and stub.has_base("typing.Mapping")) or ( - name in ("__mul__", "__rmul__", "__imul__") and stub.has_base("typing.Sequence") + if (name in ("__or__", "__ror__") and stub.has_base("typing.Mapping")) or ( + name in ("__mul__", "__rmul__") and stub.has_base("typing.Sequence") + ): + return True + + # In-place syntax such as `*=` and `|=` can work with immutable types (for example, + # tuples or frozensets), but generally delegates to the non-in-place dunder in + # these cases. The in-place dunders themselves are generally only defined for + # mutable types. + return (name == "__ior__" and stub.has_base("typing.MutableMapping")) or ( + name == "__imul__" and stub.has_base("typing.MutableSequence") ) @@ -2026,7 +2035,7 @@ def safe_inspect_signature(runtime: Any) -> inspect.Signature | None: def describe_runtime_callable(signature: inspect.Signature, *, is_async: bool) -> str: - return f"{'async ' if is_async else ''}def {signature}" + return f'{"async " if is_async else ""}def {signature}' class _TypeCheckOnlyBaseMapper(mypy.types.TypeTranslator): diff --git a/mypy/test/teststubtest.py b/mypy/test/teststubtest.py index 55d510355ae0f..2db149ce65c97 100644 --- a/mypy/test/teststubtest.py +++ b/mypy/test/teststubtest.py @@ -81,6 +81,7 @@ class Coroutine(Generic[_T_co, _S, _R]): ... class Iterable(Generic[_T_co]): ... class Iterator(Iterable[_T_co]): ... class Mapping(Generic[_K, _V]): ... +class MutableMapping(Mapping[_K, _V]): ... class Match(Generic[AnyStr]): ... class Sequence(Iterable[_T_co]): ... class Tuple(Sequence[_T_co]): ... @@ -1981,14 +1982,14 @@ class MappingOr: ) yield Case( stub=""" - from typing import Mapping - class MappingIor(Mapping[str, int]): ... + from typing import MutableMapping + class MutableMappingIor(MutableMapping[str, int]): ... """, runtime=""" - class MappingIor: + class MutableMappingIor: __ior__ = dict.__ior__ """, - error="MappingIor.__ior__", + error="MutableMappingIor.__ior__", ) yield Case( stub="""