Skip to content

Commit 85ffb50

Browse files
authored
Refactor/unify stub discovery (#16021)
* Add a StubFile class with sub-classes StdlibStubFile and ThirdPartyStubFile to describe stubs files. * Add a stdlib_stubs function to collect the stdlib stub files for a specific Python version. * Add a third_party_stubs function to collect all third-party stub files or the stub files of a specific distribution. * Add path_stubs to collect all stub files in a certain path. * Use the new functions in mypy_test.py, ty_test.py, and stubsabot.py.
1 parent 7dee8ae commit 85ffb50

4 files changed

Lines changed: 110 additions & 90 deletions

File tree

lib/ts_utils/stubs.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Stub file discovery."""
2+
3+
from functools import cached_property
4+
from pathlib import Path
5+
6+
from ts_utils.paths import STDLIB_PATH, STUBS_PATH, TESTS_DIR, distribution_path
7+
from ts_utils.utils import parse_stdlib_versions_file
8+
9+
10+
class StubFile:
11+
"""Base class for stub files."""
12+
13+
def __init__(self, path: Path) -> None:
14+
self.path = path
15+
16+
def __fspath__(self) -> str:
17+
return self.path.__fspath__()
18+
19+
def __str__(self) -> str:
20+
return str(self.path)
21+
22+
@cached_property
23+
def module_name(self) -> str:
24+
return ".".join(self.module_parts)
25+
26+
@cached_property
27+
def module_parts(self) -> tuple[str, ...]:
28+
raise NotImplementedError
29+
30+
31+
class StdlibStubFile(StubFile):
32+
"""A stdlib stub file."""
33+
34+
@cached_property
35+
def module_parts(self) -> tuple[str, ...]:
36+
relative = self.path.relative_to(STDLIB_PATH)
37+
parts = list(relative.parts[:-1])
38+
if relative.name != "__init__.pyi":
39+
parts.append(relative.stem)
40+
return tuple(parts)
41+
42+
43+
class ThirdPartyStubFile(StubFile):
44+
"""A third-party stub file."""
45+
46+
@cached_property
47+
def upstream_distribution(self) -> str:
48+
return self.path.relative_to(STUBS_PATH).parts[0]
49+
50+
@cached_property
51+
def module_parts(self) -> tuple[str, ...]:
52+
relative = self.path.relative_to(STUBS_PATH)
53+
parts = list(relative.parts[1:-1])
54+
if relative.name != "__init__.pyi":
55+
parts.append(relative.stem)
56+
return tuple(parts)
57+
58+
59+
def stdlib_stubs(version: str) -> list[StdlibStubFile]:
60+
"""Return the stdlib stubs available for the requested Python version."""
61+
module_versions = parse_stdlib_versions_file()
62+
stubs = (StdlibStubFile(path) for path in path_stubs(STDLIB_PATH))
63+
return [stub for stub in stubs if module_versions.is_supported(stub.module_name, version)]
64+
65+
66+
def third_party_stubs(distribution: str | None = None) -> list[ThirdPartyStubFile]:
67+
"""Return third-party stubs.
68+
69+
If distribution is None, return all third-party stubs. Otherwise,
70+
return only stubs for the given distribution.
71+
"""
72+
stub_path = distribution_path(distribution) if distribution else STUBS_PATH
73+
return [ThirdPartyStubFile(path) for path in path_stubs(stub_path)]
74+
75+
76+
def path_stubs(path: Path) -> list[Path]:
77+
"""Return paths to all stub files in a certain path."""
78+
if path.is_file():
79+
return [path] if path.suffix == ".pyi" and TESTS_DIR not in path.parts else []
80+
return sorted(p for p in path.rglob("*.pyi") if TESTS_DIR not in p.parts)

scripts/stubsabot.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
from ts_utils.metadata import ObsoleteMetadata, StubMetadata, read_metadata, update_metadata
4040
from ts_utils.paths import PYRIGHT_CONFIG, STUBS_PATH, distribution_path
41+
from ts_utils.stubs import third_party_stubs
4142

4243
TYPESHED_OWNER = "python"
4344
TYPESHED_API_URL = f"https://api.github.com/repos/{TYPESHED_OWNER}/typeshed"
@@ -534,7 +535,7 @@ async def analyze_github_diff(
534535
# https://docs.github.com/en/rest/commits/commits#compare-two-commits
535536
py_files: list[FileInfo] = [file for file in json_resp["files"] if Path(file["filename"]).suffix == ".py"]
536537
stub_path = distribution_path(distribution)
537-
files_in_typeshed = set(stub_path.rglob("*.pyi"))
538+
files_in_typeshed = {stub.path for stub in third_party_stubs(distribution)}
538539
py_files_stubbed_in_typeshed = [file for file in py_files if (stub_path / f"{file['filename']}i") in files_in_typeshed]
539540
return DiffAnalysis(py_files=py_files, py_files_stubbed_in_typeshed=py_files_stubbed_in_typeshed)
540541

@@ -570,7 +571,7 @@ async def analyze_gitlab_diff(
570571
py_files.append(FileInfo(filename=filename, status=status, additions=additions, deletions=deletions))
571572

572573
stub_path = distribution_path(distribution)
573-
files_in_typeshed = set(stub_path.rglob("*.pyi"))
574+
files_in_typeshed = {stub.path for stub in third_party_stubs(distribution)}
574575
py_files_stubbed_in_typeshed = [file for file in py_files if (stub_path / f"{file['filename']}i") in files_in_typeshed]
575576
return DiffAnalysis(py_files=py_files, py_files_stubbed_in_typeshed=py_files_stubbed_in_typeshed)
576577

tests/mypy_test.py

Lines changed: 18 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222

2323
from ts_utils.metadata import PackageDependencies, get_recursive_requirements, read_metadata
2424
from ts_utils.mypy import MypyDistConf, mypy_configuration_from_distribution, temporary_mypy_config_file
25-
from ts_utils.paths import STDLIB_PATH, STUBS_PATH, TESTS_DIR, TS_BASE_PATH, distribution_path
25+
from ts_utils.paths import STDLIB_PATH, STUBS_PATH, TS_BASE_PATH, distribution_path
2626
from ts_utils.py315 import PY315_INCOMPATIBLE_RUNTIME_DEPENDENCIES
27+
from ts_utils.stubs import StubFile, stdlib_stubs, third_party_stubs
2728
from ts_utils.utils import (
2829
PYTHON_VERSION,
2930
colored,
3031
get_gitignore_spec,
3132
get_mypy_req,
32-
parse_stdlib_versions_file,
3333
print_error,
3434
print_success_msg,
3535
spec_matches_path,
@@ -125,39 +125,28 @@ def log(args: TestConfig, *varargs: object) -> None:
125125
print(colored(" ".join(map(str, varargs)), "blue"))
126126

127127

128-
def match(path: Path, args: TestConfig) -> bool:
128+
def match(stub: StubFile, args: TestConfig) -> bool:
129129
for excluded_path in args.exclude:
130-
if path == excluded_path:
131-
log(args, path, "explicitly excluded")
130+
if stub.path == excluded_path:
131+
log(args, stub, "explicitly excluded")
132132
return False
133-
if excluded_path in path.parents:
134-
log(args, path, f'is in an explicitly excluded directory "{excluded_path}"')
133+
if excluded_path in stub.path.parents:
134+
log(args, stub, f'is in an explicitly excluded directory "{excluded_path}"')
135135
return False
136136
for included_path in args.filter:
137-
if path == included_path:
138-
log(args, path, "was explicitly included")
137+
if stub.path == included_path:
138+
log(args, stub, "was explicitly included")
139139
return True
140-
if included_path in path.parents:
141-
log(args, path, f'is in an explicitly included directory "{included_path}"')
140+
if included_path in stub.path.parents:
141+
log(args, stub, f'is in an explicitly included directory "{included_path}"')
142142
return True
143143
log_msg = (
144144
f'is implicitly excluded: was not in any of the directories or paths specified on the command line: "{args.filter!r}"'
145145
)
146-
log(args, path, log_msg)
146+
log(args, stub, log_msg)
147147
return False
148148

149149

150-
def add_files(files: list[Path], module: Path, args: TestConfig) -> None:
151-
"""Add all files in package or module represented by 'name' located in 'root'."""
152-
if module.name.startswith("."):
153-
return
154-
if module.is_file() and module.suffix == ".pyi":
155-
if match(module, args):
156-
files.append(module)
157-
else:
158-
files.extend(sorted(file for file in module.rglob("*.pyi") if match(file, args)))
159-
160-
161150
class MypyResult(Enum):
162151
SUCCESS = 0
163152
FAILURE = 1
@@ -238,15 +227,14 @@ def run_mypy(
238227
return MypyResult.from_process_result(result)
239228

240229

241-
def add_third_party_files(distribution: str, files: list[Path], args: TestConfig, seen_dists: set[str]) -> None:
230+
def distribution_stub_files(distribution: str, args: TestConfig, seen_dists: set[str]) -> list[Path]:
242231
typeshed_reqs = get_recursive_requirements(distribution).typeshed_pkgs
243232
if distribution in seen_dists:
244-
return
233+
return []
245234
seen_dists.add(distribution)
246235
seen_dists.update(r.name for r in typeshed_reqs)
247-
root = distribution_path(distribution)
248-
for path in root.iterdir():
249-
add_files(files, path, args)
236+
237+
return [stub.path for stub in third_party_stubs(distribution) if match(stub, args)]
250238

251239

252240
class TestResult(NamedTuple):
@@ -262,9 +250,8 @@ def test_third_party_distribution(
262250
Return a tuple, where the first element indicates mypy's return code
263251
and the second element is the number of checked files.
264252
"""
265-
files: list[Path] = []
266253
seen_dists: set[str] = set()
267-
add_third_party_files(distribution, files, args, seen_dists)
254+
files = distribution_stub_files(distribution, args, seen_dists)
268255
configurations = mypy_configuration_from_distribution(distribution)
269256

270257
if not files and args.filter:
@@ -292,13 +279,7 @@ def test_third_party_distribution(
292279

293280

294281
def test_stdlib(args: TestConfig) -> TestResult:
295-
files: list[Path] = []
296-
for file in STDLIB_PATH.iterdir():
297-
if file.name in ("VERSIONS", TESTS_DIR):
298-
continue
299-
add_files(files, file, args)
300-
301-
files = remove_modules_not_in_python_version(files, args.version)
282+
files = [stub.path for stub in stdlib_stubs(args.version) if match(stub, args)]
302283

303284
if not files:
304285
return TestResult(MypyResult.SUCCESS, 0)
@@ -309,27 +290,6 @@ def test_stdlib(args: TestConfig) -> TestResult:
309290
return TestResult(result, len(files))
310291

311292

312-
def remove_modules_not_in_python_version(paths: list[Path], py_version: VersionString) -> list[Path]:
313-
module_versions = parse_stdlib_versions_file()
314-
new_paths: list[Path] = []
315-
for path in paths:
316-
if path.parts[0] != "stdlib" or path.suffix != ".pyi":
317-
continue
318-
module_name = stdlib_module_name_from_path(path)
319-
if module_versions.is_supported(module_name, py_version):
320-
new_paths.append(path)
321-
return new_paths
322-
323-
324-
def stdlib_module_name_from_path(path: Path) -> str:
325-
assert path.parts[0] == "stdlib"
326-
assert path.suffix == ".pyi"
327-
parts = list(path.parts[1:-1])
328-
if path.parts[-1] != "__init__.pyi":
329-
parts.append(path.parts[-1].removesuffix(".pyi"))
330-
return ".".join(parts)
331-
332-
333293
@dataclass
334294
class TestSummary:
335295
mypy_result: MypyResult = MypyResult.SUCCESS

tests/ty_test.py

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pathlib import Path
1010

1111
from ts_utils.paths import STDLIB_PATH, STUBS_PATH, TS_BASE_PATH
12-
from ts_utils.utils import parse_stdlib_versions_file
12+
from ts_utils.stubs import path_stubs, stdlib_stubs, third_party_stubs
1313

1414
SUPPORTED_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14", "3.15")
1515
SUPPORTED_PLATFORMS = ("linux", "darwin", "win32")
@@ -19,34 +19,17 @@
1919

2020
def stdlib_files(version: str) -> list[Path]:
2121
"""Return the stdlib stubs available in the requested Python version."""
22-
module_versions = parse_stdlib_versions_file()
23-
files: list[Path] = []
22+
# ty cannot resolve relative imports in the legacy distutils stubs.
23+
return [stub.path for stub in stdlib_stubs(version) if stub.module_parts[0] != "distutils"]
2424

25-
for path in sorted(STDLIB_PATH.rglob("*.pyi")):
26-
if "@tests" in path.parts:
27-
continue
28-
relative = path.relative_to(STDLIB_PATH)
29-
# ty cannot resolve relative imports in the legacy distutils stubs.
30-
if relative.parts[0] == "distutils":
31-
continue
32-
parts = list(relative.parts[:-1])
33-
if relative.name != "__init__.pyi":
34-
parts.append(relative.stem)
35-
if module_versions.is_supported(".".join(parts), version):
36-
files.append(path)
3725

38-
return files
39-
40-
41-
def _path_files(path: Path) -> list[Path]:
42-
if path.is_file():
43-
return [path] if path.suffix == ".pyi" and "@tests" not in path.parts else []
44-
return sorted(p for p in path.rglob("*.pyi") if "@tests" not in p.parts)
26+
def third_party_files() -> list[Path]:
27+
return [stub.path for stub in third_party_stubs() if stub.upstream_distribution not in EXCLUDED_STUBS]
4528

4629

4730
def _filter_files(files: list[Path], paths: list[Path], root: Path) -> list[Path]:
4831
selected_paths = [path if path.is_absolute() else TS_BASE_PATH / path for path in paths]
49-
selected_files = {file for path in selected_paths if path.is_relative_to(root) for file in _path_files(path)}
32+
selected_files = {file for path in selected_paths if path.is_relative_to(root) for file in path_stubs(path)}
5033
return [file for file in files if file in selected_files]
5134

5235

@@ -58,16 +41,12 @@ def main() -> int:
5841
parser.add_argument("--platform", choices=SUPPORTED_PLATFORMS, default="linux")
5942
args = parser.parse_args()
6043

61-
third_party_files = sorted(
62-
path
63-
for path in STUBS_PATH.rglob("*.pyi")
64-
if "@tests" not in path.parts and path.relative_to(STUBS_PATH).parts[0] not in EXCLUDED_STUBS
65-
)
6644
stdlib = stdlib_files(args.python_version)
45+
third_party = third_party_files()
6746
if args.paths:
6847
stdlib = _filter_files(stdlib, args.paths, STDLIB_PATH)
69-
third_party_files = _filter_files(third_party_files, args.paths, STUBS_PATH)
70-
files = [*stdlib, *third_party_files]
48+
third_party = _filter_files(third_party, args.paths, STUBS_PATH)
49+
files = [*stdlib, *third_party]
7150
if not files:
7251
print("No stubs to check with ty.", flush=True)
7352
return 0

0 commit comments

Comments
 (0)