Files
typeshed/tests/mypy_test.py
Alex Waygood 9d450cb50c Make test scripts pass mypy --strict (#7745)
- Add several type hints to untyped functions.
- While we're at it, upgrade several annotations to use modern syntax by using `from __future__ import annotations`. This means that the tests can't be run on Python 3.6, but 3.6 is EOL, and it is already the case that some scripts in this directory can only be run on more recent Python versions. E.g. `check_new_syntax.py` uses `ast.unparse`, which is only available in Python 3.9+.
- Fix a few pieces of code that didn't type check.
2022-04-29 21:55:12 -06:00

355 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Test runner for typeshed.
Depends on mypy being installed.
Approach:
1. Parse sys.argv
2. Compute appropriate arguments for mypy
3. Pass those arguments to mypy.api.run()
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING:
from _typeshed import StrPath
from typing_extensions import TypeAlias
import tomli
parser = argparse.ArgumentParser(description="Test runner for typeshed. Patterns are unanchored regexps on the full path.")
parser.add_argument("-v", "--verbose", action="count", default=0, help="More output")
parser.add_argument("-n", "--dry-run", action="store_true", help="Don't actually run mypy")
parser.add_argument("-x", "--exclude", type=str, nargs="*", help="Exclude pattern")
parser.add_argument("-p", "--python-version", type=str, nargs="*", help="These versions only (major[.minor])")
parser.add_argument("--platform", help="Run mypy for a certain OS platform (defaults to sys.platform)")
parser.add_argument("filter", type=str, nargs="*", help="Include pattern (default all)")
def log(args: argparse.Namespace, *varargs: object) -> None:
if args.verbose >= 2:
print(*varargs)
def match(fn: str, args: argparse.Namespace) -> bool:
if not args.filter and not args.exclude:
log(args, fn, "accept by default")
return True
if args.exclude:
for f in args.exclude:
if re.search(f, fn):
log(args, fn, "excluded by pattern", f)
return False
if args.filter:
for f in args.filter:
if re.search(f, fn):
log(args, fn, "accepted by pattern", f)
return True
if args.filter:
log(args, fn, "rejected (no pattern matches)")
return False
log(args, fn, "accepted (no exclude pattern matches)")
return True
_VERSION_LINE_RE = re.compile(r"^([a-zA-Z_][a-zA-Z0-9_.]*): ([23]\.\d{1,2})-([23]\.\d{1,2})?$")
MinVersion: TypeAlias = tuple[int, int]
MaxVersion: TypeAlias = tuple[int, int]
def parse_versions(fname: StrPath) -> dict[str, tuple[MinVersion, MaxVersion]]:
result = {}
with open(fname) as f:
for line in f:
# Allow having some comments or empty lines.
line = line.split("#")[0].strip()
if line == "":
continue
m = _VERSION_LINE_RE.match(line)
assert m, "invalid VERSIONS line: " + line
mod: str = m.group(1)
min_version = parse_version(m.group(2))
max_version = parse_version(m.group(3)) if m.group(3) else (99, 99)
result[mod] = min_version, max_version
return result
_VERSION_RE = re.compile(r"^([23])\.(\d+)$")
def parse_version(v_str: str) -> tuple[int, int]:
m = _VERSION_RE.match(v_str)
assert m, "invalid version: " + v_str
return int(m.group(1)), int(m.group(2))
def add_files(files: list[str], seen: set[str], root: str, name: str, args: argparse.Namespace) -> None:
"""Add all files in package or module represented by 'name' located in 'root'."""
full = os.path.join(root, name)
mod, ext = os.path.splitext(name)
if ext in [".pyi", ".py"]:
if match(full, args):
seen.add(mod)
files.append(full)
elif os.path.isfile(os.path.join(full, "__init__.pyi")) or os.path.isfile(os.path.join(full, "__init__.py")):
for r, ds, fs in os.walk(full):
ds.sort()
fs.sort()
for f in fs:
m, x = os.path.splitext(f)
if x in [".pyi", ".py"]:
fn = os.path.join(r, f)
if match(fn, args):
seen.add(mod)
files.append(fn)
class MypyDistConf(NamedTuple):
module_name: str
values: dict
# The configuration section in the metadata file looks like the following, with multiple module sections possible
# [mypy-tests]
# [mypy-tests.yaml]
# module_name = "yaml"
# [mypy-tests.yaml.values]
# disallow_incomplete_defs = true
# disallow_untyped_defs = true
def add_configuration(configurations: list[MypyDistConf], distribution: str) -> None:
with open(os.path.join("stubs", distribution, "METADATA.toml")) as f:
data = dict(tomli.loads(f.read()))
mypy_tests_conf = data.get("mypy-tests")
if not mypy_tests_conf:
return
assert isinstance(mypy_tests_conf, dict), "mypy-tests should be a section"
for section_name, mypy_section in mypy_tests_conf.items():
assert isinstance(mypy_section, dict), "{} should be a section".format(section_name)
module_name = mypy_section.get("module_name")
assert module_name is not None, "{} should have a module_name key".format(section_name)
assert isinstance(module_name, str), "{} should be a key-value pair".format(section_name)
values = mypy_section.get("values")
assert values is not None, "{} should have a values section".format(section_name)
assert isinstance(values, dict), "values should be a section"
configurations.append(MypyDistConf(module_name, values.copy()))
def run_mypy(
args: argparse.Namespace,
configurations: list[MypyDistConf],
major: int,
minor: int,
files: list[str],
*,
custom_typeshed: bool = False,
) -> int:
try:
from mypy.api import run as mypy_run
except ImportError:
print("Cannot import mypy. Did you install it?")
sys.exit(1)
with tempfile.NamedTemporaryFile("w+") as temp:
temp.write("[mypy]\n")
for dist_conf in configurations:
temp.write("[mypy-%s]\n" % dist_conf.module_name)
for k, v in dist_conf.values.items():
temp.write("{} = {}\n".format(k, v))
temp.flush()
flags = get_mypy_flags(args, major, minor, temp.name, custom_typeshed=custom_typeshed)
mypy_args = [*flags, *files]
if args.verbose:
print("running mypy", " ".join(mypy_args))
if args.dry_run:
exit_code = 0
else:
stdout, stderr, exit_code = mypy_run(mypy_args)
print(stdout, end="")
print(stderr, file=sys.stderr, end="")
return exit_code
def get_mypy_flags(
args: argparse.Namespace, major: int, minor: int, temp_name: str, *, custom_typeshed: bool = False
) -> list[str]:
flags = [
"--python-version",
"%d.%d" % (major, minor),
"--config-file",
temp_name,
"--no-site-packages",
"--show-traceback",
"--no-implicit-optional",
"--disallow-untyped-decorators",
"--disallow-any-generics",
"--warn-incomplete-stub",
"--show-error-codes",
"--no-error-summary",
"--enable-error-code",
"ignore-without-code",
"--strict-equality",
]
if custom_typeshed:
# Setting custom typeshed dir prevents mypy from falling back to its bundled
# typeshed in case of stub deletions
flags.extend(["--custom-typeshed-dir", os.path.dirname(os.path.dirname(__file__))])
if args.platform:
flags.extend(["--platform", args.platform])
return flags
def read_dependencies(distribution: str) -> list[str]:
with open(os.path.join("stubs", distribution, "METADATA.toml")) as f:
data = dict(tomli.loads(f.read()))
requires = data.get("requires", [])
assert isinstance(requires, list)
dependencies = []
for dependency in requires:
assert isinstance(dependency, str)
assert dependency.startswith("types-")
dependencies.append(dependency[6:].split("<")[0])
return dependencies
def add_third_party_files(
distribution: str,
major: int,
files: list[str],
args: argparse.Namespace,
configurations: list[MypyDistConf],
seen_dists: set[str],
) -> None:
if distribution in seen_dists:
return
seen_dists.add(distribution)
dependencies = read_dependencies(distribution)
for dependency in dependencies:
add_third_party_files(dependency, major, files, args, configurations, seen_dists)
root = os.path.join("stubs", distribution)
for name in os.listdir(root):
mod, _ = os.path.splitext(name)
if mod.startswith("."):
continue
add_files(files, set(), root, name, args)
add_configuration(configurations, distribution)
def test_third_party_distribution(distribution: str, major: int, minor: int, args: argparse.Namespace) -> tuple[int, int]:
"""Test the stubs of a third-party distribution.
Return a tuple, where the first element indicates mypy's return code
and the second element is the number of checked files.
"""
files: list[str] = []
configurations: list[MypyDistConf] = []
seen_dists: set[str] = set()
add_third_party_files(distribution, major, files, args, configurations, seen_dists)
print(f"testing {distribution} ({len(files)} files)...")
if not files:
print("--- no files found ---")
sys.exit(1)
code = run_mypy(args, configurations, major, minor, files)
return code, len(files)
def is_probably_stubs_folder(distribution: str, distribution_path: Path) -> bool:
"""Validate that `dist_path` is a folder containing stubs"""
return distribution != ".mypy_cache" and distribution_path.is_dir()
def main() -> None:
args = parser.parse_args()
versions = [(3, 11), (3, 10), (3, 9), (3, 8), (3, 7), (3, 6), (2, 7)]
if args.python_version:
versions = [v for v in versions if any(("%d.%d" % v).startswith(av) for av in args.python_version)]
if not versions:
print("--- no versions selected ---")
sys.exit(1)
code = 0
files_checked = 0
for major, minor in versions:
print(f"*** Testing Python {major}.{minor}")
seen = {"__builtin__", "builtins", "typing"} # Always ignore these.
# Test standard library files.
files: list[str] = []
if major == 2:
root = os.path.join("stdlib", "@python2")
for name in os.listdir(root):
mod, _ = os.path.splitext(name)
if mod in seen or mod.startswith("."):
continue
add_files(files, seen, root, name, args)
else:
supported_versions = parse_versions(os.path.join("stdlib", "VERSIONS"))
root = "stdlib"
for name in os.listdir(root):
if name == "@python2" or name == "VERSIONS" or name.startswith("."):
continue
mod, _ = os.path.splitext(name)
if supported_versions[mod][0] <= (major, minor) <= supported_versions[mod][1]:
add_files(files, seen, root, name, args)
if files:
print("Running mypy " + " ".join(get_mypy_flags(args, major, minor, "/tmp/...", custom_typeshed=True)))
print(f"testing stdlib ({len(files)} files)...")
this_code = run_mypy(args, [], major, minor, files, custom_typeshed=True)
code = max(code, this_code)
files_checked += len(files)
# Test files of all third party distributions.
if major != 2:
print("Running mypy " + " ".join(get_mypy_flags(args, major, minor, "/tmp/...")))
for distribution in sorted(os.listdir("stubs")):
if distribution == "SQLAlchemy":
continue # Crashes
distribution_path = Path("stubs", distribution)
if not is_probably_stubs_folder(distribution, distribution_path):
continue
this_code, checked = test_third_party_distribution(distribution, major, minor, args)
code = max(code, this_code)
files_checked += checked
print()
if code:
print(f"--- exit status {code}, {files_checked} files checked ---")
sys.exit(code)
if not files_checked:
print("--- nothing to do; exit 1 ---")
sys.exit(1)
print(f"--- success, {files_checked} files checked ---")
if __name__ == "__main__":
main()