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 1 commit
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/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.typeshed.json
23 changes: 23 additions & 0 deletions pyrightconfig.typeshed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
"$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,
// Extra strict settings
"reportMissingModuleSource": "error",
"reportShadowedImports": "error",
"reportCallInDefaultInitializer": "error",
"reportImplicitStringConcatenation": "error",
"reportPropertyTypeMismatch": "error",
"reportUninitializedInstanceVariable": "error",
"reportUnnecessaryTypeIgnoreComment": "error",
// Leave "type: ignore" comments to mypy
"enableTypeIgnoreComments": false,
// Too strict
"reportMissingSuperCall": "none",
}
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
36 changes: 25 additions & 11 deletions scripts/stubsabot.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@

class ActionLevel(enum.IntEnum):
def __new__(cls, value: int, doc: str) -> Self:
member = int.__new__(cls, value)
# Fails type-checking since updating "Self" types in #9694
# See: https://github.com/microsoft/pyright/issues/4668
member: Self = int.__new__(cls, value) # pyright: ignore[reportGeneralTypeIssues]
Avasam marked this conversation as resolved.
Show resolved Hide resolved
member._value_ = value
member.__doc__ = doc
return member
Expand Down Expand Up @@ -241,7 +243,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 +268,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 +380,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 +400,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 +583,12 @@ 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)
stubtest_will_run = (
not metadata.get("tool", {}).get("stubtest", {})
# Loss of type due to infered [dict[Unknown, Unknown]]
# scripts/stubsabot.py can't import tests/parse_metadata
Avasam marked this conversation as resolved.
Show resolved Hide resolved
.get("skip", False) # pyright: ignore[reportUnknownMemberType]
)
if stubtest_will_run:
body += textwrap.dedent(
"""
Expand Down Expand Up @@ -611,10 +618,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 +647,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 +740,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 stubs/setuptools/pkg_resources/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import zipimport
from _typeshed import Incomplete
from abc import ABCMeta
from collections.abc import Callable, Generator, Iterable, Sequence
from typing import IO, Any, TypeVar, overload
from typing_extensions import Self, TypeAlias
from re import Pattern
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
from typing import IO, Any, ClassVar, TypeVar, overload
from typing_extensions import Literal, Self, TypeAlias

_Version: TypeAlias = Incomplete # from packaging.version

_T = TypeVar("_T")
_D = TypeVar("_D", bound=Distribution)
_NestedStr: TypeAlias = str | Iterable[str | Iterable[Any]]
_InstallerType: TypeAlias = Callable[[Requirement], Distribution | None]
_EPDistType: TypeAlias = Distribution | Requirement | str
Expand Down Expand Up @@ -68,6 +70,10 @@ class Environment:
def obtain(self, requirement: Requirement, installer: Callable[[Requirement], _T]) -> _T: ...
def scan(self, search_path: Sequence[str] | None = ...) -> None: ...

class DistInfoDistribution(Distribution):
PKG_INFO: ClassVar[Literal["METADATA"]]
EQEQ: ClassVar[Pattern[str]]

def parse_requirements(strs: str | Iterable[str]) -> Generator[Requirement, None, None]: ...

class Requirement:
Expand Down Expand Up @@ -119,10 +125,15 @@ class EntryPoint:
def resolve(self) -> Any: ...

def find_distributions(path_item: str, only: bool = ...) -> Generator[Distribution, None, None]: ...
def get_distribution(dist: Requirement | str | Distribution) -> Distribution: ...
@overload
def get_distribution(dist: _D) -> _D: ...
@overload
def get_distribution(dist: _PkgReqType) -> DistInfoDistribution: ...

class Distribution(IResourceProvider, IMetadataProvider):
PKG_INFO: str
PKG_INFO: ClassVar[str]
# Initialized to None, but is not meant to be instanciated directly
egg_info: str
location: str
project_name: str
@property
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]()
Avasam marked this conversation as resolved.
Show resolved Hide resolved
for repo in yam["repos"]:
if not repo.get("python_requirement", True):
continue
Expand Down
6 changes: 3 additions & 3 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 @@ -22,7 +22,7 @@ def visit_If(self, node: ast.If) -> None:
new_syntax = "if " + ast.unparse(node.test).replace("<", ">=", 1)
errors.append(
f"{path}:{node.lineno}: When using if/else with sys.version_info, "
f"put the code for new Python versions first, e.g. `{new_syntax}`"
+ f"put the code for new Python versions first, e.g. `{new_syntax}`"
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
)
self.generic_visit(node)

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
10 changes: 6 additions & 4 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,6 +57,7 @@
Platform: TypeAlias = Annotated[str, "Must be one of the entries in SUPPORTED_PLATFORMS"]


@dataclass
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
class CommandLineArgs(argparse.Namespace):
verbose: int
filter: list[Path]
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, dict[str, dict[str, Any]]]] = data.get("mypy-tests", {})
Avasam marked this conversation as resolved.
Show resolved Hide resolved
if not mypy_tests_conf:
return

Expand Down Expand Up @@ -531,7 +533,7 @@ def test_typeshed(code: int, args: TestConfig, tempdir: Path) -> TestResults:


def main() -> None:
args = parser.parse_args(namespace=CommandLineArgs())
args = parser.parse_args(namespace=CommandLineArgs)
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
versions = args.python_version or SUPPORTED_VERSIONS
platforms = args.platform or [sys.platform]
filter = args.filter or DIRECTORIES_TO_TEST
Expand Down
13 changes: 9 additions & 4 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

"""Tools to help parse and validate information stored in METADATA.toml files."""
from __future__ import annotations

Expand All @@ -6,7 +9,7 @@
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple
from typing import Any, NamedTuple
from typing_extensions import Annotated, Final, TypeGuard, final

import tomli
Expand Down Expand Up @@ -151,7 +154,7 @@ def read_metadata(distribution: str) -> StubMetadata:
given in the `requires` field, for example.
"""
with Path("stubs", distribution, "METADATA.toml").open("rb") as f:
data: dict[str, object] = tomli.load(f)
data: dict[str, Any] = tomli.load(f)
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved

unknown_metadata_fields = data.keys() - _KNOWN_METADATA_FIELDS
assert not unknown_metadata_fields, f"Unexpected keys in METADATA.toml for {distribution!r}: {unknown_metadata_fields}"
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, set[str]] = {}
Avasam marked this conversation as resolved.
Show resolved Hide resolved
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
Loading