-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
/
Copy pathcheck_typeshed_structure.py
executable file
·181 lines (144 loc) · 7.48 KB
/
check_typeshed_structure.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
#!/usr/bin/env python3
"""
Check that the typeshed repository contains the correct files in the
correct places, and that various configuration files are correct.
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
from ts_utils.metadata import read_metadata
from ts_utils.paths import REQUIREMENTS_PATH, STDLIB_PATH, STUBS_PATH, TEST_CASES_DIR, TESTS_DIR, tests_path
from ts_utils.utils import (
get_all_testcase_directories,
get_gitignore_spec,
parse_requirements,
parse_stdlib_versions_file,
spec_matches_path,
)
extension_descriptions = {".pyi": "stub", ".py": ".py"}
# These type checkers and linters must have exact versions in the requirements file to ensure
# consistent CI runs.
linters = {"mypy", "pyright", "pytype", "ruff"}
def assert_consistent_filetypes(
directory: Path, *, kind: str, allowed: set[str], allow_nonidentifier_filenames: bool = False
) -> None:
"""Check that given directory contains only valid Python files of a certain kind."""
allowed_paths = {Path(f) for f in allowed}
contents = list(directory.iterdir())
gitignore_spec = get_gitignore_spec()
while contents:
entry = contents.pop()
if spec_matches_path(gitignore_spec, entry):
continue
if entry.relative_to(directory) in allowed_paths:
# Note if a subdirectory is allowed, we will not check its contents
continue
if entry.is_file():
if not allow_nonidentifier_filenames:
assert entry.stem.isidentifier(), f'Files must be valid modules, got: "{entry}"'
bad_filetype = f'Only {extension_descriptions[kind]!r} files allowed in the "{directory}" directory; got: {entry}'
assert entry.suffix == kind, bad_filetype
else:
assert entry.name.isidentifier(), f"Directories must be valid packages, got: {entry}"
contents.extend(entry.iterdir())
def check_stdlib() -> None:
"""Check that the stdlib directory contains only the correct files."""
assert_consistent_filetypes(STDLIB_PATH, kind=".pyi", allowed={"_typeshed/README.md", "VERSIONS", TESTS_DIR})
check_tests_dir(tests_path("stdlib"))
def check_stubs() -> None:
"""Check that the stubs directory contains only the correct files."""
gitignore_spec = get_gitignore_spec()
for dist in STUBS_PATH.iterdir():
if spec_matches_path(gitignore_spec, dist):
continue
assert dist.is_dir(), f"Only directories allowed in stubs, got {dist}"
valid_dist_name = "^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$" # courtesy of PEP 426
assert re.fullmatch(
valid_dist_name, dist.name, re.IGNORECASE
), f"Directory name must be a valid distribution name: {dist}"
assert not dist.name.startswith("types-"), f"Directory name not allowed to start with 'types-': {dist}"
allowed = {"METADATA.toml", "README", "README.md", "README.rst", TESTS_DIR}
assert_consistent_filetypes(dist, kind=".pyi", allowed=allowed)
tests_dir = tests_path(dist.name)
if tests_dir.exists() and tests_dir.is_dir():
check_tests_dir(tests_dir)
def check_tests_dir(tests_dir: Path) -> None:
py_files_present = any(file.suffix == ".py" for file in tests_dir.iterdir())
error_message = f"Test-case files must be in an `{TESTS_DIR}/{TEST_CASES_DIR}` directory, not in the `{TESTS_DIR}` directory"
assert not py_files_present, error_message
def check_distutils() -> None:
"""Check whether all setuptools._distutils files are re-exported from distutils."""
def all_relative_paths_in_directory(path: Path) -> set[Path]:
return {pyi.relative_to(path) for pyi in path.rglob("*.pyi")}
setuptools_path = STUBS_PATH / "setuptools" / "setuptools" / "_distutils"
distutils_path = STUBS_PATH / "setuptools" / "distutils"
all_setuptools_files = all_relative_paths_in_directory(setuptools_path)
all_distutils_files = all_relative_paths_in_directory(distutils_path)
assert all_setuptools_files and all_distutils_files, "Looks like this test might be out of date!"
extra_files = all_setuptools_files - all_distutils_files
joined = "\n".join(f" * {distutils_path / f}" for f in extra_files)
assert not extra_files, f"Files missing from distutils:\n{joined}"
def check_test_cases() -> None:
"""Check that the test_cases directory contains only the correct files."""
for _, testcase_dir in get_all_testcase_directories():
assert_consistent_filetypes(testcase_dir, kind=".py", allowed={"README.md"}, allow_nonidentifier_filenames=True)
bad_test_case_filename = f'Files in a `{TEST_CASES_DIR}` directory must have names starting with "check_"; got "{{}}"'
for file in testcase_dir.rglob("*.py"):
assert file.stem.startswith("check_"), bad_test_case_filename.format(file)
def check_no_symlinks() -> None:
"""Check that there are no symlinks in the typeshed repository."""
files = [os.path.join(root, file) for root, _, files in os.walk(".") for file in files]
no_symlink = "You cannot use symlinks in typeshed, please copy {} to its link."
for file in files:
_, ext = os.path.splitext(file)
if ext == ".pyi" and os.path.islink(file):
raise ValueError(no_symlink.format(file))
def check_versions_file() -> None:
"""Check that the stdlib/VERSIONS file has the correct format."""
version_map = parse_stdlib_versions_file()
versions = list(version_map.keys())
sorted_versions = sorted(versions)
assert versions == sorted_versions, f"{versions=}\n\n{sorted_versions=}"
modules = _find_stdlib_modules()
# Sub-modules don't need to be listed in VERSIONS.
extra = {m.split(".")[0] for m in modules} - version_map.keys()
assert not extra, f"Modules not in versions: {extra}"
extra = version_map.keys() - modules
assert not extra, f"Versions not in modules: {extra}"
def _find_stdlib_modules() -> set[str]:
modules = set[str]()
for path, _, files in os.walk(STDLIB_PATH):
for filename in files:
base_module = ".".join(os.path.normpath(path).split(os.sep)[1:])
if filename == "__init__.pyi":
modules.add(base_module)
elif filename.endswith(".pyi"):
mod, _ = os.path.splitext(filename)
modules.add(f"{base_module}.{mod}" if base_module else mod)
return modules
def check_metadata() -> None:
"""Check that all METADATA.toml files are valid."""
for distribution in os.listdir("stubs"):
# This function does various sanity checks for METADATA.toml files
read_metadata(distribution)
def check_requirement_pins() -> None:
"""Check that type checkers and linters are pinned to an exact version."""
requirements = parse_requirements()
for package in linters:
assert package in requirements, f"type checker/linter '{package}' not found in {REQUIREMENTS_PATH.name}"
spec = requirements[package].specifier
assert len(spec) == 1, f"type checker/linter '{package}' has complex specifier in {REQUIREMENTS_PATH.name}"
msg = f"type checker/linter '{package}' is not pinned to an exact version in {REQUIREMENTS_PATH.name}"
assert str(spec).startswith("=="), msg
if __name__ == "__main__":
assert sys.version_info >= (3, 9), "Python 3.9+ is required to run this test"
check_versions_file()
check_metadata()
check_requirement_pins()
check_no_symlinks()
check_stdlib()
check_stubs()
check_distutils()
check_test_cases()