Skip to content

Commit

Permalink
Typecheck typeshed's code with pyright (python#9793)
Browse files Browse the repository at this point in the history
Co-authored-by: Alex Waygood <[email protected]>
Co-authored-by: Jelle Zijlstra <[email protected]>
  • Loading branch information
3 people authored Feb 22, 2023
1 parent b0dc6a3 commit 75f47d0
Show file tree
Hide file tree
Showing 12 changed files with 125 additions and 32 deletions.
28 changes: 28 additions & 0 deletions .github/workflows/typecheck_typeshed_code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]
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 changes: 24 additions & 0 deletions pyrightconfig.scripts_and_tests.json
Original file line number Diff line number Diff line change
@@ -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",
}
2 changes: 1 addition & 1 deletion requirements-tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions scripts/runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import re
import subprocess
import sys
from collections.abc import Iterable
from pathlib import Path
from typing import Iterable

try:
from termcolor import colored
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 21 additions & 10 deletions scripts/stubsabot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -378,7 +378,7 @@ def describe_typeshed_files_modified(self) -> str:
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(),
Expand All @@ -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"]
Expand Down Expand Up @@ -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(
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 15 additions & 4 deletions tests/check_consistent.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import sys
import urllib.parse
from pathlib import Path
from typing import TypedDict

import yaml
from packaging.requirements import Requirement
Expand Down Expand Up @@ -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:
Expand All @@ -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:])
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/check_new_syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


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:
Expand All @@ -31,7 +31,7 @@ def visit_If(self, node: ast.If) -> None:


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()
Expand Down
14 changes: 8 additions & 6 deletions tests/mypy_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@

# 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)
Expand All @@ -57,7 +57,8 @@
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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()))
Expand Down
9 changes: 7 additions & 2 deletions tests/parse_metadata.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 12 additions & 3 deletions tests/pytype_test.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -19,11 +21,14 @@
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 = {}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/typecheck_typeshed.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
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})",
Expand Down
4 changes: 3 additions & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

0 comments on commit 75f47d0

Please sign in to comment.