Files
typeshed/tests/check_consistent.py
2023-02-07 11:53:54 +00:00

184 lines
7.7 KiB
Python

#!/usr/bin/env python3
# For security (and simplicity) reasons, only a limited kind of files can be
# present in /stdlib and /stubs directories, see README for detail. Here we
# verify these constraints.
from __future__ import annotations
import os
import re
import sys
import urllib.parse
from pathlib import Path
import yaml
from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
from parse_metadata import read_metadata
from utils import VERSIONS_RE, get_all_testcase_directories, get_gitignore_spec, spec_matches_path, strip_comments
extension_descriptions = {".pyi": "stub", ".py": ".py"}
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:
assert_consistent_filetypes(Path("stdlib"), kind=".pyi", allowed={"_typeshed/README.md", "VERSIONS"})
def check_stubs() -> None:
gitignore_spec = get_gitignore_spec()
for dist in Path("stubs").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"}
assert_consistent_filetypes(dist, kind=".pyi", allowed=allowed)
tests_dir = dist / "@tests"
if tests_dir.exists() and tests_dir.is_dir():
py_files_present = any(file.suffix == ".py" for file in tests_dir.iterdir())
error_message = "Test-case files must be in an `@tests/test_cases/` directory, not in the `@tests/` directory"
assert not py_files_present, error_message
def check_test_cases() -> None:
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 = 'Files in a `test_cases` 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)
with open(file, encoding="UTF-8") as f:
lines = {line.strip() for line in f}
assert "from __future__ import annotations" in lines, "Test-case files should use modern typing syntax where possible"
def check_no_symlinks() -> None:
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() -> None:
versions = set()
with open("stdlib/VERSIONS", encoding="UTF-8") as f:
data = f.read().splitlines()
for line in data:
line = strip_comments(line)
if line == "":
continue
m = VERSIONS_RE.match(line)
if not m:
raise AssertionError(f"Bad line in VERSIONS: {line}")
module = m.group(1)
assert module not in versions, f"Duplicate module {module} in VERSIONS"
versions.add(module)
modules = _find_stdlib_modules()
# Sub-modules don't need to be listed in VERSIONS.
extra = {m.split(".")[0] for m in modules} - versions
assert not extra, f"Modules not in versions: {extra}"
extra = versions - modules
assert not extra, f"Versions not in modules: {extra}"
def _find_stdlib_modules() -> set[str]:
modules = set()
for path, _, files in os.walk("stdlib"):
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:
for distribution in os.listdir("stubs"):
# This function does various sanity checks for METADATA.toml files
read_metadata(distribution)
def get_txt_requirements() -> dict[str, SpecifierSet]:
with open("requirements-tests.txt", encoding="UTF-8") as requirements_file:
stripped_lines = map(strip_comments, requirements_file)
requirements = map(Requirement, filter(None, stripped_lines))
return {requirement.name: requirement.specifier for requirement in requirements}
def get_precommit_requirements() -> dict[str, SpecifierSet]:
with open(".pre-commit-config.yaml", encoding="UTF-8") as precommit_file:
precommit = precommit_file.read()
yam = yaml.load(precommit, Loader=yaml.Loader)
precommit_requirements = {}
for repo in yam["repos"]:
if not repo.get("python_requirement", True):
continue
hook = repo["hooks"][0]
package_name = Path(urllib.parse.urlparse(repo["repo"]).path).name
package_rev = repo["rev"].removeprefix("v")
package_specifier = SpecifierSet(f"=={package_rev}")
precommit_requirements[package_name] = package_specifier
for additional_req in hook.get("additional_dependencies", []):
req = Requirement(additional_req)
precommit_requirements[req.name] = req.specifier
return precommit_requirements
def check_requirements() -> None:
requirements_txt_requirements = get_txt_requirements()
precommit_requirements = get_precommit_requirements()
no_txt_entry_msg = "All pre-commit requirements must also be listed in `requirements-tests.txt` (missing {requirement!r})"
for requirement, specifier in precommit_requirements.items():
assert requirement in requirements_txt_requirements, no_txt_entry_msg.format(requirement=requirement)
specifier_mismatch = (
f'Specifier "{specifier}" for {requirement!r} in `.pre-commit-config.yaml` '
f'does not match specifier "{requirements_txt_requirements[requirement]}" in `requirements-tests.txt`'
)
assert specifier == requirements_txt_requirements[requirement], specifier_mismatch
if __name__ == "__main__":
assert sys.version_info >= (3, 9), "Python 3.9+ is required to run this test"
check_stdlib()
check_versions()
check_stubs()
check_metadata()
check_no_symlinks()
check_test_cases()
check_requirements()