Skip to content

gh-132064: Make annotationlib use __annotate__ if only it is present #132195

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

Merged
merged 5 commits into from
Apr 13, 2025
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
15 changes: 13 additions & 2 deletions Doc/library/annotationlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -317,11 +317,22 @@ Functions
Compute the annotations dict for an object.

*obj* may be a callable, class, module, or other object with
:attr:`~object.__annotate__` and :attr:`~object.__annotations__` attributes.
Passing in an object of any other type raises :exc:`TypeError`.
:attr:`~object.__annotate__` or :attr:`~object.__annotations__` attributes.
Passing any other object raises :exc:`TypeError`.

The *format* parameter controls the format in which annotations are returned,
and must be a member of the :class:`Format` enum or its integer equivalent.
The different formats work as follows:

* VALUE: :attr:`!object.__annotations__` is tried first; if that does not exist,
the :attr:`!object.__annotate__` function is called if it exists.
* FORWARDREF: If :attr:`!object.__annotations__` exists and can be evaluated successfully,
it is used; otherwise, the :attr:`!object.__annotate__` function is called. If it
does not exist either, :attr:`!object.__annotations__` is tried again and any error
from accessing it is re-raised.
* STRING: If :attr:`!object.__annotate__` exists, it is called first;
otherwise, :attr:`!object.__annotations__` is used and stringified
using :func:`annotations_to_string`.

Returns a dict. :func:`!get_annotations` returns a new dict every time
it's called; calling it twice on the same object will return two
Expand Down
57 changes: 36 additions & 21 deletions Lib/annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,12 +649,18 @@ def get_annotations(
):
"""Compute the annotations dict for an object.

obj may be a callable, class, or module.
Passing in an object of any other type raises TypeError.

Returns a dict. get_annotations() returns a new dict every time
it's called; calling it twice on the same object will return two
different but equivalent dicts.
obj may be a callable, class, module, or other object with
__annotate__ or __annotations__ attributes.
Passing any other object raises TypeError.

The *format* parameter controls the format in which annotations are returned,
and must be a member of the Format enum or its integer equivalent.
For the VALUE format, the __annotations__ is tried first; if it
does not exist, the __annotate__ function is called. The
FORWARDREF format uses __annotations__ if it exists and can be
evaluated, and otherwise falls back to calling the __annotate__ function.
The SOURCE format tries __annotate__ first, and falls back to
using __annotations__, stringified using annotations_to_string().

This function handles several details for you:

Expand Down Expand Up @@ -696,37 +702,48 @@ def get_annotations(

match format:
case Format.VALUE:
# For VALUE, we only look at __annotations__
# For VALUE, we first look at __annotations__
ann = _get_dunder_annotations(obj)

# If it's not there, try __annotate__ instead
if ann is None:
ann = _get_and_call_annotate(obj, format)
case Format.FORWARDREF:
# For FORWARDREF, we use __annotations__ if it exists
try:
return dict(_get_dunder_annotations(obj))
ann = _get_dunder_annotations(obj)
except NameError:
pass
else:
if ann is not None:
return dict(ann)

# But if __annotations__ threw a NameError, we try calling __annotate__
ann = _get_and_call_annotate(obj, format)
if ann is not None:
return ann

# If that didn't work either, we have a very weird object: evaluating
# __annotations__ threw NameError and there is no __annotate__. In that case,
# we fall back to trying __annotations__ again.
return dict(_get_dunder_annotations(obj))
if ann is None:
# If that didn't work either, we have a very weird object: evaluating
# __annotations__ threw NameError and there is no __annotate__. In that case,
# we fall back to trying __annotations__ again.
ann = _get_dunder_annotations(obj)
case Format.STRING:
# For STRING, we try to call __annotate__
ann = _get_and_call_annotate(obj, format)
if ann is not None:
return ann
# But if we didn't get it, we use __annotations__ instead.
ann = _get_dunder_annotations(obj)
return annotations_to_string(ann)
if ann is not None:
ann = annotations_to_string(ann)
case Format.VALUE_WITH_FAKE_GLOBALS:
raise ValueError("The VALUE_WITH_FAKE_GLOBALS format is for internal use only")
case _:
raise ValueError(f"Unsupported format {format!r}")

if ann is None:
if isinstance(obj, type) or callable(obj):
return {}
raise TypeError(f"{obj!r} does not have annotations")

if not ann:
return {}

Expand Down Expand Up @@ -755,10 +772,8 @@ def get_annotations(
obj_globals = getattr(obj, "__globals__", None)
obj_locals = None
unwrap = obj
elif ann is not None:
obj_globals = obj_locals = unwrap = None
else:
raise TypeError(f"{obj!r} is not a module, class, or callable.")
obj_globals = obj_locals = unwrap = None

if unwrap is not None:
while True:
Expand Down Expand Up @@ -836,11 +851,11 @@ def _get_dunder_annotations(obj):
ann = _BASE_GET_ANNOTATIONS(obj)
except AttributeError:
# For static types, the descriptor raises AttributeError.
return {}
return None
else:
ann = getattr(obj, "__annotations__", None)
if ann is None:
return {}
return None

if not isinstance(ann, dict):
raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None")
Expand Down
44 changes: 44 additions & 0 deletions Lib/test/test_annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,50 @@ def __annotate__(self):
annotationlib.get_annotations(hb, format=Format.STRING), {"x": str}
)

def test_only_annotate(self):
def f(x: int):
pass

class OnlyAnnotate:
@property
def __annotate__(self):
return f.__annotate__

oa = OnlyAnnotate()
self.assertEqual(
annotationlib.get_annotations(oa, format=Format.VALUE), {"x": int}
)
self.assertEqual(
annotationlib.get_annotations(oa, format=Format.FORWARDREF), {"x": int}
)
self.assertEqual(
annotationlib.get_annotations(oa, format=Format.STRING),
{"x": "int"},
)

def test_no_annotations(self):
class CustomClass:
pass

class MyCallable:
def __call__(self):
pass

for format in Format:
if format == Format.VALUE_WITH_FAKE_GLOBALS:
continue
for obj in (None, 1, object(), CustomClass()):
with self.subTest(format=format, obj=obj):
with self.assertRaises(TypeError):
annotationlib.get_annotations(obj, format=format)

# Callables and types with no annotations return an empty dict
for obj in (int, len, MyCallable()):
with self.subTest(format=format, obj=obj):
self.assertEqual(
annotationlib.get_annotations(obj, format=format), {}
)

def test_pep695_generic_class_with_future_annotations(self):
ann_module695 = inspect_stringized_annotations_pep695
A_annotations = annotationlib.get_annotations(ann_module695.A, eval_str=True)
Expand Down
30 changes: 30 additions & 0 deletions Lib/test/test_functools.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import abc
from annotationlib import Format, get_annotations
import builtins
import collections
import collections.abc
Expand All @@ -22,6 +23,7 @@

from test.support import import_helper
from test.support import threading_helper
from test.support import EqualToForwardRef

import functools

Expand Down Expand Up @@ -2075,6 +2077,34 @@ def orig(a, /, b, c=True): ...
self.assertEqual(str(Signature.from_callable(lru.cache_info)), '()')
self.assertEqual(str(Signature.from_callable(lru.cache_clear)), '()')

def test_get_annotations(self):
def orig(a: int) -> str: ...
lru = self.module.lru_cache(1)(orig)

self.assertEqual(
get_annotations(orig), {"a": int, "return": str},
)
self.assertEqual(
get_annotations(lru), {"a": int, "return": str},
)

def test_get_annotations_with_forwardref(self):
def orig(a: int) -> nonexistent: ...
lru = self.module.lru_cache(1)(orig)

self.assertEqual(
get_annotations(orig, format=Format.FORWARDREF),
{"a": int, "return": EqualToForwardRef('nonexistent', owner=orig)},
)
self.assertEqual(
get_annotations(lru, format=Format.FORWARDREF),
{"a": int, "return": EqualToForwardRef('nonexistent', owner=lru)},
)
with self.assertRaises(NameError):
get_annotations(orig, format=Format.VALUE)
with self.assertRaises(NameError):
get_annotations(lru, format=Format.VALUE)

@support.skip_on_s390x
@unittest.skipIf(support.is_wasi, "WASI has limited C stack")
@support.skip_if_sanitizer("requires deep stack", ub=True, thread=True)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
:func:`annotationlib.get_annotations` now uses the ``__annotate__``
attribute if it is present, even if ``__annotations__`` is not present.
Additionally, the function now raises a :py:exc:`TypeError` if it is passed
an object that does not have any annotatins.
Loading