patera-caching 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.3
2
+ Name: patera-caching
3
+ Version: 0.1.0
4
+ Requires-Dist: cachetools>=7.0.5
5
+ Requires-Dist: patera
6
+ Requires-Dist: redis>=5.0 ; extra == 'all'
7
+ Requires-Dist: aiosqlite>=0.20 ; extra == 'all'
8
+ Requires-Dist: redis>=5.0 ; extra == 'redis'
9
+ Requires-Dist: aiosqlite>=0.20 ; extra == 'sqlite'
10
+ Requires-Python: >=3.12
11
+ Provides-Extra: all
12
+ Provides-Extra: redis
13
+ Provides-Extra: sqlite
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "patera-caching"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "cachetools>=7.0.5",
7
+ "patera",
8
+ ]
9
+
10
+ [project.optional-dependencies]
11
+ redis = [
12
+ "redis>=5.0",
13
+ ]
14
+ sqlite = [
15
+ "aiosqlite>=0.20",
16
+ ]
17
+ all = [
18
+ "redis>=5.0",
19
+ "aiosqlite>=0.20",
20
+ ]
21
+
22
+ [tool.uv.sources]
23
+ patera = { workspace = true }
24
+
25
+ [tool.uv.build-backend]
26
+ module-name = "patera.caching"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.10.9,<0.11.0"]
30
+ build-backend = "uv_build"
@@ -0,0 +1,8 @@
1
+ """
2
+ Caching module
3
+ """
4
+
5
+ from .cache import Cache, CacheConfig
6
+ from .backends.base_cache_backend import BaseCacheBackend
7
+
8
+ __all__ = ["Cache", "BaseCacheBackend", "CacheConfig"]
@@ -0,0 +1,51 @@
1
+ """
2
+ Base/Blueprint class for cache implementation
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Optional, TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from patera import Patera
10
+
11
+
12
+ class BaseCacheBackend(ABC):
13
+ """
14
+ Abstract cache backend blueprint.
15
+
16
+ Subclasses should implement:
17
+ - configure_from_app(cls, app) -> BaseCacheBackend
18
+ - connect / disconnect
19
+ - get / set / delete / clear
20
+ """
21
+
22
+ @classmethod
23
+ @abstractmethod
24
+ def configure_from_app(
25
+ cls, app: "Patera", configs: dict[str, Any]
26
+ ) -> "BaseCacheBackend":
27
+ """Create a configured backend instance using app config."""
28
+
29
+ @abstractmethod
30
+ async def connect(self) -> None:
31
+ """Establish any required connections (no-op for memory)."""
32
+
33
+ @abstractmethod
34
+ async def disconnect(self) -> None:
35
+ """Tear down connections (no-op for memory)."""
36
+
37
+ @abstractmethod
38
+ async def get(self, key: str) -> Optional[dict]:
39
+ """Return cached payload dict or None."""
40
+
41
+ @abstractmethod
42
+ async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
43
+ """Store payload dict under key with optional TTL in seconds."""
44
+
45
+ @abstractmethod
46
+ async def delete(self, key: str) -> None:
47
+ """Delete a cached entry if present."""
48
+
49
+ @abstractmethod
50
+ async def clear(self) -> None:
51
+ """Clear the entire cache namespace."""
@@ -0,0 +1,72 @@
1
+ """
2
+ In-memory cache implementation
3
+ """
4
+
5
+ from typing import Optional, cast, TYPE_CHECKING, Any
6
+ import asyncio
7
+
8
+ from cachetools import TTLCache
9
+
10
+ from .base_cache_backend import BaseCacheBackend
11
+
12
+ if TYPE_CHECKING:
13
+ from patera import Patera
14
+
15
+
16
+ class MemoryCacheBackend(BaseCacheBackend):
17
+ """
18
+ In-memory cache using cachetools.TTLCache for bounded size and base TTL.
19
+
20
+
21
+ Per-item TTL by storing an explicit expire timestamp alongside
22
+ the payload; TTLCache provides a global upper bound and eviction.
23
+ """
24
+
25
+ def __init__(self, default_ttl: int = 300, maxsize: int = 10_000):
26
+ self.default_ttl = int(default_ttl)
27
+ # Stores: key -> {payload: dict, expire: float}
28
+ self._cache: TTLCache[str, dict] = TTLCache(
29
+ maxsize=maxsize, ttl=self.default_ttl
30
+ )
31
+
32
+ # ---- config ----
33
+ @classmethod
34
+ def configure_from_app(
35
+ cls, app: "Patera", configs: dict[str, Any]
36
+ ) -> "MemoryCacheBackend":
37
+ default_ttl = configs["DURATION"]
38
+ maxsize = configs.get("MEMORY_MAXSIZE", 10_000)
39
+ return cls(default_ttl=default_ttl, maxsize=maxsize)
40
+
41
+ async def connect(self) -> None: # pragma: no cover - nothing to do
42
+ return None
43
+
44
+ async def disconnect(self) -> None:
45
+ self._cache.clear()
46
+
47
+ async def get(self, key: str) -> Optional[dict]:
48
+ item = self._cache.get(key)
49
+ if not item:
50
+ return None
51
+ expire = item.get("expire", 0)
52
+ if expire < asyncio.get_event_loop().time():
53
+ try:
54
+ del self._cache[key]
55
+ except KeyError:
56
+ pass
57
+ return None
58
+ return cast(dict, item.get("payload"))
59
+
60
+ async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
61
+ ttl = int(duration) if duration is not None else self.default_ttl
62
+ expire = asyncio.get_event_loop().time() + ttl
63
+ self._cache[key] = {"payload": value, "expire": expire}
64
+
65
+ async def delete(self, key: str) -> None:
66
+ try:
67
+ del self._cache[key]
68
+ except KeyError:
69
+ pass
70
+
71
+ async def clear(self) -> None:
72
+ self._cache.clear()
@@ -0,0 +1,111 @@
1
+ """
2
+ Redis cache implementation
3
+
4
+ CACHE_REDIS_URL = "redis://localhost:6379/0" # required
5
+ CACHE_REDIS_PASSWORD = None # optional
6
+ CACHE_DURATION = 300 # optional (default TTL)
7
+ CACHE_KEY_PREFIX = "patera:cache:" # optional prefix/namespace
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pickle
13
+ from typing import Optional, cast, List, TYPE_CHECKING, Any
14
+
15
+ from redis.asyncio import Redis, from_url
16
+
17
+ from .base_cache_backend import BaseCacheBackend
18
+
19
+ if TYPE_CHECKING:
20
+ from patera import Patera
21
+
22
+
23
+ class RedisCacheBackend(BaseCacheBackend):
24
+ """Redis-backed cache using binary pickled payloads."""
25
+
26
+ def __init__(
27
+ self,
28
+ url: str,
29
+ password: Optional[str] = None,
30
+ default_ttl: int = 300,
31
+ key_prefix: str = "",
32
+ ) -> None:
33
+ if not url:
34
+ raise ValueError("CACHE_REDIS_URL must be set for RedisCacheBackend")
35
+ self._url = url
36
+ self._password = password
37
+ self._client: Optional[Redis] = None
38
+ self.default_ttl = int(default_ttl)
39
+ # Normalize prefix to empty or end with ':' for nicer namespaces
40
+ if key_prefix and not key_prefix.endswith(":") and key_prefix != "":
41
+ key_prefix = key_prefix + ":"
42
+ self._prefix = key_prefix
43
+
44
+ @classmethod
45
+ def configure_from_app(
46
+ cls, app: Patera, configs: dict[str, Any]
47
+ ) -> "RedisCacheBackend":
48
+ url = configs.get("REDIS_URL", "")
49
+ password = configs.get("REDIS_PASSWORD", None)
50
+ ttl = cast(int, configs.get("DURATION"))
51
+ key_prefix = configs.get("KEY_PREFIX", "")
52
+ return cls(url=url, password=password, default_ttl=ttl, key_prefix=key_prefix)
53
+
54
+ async def connect(self) -> None:
55
+ if not self._client:
56
+ # decode_responses=False -> bytes in/out, good for pickled values
57
+ self._client = await from_url(
58
+ self._url,
59
+ encoding="utf-8",
60
+ decode_responses=False,
61
+ password=self._password,
62
+ )
63
+
64
+ async def disconnect(self) -> None:
65
+ if self._client:
66
+ await self._client.close()
67
+ self._client = None
68
+
69
+ # ---- helpers ----
70
+ def _k(self, key: str) -> str:
71
+ return f"{self._prefix}{key}" if self._prefix else key
72
+
73
+ async def _ensure(self) -> Redis:
74
+ if not self._client:
75
+ # Allow lazy connect if caller forgot to call connect()
76
+ await self.connect()
77
+ assert self._client is not None
78
+ return self._client
79
+
80
+ async def get(self, key: str) -> Optional[dict]:
81
+ client = await self._ensure()
82
+ raw = await client.get(self._k(key))
83
+ if not raw:
84
+ return None
85
+ return cast(dict, pickle.loads(raw))
86
+
87
+ async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
88
+ client = await self._ensure()
89
+ ttl = int(duration) if duration is not None else self.default_ttl
90
+ await client.setex(self._k(key), ttl, pickle.dumps(value))
91
+
92
+ async def delete(self, key: str) -> None:
93
+ client = await self._ensure()
94
+ await client.delete(self._k(key))
95
+
96
+ async def clear(self) -> None:
97
+ client = await self._ensure()
98
+ if self._prefix and self._prefix != "":
99
+ keys: List[bytes] = []
100
+ async for k in client.scan_iter(match=f"{self._prefix}*"):
101
+ keys.append(k)
102
+ if keys:
103
+ # Pipeline deletion in chunks to avoid huge payloads
104
+ # pylint: disable-next=C0103
105
+ CHUNK = 1000
106
+ for i in range(0, len(keys), CHUNK):
107
+ chunk = keys[i : i + CHUNK]
108
+ await client.delete(*chunk)
109
+ else:
110
+ # flushes entire Redis db in case no namespace prefix is configured
111
+ await client.flushdb()
@@ -0,0 +1,239 @@
1
+ """
2
+ SQLite cache backend
3
+
4
+ Indicated values are defaults
5
+ CACHE_SQLITE_PATH: str = "./pyjolt_cache.sqlite3" # file path (required for persistence)
6
+ CACHE_SQLITE_TABLE: str = "cache_entries" # table name
7
+ CACHE_KEY_PREFIX: str = "patera:cache:" # optional key namespace
8
+ CACHE_DURATION: int = 300 # default TTL seconds
9
+ CACHE_SQLITE_WAL_CHECKPOINT_MODE: str = "PASSIVE" #PASSIVE|FULL|RESTART|TRUNCATE
10
+ CACHE_SQLITE_WAL_CHECKPOINT_EVERY: int = 100 #run checkpoint every N write ops
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import pickle
17
+ import time
18
+ from typing import Optional, TYPE_CHECKING, Tuple, cast, Any
19
+ from pydantic import BaseModel, Field
20
+
21
+ import aiosqlite
22
+
23
+ from .base_cache_backend import BaseCacheBackend
24
+
25
+ if TYPE_CHECKING:
26
+ from patera import Patera
27
+
28
+
29
+ class SQLiteCacheConfig(BaseModel):
30
+ CACHE_SQLITE_PATH: str = Field(
31
+ "./pyjolt_cache.db",
32
+ description="SQLite DB file path (use ':memory:' for in-memory)",
33
+ )
34
+ CACHE_SQLITE_TABLE: str = Field(
35
+ "cache_entries", description="Table name for cache entries"
36
+ )
37
+ CACHE_DURATION: int = Field(300, description="Cache default TTL in seconds")
38
+ CACHE_KEY_PREFIX: str = Field("", description="Cache key prefix/namespace")
39
+ CACHE_SQLITE_WAL_CHECKPOINT_MODE: str = Field(
40
+ "PASSIVE",
41
+ description="Mode for WAL checkpointing: PASSIVE|FULL|RESTART|TRUNCATE",
42
+ )
43
+ CACHE_SQLITE_WAL_CHECKPOINT_EVERY: int = Field(
44
+ 100, description="Insert WAL checkpoint every N write operations"
45
+ )
46
+
47
+
48
+ class SQLiteCacheBackend(BaseCacheBackend):
49
+ """SQLite-backed cache using pickled payloads (async via aiosqlite)."""
50
+
51
+ def __init__(
52
+ self,
53
+ db_path: str,
54
+ table: str = "cache_entries",
55
+ default_ttl: int = 300,
56
+ key_prefix: str = "",
57
+ checkpoint_mode: str = "PASSIVE",
58
+ checkpoint_every: int = 100,
59
+ ) -> None:
60
+ if not db_path:
61
+ raise ValueError(
62
+ "CACHE_SQLITE_PATH must be provided for SQLiteCacheBackend"
63
+ )
64
+ self._db_path = db_path
65
+ self._table = table
66
+ self.default_ttl = int(default_ttl)
67
+ self._conn: Optional[aiosqlite.Connection] = None
68
+ if key_prefix and not key_prefix.endswith(":"):
69
+ key_prefix = key_prefix + ":"
70
+ self._prefix = key_prefix
71
+ # WAL checkpoint settings
72
+ self._checkpoint_mode = checkpoint_mode.upper()
73
+ if self._checkpoint_mode not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}:
74
+ raise ValueError(
75
+ "CACHE_SQLITE_WAL_CHECKPOINT_MODE must be one of PASSIVE|FULL|RESTART|TRUNCATE"
76
+ )
77
+ self._checkpoint_every = max(1, int(checkpoint_every))
78
+ self._write_ops = 0
79
+
80
+ @classmethod
81
+ def configure_from_app(
82
+ cls, app: "Patera", configs: dict[str, Any]
83
+ ) -> "SQLiteCacheBackend":
84
+ db_path = cast(str, configs.get("SQLITE_PATH", "./pyjolt_cache.db"))
85
+ table = cast(str, configs.get("SQLITE_TABLE", "cache_entries"))
86
+ default_ttl = int(configs.get("DURATION", 300))
87
+ key_prefix = cast(str, configs.get("KEY_PREFIX", ""))
88
+ checkpoint_mode = cast(
89
+ str, configs.get("SQLITE_WAL_CHECKPOINT_MODE", "PASSIVE")
90
+ )
91
+ checkpoint_every = int(configs.get("SQLITE_WAL_CHECKPOINT_EVERY", 100))
92
+ if db_path != ":memory:":
93
+ os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True)
94
+ return cls(
95
+ db_path=db_path,
96
+ table=table,
97
+ default_ttl=default_ttl,
98
+ key_prefix=key_prefix,
99
+ checkpoint_mode=checkpoint_mode,
100
+ checkpoint_every=checkpoint_every,
101
+ )
102
+
103
+ async def connect(self) -> None:
104
+ if self._conn:
105
+ return
106
+ self._conn = await aiosqlite.connect(self._db_path)
107
+ # Pragmas tuned for app caches (good perf/safety tradeoffs)
108
+ await self._conn.execute("PRAGMA journal_mode=WAL;")
109
+ await self._conn.execute("PRAGMA synchronous=NORMAL;")
110
+ await self._conn.execute("PRAGMA foreign_keys=ON;")
111
+ await self._conn.execute(
112
+ "PRAGMA busy_timeout=3000;"
113
+ ) # 3s wait instead of immediate 'database is locked'. Useful for WAL mode with concurrent readers/writers (multiple app workers)
114
+ await self._conn.execute(
115
+ "PRAGMA wal_autocheckpoint=1000;"
116
+ ) # checkpoint every ~1000 pages (~4MB)
117
+ await self._ensure_schema()
118
+ await self._conn.commit()
119
+
120
+ async def disconnect(self) -> None:
121
+ if self._conn is None:
122
+ return
123
+ await self._conn.close()
124
+ self._conn = None
125
+
126
+ async def _ensure_schema(self) -> None:
127
+ assert self._conn is not None
128
+ t = self._table
129
+ await self._conn.executescript(
130
+ f"""
131
+ CREATE TABLE IF NOT EXISTS {t} (
132
+ k TEXT PRIMARY KEY,
133
+ v BLOB NOT NULL,
134
+ expire REAL NOT NULL,
135
+ updated_at REAL NOT NULL
136
+ );
137
+ CREATE INDEX IF NOT EXISTS idx_{t}_expire ON {t}(expire);
138
+ CREATE INDEX IF NOT EXISTS idx_{t}_k_pref ON {t}(k);
139
+ """
140
+ )
141
+
142
+ def _k(self, key: str) -> str:
143
+ return f"{self._prefix}{key}" if self._prefix else key
144
+
145
+ async def _cleanup_expired(self) -> None:
146
+ assert self._conn is not None
147
+ now = time.time()
148
+ await self._conn.execute(f"DELETE FROM {self._table} WHERE expire <= ?", (now,))
149
+ await self._conn.commit()
150
+
151
+ async def _maybe_checkpoint(self) -> None:
152
+ """Run WAL checkpoint every N write operations."""
153
+ assert self._conn is not None
154
+ self._write_ops += 1
155
+ if (self._write_ops % self._checkpoint_every) == 0:
156
+ # PRAGMA wal_checkpoint(PASSIVE)
157
+ await self._conn.execute(f"PRAGMA wal_checkpoint({self._checkpoint_mode});")
158
+ await self._conn.commit()
159
+
160
+ async def get(self, key: str) -> Optional[dict]:
161
+ if self._conn is None:
162
+ await self.connect()
163
+ assert self._conn is not None
164
+ k = self._k(key)
165
+ async with self._conn.execute(
166
+ f"SELECT v, expire FROM {self._table} WHERE k = ?",
167
+ (k,),
168
+ ) as cur:
169
+ row = await cur.fetchone()
170
+ if row is None:
171
+ return None
172
+ raw, expire = cast(Tuple[bytes, float], row)
173
+ now = time.time()
174
+ if expire <= now:
175
+ await self._conn.execute(f"DELETE FROM {self._table} WHERE k = ?", (k,))
176
+ await self._conn.commit()
177
+ # count deletion as a write op for checkpoint cadence
178
+ await self._maybe_checkpoint()
179
+ return None
180
+ try:
181
+ return cast(dict, pickle.loads(raw))
182
+ except Exception:
183
+ # Drop corrupt entry
184
+ await self._conn.execute(f"DELETE FROM {self._table} WHERE k = ?", (k,))
185
+ await self._conn.commit()
186
+ await self._maybe_checkpoint()
187
+ return None
188
+
189
+ async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
190
+ if self._conn is None:
191
+ await self.connect()
192
+ assert self._conn is not None
193
+ ttl = int(duration) if duration is not None else self.default_ttl
194
+ expire = time.time() + ttl
195
+ k = self._k(key)
196
+ raw = pickle.dumps(value)
197
+ # Upsert with ON CONFLICT
198
+ await self._conn.execute(
199
+ f"""
200
+ INSERT INTO {self._table}(k, v, expire, updated_at)
201
+ VALUES(?, ?, ?, ?)
202
+ ON CONFLICT(k) DO UPDATE SET
203
+ v=excluded.v,
204
+ expire=excluded.expire,
205
+ updated_at=excluded.updated_at
206
+ """,
207
+ (k, raw, expire, time.time()),
208
+ )
209
+ await self._conn.commit()
210
+ # count insertion as a write op for checkpoint cadence
211
+ await self._cleanup_expired()
212
+ await self._maybe_checkpoint()
213
+
214
+ async def delete(self, key: str) -> None:
215
+ if self._conn is None:
216
+ await self.connect()
217
+ assert self._conn is not None
218
+ await self._conn.execute(
219
+ f"DELETE FROM {self._table} WHERE k = ?", (self._k(key),)
220
+ )
221
+ await self._conn.commit()
222
+ await self._maybe_checkpoint()
223
+
224
+ async def clear(self) -> None:
225
+ if self._conn is None:
226
+ await self.connect()
227
+ assert self._conn is not None
228
+ if self._prefix:
229
+ like = f"{self._prefix}%"
230
+ await self._conn.execute(
231
+ f"DELETE FROM {self._table} WHERE k LIKE ?", (like,)
232
+ )
233
+ else:
234
+ await self._conn.execute(f"DELETE FROM {self._table}")
235
+ # Checkpoint after full clear to truncate WAL
236
+ await self._conn.execute(f"PRAGMA wal_checkpoint({self._checkpoint_mode});")
237
+ await self._conn.commit()
238
+ # reset write op count after full clear
239
+ self._write_ops = 0
@@ -0,0 +1,153 @@
1
+ """ """
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import wraps
6
+ from typing import (
7
+ Callable,
8
+ NotRequired,
9
+ Optional,
10
+ Type,
11
+ TypedDict,
12
+ cast,
13
+ TYPE_CHECKING,
14
+ Any,
15
+ )
16
+ from pydantic import BaseModel, Field
17
+
18
+ from patera.utilities import run_sync_or_async
19
+ from patera.base_extension import BaseExtension
20
+
21
+ from .backends.base_cache_backend import BaseCacheBackend
22
+
23
+ if TYPE_CHECKING:
24
+ from patera import Patera, Response, Request
25
+
26
+
27
+ class _CacheConfigs(BaseModel):
28
+ """Configuration model for Cache extension."""
29
+
30
+ BACKEND: Optional[Type[BaseCacheBackend]] = Field(
31
+ default=None,
32
+ description="Caching backend class, must be subclass of BaseCacheBackend",
33
+ )
34
+ DURATION: Optional[int] = Field(
35
+ default=300, description="Default cache duration in seconds"
36
+ )
37
+
38
+
39
+ class CacheConfig(TypedDict):
40
+ """Cache configurations"""
41
+
42
+ BACKEND: NotRequired[Type[BaseCacheBackend]]
43
+ DURATION: NotRequired[int]
44
+
45
+
46
+ class Cache(BaseExtension):
47
+ """
48
+ Caching system for route handlers with **pluggable backend class**.
49
+
50
+ Provide caching implementation as `BACKEND` config. This should be
51
+ a valid caching implementation of the BaseCacheBackend class.
52
+ If not provided, defaults to in-memory caching (MemoryCacheBackend).
53
+
54
+ Default cache duration is set with `DURATION` config (seconds)
55
+ """
56
+
57
+ def __init__(self, configs_name: Optional[str] = "CACHE"):
58
+ self._app: "Optional[Patera]" = None
59
+ self._duration: int = 300
60
+ self._backend: Optional[BaseCacheBackend] = None
61
+ self._configs_name = cast(str, configs_name)
62
+ self._configs: dict[str, Any] = {}
63
+
64
+ def init_app(self, app: "Patera") -> None:
65
+ self._app = app
66
+ self._configs = app.get_conf(self._configs_name, {})
67
+ self._configs = self.validate_configs(self._configs, _CacheConfigs)
68
+
69
+ self._duration = self._configs["DURATION"]
70
+ backend_cls = self._configs.get("BACKEND", None)
71
+ if backend_cls is None:
72
+ # loads default backend - MemoryCacheBackend
73
+ # pylint: disable-next=C0415
74
+ from .backends.memory_cache_backend import MemoryCacheBackend
75
+
76
+ backend_cls = MemoryCacheBackend
77
+ if not issubclass(backend_cls, BaseCacheBackend):
78
+ raise TypeError(
79
+ "CACHE_BACKEND must be a class and subclass of BaseCacheBackend"
80
+ )
81
+
82
+ self._backend = cast(Type[BaseCacheBackend], backend_cls).configure_from_app(
83
+ app, self._configs
84
+ )
85
+
86
+ self._app.add_extension(self)
87
+ self._app.add_on_startup_method(self.connect)
88
+ self._app.add_on_shutdown_method(self.disconnect)
89
+
90
+ async def connect(self) -> None:
91
+ if self._backend:
92
+ await self._backend.connect()
93
+
94
+ async def disconnect(self) -> None:
95
+ if self._backend:
96
+ await self._backend.disconnect()
97
+
98
+ async def set(
99
+ self, key: str, value: "Response", duration: Optional[int] = None
100
+ ) -> None:
101
+ cached_value = {
102
+ "status_code": value.status_code,
103
+ "headers": value.headers,
104
+ "body": value.body,
105
+ }
106
+ await cast(BaseCacheBackend, self._backend).set(
107
+ key, cached_value, duration or self._duration
108
+ )
109
+
110
+ async def get(self, key: str, req: "Request") -> "Optional[Response]":
111
+ payload = await cast(BaseCacheBackend, self._backend).get(key)
112
+ if payload is None:
113
+ return None
114
+ return await self._make_cached_response(payload, req)
115
+
116
+ async def delete(self, key: str) -> None:
117
+ await cast(BaseCacheBackend, self._backend).delete(key)
118
+
119
+ async def clear(self) -> None:
120
+ await cast(BaseCacheBackend, self._backend).clear()
121
+
122
+ async def _make_cached_response(
123
+ self, cached_data: dict, req: "Request"
124
+ ) -> "Response":
125
+ req.res.body = cached_data["body"]
126
+ req.res.status_code = cached_data["status_code"]
127
+ req.res.headers = cached_data["headers"]
128
+ return req.res
129
+
130
+ def cache(self, duration: Optional[int] = None) -> Callable:
131
+ """Decorator for caching route handler results."""
132
+ cache = self
133
+
134
+ def decorator(handler: Callable) -> Callable:
135
+ @wraps(handler)
136
+ async def wrapper(self, *args, **kwargs) -> "Response": # type: ignore[override]
137
+ req: Request = args[0]
138
+ method: str = req.method
139
+ path: str = req.path
140
+ query_params = sorted(req.query_params.items())
141
+ cache_key = f"{handler.__name__}:{method}:{path}:{hash(frozenset(query_params))}"
142
+
143
+ cached_value = await cache.get(cache_key, req)
144
+ if cached_value is not None:
145
+ return cached_value
146
+
147
+ res: Response = await run_sync_or_async(handler, self, *args, **kwargs)
148
+ await cache.set(cache_key, res, duration)
149
+ return res
150
+
151
+ return wrapper
152
+
153
+ return decorator