Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ jobs:
"3.13.7",
"3.13.8",
"3.13.9",
"3.14.0",

# manual additions
"pypy-3.8",
Expand Down
31 changes: 20 additions & 11 deletions src/typeapi/backport/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -78,16 +90,13 @@ 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
elif callable(obj):
# 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
Expand Down
28 changes: 27 additions & 1 deletion src/typeapi/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import collections
import inspect
import sys
import types
import typing
Expand All @@ -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)
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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)
)
13 changes: 12 additions & 1 deletion src/typeapi/utils_test.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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__")
Expand Down Expand Up @@ -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
Expand Down