From 7d4fbc0d0f09188d80f0f950fa990b235b2bfebe Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 10 Jul 2026 14:36:59 -0700 Subject: [PATCH 1/2] Run ty on typeshed stubs in CI Add a pinned ty check for the standard-library and third-party stubs across Python 3.10-3.14 and the supported target platforms. The runner honors stdlib/VERSIONS, resolves checked-in stub packages and their external dependencies, and avoids duplicate published stub packages that shadow local sources. Match pyright's policy for non-actionable override and deprecation diagnostics, add narrowly scoped ignores beside existing checker exceptions, and exclude only the obsolete requests and legacy distutils stubs. Check geopandas, seaborn, and shapely on every target version, with two existing pandas-stubs type-bound exceptions mirrored for ty. Also fix the remaining Windows-only dateutil builtin-name collision discovered by the new check. Closes #15999. --- .github/workflows/tests.yml | 33 +++++++ requirements-tests.txt | 1 + stdlib/_asyncio.pyi | 2 +- stdlib/collections/__init__.pyi | 6 +- stdlib/contextlib.pyi | 4 +- stdlib/dataclasses.pyi | 12 ++- stdlib/os/__init__.pyi | 2 +- stdlib/platform.pyi | 4 +- stdlib/ssl.pyi | 14 ++- stdlib/types.pyi | 2 +- stdlib/typing.pyi | 6 +- stdlib/typing_extensions.pyi | 6 +- stubs/JACK-Client/jack/__init__.pyi | 2 +- stubs/WebOb/webob/cachecontrol.pyi | 2 +- stubs/geopandas/geopandas/geoseries.pyi | 2 +- stubs/geopandas/geopandas/plotting.pyi | 2 +- stubs/pyogrio/pyogrio/raw.pyi | 2 +- .../python-dateutil/dateutil/tz/__init__.pyi | 3 +- stubs/setuptools/setuptools/__init__.pyi | 2 +- stubs/yt-dlp/yt_dlp/extractor/common.pyi | 2 +- tests/README.md | 16 ++++ tests/ty_test.py | 88 +++++++++++++++++++ ty.toml | 20 +++++ 23 files changed, 203 insertions(+), 30 deletions(-) create mode 100755 tests/ty_test.py create mode 100644 ty.toml 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..6f175ca37dad 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,21 @@ 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 +``` + +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/ty_test.py b/tests/ty_test.py new file mode 100755 index 000000000000..3783616fcc86 --- /dev/null +++ b/tests/ty_test.py @@ -0,0 +1,88 @@ +#!/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 main() -> int: + parser = argparse.ArgumentParser(description="Typecheck typeshed's stdlib and third-party stubs with ty.") + 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 + ) + files = [*stdlib_files(args.python_version), *third_party_files] + 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" From 16855910b2286821fd4c35fb67bf8bac7a10ab42 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 15 Jul 2026 10:54:00 -0700 Subject: [PATCH 2/2] add to runtests.py --- tests/README.md | 1 + tests/runtests.py | 33 +++++++++++++++++++++++++++++++++ tests/ty_test.py | 22 +++++++++++++++++++++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index 6f175ca37dad..f64fe02059ee 100644 --- a/tests/README.md +++ b/tests/README.md @@ -87,6 +87,7 @@ the stubs in typeshed (including the standard library). 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 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 index 3783616fcc86..909a1f4d852b 100755 --- a/tests/ty_test.py +++ b/tests/ty_test.py @@ -40,8 +40,21 @@ def stdlib_files(version: str) -> list[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") @@ -52,7 +65,14 @@ def main() -> int: 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 ) - files = [*stdlib_files(args.python_version), *third_party_files] + 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",