From 81e4155298b7a50b30a7b97bd9ec3fe90c870ba3 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Thu, 23 Oct 2025 13:26:19 +0200 Subject: [PATCH 1/6] test 3.14.0 --- .github/workflows/python.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 6f48f7d..8eb73e6 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -110,6 +110,7 @@ jobs: "3.13.7", "3.13.8", "3.13.9", + "3.14.0", # manual additions "pypy-3.8", From a90a3fc2d4763ab44f3a3b9697c34f29061af8da Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Thu, 23 Oct 2025 13:49:09 +0200 Subject: [PATCH 2/6] fix: handle descriptors on type-hint objects that are generic classes changed in 3.14 in get_type_hint_args(), get_type_hint_parameters() and get_type_hint_origin() --- src/typeapi/utils.py | 20 ++++++++++++++++++++ src/typeapi/utils_test.py | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/src/typeapi/utils.py b/src/typeapi/utils.py index 7514c4c..b00e170 100644 --- a/src/typeapi/utils.py +++ b/src/typeapi/utils.py @@ -1,4 +1,5 @@ import collections +import inspect import sys import types import typing @@ -16,6 +17,7 @@ IS_PYTHON_AT_LEAST_3_7 = sys.version_info[:2] >= (3, 7) IS_PYTHON_AT_LEAST_3_9 = sys.version_info[:2] >= (3, 9) IS_PYTHON_AT_LEAST_3_10 = sys.version_info[:2] >= (3, 10) +IS_PYTHON_AT_LAST_3_14 = sys.version_info[:2] >= (3, 14) TYPING_MODULE_NAMES = frozenset(["typing", "typing_extensions", "collections.abc"]) T_contra = TypeVar("T_contra", contravariant=True) U_co = TypeVar("U_co", covariant=True) @@ -45,6 +47,11 @@ def get_type_hint_origin_or_none(hint: object) -> "Any | None": hint_origin = getattr(hint, "__origin__", None) + # With Python 3.14, the `__origin__` field on typing classes is a getset_descriptor; but we must treat it + # as if the type hint has no origin. For completeness, we ignore any kind of descriptor. + if IS_PYTHON_AT_LAST_3_14 and is_any_descriptor(hint_origin): + hint_origin = None + # In Python 3.6, List[int].__origin__ points to List; but we can look for # the Python native type in its __bases__. if ( @@ -117,6 +124,8 @@ def get_type_hint_args(hint: object) -> Tuple[Any, ...]: """ hint_args = getattr(hint, "__args__", None) or () + if IS_PYTHON_AT_LAST_3_14 and is_any_descriptor(hint_args): + hint_args = () # In Python 3.7 and 3.8, generics like List and Tuple have a "_special" # but their __args__ contain type vars. For consistent results across @@ -152,6 +161,8 @@ def get_type_hint_parameters(hint: object) -> Tuple[Any, ...]: """ hint_params = getattr(hint, "__parameters__", None) or () + if IS_PYTHON_AT_LAST_3_14 and is_any_descriptor(hint_params): + hint_params = () # In Python 3.9+, special generic aliases like List and Tuple don't store # their type variables as parameters anymore; we try to restore those. @@ -398,3 +409,12 @@ def is_new_type(hint: Any) -> TypeGuard[NewTypeP]: # NOTE: Starting with Python 3.10, `typing.NewType` is actually a class instead of a function, but it is # still typed as a function in Mypy until 3.12. return hasattr(hint, "__name__") and hasattr(hint, "__supertype__") + + +def is_any_descriptor(value: Any) -> bool: + return ( + inspect.isdatadescriptor(value) + or inspect.ismethoddescriptor(value) + or inspect.isgetsetdescriptor(value) + or inspect.ismethoddescriptor(value) + ) diff --git a/src/typeapi/utils_test.py b/src/typeapi/utils_test.py index ff71b25..7f8b265 100644 --- a/src/typeapi/utils_test.py +++ b/src/typeapi/utils_test.py @@ -1,6 +1,7 @@ # type: ignore import collections.abc +import inspect import sys import typing as t from typing import Any, Dict, Generic, List, Mapping, MutableMapping, Optional, TypeVar, Union @@ -9,6 +10,7 @@ import typing_extensions from typeapi.utils import ( + IS_PYTHON_AT_LAST_3_14, IS_PYTHON_AT_LEAST_3_7, IS_PYTHON_AT_LEAST_3_9, ForwardRef, @@ -292,6 +294,8 @@ def test__typing_Union__introspection(): if sys.version_info[:2] <= (3, 6): assert Union.__origin__ is None assert Union[int, str].__origin__ is Union + elif IS_PYTHON_AT_LAST_3_14: + assert inspect.isgetsetdescriptor(Union.__origin__) else: assert not hasattr(Union, "__origin__") assert Union[int, str].__origin__ is Union @@ -304,6 +308,9 @@ def test__typing_Union__introspection(): if sys.version_info[:2] <= (3, 6): assert Union.__args__ is None assert Union.__parameters__ is None + elif IS_PYTHON_AT_LAST_3_14: + assert inspect.ismemberdescriptor(Union.__args__) + assert inspect.isgetsetdescriptor(Union.__parameters__) else: assert not hasattr(Union, "__args__") assert not hasattr(Union, "__parameters__") From 90a1a784766be1d3e9a4506e7f8e5768cd4cd569 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Thu, 23 Oct 2025 13:58:29 +0200 Subject: [PATCH 3/6] improvement: use `annotationlib.type_repr()` if available (since 3.14) --- src/typeapi/utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/typeapi/utils.py b/src/typeapi/utils.py index b00e170..a6f5ac1 100644 --- a/src/typeapi/utils.py +++ b/src/typeapi/utils.py @@ -282,7 +282,7 @@ def _populate(hint: Any) -> None: } -def type_repr(obj: Any) -> str: +def _type_repr_pre_3_14(obj: Any) -> str: """#typing._type_repr() stolen from Python 3.8.""" if (getattr(obj, "__module__", None) or getattr(type(obj), "__module__", None)) in TYPING_MODULE_NAMES or hasattr( @@ -303,6 +303,12 @@ def type_repr(obj: Any) -> str: return repr(obj) +if sys.version_info[:2] >= (3, 14): # Can't use IS_PYTHON_AT_LEAST_3_14 because Mypy won't recognize it + from annotationlib import type_repr +else: + type_repr = _type_repr_pre_3_14 + + def get_annotations( obj: Union[Callable[..., Any], ModuleType, type], include_bases: bool = False, From 14b36e9b8abe2339f502baeaca43c7e4b4f05846 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Thu, 23 Oct 2025 14:07:18 +0200 Subject: [PATCH 4/6] fix: our `get_annotations()` backport now reads the `__annotations__` attribute on types directly and supports calling the `__annotate__()` function --- src/typeapi/backport/inspect.py | 36 +++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/typeapi/backport/inspect.py b/src/typeapi/backport/inspect.py index 6559365..31bb9ef 100644 --- a/src/typeapi/backport/inspect.py +++ b/src/typeapi/backport/inspect.py @@ -59,16 +59,33 @@ def get_annotations( although if obj is a wrapped function (using functools.update_wrapper()) it is first unwrapped. """ - if isinstance(obj, type): - # class - obj_dict = getattr(obj, "__dict__", None) - if obj_dict and hasattr(obj_dict, "get"): - ann = obj_dict.get("__annotations__", None) - if isinstance(ann, types.GetSetDescriptorType): - ann = None + + ann: Any = None + + if sys.version_info[:2] >= (3, 14): + if (annotate := getattr(obj, "__annotate__", None)) is not None: + from annotationlib import Format + + try: + ann = annotate(Format.STRING) # We do our own evaluation later + except NotImplementedError: + pass + if ann is None: + ann = getattr(obj, "__annotations__", None) + else: + if isinstance(obj, type): + # class + obj_dict = getattr(obj, "__dict__", None) + if obj_dict and hasattr(obj_dict, "get"): + ann = obj_dict.get("__annotations__", None) + if isinstance(ann, types.GetSetDescriptorType): + ann = None else: - ann = None + ann = getattr(obj, "__annotations__", None) + + # Determine the scope in which the annotations are to be evaluated. + if isinstance(obj, type): obj_globals = None module_name = getattr(obj, "__module__", None) if module_name: @@ -78,8 +95,6 @@ def get_annotations( obj_locals = dict(vars(obj)) unwrap = obj elif isinstance(obj, types.ModuleType): - # module - ann = getattr(obj, "__annotations__", None) obj_globals = getattr(obj, "__dict__") obj_locals = None unwrap = None @@ -87,7 +102,6 @@ def get_annotations( # this includes types.Function, types.BuiltinFunctionType, # types.BuiltinMethodType, functools.partial, functools.singledispatch, # "class funclike" from Lib/test/test_inspect... on and on it goes. - ann = getattr(obj, "__annotations__", None) obj_globals = getattr(obj, "__globals__", None) obj_locals = None unwrap = obj From 01f12d6fba1a1352a20f30344ea4cb4abc552a14 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Thu, 23 Oct 2025 14:14:23 +0200 Subject: [PATCH 5/6] fix: test for resolving forward references to a Union returns a slightly different result in 3.14 --- src/typeapi/utils_test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/typeapi/utils_test.py b/src/typeapi/utils_test.py index 7f8b265..91d2bea 100644 --- a/src/typeapi/utils_test.py +++ b/src/typeapi/utils_test.py @@ -458,9 +458,13 @@ class A: annotations = get_annotations(A) assert annotations == {"a": Optional[str]} + if IS_PYTHON_AT_LAST_3_14: + # from typing import Union + assert type(annotations["a"]) is Union + # NOTE(@NiklasRosenstein): Even though `str | None` is of type `types.UnionType` in Python 3.10+, # our fake evaluation will still return legacy type hints. - if IS_PYTHON_AT_LEAST_3_9: + elif IS_PYTHON_AT_LEAST_3_9: from typing import _UnionGenericAlias # type: ignore assert type(annotations["a"]) is _UnionGenericAlias From 62fca7e5edac9e06dc1634d55138de841301aebf Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Thu, 23 Oct 2025 14:21:38 +0200 Subject: [PATCH 6/6] actually use `annotatelib.get_annotations()` --- src/typeapi/backport/inspect.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/typeapi/backport/inspect.py b/src/typeapi/backport/inspect.py index 31bb9ef..38692c2 100644 --- a/src/typeapi/backport/inspect.py +++ b/src/typeapi/backport/inspect.py @@ -63,15 +63,10 @@ def get_annotations( ann: Any = None if sys.version_info[:2] >= (3, 14): - if (annotate := getattr(obj, "__annotate__", None)) is not None: - from annotationlib import Format - - try: - ann = annotate(Format.STRING) # We do our own evaluation later - except NotImplementedError: - pass - if ann is None: - ann = getattr(obj, "__annotations__", None) + from annotationlib import Format + from annotationlib import get_annotations as _get_annotations + + ann = _get_annotations(obj, format=Format.VALUE, eval_str=False) else: if isinstance(obj, type): # class