diff --git a/.github/workflows/typecheck_typeshed_code.yml b/.github/workflows/typecheck_typeshed_code.yml index a92baf684..4d0ac8a88 100644 --- a/.github/workflows/typecheck_typeshed_code.yml +++ b/.github/workflows/typecheck_typeshed_code.yml @@ -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 diff --git a/pyrightconfig.scripts_and_tests.json b/pyrightconfig.scripts_and_tests.json new file mode 100644 index 000000000..ef506c53e --- /dev/null +++ b/pyrightconfig.scripts_and_tests.json @@ -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", +} diff --git a/requirements-tests.txt b/requirements-tests.txt index 5133b28b1..c8dab9528 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -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 diff --git a/scripts/runtests.py b/scripts/runtests.py index 44e7e71a1..f633e79e8 100644 --- a/scripts/runtests.py +++ b/scripts/runtests.py @@ -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) diff --git a/scripts/stubsabot.py b/scripts/stubsabot.py index 9632bdace..31b02e067 100644 --- a/scripts/stubsabot.py +++ b/scripts/stubsabot.py @@ -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: diff --git a/tests/check_consistent.py b/tests/check_consistent.py index fd0111bb0..976b7fbef 100644 --- a/tests/check_consistent.py +++ b/tests/check_consistent.py @@ -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 diff --git a/tests/check_new_syntax.py b/tests/check_new_syntax.py index c99c0137b..84a4c8c3d 100755 --- a/tests/check_new_syntax.py +++ b/tests/check_new_syntax.py @@ -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() diff --git a/tests/mypy_test.py b/tests/mypy_test.py index 56ad56842..b812731da 100644 --- a/tests/mypy_test.py +++ b/tests/mypy_test.py @@ -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())) diff --git a/tests/parse_metadata.py b/tests/parse_metadata.py index b0ccadfab..41ff6ed39 100644 --- a/tests/parse_metadata.py +++ b/tests/parse_metadata.py @@ -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: diff --git a/tests/pytype_test.py b/tests/pytype_test.py index 4d771af36..b24d7cb79 100755 --- a/tests/pytype_test.py +++ b/tests/pytype_test.py @@ -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 diff --git a/tests/typecheck_typeshed.py b/tests/typecheck_typeshed.py index 4a97c342d..23956fb30 100644 --- a/tests/typecheck_typeshed.py +++ b/tests/typecheck_typeshed.py @@ -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})", diff --git a/tests/utils.py b/tests/utils.py index ab1deb594..f5a7a4cbf 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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]