Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typecheck typeshed's code with pyright #9793

Merged
merged 14 commits into from
Feb 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
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]