Skip to content

Commit f5cb01b

Browse files
committed
Add infrastructure allowing for test cases for third-party stubs
1 parent b98ee67 commit f5cb01b

File tree

11 files changed

+337
-112
lines changed

11 files changed

+337
-112
lines changed

.github/workflows/tests.yml

+13-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
pull_request:
1010
paths-ignore:
1111
- '**/*.md'
12+
- 'scripts/**'
1213

1314
permissions:
1415
contents: read
@@ -65,7 +66,7 @@ jobs:
6566
- run: ./tests/pytype_test.py --print-stderr
6667

6768
mypy:
68-
name: Test the stubs with mypy
69+
name: Run mypy against the stubs
6970
runs-on: ubuntu-latest
7071
strategy:
7172
matrix:
@@ -80,6 +81,17 @@ jobs:
8081
- run: pip install -r requirements-tests.txt
8182
- run: ./tests/mypy_test.py --platform=${{ matrix.platform }} --python-version=${{ matrix.python-version }}
8283

84+
regression-tests:
85+
name: Run mypy on the test cases
86+
runs-on: ubuntu-latest
87+
steps:
88+
- uses: actions/checkout@v3
89+
- uses: actions/setup-python@v4
90+
with:
91+
python-version: "3.10"
92+
- run: pip install -r requirements-tests.txt
93+
- run: python ./tests/regr_test.py --all
94+
8395
pyright:
8496
name: Test the stubs with pyright
8597
runs-on: ubuntu-latest
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# pyright: reportUnnecessaryTypeIgnoreComment=true
2+
3+
import requests
4+
5+
# Regression test for #7988 (multiple files should be allowed for the "files" argument)
6+
# This snippet comes from the requests documentation (https://requests.readthedocs.io/en/latest/user/advanced/#post-multiple-multipart-encoded-files),
7+
# so should pass a type checker without error
8+
url = "https://httpbin.org/post"
9+
multiple_files = [
10+
("images", ("foo.png", open("foo.png", "rb"), "image/png")),
11+
("images", ("bar.png", open("bar.png", "rb"), "image/png")),
12+
]
13+
r = requests.post(url, files=multiple_files)

test_cases/README.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
## Regression tests for typeshed
22

33
This directory contains code samples that act as a regression test for the
4-
standard library stubs found elsewhere in the typeshed repo.
4+
typeshed's stdlib stubs.
55

66
**This directory should *only* contain test cases for functions and classes which
77
are known to have caused problems in the past, where the stubs are difficult to
88
get right.** 100% test coverage for typeshed is neither necessary nor
99
desirable, as it would lead to code duplication. Moreover, typeshed has
1010
multiple other mechanisms for spotting errors in the stubs.
1111

12+
### Where are the third-party test cases?
13+
14+
Not all third-party stubs packages in typeshed have test cases, and not all of
15+
them need test cases. For those that do have test cases, however, the samples
16+
can be found in `@tests/test_cases` subdirectories for each stubs package. For
17+
example, the test cases for `requests` can be found in the
18+
`stubs/requests/@tests/test_cases` directory.
19+
1220
### The purpose of these tests
1321

1422
Different test cases in this directory serve different purposes. For some stubs in

tests/README.md

+13-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ tests the stubs with [mypy](https://github.com/python/mypy/)
55
[pytype](https://github.com/google/pytype/).
66
- `tests/pyright_test.py` tests the stubs with
77
[pyright](https://github.com/microsoft/pyright).
8+
- `tests/regr_test.py` runs mypy against the test cases for typeshed's
9+
stubs, guarding against accidental regressions.
810
- `tests/check_consistent.py` checks certain files in typeshed remain
911
consistent with each other.
1012
- `tests/stubtest_stdlib.py` checks standard library stubs against the
@@ -24,17 +26,15 @@ Run using:
2426
(.venv3)$ python3 tests/mypy_test.py
2527
```
2628

27-
The test has three parts. Each part uses mypy with slightly different configuration options:
28-
- Running mypy on the stdlib stubs
29-
- Running mypy on the third-party stubs
30-
- Running mypy `--strict` on the regression tests in the `test_cases` directory.
29+
The test has two parts: running mypy on the stdlib stubs,
30+
and running mypy on the third-party stubs.
3131

32-
When running mypy on the stubs, this test is shallow — it verifies that all stubs can be
32+
This test is shallow — it verifies that all stubs can be
3333
imported but doesn't check whether stubs match their implementation
3434
(in the Python standard library or a third-party package).
3535

3636
Run `python tests/mypy_test.py --help` for information on the various configuration options
37-
for this test script.
37+
for this script.
3838

3939
## pytype\_test.py
4040

@@ -64,6 +64,13 @@ checks that would typically fail on incomplete stubs (such as `Unknown` checks).
6464
In typeshed's CI, pyright is run with these configuration settings on a subset of
6565
the stubs in typeshed (including the standard library).
6666

67+
## regr\_test.py
68+
69+
This test runs mypy against the test cases for typeshed's stdlib and third-party
70+
stubs. See the README in the `test_cases` directory for more information about what
71+
these test cases are for and how they work. Run `python tests/regr_test.py --help`
72+
for information on the various configuration options.
73+
6774
## check\_consistent.py
6875

6976
Run using:

tests/check_consistent.py

+16-4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from packaging.requirements import Requirement
1616
from packaging.specifiers import SpecifierSet
1717
from packaging.version import Version
18+
from utils import get_all_testcase_directories
1819

1920
metadata_keys = {"version", "requires", "extra_description", "obsolete_since", "no_longer_updated", "tool"}
2021
tool_keys = {"stubtest": {"skip", "apt_dependencies", "extras", "ignore_missing_stub"}}
@@ -58,10 +59,21 @@ def check_stubs() -> None:
5859

5960

6061
def check_test_cases() -> None:
61-
assert_consistent_filetypes(Path("test_cases"), kind=".py", allowed={"README.md"})
62-
bad_test_case_filename = 'Files in the `test_cases` directory must have names starting with "check_"; got "{}"'
63-
for file in Path("test_cases").rglob("*.py"):
64-
assert file.stem.startswith("check_"), bad_test_case_filename.format(file)
62+
for package_name, testcase_dir in get_all_testcase_directories():
63+
assert_consistent_filetypes(testcase_dir, kind=".py", allowed={"README.md"})
64+
bad_test_case_filename = 'Files in a `test_cases` directory must have names starting with "check_"; got "{}"'
65+
for file in testcase_dir.rglob("*.py"):
66+
assert file.stem.startswith("check_"), bad_test_case_filename.format(file)
67+
if package_name != "stdlib":
68+
with open(file) as f:
69+
lines = [line.strip() for line in f]
70+
pyright_setting_not_enabled_msg = (
71+
f'Third-party test-case file "{file}" must have '
72+
f'"# pyright: reportUnnecessaryTypeIgnoreComment=true" '
73+
f"at the top of the file"
74+
)
75+
has_pyright_setting_enabled = any(line == "# pyright: reportUnnecessaryTypeIgnoreComment=true" for line in lines)
76+
assert has_pyright_setting_enabled, pyright_setting_not_enabled_msg
6577

6678

6779
def check_no_symlinks() -> None:

tests/colors.py

-31
This file was deleted.

tests/mypy_test.py

+16-67
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
#!/usr/bin/env python3
2-
"""Run mypy on various typeshed directories, with varying command-line arguments.
2+
"""Run mypy on typeshed's stdlib and third-party stubs."""
33

4-
Depends on mypy being installed.
5-
"""
64
from __future__ import annotations
75

86
import argparse
97
import os
108
import re
11-
import shutil
12-
import subprocess
139
import sys
1410
import tempfile
15-
from collections.abc import Iterable
1611
from contextlib import redirect_stderr, redirect_stdout
1712
from dataclasses import dataclass
1813
from io import StringIO
@@ -26,11 +21,11 @@
2621
from typing_extensions import Annotated, TypeAlias
2722

2823
import tomli
29-
from colors import colored, print_error, print_success_msg
24+
from utils import colored, print_error, print_success_msg, read_dependencies
3025

3126
SUPPORTED_VERSIONS = [(3, 11), (3, 10), (3, 9), (3, 8), (3, 7)]
3227
SUPPORTED_PLATFORMS = ("linux", "win32", "darwin")
33-
TYPESHED_DIRECTORIES = frozenset({"stdlib", "stubs", "test_cases"})
28+
TYPESHED_DIRECTORIES = frozenset({"stdlib", "stubs"})
3429

3530
ReturnCode: TypeAlias = int
3631
MajorVersion: TypeAlias = int
@@ -58,7 +53,9 @@ class CommandLineArgs(argparse.Namespace):
5853
filter: list[str]
5954

6055

61-
parser = argparse.ArgumentParser(description="Test runner for typeshed. Patterns are unanchored regexps on the full path.")
56+
parser = argparse.ArgumentParser(
57+
description="Typecheck typeshed's stubs with mypy. Patterns are unanchored regexps on the full path."
58+
)
6259
parser.add_argument("-v", "--verbose", action="count", default=0, help="More output")
6360
parser.add_argument("-x", "--exclude", type=str, nargs="*", help="Exclude pattern")
6461
parser.add_argument(
@@ -239,20 +236,8 @@ def run_mypy(args: TestConfig, configurations: list[MypyDistConf], files: list[P
239236
return exit_code
240237

241238

242-
def run_mypy_as_subprocess(directory: StrPath, flags: Iterable[str]) -> ReturnCode:
243-
result = subprocess.run([sys.executable, "-m", "mypy", directory, *flags], capture_output=True)
244-
stdout, stderr = result.stdout, result.stderr
245-
if stderr:
246-
print_error(stderr.decode())
247-
if stdout:
248-
print_error(stdout.decode())
249-
return result.returncode
250-
251-
252-
def get_mypy_flags(
253-
args: TestConfig, temp_name: str | None, *, strict: bool = False, enforce_error_codes: bool = True
254-
) -> list[str]:
255-
flags = [
239+
def get_mypy_flags(args: TestConfig, temp_name: str) -> list[str]:
240+
return [
256241
"--python-version",
257242
f"{args.major}.{args.minor}",
258243
"--show-traceback",
@@ -264,29 +249,15 @@ def get_mypy_flags(
264249
"--no-site-packages",
265250
"--custom-typeshed-dir",
266251
str(Path(__file__).parent.parent),
252+
"--no-implicit-optional",
253+
"--disallow-untyped-decorators",
254+
"--disallow-any-generics",
255+
"--strict-equality",
256+
"--enable-error-code",
257+
"ignore-without-code",
258+
"--config-file",
259+
temp_name,
267260
]
268-
if strict:
269-
flags.append("--strict")
270-
else:
271-
flags.extend(["--no-implicit-optional", "--disallow-untyped-decorators", "--disallow-any-generics", "--strict-equality"])
272-
if temp_name is not None:
273-
flags.extend(["--config-file", temp_name])
274-
if enforce_error_codes:
275-
flags.extend(["--enable-error-code", "ignore-without-code"])
276-
return flags
277-
278-
279-
def read_dependencies(distribution: str) -> list[str]:
280-
with Path("stubs", distribution, "METADATA.toml").open("rb") as f:
281-
data = tomli.load(f)
282-
requires = data.get("requires", [])
283-
assert isinstance(requires, list)
284-
dependencies = []
285-
for dependency in requires:
286-
assert isinstance(dependency, str)
287-
assert dependency.startswith("types-")
288-
dependencies.append(dependency[6:].split("<")[0])
289-
return dependencies
290261

291262

292263
def add_third_party_files(
@@ -382,23 +353,6 @@ def test_third_party_stubs(code: int, args: TestConfig) -> TestResults:
382353
return TestResults(code, files_checked)
383354

384355

385-
def test_the_test_cases(code: int, args: TestConfig) -> TestResults:
386-
test_case_files = list(map(str, Path("test_cases").rglob("*.py")))
387-
num_test_case_files = len(test_case_files)
388-
flags = get_mypy_flags(args, None, strict=True, enforce_error_codes=False)
389-
print(f"Running mypy on the test_cases directory ({num_test_case_files} files)...")
390-
print("Running mypy " + " ".join(flags))
391-
# --warn-unused-ignores doesn't work for files inside typeshed.
392-
# SO, to work around this, we copy the test_cases directory into a TemporaryDirectory.
393-
with tempfile.TemporaryDirectory() as td:
394-
shutil.copytree(Path("test_cases"), Path(td) / "test_cases")
395-
this_code = run_mypy_as_subprocess(td, flags)
396-
if not this_code:
397-
print_success_msg()
398-
code = max(code, this_code)
399-
return TestResults(code, num_test_case_files)
400-
401-
402356
def test_typeshed(code: int, args: TestConfig) -> TestResults:
403357
print(f"*** Testing Python {args.major}.{args.minor} on {args.platform}")
404358
files_checked_this_version = 0
@@ -412,11 +366,6 @@ def test_typeshed(code: int, args: TestConfig) -> TestResults:
412366
files_checked_this_version += third_party_files_checked
413367
print()
414368

415-
if "test_cases" in args.directories:
416-
code, test_case_files_checked = test_the_test_cases(code, args)
417-
files_checked_this_version += test_case_files_checked
418-
print()
419-
420369
return TestResults(code, files_checked_this_version)
421370

422371

0 commit comments

Comments
 (0)