From 7eb04cc8a7a484dad8f5b07329c85140346de099 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 16:04:46 +0000 Subject: [PATCH] Test on Python 3.15, and fix the typing._eval_type call it breaks Adds Python 3.15 to the CI matrix, both the default and the free-threaded build. `allow-prereleases` on setup-python resolves `3.15` to the newest pre-release the runner image publishes (3.15.0rc1 today) and will pick up 3.15.0 final when it ships, with no further change here. Setting it does not disturb the stable rows: it widens `3.X` to `~3.X.0-0`, and a pre-release only wins when no stable release satisfies the spec, so 3.10-3.14 still resolve to their newest stable patch. The rows are advisory (`experimental: true` -> `continue-on-error`) while 3.15 is a pre-release, so a CPython-side regression in rc2 or final cannot block merges on a version nothing runs in production. The comment records how to promote them. The suite did not pass as-is. `typing._eval_type` gained a `type_params` argument in 3.13, where omitting it only warned and defaulted to `()`; in 3.15 it is a required positional, so every call raised TypeError: _eval_type() missing 1 required positional argument: 'type_params' which took out all five annotation tests -- and, because faust resolves every Record model's annotations through `mode.utils.objects.annotations`, stopped faust importing at all on 3.15. Nothing resolved here carries PEP 695 type parameters of its own, so `()` is both what 3.13/3.14 already substituted and what 3.15 wants; passing it explicitly keeps behaviour identical across versions and silences the 3.13/3.14 deprecation warning. The signature is inspected once at import rather than caught per call, because `_eval_type` also raises TypeError for annotations `typing._type_check` rejects and `eval_type` relies on telling those two apart. Verified against the 3.15.0rc1 build the runners use: 866 passed on 3.15 and on 3.15t, unchanged on 3.11, with ruff and the mypy typecheck test clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jr6Lsfd3fULmP1Gi4b6nDK --- .github/workflows/tests.yml | 25 ++++++++++++++++++++++++ mode/utils/objects.py | 39 +++++++++++++++++++++++++++++++++---- pyproject.toml | 1 + 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a98e61c..35e11aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,6 +36,24 @@ jobs: # GIL disabled; see docs/free-threading.md. - "3.14t" experimental: [ false ] + include: + # Python 3.15, which `allow-prereleases` below resolves to the + # newest pre-release the runner image publishes -- 3.15.0rc1 at the + # time of writing, and 3.15.0 itself once it ships in October 2026, + # with no change needed here. + # + # Advisory (`experimental: true` -> `continue-on-error`) while 3.15 + # is a pre-release: the suite passes on 3.15.0rc1 today, but rc2 and + # the final release are still to come, and a regression in CPython + # itself -- or a test dependency that has no 3.15 wheels yet and + # fails to build from source -- must not block merges on a version + # nothing runs in production. Move these two entries up into the + # `python-version` list above (and delete them from `include`) to + # make 3.15 a required check once 3.15.0 final is out. + - python-version: "3.15" + experimental: true + - python-version: "3.15t" + experimental: true steps: - uses: actions/checkout@v4 @@ -49,6 +67,13 @@ jobs: - uses: "actions/setup-python@v5" with: python-version: "${{ matrix.python-version }}" + # Required for the 3.15 rows: without it the version spec only + # matches stable releases and the job fails with "Version 3.15 was + # not found in the local cache". It is safe to set for every row -- + # it widens `3.X` to `~3.X.0-0`, and a pre-release only wins when no + # stable release satisfies the spec, so 3.10-3.14 still resolve to + # their newest stable patch. + allow-prereleases: true cache: "pip" cache-dependency-path: | requirements-docs.txt diff --git a/mode/utils/objects.py b/mode/utils/objects.py index 25a2822..0e7cf79 100644 --- a/mode/utils/objects.py +++ b/mode/utils/objects.py @@ -18,7 +18,7 @@ from contextlib import suppress from decimal import Decimal from functools import total_ordering -from inspect import get_annotations +from inspect import get_annotations, signature from pathlib import Path from typing import ( Any, @@ -34,11 +34,42 @@ ) try: - from typing import _eval_type # type: ignore -except ImportError: + from typing import _eval_type as _typing_eval_type # type: ignore +except ImportError: # pragma: no cover + _typing_eval_type = None + _EVAL_TYPE_WANTS_TYPE_PARAMS = False +else: + # `type_params` (the PEP 695 type parameters in scope) was added to + # `typing._eval_type` in 3.13, where omitting it only emitted a + # DeprecationWarning and defaulted to `()`. On 3.15 it became a required + # positional argument, so calling without it now fails outright with + # "TypeError: _eval_type() missing 1 required positional argument: + # 'type_params'". + # + # The signature is inspected once, here at import. A try/except TypeError + # around the call would be wrong: `_eval_type` legitimately raises + # TypeError for annotations `typing._type_check` rejects, and `eval_type` + # below depends on being able to tell those apart. + _EVAL_TYPE_WANTS_TYPE_PARAMS = ( + "type_params" in signature(_typing_eval_type).parameters + ) + - def _eval_type(t, globalns, localns, recursive_guard=frozenset()): # type: ignore +def _eval_type( + t: Any, + globalns: Optional[dict[str, Any]], + localns: Optional[dict[str, Any]], +) -> Any: + # Nothing resolved through here carries PEP 695 type parameters of its + # own, so `()` is both what 3.13/3.14 already substituted when the + # argument was omitted and what 3.15 requires -- passing it explicitly + # keeps behaviour identical across versions, and silences the 3.13/3.14 + # deprecation warning on the way. + if _typing_eval_type is None: # pragma: no cover return t + if _EVAL_TYPE_WANTS_TYPE_PARAMS: + return _typing_eval_type(t, globalns, localns, ()) + return _typing_eval_type(t, globalns, localns) def _is_class_var(typ: Any) -> bool: diff --git a/pyproject.toml b/pyproject.toml index 0c2a806..ae48c7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Free Threading :: 2 - Beta", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy",