diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0fed39033a43..6660d270c45a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,6 +82,39 @@ jobs: --all \ --verbosity=QUIET + ty: + name: "ty: Check stubs" + runs-on: ubuntu-latest + strategy: + matrix: + platform: ["linux", "win32", "darwin"] + # TODO: Add 3.15 once third-party runtime dependencies provide compatible wheels. + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - uses: astral-sh/setup-uv@v8.2.0 + with: + version-file: "requirements-tests.txt" + - name: Install typeshed test-suite requirements + run: uv pip install -r requirements-tests.txt --system + - name: Create an isolated venv for testing + run: uv venv .venv + - name: Install third-party stub dependencies + run: | + PACKAGES=$(python tests/get_external_stub_requirements.py) + if [ -n "$PACKAGES" ]; then + uv pip install --python-version ${{ matrix.python-version }} $PACKAGES + fi + # Published stub packages can shadow the checked-in stubs when ty + # resolves their relative imports. + uv pip uninstall types-PyYAML types-pytz + - name: Run ty on all stubs + run: python tests/ty_test.py --platform=${{ matrix.platform }} --python-version=${{ matrix.python-version }} --python=.venv + pyright: name: "pyright: Run test cases" runs-on: ubuntu-latest diff --git a/requirements-tests.txt b/requirements-tests.txt index 8ef8a20d335f..4e349ad33d0a 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -2,6 +2,7 @@ # be pinned to a specific version to make failure reproducible. mypy==2.2.0 pyright==1.1.411 +ty==0.0.59 # Libraries used by our various scripts. aiohttp==3.14.1 diff --git a/stdlib/_asyncio.pyi b/stdlib/_asyncio.pyi index 17999651f7d3..242093e1ec79 100644 --- a/stdlib/_asyncio.pyi +++ b/stdlib/_asyncio.pyi @@ -53,7 +53,7 @@ else: # since the only reason why `asyncio.Future` is invariant is the `set_result()` method, # and `asyncio.Task.set_result()` always raises. @disjoint_base -class Task(Future[_T_co]): # type: ignore[type-var] # pyright: ignore[reportInvalidTypeArguments] +class Task(Future[_T_co]): # type: ignore[type-var] # pyright: ignore[reportInvalidTypeArguments] # ty:ignore[invalid-generic-class] if sys.version_info >= (3, 12): def __init__( self, diff --git a/stdlib/collections/__init__.pyi b/stdlib/collections/__init__.pyi index d9c2e342c587..39b8a3c9ab6a 100644 --- a/stdlib/collections/__init__.pyi +++ b/stdlib/collections/__init__.pyi @@ -364,17 +364,17 @@ class _OrderedDictValuesView(ValuesView[_VT_co]): # pyright doesn't have a specific error code for subclassing error! @final @type_check_only -class _odict_keys(dict_keys[_KT_co, _VT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] +class _odict_keys(dict_keys[_KT_co, _VT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[subclass-of-final-class] def __reversed__(self) -> Iterator[_KT_co]: ... @final @type_check_only -class _odict_items(dict_items[_KT_co, _VT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] +class _odict_items(dict_items[_KT_co, _VT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[subclass-of-final-class] def __reversed__(self) -> Iterator[tuple[_KT_co, _VT_co]]: ... @final @type_check_only -class _odict_values(dict_values[_KT_co, _VT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] +class _odict_values(dict_values[_KT_co, _VT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[subclass-of-final-class] def __reversed__(self) -> Iterator[_VT_co]: ... @disjoint_base diff --git a/stdlib/contextlib.pyi b/stdlib/contextlib.pyi index b95c23502a8b..dd601675265c 100644 --- a/stdlib/contextlib.pyi +++ b/stdlib/contextlib.pyi @@ -43,7 +43,7 @@ _CM_EF = TypeVar("_CM_EF", bound=AbstractContextManager[Any, Any] | _ExitFunc) # At runtime it inherits from ABC and is not a Protocol, but it is on the # allowlist for use as a Protocol. @runtime_checkable -class AbstractContextManager(ABC, Protocol[_T_co, _ExitT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] +class AbstractContextManager(ABC, Protocol[_T_co, _ExitT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-protocol] __slots__ = () def __enter__(self) -> _T_co: ... @abstractmethod @@ -55,7 +55,7 @@ class AbstractContextManager(ABC, Protocol[_T_co, _ExitT_co]): # type: ignore[m # At runtime it inherits from ABC and is not a Protocol, but it is on the # allowlist for use as a Protocol. @runtime_checkable -class AbstractAsyncContextManager(ABC, Protocol[_T_co, _ExitT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] +class AbstractAsyncContextManager(ABC, Protocol[_T_co, _ExitT_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-protocol] __slots__ = () async def __aenter__(self) -> _T_co: ... @abstractmethod diff --git a/stdlib/dataclasses.pyi b/stdlib/dataclasses.pyi index 1a7b8fd64558..fd5588e03490 100644 --- a/stdlib/dataclasses.pyi +++ b/stdlib/dataclasses.pyi @@ -298,7 +298,7 @@ def fields(class_or_instance: DataclassInstance | type[DataclassInstance]) -> tu # HACK: `obj: Never` typing matches if object argument is using `Any` type. @overload -def is_dataclass(obj: Never) -> TypeIs[DataclassInstance | type[DataclassInstance]]: ... # type: ignore[narrowed-type-not-subtype] # pyright: ignore[reportGeneralTypeIssues] +def is_dataclass(obj: Never) -> TypeIs[DataclassInstance | type[DataclassInstance]]: ... # type: ignore[narrowed-type-not-subtype] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-type-guard-definition] @overload def is_dataclass(obj: type) -> TypeIs[type[DataclassInstance]]: ... @overload @@ -308,13 +308,17 @@ class FrozenInstanceError(AttributeError): ... class InitVar(Generic[_T]): __slots__ = ("type",) - type: Type[_T] + type: Type[_T] # ty:ignore[unbound-type-variable] def __init__(self, type: Type[_T]) -> None: ... @overload - def __class_getitem__(cls, type: Type[_T]) -> InitVar[_T]: ... # pyright: ignore[reportInvalidTypeForm] + def __class_getitem__( + cls, type: Type[_T] + ) -> InitVar[_T]: ... # pyright: ignore[reportInvalidTypeForm] # ty:ignore[invalid-type-form] @overload - def __class_getitem__(cls, type: Any) -> InitVar[Any]: ... # pyright: ignore[reportInvalidTypeForm] + def __class_getitem__( + cls, type: Any + ) -> InitVar[Any]: ... # pyright: ignore[reportInvalidTypeForm] # ty:ignore[invalid-type-form] if sys.version_info >= (3, 14): def make_dataclass( diff --git a/stdlib/os/__init__.pyi b/stdlib/os/__init__.pyi index 726e9702fb16..00c155956fb0 100644 --- a/stdlib/os/__init__.pyi +++ b/stdlib/os/__init__.pyi @@ -940,7 +940,7 @@ In the future, this property will contain the last metadata change time.""") # At runtime it inherits from ABC and is not a Protocol, but it will be # on the allowlist for use as a Protocol starting in 3.14. @runtime_checkable -class PathLike(ABC, Protocol[AnyStr_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] +class PathLike(ABC, Protocol[AnyStr_co]): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-protocol] __slots__ = () @abstractmethod def __fspath__(self) -> AnyStr_co: ... diff --git a/stdlib/platform.pyi b/stdlib/platform.pyi index 7d837436ef79..07100d3bd592 100644 --- a/stdlib/platform.pyi +++ b/stdlib/platform.pyi @@ -44,7 +44,7 @@ if sys.version_info >= (3, 12): def __new__(_cls, system: str, node: str, release: str, version: str, machine: str) -> Self: ... @property - def processor(self) -> str: ... + def processor(self) -> str: ... # ty:ignore[invalid-named-tuple-override] else: @disjoint_base @@ -52,7 +52,7 @@ else: __match_args__ = ("system", "node", "release", "version", "machine") # pyright: ignore[reportAssignmentType] def __new__(_cls, system: str, node: str, release: str, version: str, machine: str) -> Self: ... @property - def processor(self) -> str: ... + def processor(self) -> str: ... # ty:ignore[invalid-named-tuple-override] def uname() -> uname_result: ... def system() -> str: ... diff --git a/stdlib/ssl.pyi b/stdlib/ssl.pyi index 590642d99607..8d71e4763959 100644 --- a/stdlib/ssl.pyi +++ b/stdlib/ssl.pyi @@ -262,8 +262,18 @@ class Purpose(_ASN1Object, enum.Enum): # because this is an enum, the inherited __new__ is replaced at runtime with # Enum.__new__. def __new__(cls, value: object) -> Self: ... - SERVER_AUTH = (129, "serverAuth", "TLS Web Server Authentication", "1.3.6.1.5.5.7.3.2") # pyright: ignore[reportCallIssue] - CLIENT_AUTH = (130, "clientAuth", "TLS Web Client Authentication", "1.3.6.1.5.5.7.3.1") # pyright: ignore[reportCallIssue] + SERVER_AUTH = ( # ty:ignore[invalid-assignment] + 129, + "serverAuth", + "TLS Web Server Authentication", + "1.3.6.1.5.5.7.3.2", + ) # pyright: ignore[reportCallIssue] + CLIENT_AUTH = ( # ty:ignore[invalid-assignment] + 130, + "clientAuth", + "TLS Web Client Authentication", + "1.3.6.1.5.5.7.3.1", + ) # pyright: ignore[reportCallIssue] class SSLSocket(socket.socket): context: SSLContext diff --git a/stdlib/types.pyi b/stdlib/types.pyi index 68b6b3fbe41d..328bb7552538 100644 --- a/stdlib/types.pyi +++ b/stdlib/types.pyi @@ -268,7 +268,7 @@ class CodeType: __replace__ = replace @final -class MappingProxyType(Mapping[_KT_co, _VT_co]): # type: ignore[type-var] # pyright: ignore[reportInvalidTypeArguments] +class MappingProxyType(Mapping[_KT_co, _VT_co]): # type: ignore[type-var] # pyright: ignore[reportInvalidTypeArguments] # ty:ignore[invalid-generic-class] __hash__: ClassVar[None] # type: ignore[assignment] def __new__(cls, mapping: SupportsKeysAndGetItem[_KT_co, _VT_co]) -> Self: ... def __getitem__(self, key: _KT_co, /) -> _VT_co: ... # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] diff --git a/stdlib/typing.pyi b/stdlib/typing.pyi index 61439f15e785..8c1fb9d28d7c 100644 --- a/stdlib/typing.pyi +++ b/stdlib/typing.pyi @@ -1063,13 +1063,13 @@ class NamedTuple(tuple[Any, ...]): @final @classmethod - def _make(cls, iterable: Iterable[Any]) -> typing_extensions.Self: ... + def _make(cls, iterable: Iterable[Any]) -> typing_extensions.Self: ... # ty:ignore[invalid-type-form] @final def _asdict(self) -> dict[str, Any]: ... @final - def _replace(self, **kwargs: Any) -> typing_extensions.Self: ... + def _replace(self, **kwargs: Any) -> typing_extensions.Self: ... # ty:ignore[invalid-type-form] if sys.version_info >= (3, 13): - def __replace__(self, **kwargs: Any) -> typing_extensions.Self: ... + def __replace__(self, **kwargs: Any) -> typing_extensions.Self: ... # ty:ignore[invalid-type-form] # Internal mypy fallback type for all typed dicts (does not exist at runtime) # N.B. Keep this mostly in sync with typing_extensions._TypedDict/mypy_extensions._TypedDict diff --git a/stdlib/typing_extensions.pyi b/stdlib/typing_extensions.pyi index 80341175e6e1..5a8848e9151d 100644 --- a/stdlib/typing_extensions.pyi +++ b/stdlib/typing_extensions.pyi @@ -356,9 +356,9 @@ else: def __init__(self, typename: str, fields: None = None, **kwargs: Any) -> None: ... @classmethod - def _make(cls, iterable: Iterable[Any]) -> Self: ... + def _make(cls, iterable: Iterable[Any]) -> Self: ... # ty:ignore[invalid-type-form] def _asdict(self) -> dict[str, Any]: ... - def _replace(self, **kwargs: Any) -> Self: ... + def _replace(self, **kwargs: Any) -> Self: ... # ty:ignore[invalid-type-form] class NewType: def __init__(self, name: str, tp: AnnotationForm) -> None: ... @@ -389,7 +389,7 @@ else: # At runtime it inherits from ABC and is not a Protocol, but it is on the # allowlist for use as a Protocol. @runtime_checkable - class Buffer(Protocol, abc.ABC): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] + class Buffer(Protocol, abc.ABC): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[invalid-protocol] # Not actually a Protocol at runtime; see # https://github.com/python/typeshed/issues/10224 for why we're defining it this way def __buffer__(self, flags: int, /) -> memoryview: ... diff --git a/stubs/JACK-Client/jack/__init__.pyi b/stubs/JACK-Client/jack/__init__.pyi index 95d6e4857c2c..c173e7be07cc 100644 --- a/stubs/JACK-Client/jack/__init__.pyi +++ b/stubs/JACK-Client/jack/__init__.pyi @@ -12,7 +12,7 @@ from numpy.typing import NDArray # This is not a real subclassing. Just ensuring type-checkers sees this type as compatible with _CDataBase # pyright has no error code for subclassing final @type_check_only -class _JackPositionT(_CDataBase): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] +class _JackPositionT(_CDataBase): # type: ignore[misc] # pyright: ignore[reportGeneralTypeIssues] # ty:ignore[subclass-of-final-class] audio_frames_per_video_frame: float bar: int bar_start_tick: float diff --git a/stubs/WebOb/webob/cachecontrol.pyi b/stubs/WebOb/webob/cachecontrol.pyi index bf291dc9b494..94434539a213 100644 --- a/stubs/WebOb/webob/cachecontrol.pyi +++ b/stubs/WebOb/webob/cachecontrol.pyi @@ -38,7 +38,7 @@ class exists_property(Generic[_ScopeT]): def __delete__(self, obj: CacheControl[_ScopeT]) -> None: ... class value_property(Generic[_T, _DefaultT, _NoneLiteral, _ScopeT]): - def __init__(self, prop: str, default: _DefaultT = None, none: _NoneLiteral = None, type: _ScopeT = None) -> None: ... # type: ignore[assignment] + def __init__(self, prop: str, default: _DefaultT = None, none: _NoneLiteral = None, type: _ScopeT = None) -> None: ... # type: ignore[assignment] # ty:ignore[invalid-parameter-default] @overload def __get__(self, obj: None, type: type[CacheControl[Any]] | None = None) -> Self: ... diff --git a/stubs/geopandas/geopandas/geoseries.pyi b/stubs/geopandas/geopandas/geoseries.pyi index 8e1a23f17560..b5a156cb7874 100644 --- a/stubs/geopandas/geopandas/geoseries.pyi +++ b/stubs/geopandas/geopandas/geoseries.pyi @@ -19,7 +19,7 @@ from .explore import _explore_geoseries from .io._geoarrow import GeoArrowArray from .plotting import plot_series -class GeoSeries(GeoPandasBase, pd.Series[BaseGeometry]): # type: ignore[type-var,misc] # pyright: ignore[reportInvalidTypeArguments] +class GeoSeries(GeoPandasBase, pd.Series[BaseGeometry]): # type: ignore[type-var,misc] # pyright: ignore[reportInvalidTypeArguments] # ty:ignore[invalid-type-arguments] # Override the weird annotation of Series.__new__ in pandas-stubs def __new__( self, diff --git a/stubs/geopandas/geopandas/plotting.pyi b/stubs/geopandas/geopandas/plotting.pyi index b24d213f7639..e8c7c85c545c 100644 --- a/stubs/geopandas/geopandas/plotting.pyi +++ b/stubs/geopandas/geopandas/plotting.pyi @@ -193,7 +193,7 @@ class GeoplotAccessor(PlotAccessor): include_bool: bool = False, backend: str | None = None, **kwargs, - ) -> pd.Series[Axes]: ... # type: ignore[type-var] # pyright: ignore[reportInvalidTypeArguments] + ) -> pd.Series[Axes]: ... # type: ignore[type-var] # pyright: ignore[reportInvalidTypeArguments] # ty:ignore[invalid-type-arguments] @overload def __call__( self, diff --git a/stubs/pyogrio/pyogrio/raw.pyi b/stubs/pyogrio/pyogrio/raw.pyi index 39a97f65a82d..cc55e91ddc1a 100644 --- a/stubs/pyogrio/pyogrio/raw.pyi +++ b/stubs/pyogrio/pyogrio/raw.pyi @@ -3,7 +3,7 @@ from contextlib import AbstractContextManager from typing import Any, Literal, TypedDict, overload, type_check_only import numpy as np -import pyarrow as pa # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] +import pyarrow as pa # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import shapely as shp from ._typing import Array1D, ArrayLikeInt, ReadPathOrBuffer, SupportsArrowCStream, WritePathOrBuffer diff --git a/stubs/python-dateutil/dateutil/tz/__init__.pyi b/stubs/python-dateutil/dateutil/tz/__init__.pyi index 3dbbe3199855..406980bdfdab 100644 --- a/stubs/python-dateutil/dateutil/tz/__init__.pyi +++ b/stubs/python-dateutil/dateutil/tz/__init__.pyi @@ -1,3 +1,4 @@ +import builtins import sys from datetime import datetime from typing_extensions import Self @@ -25,7 +26,7 @@ if sys.platform == "win32": hasdst: bool def __eq__(self, other: tzwinbase) -> bool: ... # type: ignore[override] @staticmethod - def list() -> list[str]: ... + def list() -> builtins.list[str]: ... def display(self) -> str | None: ... def transitions(self, year: int) -> tuple[datetime, datetime] | None: ... diff --git a/stubs/setuptools/setuptools/__init__.pyi b/stubs/setuptools/setuptools/__init__.pyi index 5b2c77ab99f2..e89fce7ebdc6 100644 --- a/stubs/setuptools/setuptools/__init__.pyi +++ b/stubs/setuptools/setuptools/__init__.pyi @@ -155,7 +155,7 @@ def setup( install_requires: str | Iterable[str] = [], extras_require: _DictLike[Incomplete, Incomplete] = {}, # kwargs used directly in distutils.core.setup - distclass: type[_DistributionT] = Distribution, # type: ignore[assignment] # noqa: Y011 + distclass: type[_DistributionT] = Distribution, # type: ignore[assignment] # noqa: Y011 # ty:ignore[invalid-parameter-default] # Custom Distributions could accept more params **attrs: Any, ) -> _DistributionT: ... diff --git a/stubs/yt-dlp/yt_dlp/extractor/common.pyi b/stubs/yt-dlp/yt_dlp/extractor/common.pyi index ece237453e0a..821f045b0bf3 100644 --- a/stubs/yt-dlp/yt_dlp/extractor/common.pyi +++ b/stubs/yt-dlp/yt_dlp/extractor/common.pyi @@ -943,7 +943,7 @@ class InfoExtractor: self, url: str, *args: Unused, - info_dict: _InfoDict = {}, # type: ignore[typeddict-item] # pyright: ignore[reportArgumentType] + info_dict: _InfoDict = {}, # type: ignore[typeddict-item] # pyright: ignore[reportArgumentType] # ty:ignore[missing-typed-dict-key] note: str = "Extracting generic embeds", **kwargs: Unused, ) -> list[dict[str, Any]]: ... diff --git a/tests/README.md b/tests/README.md index 6ae812cfab36..f64fe02059ee 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,6 +3,7 @@ This directory contains several tests: tests the stubs with [mypy](https://github.com/python/mypy/) - `tests/pyright_test.py` tests the stubs with [pyright](https://github.com/microsoft/pyright). +- `tests/ty_test.py` tests the stubs with [ty](https://github.com/astral-sh/ty). - `tests/regr_test.py` runs mypy against the test cases for typeshed's stubs, guarding against accidental regressions. - `tests/check_typeshed_structure.py` checks that typeshed's directory @@ -81,6 +82,22 @@ checks that would typically fail on incomplete stubs (such as `Unknown` checks). In typeshed's CI, pyright is run with these configuration settings on a subset of the stubs in typeshed (including the standard library). +## ty\_test.py + +Run using: +```bash +(.venv)$ python3 tests/ty_test.py --python=.venv +(.venv)$ python3 tests/ty_test.py stubs/PySocks --python=.venv +``` + +This test checks the stdlib and third-party stubs with ty, using the configuration +in `ty.toml`. It selects the stdlib modules supported by the requested Python +version using `stdlib/VERSIONS` and adds the third-party stub roots to ty's module +search path. Unlike pyright, it checks `geopandas`, `seaborn`, and `shapely` on +every target version; only the obsolete `requests` and legacy `distutils` stubs +are excluded. Run +`python tests/ty_test.py --help` for the supported options. + ## regr\_test.py This test runs mypy against the test cases for typeshed's stdlib and third-party diff --git a/tests/runtests.py b/tests/runtests.py index 9da379b723a6..1ae28ea3f786 100755 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -3,6 +3,7 @@ import argparse import json +import os import re import subprocess import sys @@ -21,6 +22,19 @@ _FAILED = colored("Failed", "red") +def _pythonpath_env() -> dict[str, str]: + pythonpath = os.environ.get("PYTHONPATH") + env = os.environ.copy() + env["PYTHONPATH"] = "lib" if not pythonpath else os.pathsep.join(("lib", pythonpath)) + return env + + +def _ty_platform() -> str: + if sys.platform.startswith("win"): + return "win32" + return sys.platform + + def _parse_jsonc(json_text: str) -> str: # strip comments from the file lines = [line for line in json_text.split("\n") if not line.strip().startswith("//")] @@ -100,6 +114,23 @@ def main() -> None: pyright_returncode = pyright_result.returncode pyright_skipped = False + print(f"\nRunning ty for Python {python_version}...") + ty_result = subprocess.run( + [ + sys.executable, + "tests/ty_test.py", + path, + "--python-version", + python_version, + "--platform", + _ty_platform(), + "--python", + sys.executable, + ], + env=_pythonpath_env(), + check=False, + ) + print(f"\nRunning mypy for Python {python_version}...") mypy_result = subprocess.run([sys.executable, "tests/mypy_test.py", path, "--python-version", python_version], check=False) # If mypy failed, stubtest will fail without any helpful error @@ -171,6 +202,7 @@ def main() -> None: pre_commit_result.returncode, check_structure_result.returncode, pyright_returncode, + ty_result.returncode, mypy_result.returncode, getattr(stubtest_result, "returncode", 0), pyright_testcases_returncode, @@ -197,6 +229,7 @@ def main() -> None: print("Pyright:", _SKIPPED) else: print("Pyright:", _SUCCESS if pyright_returncode == 0 else _FAILED) + print("ty:", _SUCCESS if ty_result.returncode == 0 else _FAILED) print("mypy:", _SUCCESS if mypy_result.returncode == 0 else _FAILED) if stubtest_result is None: print("stubtest:", _SKIPPED) diff --git a/tests/ty_test.py b/tests/ty_test.py new file mode 100755 index 000000000000..909a1f4d852b --- /dev/null +++ b/tests/ty_test.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Run ty on typeshed's stdlib and third-party stubs.""" + +from __future__ import annotations + +import argparse +import subprocess +import tempfile +from pathlib import Path + +from ts_utils.paths import STDLIB_PATH, STUBS_PATH, TS_BASE_PATH +from ts_utils.utils import parse_stdlib_versions_file, supported_versions_for_module + +SUPPORTED_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14", "3.15") +SUPPORTED_PLATFORMS = ("linux", "darwin", "win32") +# requests is obsolete and the typed runtime package is installed instead. +EXCLUDED_STUBS = {"requests"} + + +def stdlib_files(version: str) -> list[Path]: + """Return the stdlib stubs available in the requested Python version.""" + version_tuple = tuple(map(int, version.split("."))) + module_versions = parse_stdlib_versions_file() + files: list[Path] = [] + + for path in sorted(STDLIB_PATH.rglob("*.pyi")): + if "@tests" in path.parts: + continue + relative = path.relative_to(STDLIB_PATH) + # ty cannot resolve relative imports in the legacy distutils stubs. + if relative.parts[0] == "distutils": + continue + parts = list(relative.parts[:-1]) + if relative.name != "__init__.pyi": + parts.append(relative.stem) + minimum, maximum = supported_versions_for_module(module_versions, ".".join(parts)) + if minimum <= version_tuple <= maximum: + files.append(path) + + return files + + +def _path_files(path: Path) -> list[Path]: + if path.is_file(): + return [path] if path.suffix == ".pyi" and "@tests" not in path.parts else [] + return sorted(p for p in path.rglob("*.pyi") if "@tests" not in p.parts) + + +def _filter_files(files: list[Path], paths: list[Path], root: Path) -> list[Path]: + selected_paths = [path if path.is_absolute() else TS_BASE_PATH / path for path in paths] + selected_files = {file for path in selected_paths if path.is_relative_to(root) for file in _path_files(path)} + return [file for file in files if file in selected_files] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Typecheck typeshed's stdlib and third-party stubs with ty.") + parser.add_argument("paths", nargs="*", type=Path, help="Specific stdlib or third-party stubs to check") + parser.add_argument("--python", type=Path, help="Python interpreter or environment used to resolve third-party imports") + parser.add_argument("--python-version", choices=SUPPORTED_VERSIONS, default=SUPPORTED_VERSIONS[0]) + parser.add_argument("--platform", choices=SUPPORTED_PLATFORMS, default="linux") + args = parser.parse_args() + + third_party_files = sorted( + path + for path in STUBS_PATH.rglob("*.pyi") + if "@tests" not in path.parts and path.relative_to(STUBS_PATH).parts[0] not in EXCLUDED_STUBS + ) + stdlib = stdlib_files(args.python_version) + if args.paths: + stdlib = _filter_files(stdlib, args.paths, STDLIB_PATH) + third_party_files = _filter_files(third_party_files, args.paths, STUBS_PATH) + files = [*stdlib, *third_party_files] + if not files: + print("No stubs to check with ty.", flush=True) + return 0 + command = [ + "ty", + "check", + "--config-file", + str(TS_BASE_PATH / "ty.toml"), + "--typeshed", + str(TS_BASE_PATH), + "--python-version", + args.python_version, + "--python-platform", + args.platform, + "--output-format", + "concise", + ] + if args.python is not None: + command.extend(("--python", str(args.python))) + + for path in sorted(STUBS_PATH.iterdir()): + # requests is obsolete and the typed runtime package provides requests._types. + if path.is_dir() and path.name != "requests": + command.extend(("--extra-search-path", str(path))) + command.extend(map(str, files)) + + print(f"Checking {len(files)} stubs with ty ({args.python_version}, {args.platform})...", flush=True) + # The custom typeshed cannot also be the project root: ty would treat builtins.pyi + # as project source and panic while constructing its builtins model. + with tempfile.TemporaryDirectory() as project: + command[2:2] = ("--project", project) + return subprocess.run(command, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ty.toml b/ty.toml new file mode 100644 index 000000000000..36de911f6fca --- /dev/null +++ b/ty.toml @@ -0,0 +1,20 @@ +[analysis] +respect-type-ignore-comments = false +# These imports are intentionally optional and are also ignored by mypy and pyright. +allowed-unresolved-imports = [ + "branca", + "folium", + "pydot", + "pygraphviz.**", + "scipy.**", + "twisted.**", + "xyzservices", +] + +[rules] +# Incompatible overrides and deprecations are inherited from the implementation +# and are not actionable in typeshed. Keep in sync with pyrightconfig.json. +deprecated = "ignore" +invalid-attribute-override = "ignore" +invalid-method-override = "ignore" +override-of-final-method = "ignore"