Skip to content

Commit 21befa2

Browse files
author
Dima Kryukov
committed
finish with prometheus and fastapi contrib modules
1 parent 2906888 commit 21befa2

16 files changed

+221
-90
lines changed

.coveragerc

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ exclude_lines =
2020
@(abc\.)?abstractmethod
2121
@overload
2222

23+
omit =
24+
cashews/_typing.py
25+
2326
[report]
2427
precision = 2
2528
fail_under = 70

Readme.md

+89
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ More examples [here](https://github.com/Krukov/cashews/tree/master/examples)
7070
- [Cache invalidation on code change](#cache-invalidation-on-code-change)
7171
- [Detect the source of a result](#detect-the-source-of-a-result)
7272
- [Middleware](#middleware)
73+
- [Callbacks](#callbacks)
7374
- [Transactional mode](#transactional)
75+
- [Contrib](#contrib)
76+
- [Fastapi](#fastapi)
77+
- [Prometheus](#prometheus)
7478

7579
### Configuration
7680

@@ -891,6 +895,23 @@ async def logging_middleware(call, cmd: Command, backend: Backend, *args, **kwar
891895
cache.setup("mem://", middlewares=(logging_middleware, ))
892896
```
893897

898+
#### Callbacks
899+
900+
One of the middleware that is preinstalled in cache instance is `CallbackMiddleware`.
901+
This middleware also add to a cache a new interface that allow to add a function that will be called before given command will be triggered
902+
903+
```python
904+
from cashews import cache, Command
905+
906+
907+
def callback(key, result):
908+
print(f"GET key={key}")
909+
910+
with cache.callback(callback, cmd=Command.GET):
911+
await cache.get("test") # also will print "GET key=test"
912+
913+
```
914+
894915
### Transactional
895916

896917
Applications are more often based on a database with transaction (OLTP) usage. Usually cache supports transactions poorly.
@@ -963,6 +984,74 @@ async def my_handler():
963984
...
964985
```
965986

987+
### Contrib
988+
989+
This library is framework agnostic, but includes several "batteries" for most popular tools.
990+
991+
#### Fastapi
992+
993+
You may find a few middlewares useful that can help you to control a cache in you web application based on fastapi.
994+
995+
1. `CacheEtagMiddleware` - middleware add Etag and check 'If-None-Match' header based on Etag
996+
2. `CacheRequestControlMiddleware` - middleware check and add `Cache-Control` header
997+
3. `CacheDeleteMiddleware` - clear cache for an endpoint based on `Clear-Site-Data` header
998+
999+
Example:
1000+
1001+
```python
1002+
from fastapi import FastAPI, Header, Query
1003+
from fastapi.responses import StreamingResponse
1004+
1005+
from cashews import cache
1006+
from cashews.contrib.fastapi import (
1007+
CacheDeleteMiddleware,
1008+
CacheEtagMiddleware,
1009+
CacheRequestControlMiddleware,
1010+
cache_control_ttl,
1011+
)
1012+
1013+
app = FastAPI()
1014+
app.add_middleware(CacheDeleteMiddleware)
1015+
app.add_middleware(CacheEtagMiddleware)
1016+
app.add_middleware(CacheRequestControlMiddleware)
1017+
metrics_middleware = create_metrics_middleware()
1018+
cache.setup(os.environ.get("CACHE_URI", "redis://"))
1019+
1020+
1021+
1022+
@app.get("/")
1023+
@cache.failover(ttl="1h")
1024+
@cache(ttl=cache_control_ttl(default="4m"), key="simple:{user_agent:hash}", time_condition="1s")
1025+
async def simple(user_agent: str = Header("No")):
1026+
...
1027+
1028+
1029+
@app.get("/stream")
1030+
@cache(ttl="1m", key="stream:{file_path}")
1031+
async def stream(file_path: str = Query(__file__)):
1032+
return StreamingResponse(_read_file(file_path=file_path))
1033+
1034+
1035+
async def _read_file(_read_file):
1036+
...
1037+
1038+
```
1039+
1040+
Also cashews can cache stream responses
1041+
1042+
#### Prometheus
1043+
1044+
You can easily provide metrics using the Prometheus middleware.
1045+
1046+
```python
1047+
from cashews import cache
1048+
from cashews.contrib.prometheus import create_metrics_middleware
1049+
1050+
metrics_middleware = create_metrics_middleware(with_tag=False)
1051+
cache.setup("redis://", middlewares=(metrics_middleware,))
1052+
1053+
```
1054+
9661055
## Development
9671056

9681057
### Setup

cashews/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .cache_condition import NOT_NONE, only_exceptions, with_exceptions
44
from .commands import Command
55
from .contrib import * # noqa
6-
from .decorators import context_cache_detect, fast_condition, thunder_protection
6+
from .decorators import CacheDetect, context_cache_detect, fast_condition, thunder_protection
77
from .exceptions import CacheBackendInteractionError, CircuitBreakerOpen, LockedError, RateLimitError
88
from .formatter import default_formatter, get_template_and_func_for, get_template_for_key
99
from .helpers import add_prefix, all_keys_lower, memory_limit
@@ -20,7 +20,7 @@
2020
hit = cache.hit
2121
transaction = cache.transaction
2222
setup = cache.setup
23-
cache_detect: ContextManager = cache.detect
23+
cache_detect: ContextManager[CacheDetect] = cache.detect
2424

2525
circuit_breaker = cache.circuit_breaker
2626
dynamic = cache.dynamic

cashews/_typing.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def __call__(
5252
...
5353

5454

55-
class Callback(Protocol):
55+
class OnRemoveCallback(Protocol):
5656
async def __call__(
5757
self,
5858
keys: Iterable[Key],
@@ -61,11 +61,23 @@ async def __call__(
6161
...
6262

6363

64+
class Callback(Protocol):
65+
async def __call__(self, cmd: Command, key: Key, result: Any, backend: Backend) -> None:
66+
pass
67+
68+
69+
class ShortCallback(Protocol):
70+
def __call__(self, key: Key, result: Any) -> None:
71+
pass
72+
73+
6474
class ICustomEncoder(Protocol):
65-
async def __call__(self, value: Value, backend, key: Key, expire: float | None) -> bytes: # pragma: no cover
75+
async def __call__(
76+
self, value: Value, backend: Backend, key: Key, expire: float | None
77+
) -> bytes: # pragma: no cover
6678
...
6779

6880

6981
class ICustomDecoder(Protocol):
70-
async def __call__(self, value: bytes, backend, key: Key) -> Value: # pragma: no cover
82+
async def __call__(self, value: bytes, backend: Backend, key: Key) -> Value: # pragma: no cover
7183
...

cashews/backends/interface.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from cashews.exceptions import CacheBackendInteractionError, LockedError
1212

1313
if TYPE_CHECKING: # pragma: no cover
14-
from cashews._typing import Callback, Default, Key, Value
14+
from cashews._typing import Default, Key, OnRemoveCallback, Value
1515

1616
NOT_EXIST = -2
1717
UNLIMITED = -1
@@ -245,9 +245,9 @@ def enable(self, *cmds: Command) -> None:
245245
class Backend(ControlMixin, _BackendInterface, metaclass=ABCMeta):
246246
def __init__(self, *args, **kwargs) -> None:
247247
super().__init__()
248-
self._on_remove_callbacks: list[Callback] = []
248+
self._on_remove_callbacks: list[OnRemoveCallback] = []
249249

250-
def on_remove_callback(self, callback: Callback) -> None:
250+
def on_remove_callback(self, callback: OnRemoveCallback) -> None:
251251
self._on_remove_callbacks.append(callback)
252252

253253
async def _call_on_remove_callbacks(self, *keys: Key) -> None:

cashews/backends/transaction.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from uuid import uuid4
55

66
from cashews import LockedError
7-
from cashews._typing import Callback, Key, Value
7+
from cashews._typing import Key, OnRemoveCallback, Value
88
from cashews.backends.interface import NOT_EXIST, UNLIMITED, Backend
99
from cashews.backends.memory import Memory
1010

@@ -53,7 +53,7 @@ def _clear_local_storage(self):
5353
self._local_cache = Memory()
5454
self._to_delete = set()
5555

56-
def on_remove_callback(self, callback: Callback):
56+
def on_remove_callback(self, callback: OnRemoveCallback):
5757
self._backend.on_remove_callback(callback)
5858
self._local_cache.on_remove_callback(callback)
5959

cashews/contrib/fastapi.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from contextlib import nullcontext
55
from contextvars import ContextVar
66
from hashlib import blake2s
7-
from typing import ContextManager, Sequence
7+
from typing import Any, ContextManager, Sequence
88

99
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
1010
from starlette.requests import Request
@@ -121,11 +121,11 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
121121

122122
set_key = None
123123

124-
def set_callback(key, result):
124+
def set_callback(key: str, result: Any):
125125
nonlocal set_key
126126
set_key = key
127127

128-
with self._cache.detect as detector, self._cache.callback(Command.SET, set_callback):
128+
with self._cache.detect as detector, self._cache.callback(set_callback, cmd=Command.SET):
129129
response = await call_next(request)
130130
calls = detector.calls_list
131131
if not calls:

cashews/contrib/prometheus.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ def create_metrics_middleware(latency_metric: Optional[Histogram] = None, with_t
1212
_DEFAULT_METRIC = Histogram(
1313
"cashews_operations_latency_seconds",
1414
"Latency of different operations with a cache",
15-
labels=["operation", "backend_class"] if not with_tag else ["operation", "backend_class", "tag"],
15+
labelnames=["operation", "backend_class"] if not with_tag else ["operation", "backend_class", "tag"],
1616
)
1717
_latency_metric = latency_metric or _DEFAULT_METRIC
1818

1919
async def metrics_middleware(call, cmd: Command, backend: Backend, *args, **kwargs):
20-
with _latency_metric as metric:
20+
with _latency_metric.time() as metric:
2121
metric.labels(operation=cmd.value, backend_class=backend.__class__.__name__)
2222
if with_tag and "key" in kwargs:
2323
tags = cache.get_key_tags(kwargs["key"])

cashews/wrapper/callback.py

+14-12
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import contextlib
22
import uuid
3-
from typing import TYPE_CHECKING
3+
from typing import TYPE_CHECKING, Any, Iterator
44

5-
from cashews._typing import AsyncCallable_T
5+
from cashews._typing import AsyncCallable_T, Callback, Key, ShortCallback
66
from cashews.commands import PATTERN_CMDS, Command
77
from cashews.key import get_call_values
88

@@ -21,18 +21,20 @@ async def __call__(self, call: AsyncCallable_T, cmd: Command, backend: "Backend"
2121
as_key = "pattern" if cmd in PATTERN_CMDS else "key"
2222
call_values = get_call_values(call, args, kwargs)
2323
key = call_values.get(as_key)
24+
if key is None:
25+
return result
2426
for callback in self._callbacks.values():
25-
callback(cmd, key=key, result=result, backend=backend)
27+
await callback(cmd, key=key, result=result, backend=backend)
2628
return result
2729

28-
def add_callback(self, callback, name):
30+
def add_callback(self, callback: Callback, name: str):
2931
self._callbacks[name] = callback
3032

31-
def remove_callback(self, name):
33+
def remove_callback(self, name: str):
3234
del self._callbacks[name]
3335

3436
@contextlib.contextmanager
35-
def callback(self, callback):
37+
def callback(self, callback: Callback) -> Iterator[None]:
3638
name = uuid.uuid4().hex
3739
self.add_callback(callback, name)
3840
try:
@@ -44,16 +46,16 @@ def callback(self, callback):
4446
class CallbackWrapper(Wrapper):
4547
def __init__(self, name: str = ""):
4648
super().__init__(name)
47-
self._callbacks = CallbackMiddleware()
48-
self.add_middleware(self._callbacks)
49+
self.callbacks = CallbackMiddleware()
50+
self.add_middleware(self.callbacks)
4951

5052
@contextlib.contextmanager
51-
def callback(self, cmd: Command, callback):
53+
def callback(self, callback: ShortCallback, cmd: Command) -> Iterator[None]:
5254
t_cmd = cmd
5355

54-
def _wrapped_callback(cmd, key, result, backend):
56+
async def _wrapped_callback(cmd: Command, key: Key, result: Any, backend: "Backend") -> None:
5557
if cmd == t_cmd:
56-
callback(key, result)
58+
callback(key, result=result)
5759

58-
with self._callbacks.callback(_wrapped_callback):
60+
with self.callbacks.callback(_wrapped_callback):
5961
yield

cashews/wrapper/tags.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from functools import lru_cache
22
from typing import Dict, Iterable, List, Match, Optional, Pattern, Tuple
33

4-
from cashews._typing import TTL, Callback, Key, KeyOrTemplate, Tag, Tags, Value
4+
from cashews._typing import TTL, Key, KeyOrTemplate, OnRemoveCallback, Tag, Tags, Value
55
from cashews.backends.interface import Backend
66
from cashews.exceptions import TagNotRegisteredError
77
from cashews.formatter import template_to_re_pattern
@@ -69,7 +69,7 @@ def _add_backend(self, backend: Backend, *args, **kwargs):
6969
super()._add_backend(backend, *args, **kwargs)
7070
backend.on_remove_callback(self._on_remove_cb)
7171

72-
def _on_remove_callback(self) -> Callback:
72+
def _on_remove_callback(self) -> OnRemoveCallback:
7373
async def _callback(keys: Iterable[Key], backend: Backend) -> None:
7474
for tag, _keys in self._group_by_tags(keys).items():
7575
await self.tags_backend.set_remove(self._tags_key_prefix + tag, *_keys)

examples/fastapi_app.py

+12-13
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from fastapi import FastAPI, Header, Query
88
from fastapi.responses import StreamingResponse
9+
from prometheus_client import make_asgi_app
910

1011
from cashews import cache
1112
from cashews.contrib.fastapi import (
@@ -14,38 +15,36 @@
1415
CacheRequestControlMiddleware,
1516
cache_control_ttl,
1617
)
18+
from cashews.contrib.prometheus import create_metrics_middleware
1719

1820
app = FastAPI()
1921
app.add_middleware(CacheDeleteMiddleware)
2022
app.add_middleware(CacheEtagMiddleware)
2123
app.add_middleware(CacheRequestControlMiddleware)
22-
cache.setup(os.environ.get("CACHE_URI", "redis://"))
24+
25+
metrics_middleware = create_metrics_middleware()
26+
cache.setup(os.environ.get("CACHE_URI", "redis://"), middlewares=(metrics_middleware,))
27+
cache.setup("mem://", middlewares=(metrics_middleware,), prefix="srl")
28+
metrics_app = make_asgi_app()
29+
app.mount("/metrics", metrics_app)
2330
KB = 1024
2431

2532

2633
@app.get("/")
2734
@cache.failover(ttl="1h")
28-
@cache.slice_rate_limit(10, "3m")
29-
@cache(ttl=cache_control_ttl(default="4m"), key="simple:{user_agent}", time_condition="1s")
35+
@cache.slice_rate_limit(limit=10, period="3m", key="rate:{user_agent:hash}")
36+
@cache(ttl=cache_control_ttl(default="4m"), key="simple:{user_agent:hash}", time_condition="1s")
3037
async def simple(user_agent: str = Header("No")):
3138
await asyncio.sleep(1.1)
3239
return "".join([random.choice(string.ascii_letters) for _ in range(10)])
3340

3441

3542
@app.get("/stream")
36-
def stream(file_path: str = Query(__file__)):
43+
@cache(ttl="1m", key="stream:{file_path}")
44+
async def stream(file_path: str = Query(__file__)):
3745
return StreamingResponse(_read_file(file_path=file_path))
3846

3947

40-
def size_less(limit: int):
41-
def _condition(chunk, args, kwargs, key):
42-
size = os.path.getsize(kwargs["file_path"])
43-
return size < limit
44-
45-
return _condition
46-
47-
48-
@cache.iterator("2h", key="file:{file_path:hash}", condition=size_less(100 * KB))
4948
async def _read_file(*, file_path, chunk_size=10 * KB):
5049
loop = asyncio.get_running_loop()
5150
with open(file_path, encoding="latin1") as file_obj:

0 commit comments

Comments
 (0)