Skip to content

Commit 63760a4

Browse files
feat: add cache_contains() for read-only key lookup (#746)
* feat: add cache_contains method to check cache presence without affecting counters * docs: update README and tests to include examples for cache_contains method * refactor: add type hints to coro functions in cache_contains tests
1 parent e2ddf7a commit 63760a4

3 files changed

Lines changed: 159 additions & 0 deletions

File tree

README.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,23 @@ The library supports explicit invalidation for specific function call by
104104
The method returns `True` if corresponding arguments set was cached already, `False`
105105
otherwise.
106106

107+
To check whether a specific set of arguments is present in the cache without
108+
affecting hit/miss counters or LRU ordering, use `cache_contains()`:
109+
110+
.. code-block:: python
111+
112+
@alru_cache(maxsize=32)
113+
async def func(arg1, arg2):
114+
return arg1 + arg2
115+
116+
await func(1, arg2=2)
117+
118+
func.cache_contains(1, arg2=2) # True
119+
func.cache_contains(3, arg2=4) # False
120+
121+
The method returns `True` if the result for the given arguments is cached, `False`
122+
otherwise.
123+
107124
Limitations
108125
-----------
109126

async_lru/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ def _check_loop(self, loop: asyncio.AbstractEventLoop) -> None:
150150
self.cache_clear()
151151
self.__first_loop = loop
152152

153+
def cache_contains(self, /, *args: Hashable, **kwargs: Any) -> bool:
154+
"""Check if the given arguments are in the cache.
155+
156+
Does not affect hit/miss counters or LRU ordering.
157+
"""
158+
key = _make_key(args, kwargs, self.__typed)
159+
return key in self.__cache
160+
153161
def cache_invalidate(self, /, *args: Hashable, **kwargs: Any) -> bool:
154162
key = _make_key(args, kwargs, self.__typed)
155163

@@ -326,6 +334,9 @@ def __init__(
326334
self.__instance = instance
327335
self.__wrapper = wrapper
328336

337+
def cache_contains(self, /, *args: Hashable, **kwargs: Any) -> bool:
338+
return self.__wrapper.cache_contains(self.__instance, *args, **kwargs)
339+
329340
def cache_invalidate(self, /, *args: Hashable, **kwargs: Any) -> bool:
330341
return self.__wrapper.cache_invalidate(self.__instance, *args, **kwargs)
331342

tests/test_cache_contains.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import asyncio
2+
from typing import Callable
3+
4+
from async_lru import alru_cache
5+
6+
7+
async def test_cache_contains_basic(check_lru: Callable[..., None]) -> None:
8+
@alru_cache(maxsize=4)
9+
async def coro(val: int) -> int:
10+
return val
11+
12+
assert coro.cache_contains(1) is False
13+
await coro(1)
14+
assert coro.cache_contains(1) is True
15+
assert coro.cache_contains(2) is False
16+
check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=4)
17+
18+
19+
async def test_cache_contains_does_not_affect_counters(
20+
check_lru: Callable[..., None],
21+
) -> None:
22+
@alru_cache(maxsize=4)
23+
async def coro(val: int) -> int:
24+
return val
25+
26+
await coro(1)
27+
for _ in range(10):
28+
coro.cache_contains(1)
29+
coro.cache_contains(99)
30+
31+
# hits/misses must stay unchanged after cache_contains calls
32+
check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=4)
33+
34+
35+
async def test_cache_contains_does_not_change_lru_order() -> None:
36+
@alru_cache(maxsize=2)
37+
async def coro(val: int) -> int:
38+
return val
39+
40+
await coro(1)
41+
await coro(2)
42+
43+
# Peek at key 1 without refreshing its LRU position
44+
assert coro.cache_contains(1) is True
45+
46+
# Adding a third entry must evict key 1 (oldest), not key 2
47+
await coro(3)
48+
49+
assert coro.cache_contains(1) is False
50+
assert coro.cache_contains(2) is True
51+
assert coro.cache_contains(3) is True
52+
53+
54+
async def test_cache_contains_after_invalidate_and_clear() -> None:
55+
@alru_cache(maxsize=4)
56+
async def coro(val: int) -> int:
57+
return val
58+
59+
await coro(1)
60+
await coro(2)
61+
62+
coro.cache_invalidate(1)
63+
assert coro.cache_contains(1) is False
64+
assert coro.cache_contains(2) is True # unaffected
65+
66+
coro.cache_clear()
67+
assert coro.cache_contains(2) is False
68+
69+
70+
async def test_cache_contains_with_kwargs() -> None:
71+
@alru_cache(maxsize=4)
72+
async def coro(a: int, b: int = 10) -> int:
73+
return a + b
74+
75+
await coro(1, b=20)
76+
assert coro.cache_contains(1, b=20) is True
77+
assert coro.cache_contains(1, b=30) is False
78+
assert coro.cache_contains(1) is False
79+
80+
81+
async def test_cache_contains_respects_typed_flag() -> None:
82+
@alru_cache(maxsize=4, typed=True)
83+
async def coro(val: int) -> int:
84+
return val
85+
86+
await coro(1)
87+
assert coro.cache_contains(1) is True
88+
assert coro.cache_contains(1.0) is False
89+
90+
91+
async def test_cache_contains_pending_task() -> None:
92+
event = asyncio.Event()
93+
94+
@alru_cache(maxsize=4)
95+
async def coro(val: int) -> int:
96+
await event.wait()
97+
return val
98+
99+
task = asyncio.ensure_future(coro(1))
100+
await asyncio.sleep(0)
101+
102+
# Key must be present even while the underlying task is still running
103+
assert coro.cache_contains(1) is True
104+
105+
event.set()
106+
await task
107+
108+
109+
async def test_cache_contains_after_ttl_expiry() -> None:
110+
@alru_cache(maxsize=4, ttl=0.05)
111+
async def coro(val: int) -> int:
112+
return val
113+
114+
await coro(1)
115+
assert coro.cache_contains(1) is True
116+
117+
await asyncio.sleep(0.1)
118+
assert coro.cache_contains(1) is False
119+
120+
121+
async def test_cache_contains_on_method() -> None:
122+
class MyService:
123+
@alru_cache(maxsize=4)
124+
async def fetch(self, key: int) -> int:
125+
return key * 2
126+
127+
svc = MyService()
128+
await svc.fetch(5)
129+
130+
assert svc.fetch.cache_contains(5) is True
131+
assert svc.fetch.cache_contains(6) is False

0 commit comments

Comments
 (0)