Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
42 changes: 34 additions & 8 deletions async_lru/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,25 @@ def __init__(
self.__doc__ = fn.__doc__
except AttributeError:
pass
try:
self.__annotations__ = fn.__annotations__
except AttributeError:
pass
# Python 3.14+ (PEP 649): copy the lazy __annotate__ function the
# way functools.update_wrapper does; reading fn.__annotations__
# would force evaluation of deferred annotations and fail on
# names that are not defined yet. The version gate keeps older
# interpreters on the plain __annotations__ copy with no
# try/except overhead for a missing __annotate__.
if sys.version_info >= (3, 14):
try:
self.__annotate__ = fn.__annotate__
except AttributeError:
try:
self.__annotations__ = fn.__annotations__
except AttributeError:
pass
else:
try:
self.__annotations__ = fn.__annotations__
except AttributeError:
pass
try:
self.__dict__.update(fn.__dict__)
except AttributeError:
Expand Down Expand Up @@ -329,10 +344,21 @@ def __init__(
self.__doc__ = wrapper.__doc__
except AttributeError:
pass
try:
self.__annotations__ = wrapper.__annotations__
except AttributeError:
pass
# Python 3.14+ (PEP 649): prefer the lazy __annotate__ function,
# see the matching logic in _LRUCacheWrapper.__init__.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should really make both comments concise like this. I'd also drop the prefix so there's less maintenance. We can see the version check below.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 26f7eda — both comments trimmed, prefix dropped.

if sys.version_info >= (3, 14):
try:
self.__annotate__ = wrapper.__annotate__
except AttributeError:
try:
self.__annotations__ = wrapper.__annotations__
except AttributeError:
pass
else:
try:
self.__annotations__ = wrapper.__annotations__
except AttributeError:
pass
try:
self.__dict__.update(wrapper.__dict__)
except AttributeError:
Expand Down
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ exclude = .git,.env,__pycache__,.eggs
max-line-length = 88
extend-select = B950
ignore = N801,N802,N803,E252,W503,E133,E203,E501
# The deferred annotations tests reference names defined after use on
# purpose (PEP 649); flake8 flags those as undefined.
per-file-ignores =
tests/test_deferred_annotations.py:F821

[coverage:run]
branch = True
Expand Down
98 changes: 98 additions & 0 deletions tests/test_deferred_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Tests for PEP 649 deferred annotations (Python 3.14+).

On Python 3.14 annotations are evaluated lazily via ``__annotate__``.
Copying ``fn.__annotations__`` eagerly at decoration time forces that
evaluation and crashes with ``NameError`` when the annotation refers to
a name defined after the decorated function, a pattern that works with
``functools.lru_cache``.
"""

import inspect
import sys
from functools import lru_cache, partial

import pytest

from async_lru import alru_cache


requires_py314 = pytest.mark.skipif(
sys.version_info < (3, 14),
reason="deferred annotation evaluation requires Python 3.14",
)


@requires_py314
async def test_deco_with_annotation_defined_after() -> None:
@alru_cache(maxsize=1)
async def get_foo() -> Foo:
return Foo()

class Foo:
pass

first = await get_foo()
assert isinstance(first, Foo)
assert await get_foo() is first


@requires_py314
async def test_annotations_stay_lazy_like_lru_cache() -> None:
@alru_cache(maxsize=1)
async def get_foo_async() -> Foo:
return Foo()

@lru_cache(maxsize=1)
def get_foo_sync() -> Foo:
return Foo()

class Foo:
pass

assert isinstance(await get_foo_async(), Foo)
assert isinstance(get_foo_sync(), Foo)
assert (
inspect.get_annotations(get_foo_async)
== inspect.get_annotations(get_foo_sync)
== {"return": Foo}
)


@requires_py314
async def test_unresolvable_annotation_raises_only_on_access() -> None:
@alru_cache(maxsize=1)
async def broken() -> Missing: # type: ignore[name-defined] # noqa: F821
pass # pragma: no cover

with pytest.raises(NameError):
inspect.get_annotations(broken)


async def test_method_wrapping_partial_without_annotation_attributes() -> None:
"""A wrapped ``partial`` carries neither ``__annotate__`` nor
``__annotations__``; binding it as a method copies neither."""

async def impl(self: object) -> int:
return 42

class Api:
meth = alru_cache(partial(impl))

api = Api()
assert await api.meth() == 42
assert not hasattr(api.meth, "__annotations__")


@requires_py314
async def test_method_annotations_stay_lazy() -> None:
class Api:
@alru_cache(maxsize=1)
async def get_foo(self) -> Foo:
return Foo()

class Foo:
pass

api = Api()
assert isinstance(await api.get_foo(), Foo)
assert inspect.get_annotations(api.get_foo) == {"return": Foo}
Loading