Skip to content

Commit 912d368

Browse files
committed
feat(cache): implement caching system with Redis and in-memory options, add cache client interface and base classes
1 parent fa59945 commit 912d368

10 files changed

Lines changed: 347 additions & 5 deletions

File tree

fastapi_ronin/cache/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from .base import BaseCacheClient
2+
from .cache import CacheClient, cache
3+
from .cache_client_interface import CacheClientInterface
4+
from .memory import InMemoryCacheClient
5+
from .redis import RedisCacheClient
6+
7+
__all__ = [
8+
'CacheClientInterface',
9+
'BaseCacheClient',
10+
'CacheClient',
11+
'cache',
12+
'InMemoryCacheClient',
13+
'RedisCacheClient',
14+
]

fastapi_ronin/cache/base.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any, Union
3+
4+
from fastapi_ronin.defaults import CACHE_DEFAULT_NAMESPACE, NOT_SET
5+
from fastapi_ronin.types import TTLType
6+
7+
from .cache_client_interface import CacheClientInterface
8+
9+
10+
class BaseCacheClient(CacheClientInterface, ABC):
11+
def __init__(
12+
self,
13+
namespace: str = CACHE_DEFAULT_NAMESPACE,
14+
default_ttl: Union[int, float, None] = None,
15+
):
16+
self.namespace = namespace
17+
self.default_ttl = self._validate_ttl(default_ttl)
18+
19+
async def get(self, key: str) -> Any:
20+
return await self._get(self._make_key(key))
21+
22+
async def set(self, key: str, value: Any, ttl: TTLType = NOT_SET) -> None:
23+
await self._set(self._make_key(key), value, self._get_ttl(ttl))
24+
25+
async def delete(self, key: str) -> None:
26+
await self._delete(self._make_key(key))
27+
28+
async def clear(self) -> None:
29+
await self._clear()
30+
31+
async def exists(self, *keys: str) -> int:
32+
if not keys:
33+
return 0
34+
return await self._exists(*self._make_keys(*keys))
35+
36+
def _make_key(self, key: str) -> str:
37+
return f'{self.namespace}:{key}'
38+
39+
def _make_keys(self, *keys: str) -> tuple[str, ...]:
40+
return tuple(self._make_key(key) for key in keys)
41+
42+
def _get_ttl(self, ttl: TTLType) -> Union[int, float, None]:
43+
_ttl: Union[int, float, None] = None
44+
if ttl is NOT_SET:
45+
_ttl = self.default_ttl
46+
elif ttl is None or isinstance(ttl, (int, float)):
47+
_ttl = ttl
48+
else:
49+
raise ValueError(f'Invalid TTL: {ttl}')
50+
return self._validate_ttl(_ttl)
51+
52+
def _validate_ttl(self, ttl: Union[int, float, None]) -> Union[int, float, None]:
53+
if ttl is not None and ttl <= 0:
54+
raise ValueError(f'TTL must be positive, got: {ttl} seconds')
55+
return ttl
56+
57+
@abstractmethod
58+
async def _get(self, key: str) -> Any:
59+
pass
60+
61+
@abstractmethod
62+
async def _set(self, key: str, value: Any, ttl: Union[int, float, None]) -> None:
63+
pass
64+
65+
@abstractmethod
66+
async def _delete(self, key: str) -> None:
67+
pass
68+
69+
@abstractmethod
70+
async def _clear(self) -> None:
71+
pass
72+
73+
@abstractmethod
74+
async def _exists(self, *keys: str) -> int:
75+
pass
76+
77+
@abstractmethod
78+
async def ping(self) -> bool:
79+
pass
80+
81+
@abstractmethod
82+
async def close(self) -> None:
83+
pass

fastapi_ronin/cache/cache.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from typing import Any, Optional, Union
2+
3+
from fastapi_ronin.defaults import CACHE_DEFAULT_NAMESPACE, NOT_SET
4+
from fastapi_ronin.types import TTLType
5+
6+
from .cache_client_interface import CacheClientInterface
7+
from .memory import InMemoryCacheClient
8+
from .redis import RedisCacheClient
9+
10+
11+
class CacheClient:
12+
def __init__(self):
13+
self._client: Optional[CacheClientInterface] = None
14+
self._initialized = False
15+
16+
async def init(
17+
self,
18+
redis_url: Optional[str] = None,
19+
namespace: str = CACHE_DEFAULT_NAMESPACE,
20+
default_ttl: Union[int, float, None] = None,
21+
) -> None:
22+
if self._initialized:
23+
return
24+
kwargs = {'namespace': namespace, 'default_ttl': default_ttl}
25+
if redis_url:
26+
self._client = RedisCacheClient(redis_url, **kwargs)
27+
else:
28+
self._client = InMemoryCacheClient(**kwargs)
29+
self._initialized = True
30+
31+
async def close(self) -> None:
32+
if not self._initialized or not self._client:
33+
return
34+
35+
await self._client.close()
36+
self._initialized = False
37+
self._client = None
38+
39+
async def get(self, key: str) -> Any:
40+
client = self._ensure_initialized()
41+
return await client.get(key)
42+
43+
async def set(self, key: str, value: Any, ttl: TTLType = NOT_SET) -> None:
44+
client = self._ensure_initialized()
45+
await client.set(key, value, ttl)
46+
47+
async def delete(self, key: str) -> None:
48+
client = self._ensure_initialized()
49+
await client.delete(key)
50+
51+
async def clear(self) -> None:
52+
client = self._ensure_initialized()
53+
await client.clear()
54+
55+
async def exists(self, *keys: str) -> int:
56+
client = self._ensure_initialized()
57+
return await client.exists(*keys)
58+
59+
async def ping(self) -> bool:
60+
client = self._ensure_initialized()
61+
return await client.ping()
62+
63+
def is_initialized(self) -> bool:
64+
return self._initialized
65+
66+
def _ensure_initialized(self) -> CacheClientInterface:
67+
if not self._initialized or not self._client:
68+
raise RuntimeError('Cache client is not initialized. Call await cache.init() before use.')
69+
return self._client
70+
71+
72+
cache = CacheClient()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any
3+
4+
from fastapi_ronin.types import TTLType
5+
6+
7+
class CacheClientInterface(ABC):
8+
@abstractmethod
9+
async def get(self, key: str) -> Any:
10+
pass
11+
12+
@abstractmethod
13+
async def set(self, key: str, value: Any, ttl: TTLType) -> None:
14+
pass
15+
16+
@abstractmethod
17+
async def delete(self, key: str) -> None:
18+
pass
19+
20+
@abstractmethod
21+
async def clear(self) -> None:
22+
pass
23+
24+
@abstractmethod
25+
async def exists(self, *keys: str) -> int:
26+
pass
27+
28+
@abstractmethod
29+
async def ping(self) -> bool:
30+
pass
31+
32+
@abstractmethod
33+
async def close(self) -> None:
34+
pass

fastapi_ronin/cache/memory.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import time
2+
from typing import Any, Optional, Union
3+
4+
from .base import BaseCacheClient
5+
6+
7+
class InMemoryCacheClient(BaseCacheClient):
8+
def __init__(self, **kwargs):
9+
super().__init__(**kwargs)
10+
self._store: dict[str, tuple[Any, Optional[float]]] = {}
11+
12+
async def _get(self, key: str) -> Any:
13+
item = self._store.get(key)
14+
if item is None:
15+
return None
16+
17+
value, expire = item
18+
if expire is not None and expire < time.time():
19+
await self._delete(key)
20+
return None
21+
22+
return value
23+
24+
async def _set(self, key: str, value: Any, ttl: Union[int, float, None]) -> None:
25+
expire = time.time() + ttl if ttl else None
26+
self._store[key] = (value, expire)
27+
28+
async def _delete(self, key: str) -> None:
29+
self._store.pop(key, None)
30+
31+
async def _clear(self) -> None:
32+
namespace_prefix = f'{self.namespace}:'
33+
keys_to_delete = [key for key in self._store.keys() if key.startswith(namespace_prefix)]
34+
for key in keys_to_delete:
35+
del self._store[key]
36+
37+
async def _exists(self, *keys: str) -> int:
38+
if not keys:
39+
return 0
40+
count = 0
41+
for key in keys:
42+
item = self._store.get(key)
43+
if item is not None:
44+
_, expire = item
45+
if expire is None or expire >= time.time():
46+
count += 1
47+
else:
48+
await self._delete(key)
49+
return count
50+
51+
async def ping(self) -> bool:
52+
return True
53+
54+
async def close(self) -> None:
55+
pass

fastapi_ronin/cache/redis.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import json
2+
from typing import Any, Union
3+
4+
from .base import BaseCacheClient
5+
6+
try:
7+
from redis.asyncio import Redis # type: ignore
8+
except ImportError:
9+
Redis = None
10+
11+
12+
class RedisCacheClient(BaseCacheClient):
13+
def __init__(self, url: str, **kwargs):
14+
super().__init__(**kwargs)
15+
if Redis is None:
16+
raise ImportError('Redis is not installed. Install it with: pip install fastapi-ronin[redis]')
17+
self.redis = Redis.from_url(url, encoding='utf-8', decode_responses=True)
18+
19+
async def _get(self, key: str) -> Any:
20+
raw = await self.redis.get(key)
21+
if raw is None:
22+
return None
23+
try:
24+
return json.loads(raw)
25+
except (json.JSONDecodeError, TypeError):
26+
return None
27+
28+
async def _set(self, key: str, value: Any, ttl: Union[int, float, None]) -> None:
29+
try:
30+
serialized = json.dumps(value)
31+
except (TypeError, ValueError) as e:
32+
raise ValueError(f'Value cannot be serialized to JSON: {e}') from e
33+
await self.redis.set(key, serialized, px=None if ttl is None else int(ttl * 1000))
34+
35+
async def _delete(self, key: str) -> None:
36+
await self.redis.delete(key)
37+
38+
async def _clear(self) -> None:
39+
pattern = self._make_key('*')
40+
cursor = 0
41+
while True:
42+
cursor, keys = await self.redis.scan(cursor, match=pattern, count=500)
43+
if keys:
44+
await self.redis.delete(*keys)
45+
if cursor == 0:
46+
break
47+
48+
async def _exists(self, *keys: str) -> int:
49+
if not keys:
50+
return 0
51+
result = await self.redis.exists(*keys)
52+
return int(result)
53+
54+
async def ping(self) -> bool:
55+
try:
56+
await self.redis.ping() # type: ignore
57+
return True
58+
except Exception:
59+
return False
60+
61+
async def close(self) -> None:
62+
await self.redis.aclose()

fastapi_ronin/defaults.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class _NotSet:
2+
def __repr__(self):
3+
return '<fastapi_ronin.defaults.NOT_SET>'
4+
5+
6+
NOT_SET = _NotSet()
7+
8+
# Cache
9+
CACHE_DEFAULT_TTL = NOT_SET
10+
CACHE_DEFAULT_NAMESPACE = 'ronin'

fastapi_ronin/types.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
from typing import TYPE_CHECKING, Any, TypeVar, Union
1+
from typing import Any, TypeVar, Union
22

33
from pydantic import BaseModel
44
from tortoise.contrib.pydantic import PydanticModel
55
from tortoise.models import Model
66

7-
if TYPE_CHECKING:
8-
pass
9-
7+
from fastapi_ronin.defaults import _NotSet
108

119
T = TypeVar('T', bound=Any)
1210
UserType = TypeVar('UserType')
1311
SchemaType = TypeVar('SchemaType', bound=Union[PydanticModel, BaseModel])
1412
ModelType = TypeVar('ModelType', bound=Model)
13+
14+
TTLType = Union[int, float, None, _NotSet]

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ dev = [
5151
"pytest>=9.0.1",
5252
"ruff>=0.12.2",
5353
"uvicorn[standard]>=0.35.0",
54+
"redis>=5.0.0",
5455
]
5556
docs = [
5657
"mkdocs>=1.5.0",

0 commit comments

Comments
 (0)