diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst index e07081e3c5dd7a..b1fe0797dff308 100644 --- a/Doc/library/annotationlib.rst +++ b/Doc/library/annotationlib.rst @@ -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 diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index d6243c8863610e..303b26b21b6047 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -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: @@ -696,24 +702,29 @@ 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) @@ -721,12 +732,18 @@ def get_annotations( 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 {} @@ -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: @@ -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") diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index f10282042c7430..9b3619afea2d45 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -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) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 2b49615178f136..4794a7465f0b66 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -1,4 +1,5 @@ import abc +from annotationlib import Format, get_annotations import builtins import collections import collections.abc @@ -22,6 +23,7 @@ from test.support import import_helper from test.support import threading_helper +from test.support import EqualToForwardRef import functools @@ -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) diff --git a/Misc/NEWS.d/next/Library/2025-04-06-21-17-14.gh-issue-132064.ktPwDM.rst b/Misc/NEWS.d/next/Library/2025-04-06-21-17-14.gh-issue-132064.ktPwDM.rst new file mode 100644 index 00000000000000..2559b711a417f9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-04-06-21-17-14.gh-issue-132064.ktPwDM.rst @@ -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.