Repository: python/cpython
Pull request: gh-132064: Make annotationlib use __annotate__ if only it is present
Fixes #132064. Non-function objects for which `update_wrapper()` was used end up with only `__annotate__`, not `__annotations__`. It seems sensible for `get_annotations()` to support this case, and that also fixes the issue that was reported.

While implementing this I noticed a discrepancy between the docs and behavior of `get_annotations()`. The documentation said that the function would raise on unsupported objects; in fact it returned an empty dictionary, except if `eval_str=True`. I made it consistently raise a TypeError in this case instead, consistent with the docs.

<!-- gh-issue-number: gh-132064 -->
* Issue: gh-132064
<!-- /gh-issue-number -->


<!-- readthedocs-preview cpython-previews start -->
----
📚 Documentation preview 📚: https://cpython-previews--132195.org.readthedocs.build/

<!-- readthedocs-preview cpython-previews end -->

Problem statement:
`singledispatch.register` fails for `lru_cache` decorated functions
# Bug report

Function decorated with both `@singledispatch.register` and `@lru_chache` fails with a cryptic error message.

EDIT: In python 3.14a6. It works in python 3.9~3.13.

### Bug description:

```python
import sys
from functools import lru_cache, singledispatch, wraps
from typing import Any, Callable

def wrapped_lru_cache[T: Callable](maxsize: int = 1024) -> Callable[[T], T]:
    def decorator(func: T) -> T:
        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            return lru_cache(maxsize=maxsize)(func)(*args, **kwargs)
        return wrapper  # type: ignore
    return decorator

if len(sys.argv) > 1 and sys.argv[1] == "--broken-in-py314":
    # does not work in python 3.14+
    lru = lru_cache
else:
    # workaround
    lru = wrapped_lru_cache

@singledispatch
def fun(x: str | tuple) -> int:
    raise TypeError(f"Input must be str or tuple not a {type(x)}")

@fun.register
@lru(maxsize=1024)  # <- this
def _(x: str) -> int:
    return len(x)

@fun.register
def _(x: tuple) -> int:
    sum = 0
    for e in x:
        sum += fun(e)
    return sum

if __name__ == "__main__":
    print(f"{fun("hello")=}")
    print(f"{fun(("hello", "sailor"))=}")
```

`python ./singledispatch_mwe.py`, but `python ./singledispatch_mwe.py --broken-in-py314` produces the following error:

```
Traceback (most recent call last):
  File ".../singledispatch_mwe.py", line 25, in <module>
    @fun.register
     ^^^^^^^^^^^^
  File "~/.local/share/uv/python/cpython-3.14.0a6-macos-aarch64-none/lib/python3.14/functools.py", line 963, in register
    argname, cls = next(iter(get_type_hints(func, format=Format.FORWARDREF).items()))
```

In python 3.9~3.13 both of the above work.


### CPython versions tested on:

3.9~3.14

### Operating systems tested on:

macOS

<!-- gh-linked-prs -->
### Linked PRs
* gh-132195
<!-- /gh-linked-prs -->

Hints from issue discussion:
See [here](https://github.com/MarcinKonowalczyk/psll-lang/pull/7/files) (PR to my own package) for implemented workaround, tested with tox for py39~py314 on ubuntu, macOS and windows (-latest).
Also, i'd say there are at least two things wrong here:

1) The error message is very cryptic (i'm still not 100% sure what exactly is wrong)
2) Either `lru_cache` is not wrapping the function properly or `.register` is not picking up the type hints correctly (maybe a fallback to the old behaviour?)
@rhettinger Was `singledispatch` designed to work on LRU-decorated functions?

for reference, it worked as far back as i've tested, aka down to 3.9. i do realise it _might_ not have been intended to work though, and so this is not necessarily a feature.
Oh so you mean that it's a 3.14+ issue?
yes! sorry, reading back i was not clear about that 😅 (added an `EDIT` to the original report to make it clearer.)
As it uses `get_type_hints(func, format=Format.FORWARDREF)`, I'm asking first @JelleZijlstra. I can have a look tomorrow though
`functools.lru_cache` copies essential attributes from the wrapped function with `functools.update_wrapper`.  Its current default tuple of attributes is `WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__', '__annotate__', '__type_params__')`. That tuple was last updated by @JelleZijlstra in 2024. Perhaps it should include `__annotations__` as well. Also contact @ambv to verify that he expects this to work.
This is indeed a consequence of the implementation of PEP 649/PEP 749, specifically this section: https://peps.python.org/pep-0749/#wrappers-that-provide-annotations .

It is fixable in `_functoolsmodule.c` by adding an entry to `lru_cache_getsetlist` for `__annotations__`, providing both a getter and a setter that mirror those for the `__annotations__` attribute on functions. The setter should unconditionally set `__annotations__` in the `__dict__`; the getter should return `__annotations__` from the `__dict__` if available, else try to call `__annotate__` if it's in the dict, and cache the result. Anyone interested in implementing that?
@JelleZijlstra, The lru_cache has a pure python implementation as well.  The wrapping of both the pure python version and the C version is done in `functools.py` with `update_wrapper`. So, I don't think a C edit is a complete or correct solution.

Can this be fixed for the general case by expanding `WRAPPER_ASSIGNMENTS` to include `__annotations__`?  The guidance says, "The __annotate__ and __annotations__ attributes should both be supplied with semantics consistent to those of the wrapped object."  So it looks to me like `__annotations__` should be added back to `WRAPPER_ASSIGNMENTS`:

```diff
diff --git a/Lib/functools.py b/Lib/functools.py
index 714070c6ac9..489b3e1071c 100644
--- a/Lib/functools.py
+++ b/Lib/functools.py
@@ -30,7 +30,7 @@
 # wrapper functions that can handle naive introspection
 
 WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__',
-                       '__annotate__', '__type_params__')
+                       '__annotate__', '__annotations__', '__type_params__')
 WRAPPER_UPDATES = ('__dict__',)
 def update_wrapper(wrapper,
                    wrapped,
```

Looking at the `lru_cache` wrapping code in pure python, I don't see any part of the current issue that is specific to the `lru_cache`. AFAICT, this would happen to any wrapped function.

Commit message: Return an empty dict for any callable

Patch to review:
diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst
index e07081e3c5dd7a..69a8767bf10d79 100644
--- a/Doc/library/annotationlib.rst
+++ b/Doc/library/annotationlib.rst
@@ -317,11 +317,17 @@ 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.
+   For the VALUE format, the :attr:`~object.__annotations__` is tried first; if it
+   does not exist, the :attr:`~object.__annotate__` function is called. The
+   FORWARDREF format uses :attr:`~object.__annotations__` if it exists and can be
+   evaluated, and otherwise falls back to calling the :attr:`~object.__annotate__` function.
+   The SOURCE format tries :attr:`~object.__annotate__` first, and falls back to
+   using :attr:`~object.__annotations__`, 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/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.


Code review comments:
diff_hunk: @@ -317,11 +317,17 @@ 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.
+   For the VALUE format, the :attr:`~object.__annotations__` is tried first; if it
+   does not exist, the :attr:`~object.__annotate__` function is called. The
+   FORWARDREF format uses :attr:`~object.__annotations__` if it exists and can be
+   evaluated, and otherwise falls back to calling the :attr:`~object.__annotate__` function.
+   The SOURCE format tries :attr:`~object.__annotate__` first, and falls back to
+   using :attr:`~object.__annotations__`, stringified using :func:`annotations_to_string`.
path: Doc/library/annotationlib.rst
text: @user1:
I think we usually aim to have at most one link to any one anchor per paragraph. Too many links in a short span of text can be distracting, and each additional link doesn't add much if they all take you to the same place. So I'd only keep the first link to `object.__annotate__`/`object.__annotations__`, and suppress the other ones in this paragraph:

```suggestion
   For the VALUE format, the :attr:`~object.__annotations__` is tried first; if it
   does not exist, the :attr:`~object.__annotate__` function is called. The
   FORWARDREF format uses :attr:`!object.__annotations__` if it exists and can be
   evaluated, and otherwise falls back to calling the :attr:`!object.__annotate__` function.
   The SOURCE format tries :attr:`!object.__annotate__` first, and falls back to
   using :attr:`!object.__annotations__`, stringified using :func:`annotations_to_string`.
```

@user1:
Is it also worth considering using bullet points for each `Format` variant?

@author:
Done, thanks

@user1:
hmm, I don't see the change -- did you forget to push?

@author:
Oops, done