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", diff --git a/src/typeapi/backport/inspect.py b/src/typeapi/backport/inspect.py index 6559365..38692c2 100644 --- a/src/typeapi/backport/inspect.py +++ b/src/typeapi/backport/inspect.py @@ -59,16 +59,28 @@ 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): + 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 + 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 +90,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 +97,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 diff --git a/src/typeapi/utils.py b/src/typeapi/utils.py index 7514c4c..a6f5ac1 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. @@ -271,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( @@ -292,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, @@ -398,3 +415,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..91d2bea 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__") @@ -451,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