Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-114053: Fix bad interaction of PEP-695, PEP-563 and get_type_hints #118009

Merged
merged 4 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion Doc/library/typing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3024,7 +3024,9 @@ Introspection helpers

This is often the same as ``obj.__annotations__``. In addition,
forward references encoded as string literals are handled by evaluating
them in ``globals`` and ``locals`` namespaces. For a class ``C``, return
them in ``globals``, ``locals`` and (where applicable)
:ref:`type parameter <annotation-scopes>` namespaces.
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
For a class ``C``, return
Copy link
Member Author

@AlexWaygood AlexWaygood Apr 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't usually add .. versionchanged notes for bugfixes (and I think this is a bugfix), but I could add one if we think this is a significant enough change to the function's semantics

a dictionary constructed by merging all the ``__annotations__`` along
``C.__mro__`` in reverse order.

Expand Down
24 changes: 23 additions & 1 deletion Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
import types

from test.support import captured_stderr, cpython_only, infinite_recursion
from test.typinganndata import mod_generics_cache, _typed_dict_helper
from test.typinganndata import ann_module695, mod_generics_cache, _typed_dict_helper


CANNOT_SUBCLASS_TYPE = 'Cannot subclass special typing classes'
Expand Down Expand Up @@ -4641,6 +4641,28 @@ def f(x: X): ...
{'x': list[list[ForwardRef('X')]]}
)

def test_pep695_generic_with_future_annotations(self):
hints_for_A = get_type_hints(ann_module695.A)
self.assertEqual(hints_for_A.keys(), {"x", "y", "z"})
self.assertEqual(tuple(hints_for_A.values()), ann_module695.A.__type_params__)

hints_for_B = get_type_hints(ann_module695.B)
self.assertEqual(hints_for_B.keys(), {"x", "y", "z"})
self.assertEqual(
set(hints_for_B.values()) ^ set(ann_module695.B.__type_params__),
set()
)

hints_for_generic_function = get_type_hints(ann_module695.generic_function)
func_t_params = ann_module695.generic_function.__type_params__
self.assertEqual(
hints_for_generic_function.keys(), {"x", "y", "z", "zz", "return"}
)
self.assertIs(hints_for_generic_function["x"], func_t_params[0])
self.assertEqual(hints_for_generic_function["y"], Unpack[func_t_params[1]])
self.assertIs(hints_for_generic_function["z"].__origin__, func_t_params[2])
self.assertIs(hints_for_generic_function["zz"].__origin__, func_t_params[2])

def test_extended_generic_rules_subclassing(self):
class T1(Tuple[T, KT]): ...
class T2(Tuple[T, ...]): ...
Expand Down
21 changes: 21 additions & 0 deletions Lib/test/typinganndata/ann_module695.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from __future__ import annotations


class A[T, *Ts, **P]:
x: T
y: Ts
z: P


class B[T, *Ts, **P]:
T = int
Ts = str
P = bytes
x: T
y: Ts
z: P


def generic_function[T, *Ts, **P](
x: T, *y: *Ts, z: P.args, zz: P.kwargs
) -> None: ...
32 changes: 24 additions & 8 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,15 +399,16 @@ def inner(*args, **kwds):

return decorator

def _eval_type(t, globalns, localns, recursive_guard=frozenset()):

def _eval_type(t, globalns, localns, type_params, *, recursive_guard=frozenset()):
"""Evaluate all forward references in the given type t.

For use of globalns and localns see the docstring for get_type_hints().
recursive_guard is used to prevent infinite recursion with a recursive
ForwardRef.
"""
if isinstance(t, ForwardRef):
return t._evaluate(globalns, localns, recursive_guard)
return t._evaluate(globalns, localns, type_params, recursive_guard=recursive_guard)
if isinstance(t, (_GenericAlias, GenericAlias, types.UnionType)):
if isinstance(t, GenericAlias):
args = tuple(
Expand All @@ -421,7 +422,13 @@ def _eval_type(t, globalns, localns, recursive_guard=frozenset()):
t = t.__origin__[args]
if is_unpacked:
t = Unpack[t]
ev_args = tuple(_eval_type(a, globalns, localns, recursive_guard) for a in t.__args__)

ev_args = tuple(
_eval_type(
a, globalns, localns, type_params, recursive_guard=recursive_guard
)
for a in t.__args__
)
if ev_args == t.__args__:
return t
if isinstance(t, GenericAlias):
Expand Down Expand Up @@ -974,7 +981,7 @@ def __init__(self, arg, is_argument=True, module=None, *, is_class=False):
self.__forward_is_class__ = is_class
self.__forward_module__ = module

def _evaluate(self, globalns, localns, recursive_guard):
def _evaluate(self, globalns, localns, type_params, *, recursive_guard):
if self.__forward_arg__ in recursive_guard:
return self
if not self.__forward_evaluated__ or localns is not globalns:
Expand All @@ -989,13 +996,21 @@ def _evaluate(self, globalns, localns, recursive_guard):
sys.modules.get(self.__forward_module__, None), '__dict__', globalns
)
type_ = _type_check(
eval(self.__forward_code__, globalns, localns),
eval(
self.__forward_code__,
globalns,
{param.__name__: param for param in type_params} | localns
Copy link
Member Author

@AlexWaygood AlexWaygood Apr 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this means that any annotations in the "local namespace" of the class override any symbols that are present in the scope due to being listed as a type parameter. That matches the behaviour of __annotations__ at runtime in a module that doesn't use __future__ annotations:

>>> class B[T, *Ts, **P]:
...     T = int
...     Ts = str
...     P = bytes
...     x: T
...     y: Ts
...     z: P
... 
>>> B.__annotations__
{'x': <class 'int'>, 'y': <class 'str'>, 'z': <class 'bytes'>}

I'm doing it at the last possible point here as it feels less risky than doing it in _eval_type.

),
"Forward references must evaluate to types.",
is_argument=self.__forward_is_argument__,
allow_special_forms=self.__forward_is_class__,
)
self.__forward_value__ = _eval_type(
type_, globalns, localns, recursive_guard | {self.__forward_arg__}
type_,
globalns,
localns,
type_params,
recursive_guard=(recursive_guard | {self.__forward_arg__}),
)
self.__forward_evaluated__ = True
return self.__forward_value__
Expand Down Expand Up @@ -2334,7 +2349,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False):
value = type(None)
if isinstance(value, str):
value = ForwardRef(value, is_argument=False, is_class=True)
value = _eval_type(value, base_globals, base_locals)
value = _eval_type(value, base_globals, base_locals, base.__type_params__)
hints[name] = value
return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()}

Expand All @@ -2360,6 +2375,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False):
raise TypeError('{!r} is not a module, class, method, '
'or function.'.format(obj))
hints = dict(hints)
type_params = getattr(obj, "__type_params__", ())
for name, value in hints.items():
if value is None:
value = type(None)
Expand All @@ -2371,7 +2387,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False):
is_argument=not isinstance(obj, types.ModuleType),
is_class=False,
)
hints[name] = _eval_type(value, globalns, localns)
hints[name] = _eval_type(value, globalns, localns, type_params)
return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fix erroneous :exc:`NameError` when calling :func:`typing.get_type_hints` on
a class that made use of :pep:`695` type parameters in a module that had
``from __future__ import annotations`` at the top of the file. Patch by Alex
Waygood.
Loading