Skip to content
Merged
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
34 changes: 32 additions & 2 deletions mypy/stubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from mypy import nodes
from mypy.config_parser import parse_config_file
from mypy.evalexpr import UNKNOWN, evaluate_expression
from mypy.maptype import map_instance_to_supertype
from mypy.options import Options
from mypy.util import FancyFormatter, bytes_to_human_readable_repr, is_dunder, plural_s

Expand Down Expand Up @@ -1852,10 +1853,39 @@ def describe_runtime_callable(signature: inspect.Signature, *, is_async: bool) -
return f'{"async " if is_async else ""}def {signature}'


class _TypeCheckOnlyBaseMapper(mypy.types.TypeTranslator):
"""Rewrites @type_check_only instances to the nearest runtime-visible base class."""

def visit_instance(self, t: mypy.types.Instance, /) -> mypy.types.Type:
instance = mypy.types.get_proper_type(super().visit_instance(t))
assert isinstance(instance, mypy.types.Instance)

if instance.type.is_type_check_only:
# find the nearest non-@type_check_only base class
for base_info in instance.type.mro[1:]:
if not base_info.is_type_check_only:
return map_instance_to_supertype(instance, base_info)

msg = f"all base classes of {instance.type.fullname!r} are @type_check_only"
assert False, msg
Comment on lines +1869 to +1870
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume here that it will eventually always have encountered builtins.object before getting here.


return instance

def visit_type_alias_type(self, t: mypy.types.TypeAliasType, /) -> mypy.types.Type:
return t
Comment on lines +1874 to +1875
Copy link
Contributor Author

@jorenham jorenham Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method might look a bit awkward, but we need it because it's marked as abstract in mypy.types.TypeTranslator (i.e., without it there'd be a squiggly).



_TYPE_CHECK_ONLY_BASE_MAPPER = _TypeCheckOnlyBaseMapper()


def _relax_type_check_only_type(typ: mypy.types.ProperType) -> mypy.types.ProperType:
return mypy.types.get_proper_type(typ.accept(_TYPE_CHECK_ONLY_BASE_MAPPER))


def is_subtype_helper(left: mypy.types.Type, right: mypy.types.Type) -> bool:
"""Checks whether ``left`` is a subtype of ``right``."""
left = mypy.types.get_proper_type(left)
right = mypy.types.get_proper_type(right)
left = _relax_type_check_only_type(mypy.types.get_proper_type(left))
right = _relax_type_check_only_type(mypy.types.get_proper_type(right))
if (
isinstance(left, mypy.types.LiteralType)
and isinstance(left.value, int)
Expand Down
23 changes: 23 additions & 0 deletions mypy/test/teststubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,29 @@ class X:
error="X.mistyped_var",
)

@collect_cases
def test_transparent_type_check_only_subclasses(self) -> Iterator[Case]:
# See https://github.com/python/mypy/issues/20223
yield Case(
stub="""
from typing import type_check_only

class UFunc: ...

@type_check_only
class _BinaryUFunc(UFunc): ...

equal: _BinaryUFunc
""",
runtime="""
class UFunc:
pass

equal = UFunc()
""",
error=None,
)

@collect_cases
def test_coroutines(self) -> Iterator[Case]:
yield Case(stub="def bar() -> int: ...", runtime="async def bar(): return 5", error="bar")
Expand Down