Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions mypy/stubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -1844,6 +1849,69 @@ 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

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


def is_probably_private(name: str) -> bool:
return name.startswith("_") and not is_dunder(name)

Expand Down
110 changes: 110 additions & 0 deletions mypy/test/teststubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]): ...
Expand Down Expand Up @@ -1893,6 +1894,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 MutableMapping
class MutableMappingIor(MutableMapping[str, int]): ...
""",
runtime="""
class MutableMappingIor:
__ior__ = dict.__ior__
""",
error="MutableMappingIor.__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(
Expand Down
Loading