mirror of
https://github.com/davidhalter/typeshed.git
synced 2026-03-16 03:24:54 +08:00
Typecheck typeshed's code with pyright (#9793)
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com> Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
This commit is contained in:
28
.github/workflows/typecheck_typeshed_code.yml
vendored
28
.github/workflows/typecheck_typeshed_code.yml
vendored
@@ -40,3 +40,31 @@ jobs:
|
||||
cache-dependency-path: requirements-tests.txt
|
||||
- run: pip install -r requirements-tests.txt
|
||||
- run: python ./tests/typecheck_typeshed.py --platform=${{ matrix.platform }}
|
||||
pyright:
|
||||
name: Run pyright against the scripts and tests directories
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-platform: ["Linux", "Windows"]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.9"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-tests.txt
|
||||
- run: pip install -r requirements-tests.txt
|
||||
- name: Get pyright version
|
||||
uses: SebRollen/toml-action@v1.0.2
|
||||
id: pyright_version
|
||||
with:
|
||||
file: "pyproject.toml"
|
||||
field: "tool.typeshed.pyright_version"
|
||||
- name: Run pyright on typeshed
|
||||
uses: jakebailey/pyright-action@v1
|
||||
with:
|
||||
version: ${{ steps.pyright_version.outputs.value }}
|
||||
python-platform: ${{ matrix.python-platform }}
|
||||
python-version: "3.9"
|
||||
project: ./pyrightconfig.scripts_and_tests.json
|
||||
|
||||
24
pyrightconfig.scripts_and_tests.json
Normal file
24
pyrightconfig.scripts_and_tests.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/microsoft/pyright/main/packages/vscode-pyright/schemas/pyrightconfig.schema.json",
|
||||
"typeshedPath": ".",
|
||||
"include": [
|
||||
"scripts",
|
||||
"tests",
|
||||
],
|
||||
"typeCheckingMode": "strict",
|
||||
// Runtime libraries used by typeshed are not all py.typed
|
||||
"useLibraryCodeForTypes": true,
|
||||
// More of a lint. Unwanted for typeshed's own code.
|
||||
"reportImplicitStringConcatenation": "none",
|
||||
// Extra strict settings
|
||||
"reportMissingModuleSource": "error",
|
||||
"reportShadowedImports": "error",
|
||||
"reportCallInDefaultInitializer": "error",
|
||||
"reportPropertyTypeMismatch": "error",
|
||||
"reportUninitializedInstanceVariable": "error",
|
||||
"reportUnnecessaryTypeIgnoreComment": "error",
|
||||
// Leave "type: ignore" comments to mypy
|
||||
"enableTypeIgnoreComments": false,
|
||||
// Too strict
|
||||
"reportMissingSuperCall": "none",
|
||||
}
|
||||
@@ -15,6 +15,6 @@ pyyaml==6.0
|
||||
termcolor>=2
|
||||
tomli==2.0.1
|
||||
tomlkit==0.11.6
|
||||
types-pyyaml
|
||||
types-pyyaml>=6.0.12.7
|
||||
types-setuptools
|
||||
typing-extensions
|
||||
|
||||
@@ -7,8 +7,8 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
try:
|
||||
from termcolor import colored
|
||||
@@ -176,7 +176,7 @@ def main() -> None:
|
||||
print("stubtest:", _SKIPPED)
|
||||
else:
|
||||
print("stubtest:", _SUCCESS if stubtest_result.returncode == 0 else _FAILED)
|
||||
if pytype_result is None:
|
||||
if not pytype_result:
|
||||
print("pytype:", _SKIPPED)
|
||||
else:
|
||||
print("pytype:", _SUCCESS if pytype_result.returncode == 0 else _FAILED)
|
||||
|
||||
@@ -241,7 +241,7 @@ async def get_github_repo_info(session: aiohttp.ClientSession, pypi_info: PypiIn
|
||||
github_tags_info_url = f"https://api.github.com/repos/{url_path}/tags"
|
||||
async with session.get(github_tags_info_url, headers=get_github_api_headers()) as response:
|
||||
if response.status == 200:
|
||||
tags = await response.json()
|
||||
tags: list[dict[str, Any]] = await response.json()
|
||||
assert isinstance(tags, list)
|
||||
return GithubInfo(repo_path=url_path, tags=tags)
|
||||
return None
|
||||
@@ -266,7 +266,7 @@ async def get_diff_info(
|
||||
if github_info is None:
|
||||
return None
|
||||
|
||||
versions_to_tags = {}
|
||||
versions_to_tags: dict[packaging.version.Version, str] = {}
|
||||
for tag in github_info.tags:
|
||||
tag_name = tag["name"]
|
||||
# Some packages in typeshed (e.g. emoji) have tag names
|
||||
@@ -378,7 +378,7 @@ class DiffAnalysis:
|
||||
return analysis
|
||||
|
||||
def __str__(self) -> str:
|
||||
data_points = []
|
||||
data_points: list[str] = []
|
||||
if self.runtime_definitely_has_consistent_directory_structure_with_typeshed:
|
||||
data_points += [
|
||||
self.describe_public_files_added(),
|
||||
@@ -398,7 +398,7 @@ async def analyze_diff(
|
||||
url = f"https://api.github.com/repos/{github_repo_path}/compare/{old_tag}...{new_tag}"
|
||||
async with session.get(url, headers=get_github_api_headers()) as response:
|
||||
response.raise_for_status()
|
||||
json_resp = await response.json()
|
||||
json_resp: dict[str, list[FileInfo]] = await response.json()
|
||||
assert isinstance(json_resp, dict)
|
||||
# https://docs.github.com/en/rest/commits/commits#compare-two-commits
|
||||
py_files: list[FileInfo] = [file for file in json_resp["files"] if Path(file["filename"]).suffix == ".py"]
|
||||
@@ -581,7 +581,11 @@ def get_update_pr_body(update: Update, metadata: dict[str, Any]) -> str:
|
||||
if update.diff_analysis is not None:
|
||||
body += f"\n\n{update.diff_analysis}"
|
||||
|
||||
stubtest_will_run = not metadata.get("tool", {}).get("stubtest", {}).get("skip", False)
|
||||
# Loss of type due to infered [dict[Unknown, Unknown]]
|
||||
# scripts/stubsabot.py can't import tests/parse_metadata
|
||||
stubtest_will_run = (
|
||||
not metadata.get("tool", {}).get("stubtest", {}).get("skip", False) # pyright: ignore[reportUnknownMemberType]
|
||||
)
|
||||
if stubtest_will_run:
|
||||
body += textwrap.dedent(
|
||||
"""
|
||||
@@ -611,10 +615,13 @@ async def suggest_typeshed_update(update: Update, session: aiohttp.ClientSession
|
||||
branch_name = f"{BRANCH_PREFIX}/{normalize(update.distribution)}"
|
||||
subprocess.check_call(["git", "checkout", "-B", branch_name, "origin/main"])
|
||||
with open(update.stub_path / "METADATA.toml", "rb") as f:
|
||||
meta = tomlkit.load(f)
|
||||
# tomlkit.load has partially unknown IO type
|
||||
# https://github.com/sdispater/tomlkit/pull/272
|
||||
meta = tomlkit.load(f) # pyright: ignore[reportUnknownMemberType]
|
||||
meta["version"] = update.new_version_spec
|
||||
with open(update.stub_path / "METADATA.toml", "w", encoding="UTF-8") as f:
|
||||
tomlkit.dump(meta, f)
|
||||
# tomlkit.dump has partially unknown IO type
|
||||
tomlkit.dump(meta, f) # pyright: ignore[reportUnknownMemberType]
|
||||
body = get_update_pr_body(update, meta)
|
||||
subprocess.check_call(["git", "commit", "--all", "-m", f"{title}\n\n{body}"])
|
||||
if action_level <= ActionLevel.local:
|
||||
@@ -637,12 +644,15 @@ async def suggest_typeshed_obsolete(obsolete: Obsolete, session: aiohttp.ClientS
|
||||
branch_name = f"{BRANCH_PREFIX}/{normalize(obsolete.distribution)}"
|
||||
subprocess.check_call(["git", "checkout", "-B", branch_name, "origin/main"])
|
||||
with open(obsolete.stub_path / "METADATA.toml", "rb") as f:
|
||||
meta = tomlkit.load(f)
|
||||
# tomlkit.load has partially unknown IO type
|
||||
# https://github.com/sdispater/tomlkit/pull/272
|
||||
meta = tomlkit.load(f) # pyright: ignore[reportUnknownMemberType]
|
||||
obs_string = tomlkit.string(obsolete.obsolete_since_version)
|
||||
obs_string.comment(f"Released on {obsolete.obsolete_since_date.date().isoformat()}")
|
||||
meta["obsolete_since"] = obs_string
|
||||
with open(obsolete.stub_path / "METADATA.toml", "w", encoding="UTF-8") as f:
|
||||
tomlkit.dump(meta, f)
|
||||
# tomlkit.dump has partially unknown Mapping type
|
||||
tomlkit.dump(meta, f) # pyright: ignore[reportUnknownMemberType]
|
||||
body = "\n".join(f"{k}: {v}" for k, v in obsolete.links.items())
|
||||
subprocess.check_call(["git", "commit", "--all", "-m", f"{title}\n\n{body}"])
|
||||
if action_level <= ActionLevel.local:
|
||||
@@ -727,7 +737,8 @@ async def main() -> None:
|
||||
if isinstance(update, Update):
|
||||
await suggest_typeshed_update(update, session, action_level=args.action_level)
|
||||
continue
|
||||
if isinstance(update, Obsolete):
|
||||
# Redundant, but keeping for extra runtime validation
|
||||
if isinstance(update, Obsolete): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||
await suggest_typeshed_obsolete(update, session, action_level=args.action_level)
|
||||
continue
|
||||
except RemoteConflict as e:
|
||||
|
||||
@@ -10,6 +10,7 @@ import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import yaml
|
||||
from packaging.requirements import Requirement
|
||||
@@ -93,7 +94,7 @@ def check_no_symlinks() -> None:
|
||||
|
||||
|
||||
def check_versions() -> None:
|
||||
versions = set()
|
||||
versions = set[str]()
|
||||
with open("stdlib/VERSIONS", encoding="UTF-8") as f:
|
||||
data = f.read().splitlines()
|
||||
for line in data:
|
||||
@@ -115,7 +116,7 @@ def check_versions() -> None:
|
||||
|
||||
|
||||
def _find_stdlib_modules() -> set[str]:
|
||||
modules = set()
|
||||
modules = set[str]()
|
||||
for path, _, files in os.walk("stdlib"):
|
||||
for filename in files:
|
||||
base_module = ".".join(os.path.normpath(path).split(os.sep)[1:])
|
||||
@@ -140,11 +141,21 @@ def get_txt_requirements() -> dict[str, SpecifierSet]:
|
||||
return {requirement.name: requirement.specifier for requirement in requirements}
|
||||
|
||||
|
||||
class PreCommitConfigRepos(TypedDict):
|
||||
hooks: list[dict[str, str]]
|
||||
repo: str
|
||||
rev: str
|
||||
|
||||
|
||||
class PreCommitConfig(TypedDict):
|
||||
repos: list[PreCommitConfigRepos]
|
||||
|
||||
|
||||
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 = {}
|
||||
yam: PreCommitConfig = yaml.load(precommit, Loader=yaml.Loader)
|
||||
precommit_requirements: dict[str, SpecifierSet] = {}
|
||||
for repo in yam["repos"]:
|
||||
if not repo.get("python_requirement", True):
|
||||
continue
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
def check_new_syntax(tree: ast.AST, path: Path, stub: str) -> list[str]:
|
||||
errors = []
|
||||
errors: list[str] = []
|
||||
|
||||
class IfFinder(ast.NodeVisitor):
|
||||
def visit_If(self, node: ast.If) -> None:
|
||||
@@ -31,7 +31,7 @@ def check_new_syntax(tree: ast.AST, path: Path, stub: str) -> list[str]:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
errors = []
|
||||
errors: list[str] = []
|
||||
for path in chain(Path("stdlib").rglob("*.pyi"), Path("stubs").rglob("*.pyi")):
|
||||
with open(path, encoding="UTF-8") as f:
|
||||
stub = f.read()
|
||||
|
||||
@@ -42,7 +42,7 @@ from utils import (
|
||||
|
||||
# Fail early if mypy isn't installed
|
||||
try:
|
||||
import mypy # noqa: F401
|
||||
import mypy # pyright: ignore[reportUnusedImport] # noqa: F401
|
||||
except ImportError:
|
||||
print_error("Cannot import mypy. Did you install it?")
|
||||
sys.exit(1)
|
||||
@@ -57,7 +57,8 @@ VersionTuple: TypeAlias = Tuple[int, int]
|
||||
Platform: TypeAlias = Annotated[str, "Must be one of the entries in SUPPORTED_PLATFORMS"]
|
||||
|
||||
|
||||
class CommandLineArgs(argparse.Namespace):
|
||||
@dataclass(init=False)
|
||||
class CommandLineArgs:
|
||||
verbose: int
|
||||
filter: list[Path]
|
||||
exclude: list[Path] | None
|
||||
@@ -158,7 +159,7 @@ def match(path: Path, args: TestConfig) -> bool:
|
||||
|
||||
|
||||
def parse_versions(fname: StrPath) -> dict[str, tuple[VersionTuple, VersionTuple]]:
|
||||
result = {}
|
||||
result: dict[str, tuple[VersionTuple, VersionTuple]] = {}
|
||||
with open(fname, encoding="UTF-8") as f:
|
||||
for line in f:
|
||||
line = strip_comments(line)
|
||||
@@ -209,7 +210,8 @@ def add_configuration(configurations: list[MypyDistConf], distribution: str) ->
|
||||
with Path("stubs", distribution, "METADATA.toml").open("rb") as f:
|
||||
data = tomli.load(f)
|
||||
|
||||
mypy_tests_conf = data.get("mypy-tests")
|
||||
# TODO: This could be added to parse_metadata.py, but is currently unused
|
||||
mypy_tests_conf: dict[str, dict[str, Any]] = data.get("mypy-tests", {})
|
||||
if not mypy_tests_conf:
|
||||
return
|
||||
|
||||
@@ -221,8 +223,8 @@ def add_configuration(configurations: list[MypyDistConf], distribution: str) ->
|
||||
assert module_name is not None, f"{section_name} should have a module_name key"
|
||||
assert isinstance(module_name, str), f"{section_name} should be a key-value pair"
|
||||
|
||||
values = mypy_section.get("values")
|
||||
assert values is not None, f"{section_name} should have a values section"
|
||||
assert "values" in mypy_section, f"{section_name} should have a values section"
|
||||
values: dict[str, dict[str, Any]] = mypy_section["values"]
|
||||
assert isinstance(values, dict), "values should be a section"
|
||||
|
||||
configurations.append(MypyDistConf(module_name, values.copy()))
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# This module is made specifically to abstract away those type errors
|
||||
# pyright: reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownMemberType=false
|
||||
|
||||
"""Tools to help parse and validate information stored in METADATA.toml files."""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -188,7 +191,8 @@ def read_metadata(distribution: str) -> StubMetadata:
|
||||
uploaded_to_pypi = data.get("upload", True)
|
||||
assert type(uploaded_to_pypi) is bool
|
||||
|
||||
tools_settings = data.get("tool", {})
|
||||
empty_tools: dict[str, dict[str, object]] = {}
|
||||
tools_settings = data.get("tool", empty_tools)
|
||||
assert isinstance(tools_settings, dict)
|
||||
assert tools_settings.keys() <= _KNOWN_METADATA_TOOL_FIELDS.keys(), f"Unrecognised tool for {distribution!r}"
|
||||
for tool, tk in _KNOWN_METADATA_TOOL_FIELDS.items():
|
||||
@@ -234,7 +238,8 @@ def read_dependencies(distribution: str) -> PackageDependencies:
|
||||
If a typeshed stub is removed, this function will consider it to be an external dependency.
|
||||
"""
|
||||
pypi_name_to_typeshed_name_mapping = get_pypi_name_to_typeshed_name_mapping()
|
||||
typeshed, external = [], []
|
||||
typeshed: list[str] = []
|
||||
external: list[str] = []
|
||||
for dependency in read_metadata(distribution).requires:
|
||||
maybe_typeshed_dependency = Requirement(dependency).name
|
||||
if maybe_typeshed_dependency in pypi_name_to_typeshed_name_mapping:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
# Lack of pytype typing
|
||||
# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportMissingTypeStubs=false
|
||||
"""Test runner for typeshed.
|
||||
|
||||
Depends on pytype being installed.
|
||||
@@ -19,11 +21,14 @@ import traceback
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
import pkg_resources
|
||||
from pytype import config as pytype_config, load_pytd # type: ignore[import]
|
||||
from pytype.imports import typeshed # type: ignore[import]
|
||||
|
||||
from parse_metadata import read_dependencies
|
||||
|
||||
assert sys.platform != "win32"
|
||||
# pytype is not py.typed https://github.com/google/pytype/issues/1325
|
||||
from pytype import config as pytype_config, load_pytd # type: ignore[import] # noqa: E402
|
||||
from pytype.imports import typeshed # type: ignore[import] # noqa: E402
|
||||
|
||||
TYPESHED_SUBDIRS = ["stdlib", "stubs"]
|
||||
TYPESHED_HOME = "TYPESHED_HOME"
|
||||
_LOADERS = {}
|
||||
@@ -155,7 +160,11 @@ def get_missing_modules(files_to_test: Sequence[str]) -> Iterable[str]:
|
||||
for distribution in stub_distributions:
|
||||
for pkg in read_dependencies(distribution).external_pkgs:
|
||||
# See https://stackoverflow.com/a/54853084
|
||||
top_level_file = os.path.join(pkg_resources.get_distribution(pkg).egg_info, "top_level.txt") # type: ignore[attr-defined]
|
||||
top_level_file = os.path.join(
|
||||
# Fixed in #9747
|
||||
pkg_resources.get_distribution(pkg).egg_info, # type: ignore[attr-defined] # pyright: ignore[reportGeneralTypeIssues]
|
||||
"top_level.txt",
|
||||
)
|
||||
with open(top_level_file) as f:
|
||||
missing_modules.update(f.read().splitlines())
|
||||
return missing_modules
|
||||
|
||||
@@ -15,11 +15,12 @@ ReturnCode: TypeAlias = int
|
||||
SUPPORTED_PLATFORMS = ("linux", "darwin", "win32")
|
||||
SUPPORTED_VERSIONS = ("3.11", "3.10", "3.9")
|
||||
DIRECTORIES_TO_TEST = ("scripts", "tests")
|
||||
EMPTY: list[str] = []
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run mypy on typeshed's own code in the `scripts` and `tests` directories.")
|
||||
parser.add_argument(
|
||||
"dir",
|
||||
choices=DIRECTORIES_TO_TEST + ([],),
|
||||
choices=DIRECTORIES_TO_TEST + (EMPTY,),
|
||||
nargs="*",
|
||||
action="extend",
|
||||
help=f"Test only these top-level typeshed directories (defaults to {DIRECTORIES_TO_TEST!r})",
|
||||
|
||||
@@ -135,4 +135,6 @@ def spec_matches_path(spec: pathspec.PathSpec, path: Path) -> bool:
|
||||
normalized_path = path.as_posix()
|
||||
if path.is_dir():
|
||||
normalized_path += "/"
|
||||
return spec.match_file(normalized_path)
|
||||
# pathspec.PathSpec.match_file has partially Unknown file parameter
|
||||
# https://github.com/cpburnz/python-pathspec/pull/75
|
||||
return spec.match_file(normalized_path) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
Reference in New Issue
Block a user