Skip to content

Commit 67ada30

Browse files
[stubtest] Check runtime availability of private types not marked @type_check_only (#19574)
Currently, stubtest ignores symbols that are 1) private and 2) not present at runtime. Private names are often used for stub-only constructs (which we don't want stubtest to complain about), but they can also denote actual private runtime types. Without other context, it's often ambiguous which category a private names falls into. For class definitions, we can make the intent explicit by decorating the class with `@type_check_only`. Typeshed already does this for the most part, but there's still quite a few cases of stub-only private types that aren't marked `@type_check_only` (especially in older stubs). Going forward, I think it could a good idea to make stubtest start checking for `@type_check_only` on private types that are not available at runtime. A few special cases are needed to avoid false positives on types that cannot be marked with `@type_check_only`, namely: * NewType declarations * TypedDicts with fields that are not valid identifiers (and therefore need to use the functional syntax) ~This PR creates quite a few new errors on typeshed (\~100 in stdlib, \~300 in third-party stubs), which we'll probably want to sift through before merging this.~ As of this writing, all typeshed hits have been fixed.
1 parent bdef6ef commit 67ada30

File tree

3 files changed

+125
-11
lines changed

3 files changed

+125
-11
lines changed

docs/source/stubtest.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ The rest of this section documents the command line interface of stubtest.
183183

184184
Ignore errors for whether an argument should or shouldn't be positional-only
185185

186+
.. option:: --strict-type-check-only
187+
188+
Require :py:func:`@type_check_only <typing.type_check_only>` on private types
189+
that are not present at runtime.
190+
186191
.. option:: --allowlist FILE
187192

188193
Use file as an allowlist. Can be passed multiple times to combine multiple

mypy/stubtest.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import functools
1414
import importlib
1515
import inspect
16+
import keyword
1617
import os
1718
import pkgutil
1819
import re
@@ -145,6 +146,11 @@ def is_disjoint_base_related(self) -> bool:
145146
# TODO: This is hacky, use error codes or something more resilient
146147
return "@disjoint_base" in self.message
147148

149+
def is_private_type_check_only_related(self) -> bool:
150+
"""Whether or not the error is related to @type_check_only on private types."""
151+
# TODO: This is hacky, use error codes or something more resilient
152+
return self.message.endswith('Maybe mark it as "@type_check_only"?')
153+
148154
def get_description(self, concise: bool = False) -> str:
149155
"""Returns a description of the error.
150156
@@ -365,11 +371,7 @@ def verify_mypyfile(
365371
runtime_all_as_set = None
366372

367373
# Check things in the stub
368-
to_check = {
369-
m
370-
for m, o in stub.names.items()
371-
if not o.module_hidden and (not is_probably_private(m) or hasattr(runtime, m))
372-
}
374+
to_check = {m for m, o in stub.names.items() if not o.module_hidden}
373375

374376
def _belongs_to_runtime(r: types.ModuleType, attr: str) -> bool:
375377
"""Heuristics to determine whether a name originates from another module."""
@@ -439,6 +441,15 @@ def _belongs_to_runtime(r: types.ModuleType, attr: str) -> bool:
439441
# Don't recursively check exported modules, since that leads to infinite recursion
440442
continue
441443
assert stub_entry is not None
444+
if (
445+
is_probably_private(entry)
446+
and not hasattr(runtime, entry)
447+
and not isinstance(stub_entry, Missing)
448+
and not _is_decoratable(stub_entry)
449+
):
450+
# Skip private names that don't exist at runtime and which cannot
451+
# be marked with @type_check_only.
452+
continue
442453
try:
443454
runtime_entry = getattr(runtime, entry, MISSING)
444455
except Exception:
@@ -448,6 +459,19 @@ def _belongs_to_runtime(r: types.ModuleType, attr: str) -> bool:
448459
yield from verify(stub_entry, runtime_entry, object_path + [entry])
449460

450461

462+
def _is_decoratable(stub: nodes.SymbolNode) -> bool:
463+
if not isinstance(stub, nodes.TypeInfo):
464+
return False
465+
if stub.is_newtype:
466+
return False
467+
if stub.typeddict_type is not None:
468+
return all(
469+
name.isidentifier() and not keyword.iskeyword(name)
470+
for name in stub.typeddict_type.items.keys()
471+
)
472+
return True
473+
474+
451475
def _verify_final(
452476
stub: nodes.TypeInfo, runtime: type[Any], object_path: list[str]
453477
) -> Iterator[Error]:
@@ -639,7 +663,10 @@ def verify_typeinfo(
639663
return
640664

641665
if isinstance(runtime, Missing):
642-
yield Error(object_path, "is not present at runtime", stub, runtime, stub_desc=repr(stub))
666+
msg = "is not present at runtime"
667+
if is_probably_private(stub.name):
668+
msg += '. Maybe mark it as "@type_check_only"?'
669+
yield Error(object_path, msg, stub, runtime, stub_desc=repr(stub))
643670
return
644671
if not isinstance(runtime, type):
645672
# Yes, some runtime objects can be not types, no way to tell mypy about that.
@@ -2308,6 +2335,7 @@ class _Arguments:
23082335
ignore_missing_stub: bool
23092336
ignore_positional_only: bool
23102337
ignore_disjoint_bases: bool
2338+
strict_type_check_only: bool
23112339
allowlist: list[str]
23122340
generate_allowlist: bool
23132341
ignore_unused_allowlist: bool
@@ -2404,6 +2432,8 @@ def warning_callback(msg: str) -> None:
24042432
continue
24052433
if args.ignore_disjoint_bases and error.is_disjoint_base_related():
24062434
continue
2435+
if not args.strict_type_check_only and error.is_private_type_check_only_related():
2436+
continue
24072437
if error.object_desc in allowlist:
24082438
allowlist[error.object_desc] = True
24092439
continue
@@ -2500,6 +2530,11 @@ def parse_options(args: list[str]) -> _Arguments:
25002530
action="store_true",
25012531
help="Disable checks for PEP 800 @disjoint_base classes",
25022532
)
2533+
parser.add_argument(
2534+
"--strict-type-check-only",
2535+
action="store_true",
2536+
help="Require @type_check_only on private types that are not present at runtime",
2537+
)
25032538
parser.add_argument(
25042539
"--allowlist",
25052540
"--whitelist",

mypy/test/teststubtest.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def __getitem__(self, typeargs: Any) -> object: ...
5959
6060
Final = 0
6161
Literal = 0
62+
NewType = 0
6263
TypedDict = 0
6364
6465
class TypeVar:
@@ -255,7 +256,7 @@ def test(*args: Any, **kwargs: Any) -> None:
255256
output = run_stubtest(
256257
stub="\n\n".join(textwrap.dedent(c.stub.lstrip("\n")) for c in cases),
257258
runtime="\n\n".join(textwrap.dedent(c.runtime.lstrip("\n")) for c in cases),
258-
options=["--generate-allowlist"],
259+
options=["--generate-allowlist", "--strict-type-check-only"],
259260
)
260261

261262
actual_errors = set(output.splitlines())
@@ -904,8 +905,9 @@ def f(a, *args): ...
904905
def test_decorated_overload(self) -> Iterator[Case]:
905906
yield Case(
906907
stub="""
907-
from typing import overload
908+
from typing import overload, type_check_only
908909
910+
@type_check_only
909911
class _dec1:
910912
def __init__(self, func: object) -> None: ...
911913
def __call__(self, x: str) -> str: ...
@@ -921,6 +923,7 @@ def good1(unrelated: int, whatever: str) -> str: ...
921923
)
922924
yield Case(
923925
stub="""
926+
@type_check_only
924927
class _dec2:
925928
def __init__(self, func: object) -> None: ...
926929
def __call__(self, x: str, y: int) -> str: ...
@@ -936,6 +939,7 @@ def good2(unrelated: int, whatever: str) -> str: ...
936939
)
937940
yield Case(
938941
stub="""
942+
@type_check_only
939943
class _dec3:
940944
def __init__(self, func: object) -> None: ...
941945
def __call__(self, x: str, y: int) -> str: ...
@@ -1245,7 +1249,7 @@ def test_type_alias(self) -> Iterator[Case]:
12451249
import collections.abc
12461250
import re
12471251
import typing
1248-
from typing import Callable, Dict, Generic, Iterable, List, Match, Tuple, TypeVar, Union
1252+
from typing import Callable, Dict, Generic, Iterable, List, Match, Tuple, TypeVar, Union, type_check_only
12491253
""",
12501254
runtime="""
12511255
import collections.abc
@@ -1316,6 +1320,7 @@ class Y: ...
13161320
yield Case(
13171321
stub="""
13181322
_T = TypeVar("_T")
1323+
@type_check_only
13191324
class _Spam(Generic[_T]):
13201325
def foo(self) -> None: ...
13211326
IntFood = _Spam[int]
@@ -1693,6 +1698,7 @@ def test_missing(self) -> Iterator[Case]:
16931698
yield Case(stub="x = 5", runtime="", error="x")
16941699
yield Case(stub="def f(): ...", runtime="", error="f")
16951700
yield Case(stub="class X: ...", runtime="", error="X")
1701+
yield Case(stub="class _X: ...", runtime="", error="_X")
16961702
yield Case(
16971703
stub="""
16981704
from typing import overload
@@ -1749,6 +1755,8 @@ def __delattr__(self, name: str, /) -> None: ...
17491755
runtime="class FakeDelattrClass: ...",
17501756
error="FakeDelattrClass.__delattr__",
17511757
)
1758+
yield Case(stub="from typing import NewType", runtime="", error=None)
1759+
yield Case(stub="_Int = NewType('_Int', int)", runtime="", error=None)
17521760

17531761
@collect_cases
17541762
def test_missing_no_runtime_all(self) -> Iterator[Case]:
@@ -2375,8 +2383,9 @@ def test_special_subtype(self) -> Iterator[Case]:
23752383
)
23762384
yield Case(
23772385
stub="""
2378-
from typing import TypedDict
2386+
from typing import TypedDict, type_check_only
23792387
2388+
@type_check_only
23802389
class _Options(TypedDict):
23812390
a: str
23822391
b: int
@@ -2796,6 +2805,70 @@ def func2() -> None: ...
27962805
runtime="def func2() -> None: ...",
27972806
error="func2",
27982807
)
2808+
# The same is true for private types
2809+
yield Case(
2810+
stub="""
2811+
@type_check_only
2812+
class _P1: ...
2813+
""",
2814+
runtime="",
2815+
error=None,
2816+
)
2817+
yield Case(
2818+
stub="""
2819+
@type_check_only
2820+
class _P2: ...
2821+
""",
2822+
runtime="class _P2: ...",
2823+
error="_P2",
2824+
)
2825+
# Private TypedDicts which do not exist at runtime must be decorated with
2826+
# '@type_check_only', unless they contain keys that are not valid identifiers.
2827+
yield Case(
2828+
stub="""
2829+
class _TD1(TypedDict): ...
2830+
""",
2831+
runtime="",
2832+
error="_TD1",
2833+
)
2834+
yield Case(
2835+
stub="""
2836+
_TD2 = TypedDict("_TD2", {"foo": int})
2837+
""",
2838+
runtime="",
2839+
error="_TD2",
2840+
)
2841+
yield Case(
2842+
stub="""
2843+
_TD3 = TypedDict("_TD3", {"foo-bar": int})
2844+
""",
2845+
runtime="",
2846+
error=None,
2847+
)
2848+
yield Case(
2849+
stub="""
2850+
_TD4 = TypedDict("_TD4", {"class": int})
2851+
""",
2852+
runtime="",
2853+
error=None,
2854+
)
2855+
# Private NamedTuples which do not exist at runtime must be decorated with
2856+
# '@type_check_only'.
2857+
yield Case(
2858+
stub="""
2859+
class _NT1(NamedTuple): ...
2860+
""",
2861+
runtime="",
2862+
error="_NT1",
2863+
)
2864+
yield Case(
2865+
stub="""
2866+
@type_check_only
2867+
class _NT2(NamedTuple): ...
2868+
""",
2869+
runtime="",
2870+
error=None,
2871+
)
27992872
# A type that exists at runtime is allowed to alias a type marked
28002873
# as '@type_check_only' in the stubs.
28012874
yield Case(
@@ -2812,8 +2885,9 @@ class _X1: ...
28122885
def test_type_default_protocol(self) -> Iterator[Case]:
28132886
yield Case(
28142887
stub="""
2815-
from typing import Protocol
2888+
from typing import Protocol, type_check_only
28162889
2890+
@type_check_only
28172891
class _FormatterClass(Protocol):
28182892
def __call__(self, *, prog: str) -> HelpFormatter: ...
28192893

0 commit comments

Comments
 (0)