Skip to content

Commit 4af6a67

Browse files
committed
stubtest improvements
- error summary - fix issue where module is saved as stub - handle loading modules that output or raise - output messages instead of tracebacks - add some more tests Also: - move plural_s from messages to util - use plural_s in summary functions
1 parent c986e54 commit 4af6a67

File tree

4 files changed

+181
-44
lines changed

4 files changed

+181
-44
lines changed

mypy/messages.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
)
4343
from mypy.sametypes import is_same_type
4444
from mypy.typeops import separate_union_literals
45-
from mypy.util import unmangle
45+
from mypy.util import unmangle, plural_s as plural_s
4646
from mypy.errorcodes import ErrorCode
4747
from mypy import message_registry, errorcodes as codes
4848

@@ -2110,14 +2110,6 @@ def strip_quotes(s: str) -> str:
21102110
return s
21112111

21122112

2113-
def plural_s(s: Union[int, Sequence[Any]]) -> str:
2114-
count = s if isinstance(s, int) else len(s)
2115-
if count > 1:
2116-
return 's'
2117-
else:
2118-
return ''
2119-
2120-
21212113
def format_string_list(lst: List[str]) -> str:
21222114
assert len(lst) > 0
21232115
if len(lst) == 1:

mypy/stubtest.py

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@
99
import enum
1010
import importlib
1111
import inspect
12+
import io
1213
import re
1314
import sys
1415
import types
1516
import warnings
17+
from contextlib import redirect_stdout, redirect_stderr
1618
from functools import singledispatch
1719
from pathlib import Path
18-
from typing import Any, Dict, Generic, Iterator, List, Optional, Tuple, TypeVar, Union, cast
20+
from typing import Any, Dict, Generic, Iterator, List, Optional, Tuple, TypeVar, Union, cast, Set
1921

20-
from typing_extensions import Type
22+
from typing_extensions import Type, Literal
2123

2224
import mypy.build
2325
import mypy.modulefinder
@@ -26,6 +28,7 @@
2628
import mypy.version
2729
from mypy import nodes
2830
from mypy.config_parser import parse_config_file
31+
from mypy.messages import plural_s
2932
from mypy.options import Options
3033
from mypy.util import FancyFormatter, bytes_to_human_readable_repr, is_dunder
3134

@@ -51,6 +54,16 @@ def _style(message: str, **kwargs: Any) -> str:
5154
return _formatter.style(message, **kwargs)
5255

5356

57+
def log_error(message: str) -> Literal[1]:
58+
"""Print a bold red message."""
59+
print(_style(message, color="red", bold=True))
60+
return 1
61+
62+
63+
class Failure(Exception):
64+
"""Used to indicate a handled failure state"""
65+
66+
5467
class Error:
5568
def __init__(
5669
self,
@@ -151,7 +164,7 @@ def get_description(self, concise: bool = False) -> str:
151164
# ====================
152165

153166

154-
def test_module(module_name: str) -> Iterator[Error]:
167+
def test_module(module_name: str, concise: bool = False) -> Iterator[Error]:
155168
"""Tests a given module's stub against introspecting it at runtime.
156169
157170
Requires the stub to have been built already, accomplished by a call to ``build_stubs``.
@@ -161,20 +174,40 @@ def test_module(module_name: str) -> Iterator[Error]:
161174
"""
162175
stub = get_stub(module_name)
163176
if stub is None:
164-
yield Error([module_name], "failed to find stubs", MISSING, None)
177+
yield Error([module_name], "failed to find stubs", MISSING, None, runtime_desc="N/A")
165178
return
166179

180+
argv = sys.argv
181+
sys.argv = []
182+
output = io.StringIO()
183+
outerror = io.StringIO()
167184
try:
168-
with warnings.catch_warnings():
185+
with warnings.catch_warnings(), redirect_stdout(output), redirect_stderr(outerror):
169186
warnings.simplefilter("ignore")
170187
runtime = importlib.import_module(module_name)
171188
# Also run the equivalent of `from module import *`
172189
# This could have the additional effect of loading not-yet-loaded submodules
173190
# mentioned in __all__
174191
__import__(module_name, fromlist=["*"])
175-
except Exception as e:
176-
yield Error([module_name], f"failed to import: {e}", stub, MISSING)
192+
except KeyboardInterrupt:
193+
raise
194+
except BaseException as e: # to catch every possible error
195+
yield Error([module_name], f"failed to import: {type(e).__name__} {e}", stub, MISSING,
196+
stub_desc=stub.path, runtime_desc="Missing due to failed import")
177197
return
198+
finally:
199+
sys.argv = argv
200+
stdout = output.getvalue()
201+
stderr = outerror.getvalue()
202+
if stdout or stderr and not concise:
203+
print(f"Found output while loading '{module_name}'")
204+
if stdout:
205+
print(_style("======= standard output ============", bold=True))
206+
print(stdout, end="" if stdout[-1] == "\n" else "\n")
207+
if stderr:
208+
print(_style("======= standard error =============", bold=True))
209+
print(stderr, end="" if stderr[-1] == "\n" else "\n")
210+
print(_style("====================================", bold=True))
178211

179212
with warnings.catch_warnings():
180213
warnings.simplefilter("ignore")
@@ -461,21 +494,21 @@ def get_name(arg: Any) -> str:
461494
return arg.name
462495
if isinstance(arg, nodes.Argument):
463496
return arg.variable.name
464-
raise AssertionError
497+
raise Failure
465498

466499
def get_type(arg: Any) -> Optional[str]:
467500
if isinstance(arg, inspect.Parameter):
468501
return None
469502
if isinstance(arg, nodes.Argument):
470503
return str(arg.variable.type or arg.type_annotation)
471-
raise AssertionError
504+
raise Failure
472505

473506
def has_default(arg: Any) -> bool:
474507
if isinstance(arg, inspect.Parameter):
475508
return arg.default != inspect.Parameter.empty
476509
if isinstance(arg, nodes.Argument):
477510
return arg.kind.is_optional()
478-
raise AssertionError
511+
raise Failure
479512

480513
def get_desc(arg: Any) -> str:
481514
arg_type = get_type(arg)
@@ -510,7 +543,7 @@ def from_funcitem(stub: nodes.FuncItem) -> "Signature[nodes.Argument]":
510543
elif stub_arg.kind == nodes.ARG_STAR2:
511544
stub_sig.varkw = stub_arg
512545
else:
513-
raise AssertionError
546+
raise Failure
514547
return stub_sig
515548

516549
@staticmethod
@@ -529,7 +562,7 @@ def from_inspect_signature(signature: inspect.Signature) -> "Signature[inspect.P
529562
elif runtime_arg.kind == inspect.Parameter.VAR_KEYWORD:
530563
runtime_sig.varkw = runtime_arg
531564
else:
532-
raise AssertionError
565+
raise Failure
533566
return runtime_sig
534567

535568
@staticmethod
@@ -608,7 +641,7 @@ def get_kind(arg_name: str) -> nodes.ArgKind:
608641
elif arg.kind == nodes.ARG_STAR2:
609642
sig.varkw = arg
610643
else:
611-
raise AssertionError
644+
raise Failure
612645
return sig
613646

614647

@@ -918,7 +951,9 @@ def apply_decorator_to_funcitem(
918951
) or decorator.fullname in mypy.types.OVERLOAD_NAMES:
919952
return func
920953
if decorator.fullname == "builtins.classmethod":
921-
assert func.arguments[0].variable.name in ("cls", "metacls")
954+
if func.arguments[0].variable.name not in ("cls", "mcs", "metacls"):
955+
log_error(f"Error: bad class argument name: {func.arguments[0].variable.name}")
956+
raise Failure
922957
# FuncItem is written so that copy.copy() actually works, even when compiled
923958
ret = copy.copy(func)
924959
# Remove the cls argument, since it's not present in inspect.signature of classmethods
@@ -1156,7 +1191,7 @@ def anytype() -> mypy.types.AnyType:
11561191
elif arg.kind == inspect.Parameter.VAR_KEYWORD:
11571192
arg_kinds.append(nodes.ARG_STAR2)
11581193
else:
1159-
raise AssertionError
1194+
raise Failure
11601195
else:
11611196
arg_types = [anytype(), anytype()]
11621197
arg_kinds = [nodes.ARG_STAR, nodes.ARG_STAR2]
@@ -1257,14 +1292,14 @@ def build_stubs(modules: List[str], options: Options, find_submodules: bool = Fa
12571292
str(e),
12581293
]
12591294
print("".join(output))
1260-
raise RuntimeError from e
1295+
raise Failure from e
12611296
if res.errors:
12621297
output = [
12631298
_style("error: ", color="red", bold=True),
12641299
"not checking stubs due to mypy build errors:\n",
12651300
]
12661301
print("".join(output) + "\n".join(res.errors))
1267-
raise RuntimeError
1302+
raise Failure
12681303

12691304
global _all_stubs
12701305
_all_stubs = res.files
@@ -1274,7 +1309,10 @@ def build_stubs(modules: List[str], options: Options, find_submodules: bool = Fa
12741309

12751310
def get_stub(module: str) -> Optional[nodes.MypyFile]:
12761311
"""Returns a stub object for the given module, if we've built one."""
1277-
return _all_stubs.get(module)
1312+
result = _all_stubs.get(module)
1313+
if result and result.is_stub:
1314+
return result
1315+
return None
12781316

12791317

12801318
def get_typeshed_stdlib_modules(
@@ -1329,7 +1367,22 @@ def strip_comments(s: str) -> str:
13291367
yield entry
13301368

13311369

1332-
def test_stubs(args: argparse.Namespace, use_builtins_fixtures: bool = False) -> int:
1370+
class Arguments:
1371+
modules: List[str]
1372+
concise: bool
1373+
ignore_missing_stub: bool
1374+
ignore_positional_only: bool
1375+
allowlist: List[str]
1376+
generate_allowlist: bool
1377+
ignore_unused_allowlist: bool
1378+
mypy_config_file: str
1379+
custom_typeshed_dir: str
1380+
check_typeshed: bool
1381+
version: str
1382+
error_summary: bool
1383+
1384+
1385+
def test_stubs(args: Arguments, use_builtins_fixtures: bool = False) -> int:
13331386
"""This is stubtest! It's time to test the stubs!"""
13341387
# Load the allowlist. This is a series of strings corresponding to Error.object_desc
13351388
# Values in the dict will store whether we used the allowlist entry or not.
@@ -1345,13 +1398,15 @@ def test_stubs(args: argparse.Namespace, use_builtins_fixtures: bool = False) ->
13451398

13461399
modules = args.modules
13471400
if args.check_typeshed:
1348-
assert not args.modules, "Cannot pass both --check-typeshed and a list of modules"
1401+
if args.modules:
1402+
return log_error("Cannot pass both --check-typeshed and a list of modules")
13491403
modules = get_typeshed_stdlib_modules(args.custom_typeshed_dir)
13501404
# typeshed added a stub for __main__, but that causes stubtest to check itself
13511405
annoying_modules = {"antigravity", "this", "__main__"}
13521406
modules = [m for m in modules if m not in annoying_modules]
13531407

1354-
assert modules, "No modules to check"
1408+
if not modules:
1409+
return log_error("No modules to check")
13551410

13561411
options = Options()
13571412
options.incremental = False
@@ -1366,12 +1421,14 @@ def set_strict_flags() -> None: # not needed yet
13661421

13671422
try:
13681423
modules = build_stubs(modules, options, find_submodules=not args.check_typeshed)
1369-
except RuntimeError:
1424+
except Failure:
13701425
return 1
13711426

13721427
exit_code = 0
1428+
error_count = 0
1429+
error_modules: Set[str] = set()
13731430
for module in modules:
1374-
for error in test_module(module):
1431+
for error in test_module(module, args.concise):
13751432
# Filter errors
13761433
if args.ignore_missing_stub and error.is_missing_stub():
13771434
continue
@@ -1395,6 +1452,8 @@ def set_strict_flags() -> None: # not needed yet
13951452
generated_allowlist.add(error.object_desc)
13961453
continue
13971454
print(error.get_description(concise=args.concise))
1455+
error_count += 1
1456+
error_modules.add(module)
13981457

13991458
# Print unused allowlist entries
14001459
if not args.ignore_unused_allowlist:
@@ -1411,10 +1470,25 @@ def set_strict_flags() -> None: # not needed yet
14111470
print(e)
14121471
exit_code = 0
14131472

1473+
if args.error_summary:
1474+
if not error_count:
1475+
print(
1476+
_style(
1477+
f"Success: no issues found in {len(modules)} module{plural_s(modules)}",
1478+
color="green", bold=True
1479+
)
1480+
)
1481+
else:
1482+
log_error(
1483+
f"Found {error_count} error{plural_s(error_count)} in {len(error_modules)}"
1484+
f" module{plural_s(error_modules)}"
1485+
f" (checked {len(modules)} module{plural_s(modules)})"
1486+
)
1487+
14141488
return exit_code
14151489

14161490

1417-
def parse_options(args: List[str]) -> argparse.Namespace:
1491+
def parse_options(args: List[str]) -> Arguments:
14181492
parser = argparse.ArgumentParser(
14191493
description="Compares stubs to objects introspected from the runtime."
14201494
)
@@ -1475,13 +1549,24 @@ def parse_options(args: List[str]) -> argparse.Namespace:
14751549
parser.add_argument(
14761550
"--version", action="version", version="%(prog)s " + mypy.version.__version__
14771551
)
1552+
parser.add_argument(
1553+
"--no-error-summary", action="store_false", dest="error_summary",
1554+
help="Don't output an error summary"
1555+
)
14781556

1479-
return parser.parse_args(args)
1557+
return parser.parse_args(args, namespace=Arguments())
14801558

14811559

14821560
def main() -> int:
14831561
mypy.util.check_python_version("stubtest")
1484-
return test_stubs(parse_options(sys.argv[1:]))
1562+
try:
1563+
return test_stubs(parse_options(sys.argv[1:]))
1564+
except KeyboardInterrupt:
1565+
return log_error("Interrupted")
1566+
except Failure:
1567+
return log_error("Stubtest has failed and exited early")
1568+
except Exception:
1569+
return log_error("Internal error encountered")
14851570

14861571

14871572
if __name__ == "__main__":

0 commit comments

Comments
 (0)