Skip to content

Commit 1a0f393

Browse files
committed
Add async lock support and refactor storage classes
1 parent 678a40f commit 1a0f393

8 files changed

Lines changed: 235 additions & 72 deletions

File tree

py_cashier/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from ._decorators import cache
2-
from ._key_builders import KeyBuilder
3-
from ._key_builders._default import DefaultKeyBuilder
2+
from ._key_builders import DefaultKeyBuilder, KeyBuilder
43
from ._serializers import KeySerializer, Md5KeySerializer, ReprKeySerializer, StdHashKeySerializer, StrKeySerializer
54
from ._utils import CacheWith
65

py_cashier/_decorators.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@
77
from typing_extensions import ParamSpec
88

99
from py_cashier._key_builders import DefaultKeyBuilder
10-
from py_cashier._storages import BaseLock, Result
10+
from py_cashier._storages import BaseAsyncLock, BaseAsyncStorage, BaseLock, BaseStorage, Result
1111
from py_cashier.logger import logger
1212

1313
if TYPE_CHECKING:
1414
from collections.abc import Awaitable
1515

1616
from py_cashier._key_builders import KeyBuilder
17-
from py_cashier._storages import BaseStorage
1817

1918
TLock = TypeVar("TLock", bound=BaseLock)
19+
TAsyncLock = TypeVar("TAsyncLock", bound=BaseAsyncLock)
2020
P = ParamSpec("P")
2121
T = TypeVar("T")
2222
F = TypeVar("F", bound=Callable[..., Any])
@@ -30,24 +30,34 @@ class PStorage(Protocol[T, TLock]):
3030
def __call__(self) -> BaseStorage[T, TLock]: ...
3131

3232

33+
class PAsyncStorage(Protocol[T, TAsyncLock]):
34+
def __call__(self) -> BaseAsyncStorage[T, TAsyncLock]: ...
35+
36+
3337
def cache(
3438
*,
35-
storage: PStorage[T, TLock],
39+
storage: PStorage[T, TLock] | PAsyncStorage[T, TAsyncLock],
3640
key_builder: PKeyBuilder | None = None,
3741
) -> Callable[[F], F]:
3842
"""Cache decorator."""
3943

4044
def _decorator(f: F) -> F:
4145
k = key_builder() if key_builder is not None else DefaultKeyBuilder(func=f)
46+
s = storage()
4247
if iscoroutinefunction(f):
48+
if not isinstance(s, BaseAsyncStorage):
49+
msg = "Async function requires an async storage"
50+
raise TypeError(msg)
4351
return cast(
4452
"F",
45-
_async_wrapper(func=f, storage=storage(), key_builder=k),
53+
_async_wrapper(func=f, storage=s, key_builder=k),
4654
)
47-
55+
if not isinstance(s, BaseStorage):
56+
msg = "Regular function requires a synchronous storage"
57+
raise TypeError(msg)
4858
return cast(
4959
"F",
50-
_wrapper(func=f, storage=storage(), key_builder=k),
60+
_wrapper(func=f, storage=s, key_builder=k),
5161
)
5262

5363
return _decorator
@@ -79,7 +89,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
7989

8090
def _async_wrapper(
8191
func: Callable[P, Awaitable[T]],
82-
storage: BaseStorage[T, TLock],
92+
storage: BaseAsyncStorage[T, TAsyncLock],
8393
key_builder: KeyBuilder,
8494
) -> Callable[P, Awaitable[T]]:
8595

py_cashier/_storages/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
from ._abc import BaseLock, BaseStorage, Result
2-
from ._ttl_map import SimpleLock, TTLMapStorage
1+
from ._abc import BaseAsyncLock, BaseAsyncStorage, BaseLock, BaseStorage, Result
2+
from ._ttl_map import SimpleAsyncLock, SimpleLock, TTLMapAsyncStorage, TTLMapStorage
33

44
__all__ = [
5+
"BaseAsyncLock",
6+
"BaseAsyncStorage",
57
"BaseLock",
68
"BaseStorage",
79
"Result",
10+
"SimpleAsyncLock",
811
"SimpleLock",
12+
"TTLMapAsyncStorage",
913
"TTLMapStorage",
1014
]

py_cashier/_storages/_abc.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,23 @@ def value(self) -> TValue:
3030

3131
class BaseLock(ABC):
3232
@abstractmethod
33-
async def __aenter__(self) -> Self: ...
33+
def __enter__(self) -> Self: ...
3434

3535
@abstractmethod
36-
async def __aexit__(
36+
def __exit__(
3737
self,
3838
exc_type: type[BaseException] | None,
3939
exc_val: BaseException | None,
4040
exc_tb: TracebackType | None,
4141
) -> None: ...
4242

43+
44+
class BaseAsyncLock(ABC):
4345
@abstractmethod
44-
def __enter__(self) -> Self: ...
46+
async def __aenter__(self) -> Self: ...
4547

4648
@abstractmethod
47-
def __exit__(
49+
async def __aexit__(
4850
self,
4951
exc_type: type[BaseException] | None,
5052
exc_val: BaseException | None,
@@ -53,25 +55,32 @@ def __exit__(
5355

5456

5557
TLock = TypeVar("TLock", bound=BaseLock)
58+
TAsyncLock = TypeVar("TAsyncLock", bound=BaseAsyncLock)
5659

5760

5861
class BaseStorage(ABC, Generic[TValue, TLock]):
5962
@abstractmethod
6063
def lock(self, key: str) -> TLock:
6164
"""Return lock for the key."""
6265

63-
@abstractmethod
64-
async def aget(self, key: str) -> Result[TValue] | None:
65-
"""Get value by key async."""
66-
67-
@abstractmethod
68-
async def aset(self, key: str, value: TValue) -> None:
69-
"""Set value by key async."""
70-
7166
@abstractmethod
7267
def get(self, key: str) -> Result[TValue] | None:
7368
"""Get value by key."""
7469

7570
@abstractmethod
7671
def set(self, key: str, value: TValue) -> None:
7772
"""Set value by key."""
73+
74+
75+
class BaseAsyncStorage(ABC, Generic[TValue, TAsyncLock]):
76+
@abstractmethod
77+
def lock(self, key: str) -> TAsyncLock:
78+
"""Return lock for the key."""
79+
80+
@abstractmethod
81+
async def aget(self, key: str) -> Result[TValue] | None:
82+
"""Get value by key async."""
83+
84+
@abstractmethod
85+
async def aset(self, key: str, value: TValue) -> None:
86+
"""Set value by key async."""

py_cashier/_storages/_ttl_map.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from asyncio import Condition as AsyncCondition
34
from datetime import timedelta
45
from functools import partial
56
from threading import Condition
@@ -10,7 +11,7 @@
1011

1112
from py_cashier.logger import logger
1213

13-
from ._abc import BaseLock, BaseStorage, Result, TValue
14+
from ._abc import BaseAsyncLock, BaseAsyncStorage, BaseLock, BaseStorage, Result, TValue
1415

1516
if TYPE_CHECKING:
1617
from types import TracebackType
@@ -39,6 +40,27 @@ def unregister_lock(self, key: str) -> None:
3940
self._condition.notify_all()
4041

4142

43+
class AsyncLockStorage:
44+
def __init__(self) -> None:
45+
self._locks: set[str] = set()
46+
self._condition = AsyncCondition()
47+
48+
async def register_lock(self, key: str) -> None:
49+
async with self._condition:
50+
while key in self._locks:
51+
logger.debug("Key '%s' is in use, waiting for release.", key)
52+
await self._condition.wait()
53+
logger.debug("Registering lock for key '%s'.", key)
54+
self._locks.add(key)
55+
self._condition.notify_all()
56+
57+
async def unregister_lock(self, key: str) -> None:
58+
async with self._condition:
59+
self._locks.discard(key)
60+
logger.debug("Unregistering lock for key '%s'.", key)
61+
self._condition.notify_all()
62+
63+
4264
class SimpleLock(BaseLock):
4365
def __init__(self, lock_storage: LockStorage, key: str) -> None:
4466
self._lock_storage = lock_storage
@@ -58,9 +80,15 @@ def __exit__(
5880
) -> None:
5981
self._lock_storage.unregister_lock(self._key)
6082

61-
# Async context manager methods are useless here, as the lock logic is synchronous.
83+
84+
class SimpleAsyncLock(BaseAsyncLock):
85+
def __init__(self, lock_storage: AsyncLockStorage, key: str) -> None:
86+
self._lock_storage = lock_storage
87+
self._key = key
88+
6289
@override
6390
async def __aenter__(self) -> Self:
91+
await self._lock_storage.register_lock(self._key)
6492
return self
6593

6694
@override
@@ -70,7 +98,7 @@ async def __aexit__(
7098
exc_val: BaseException | None,
7199
exc_tb: TracebackType | None,
72100
) -> None:
73-
self._lock_storage.unregister_lock(self._key)
101+
await self._lock_storage.unregister_lock(self._key)
74102

75103

76104
class TTLMapStorage(BaseStorage[TValue, SimpleLock]):
@@ -101,10 +129,27 @@ def get(self, key: str) -> Result[TValue] | None:
101129
def set(self, key: str, value: TValue) -> None:
102130
self._storage[key] = value
103131

132+
133+
class TTLMapAsyncStorage(BaseAsyncStorage[TValue, SimpleAsyncLock]):
134+
def __init__(
135+
self,
136+
max_size: int | None = 1024,
137+
ttl: timedelta | None = timedelta(minutes=1),
138+
) -> None:
139+
self._lock_storage = AsyncLockStorage()
140+
self._storage: TTLMap[str, TValue] = TTLMap(max_size=max_size, ttl=ttl)
141+
142+
@override
143+
def lock(self, key: str) -> SimpleAsyncLock:
144+
return SimpleAsyncLock(self._lock_storage, key)
145+
104146
@override
105147
async def aget(self, key: str) -> Result[TValue] | None:
106-
return self.get(key)
148+
try:
149+
return Result(self._storage[key])
150+
except KeyError:
151+
return None
107152

108153
@override
109154
async def aset(self, key: str, value: TValue) -> None:
110-
return self.set(key, value)
155+
self._storage[key] = value

tests/test__decorator.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1+
from __future__ import annotations
2+
13
import asyncio
24
import time
35
from collections import defaultdict
46
from concurrent.futures.thread import ThreadPoolExecutor
57
from datetime import timedelta
8+
from typing import TYPE_CHECKING, Any
69

710
import pytest
811

912
from py_cashier import cache
1013
from py_cashier._storages import TTLMapStorage
14+
from py_cashier._storages._ttl_map import TTLMapAsyncStorage
15+
16+
if TYPE_CHECKING:
17+
from collections.abc import Callable
18+
19+
from py_cashier._decorators import PAsyncStorage, PStorage
1120

1221

1322
@pytest.mark.parametrize(
@@ -50,7 +59,7 @@ async def test_cache_async(a: int, b: int, expected: int) -> None:
5059
"""Test that the cache decorator works correctly for asynchronous functions."""
5160
calls: dict[tuple[int, int], int] = defaultdict(int)
5261

53-
@cache(storage=lambda: TTLMapStorage(max_size=1000, ttl=timedelta(seconds=1)))
62+
@cache(storage=lambda: TTLMapAsyncStorage(max_size=1000, ttl=timedelta(seconds=1)))
5463
async def func(x: int, y: int) -> int:
5564
nonlocal calls
5665
calls[(x, y)] += 1
@@ -83,7 +92,7 @@ async def test_cache_dog_piling_async(a: int, b: int, expected: int, tasks_count
8392
"""
8493
calls = 0
8594

86-
@cache(storage=lambda: TTLMapStorage(max_size=1000, ttl=timedelta(seconds=1)))
95+
@cache(storage=lambda: TTLMapAsyncStorage(max_size=1000, ttl=timedelta(seconds=1)))
8796
async def func(x: int, y: int) -> int:
8897
await asyncio.sleep(0.1)
8998
nonlocal calls
@@ -184,7 +193,7 @@ async def test_cache_failing_func_async(a: int, b: int, tasks_count: int) -> Non
184193
"""Test that failing functions are not cached in asynchronous context."""
185194
calls = 0
186195

187-
@cache(storage=lambda: TTLMapStorage(max_size=1000, ttl=timedelta(seconds=1)))
196+
@cache(storage=lambda: TTLMapAsyncStorage(max_size=1000, ttl=timedelta(seconds=1)))
188197
async def func(x: int, y: int) -> int:
189198
await asyncio.sleep(0.1)
190199
nonlocal calls
@@ -205,3 +214,24 @@ async def func2(x: int, y: int) -> int:
205214
assert all(result == 0 for result in results)
206215
# Each call should execute the function (no caching of errors)
207216
assert calls == tasks_count
217+
218+
219+
async def _af() -> None:
220+
pass
221+
222+
223+
def _f() -> None:
224+
pass
225+
226+
227+
@pytest.mark.parametrize(
228+
("func", "storage"),
229+
[
230+
(_af, lambda: TTLMapStorage(max_size=1000, ttl=timedelta(seconds=1))),
231+
(_f, lambda: TTLMapAsyncStorage(max_size=1000, ttl=timedelta(seconds=1))),
232+
],
233+
)
234+
def test__decorator__invalid_storage(func: Callable[..., Any], storage: PStorage | PAsyncStorage) -> None:
235+
"""Test that the decorator raises TypeError for invalid storage."""
236+
with pytest.raises(TypeError, match="Regular function requires a synchronous storage"):
237+
cache(storage=storage)(func)

0 commit comments

Comments
 (0)