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

singledispatch.register fails for lru_cache decorated functions #132064

Open
MarcinKonowalczyk opened this issue Apr 4, 2025 · 14 comments · May be fixed by #132195
Open

singledispatch.register fails for lru_cache decorated functions #132064

MarcinKonowalczyk opened this issue Apr 4, 2025 · 14 comments · May be fixed by #132195
Labels
3.14 new features, bugs and security fixes stdlib Python modules in the Lib dir type-bug An unexpected behavior, bug, or error

Comments

@MarcinKonowalczyk
Copy link

MarcinKonowalczyk commented Apr 4, 2025

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:

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

Linked PRs

@MarcinKonowalczyk
Copy link
Author

MarcinKonowalczyk commented Apr 4, 2025

See here (PR to my own package) for implemented workaround, tested with tox for py39~py314 on ubuntu, macOS and windows (-latest).

@MarcinKonowalczyk
Copy link
Author

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?)

@picnixz picnixz added the stdlib Python modules in the Lib dir label Apr 4, 2025
@picnixz
Copy link
Member

picnixz commented Apr 4, 2025

@rhettinger Was singledispatch designed to work on LRU-decorated functions?

@MarcinKonowalczyk
Copy link
Author

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.

@picnixz
Copy link
Member

picnixz commented Apr 4, 2025

Oh so you mean that it's a 3.14+ issue?

@MarcinKonowalczyk
Copy link
Author

MarcinKonowalczyk commented Apr 4, 2025

yes! sorry, reading back i was not clear about that 😅 (added an EDIT to the original report to make it clearer.)

@picnixz picnixz added the 3.14 new features, bugs and security fixes label Apr 4, 2025
@picnixz
Copy link
Member

picnixz commented Apr 4, 2025

As it uses get_type_hints(func, format=Format.FORWARDREF), I'm asking first @JelleZijlstra. I can have a look tomorrow though

@rhettinger
Copy link
Contributor

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.

@JelleZijlstra
Copy link
Member

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?

@rhettinger
Copy link
Contributor

@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 --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.

@JelleZijlstra
Copy link
Member

No, adding __annotations__ to WRAPPER_ASSIGNMENTS would be incorrect because it would immediately force evaluation of the annotations of any wrapped function.

@JelleZijlstra
Copy link
Member

The Python implementation of lru_cache already works on 3.14, so it doesn't need any changes to fix this issue. (To test, take the OP's script, comment out the lines in functools.py that import the C implementation, and run the script.)

@rhettinger
Copy link
Contributor

Another possibility is for singledispatch.register to follow the suggestion in the docs for update_wrapper, "To allow access to the original function for introspection and other purposes (e.g. bypassing a caching decorator such as lru_cache(), this function automatically adds a wrapped attribute to the wrapper that refers to the function being wrapped."

Presumably, lru_cache isn't the only decorator that was broken by the edit to WRAPPER_ASSIGNMENTS.

@JelleZijlstra
Copy link
Member

I found a better fix for the original issue reported here: #132195. This makes annotationlib use the __annotate__ function if present, even if __annotations__ is not also present. This should help not only @lru_cache, but any other decorator that uses update_wrapper on a wrapper that is not a Python function.

With that fix, it's still the case though that lru_cache-decorated functions don't have a direct __annotations__ attribute when the C version is used:

>>> @functools.lru_cache
... def f(x: int): pass
... 
>>> f.__annotations__
Traceback (most recent call last):
  File "<python-input-2>", line 1, in <module>
    f.__annotations__
AttributeError: 'functools._lru_cache_wrapper' object has no attribute '__annotations__'. Did you mean: '__annotate__'?
>>> f.__wrapped__.__annotations__
{'x': <class 'int'>}
>>> f.__annotate__(1)
{'x': <class 'int'>}

This could impact users who (against recommendations) access __annotations__ directly instead of using annotationlib.get_annotations or (in previous Python versions) inspect.get_annotations. To fix it, we could add a dedicated __annotations__ descriptor to the C version of functools._lru_cache_wrapper. Is that worth it? I'm not sure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3.14 new features, bugs and security fixes stdlib Python modules in the Lib dir type-bug An unexpected behavior, bug, or error
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants