diff --git a/Readme.md b/Readme.md index f9f3db0..81efd09 100644 --- a/Readme.md +++ b/Readme.md @@ -515,7 +515,7 @@ async def get(): ### Template Keys -Often, to compose a key, you need all the parameters of the function call. +Often, to compose a cache key, you need all the parameters of the function call. By default, Cashews will generate a key using the function name, module names and parameters ```python @@ -537,7 +537,99 @@ await get_name("me", "opt", "attr", opt="opt", attr="attr") # a key will be "__module__.get_name:user:me:opt:attr:version:v1:attr:attr:opt:opt" ``` -The same with a class method +For more advanced usage it better to define a cache key manually: + +```python +from cashews import cache + +cache.setup("mem://") + +@cache(ttl="2h", key="user_info:{user_id}") +async def get_info(user_id: str): + ... + +``` + +You may use objects in a key and access to an attribute through a template: + +```python + +@cache(ttl="2h", key="user_info:{user.uuid}") +async def get_info(user: User): + ... + +``` + +You may use built-in functions to format template values (`lower`, `upper`, `len`, `jwt`, `hash`) + +```python + +@cache(ttl="2h", key="user_info:{user.name:lower}:{password:hash(sha1)}") +async def get_info(user: User, password: str): + ... + + +@cache(ttl="2h", key="user:{token:jwt(client_id)}") +async def get_user_by_token(token: str) -> User: + ... + +``` + +Or define your own transformation functions: + +```python +from cashews import default_formatter, cache + +cache.setup("mem://") + +@default_formatter.register("prefix") +def _prefix(value, chars=3): + return value[:chars].upper() + + +@cache(ttl="2h", key="servers-user:{user.index:prefix(4)}") # a key will be "servers-user:DWQS" +async def get_user_servers(user): + ... + +``` + +or register type formatters: + +```python +from decimal import Decimal +from cashews import default_formatter, cache + +@default_formatter.type_format(Decimal) +def _decimal(value: Decimal) -> str: + return str(value.quantize(Decimal("0.00"))) + + +@cache(ttl="2h", key="price-{item.price}:{item.currency:upper}") # a key will be "price-10.00:USD" +async def convert_price(item): + ... + +``` + +Not only function arguments can participate in a key formation. Cashews have a `template_context`. You may use any variable registered in it: + +```python +from cashews import cache, key_context, register_key_context + +cache.setup("mem://") +register_key_context("client_id") + + +@cache(ttl="2h", key="user:{client_id}") +async def get_current_user(): + pass + +... +with key_context(client_id=135356): + await get_current_user() + +``` + +#### Template for a class method ```python from cashews import cache @@ -611,46 +703,6 @@ await MyClass().get_name("me", version="v2") # a key will be "__module__:MyClass.get_name:user:me:version:v1" ``` -Sometimes you may need to format the parameters or define your -own template for the key and Cashews allows you to do this: - -```python -from cashews import default_formatter, cache - -cache.setup("mem://") - -@cache.failover(key="name:{user.uid}") -async def get_name(user, version="v1"): - ... - -await get_name(user, version="v2") -# a key will be "fail:name:me" - -@cache.hit(key="user:{token:jwt(user_name)}", prefix="new") -async def get_name(token): - ... - -await get_name(".....") -# a key will be "new:user:alex" - - -@default_formatter.register("upper") -def _upper(value): - return value.upper() - -@default_formatter.type_format(Decimal) -def _decimal(value: Decimal) -> str: - return value.quantize(Decimal("0.00")) - - -@cache(key="price-{item.price}:{item.currency:upper}") -async def get_price(item): - ... - -await get_name(item) -# a key will be "price-10.00:USD" -``` - ### TTL Cache time to live (`ttl`) is a required parameter for all cache decorators. TTL can be: @@ -742,7 +794,7 @@ async def items(page=1): ``` Cashews provide the tag system: you can tag cache keys, so they will be stored in a separate [SET](https://redis.io/docs/data-types/sets/) -to avoid high load on redis storage. To use the tags in a more efficient way please use it with the client side feature +to avoid high load on redis storage. To use the tags in a more efficient way please use it with the client side feature. ```python from cashews import cache diff --git a/cashews/__init__.py b/cashews/__init__.py index a932ecf..c8b2d22 100644 --- a/cashews/__init__.py +++ b/cashews/__init__.py @@ -6,6 +6,8 @@ from .formatter import default_formatter from .helpers import add_prefix, all_keys_lower, memory_limit from .key import get_cache_key_template, noself +from .key_context import context as key_context +from .key_context import register as register_key_context from .validation import invalidate_further from .wrapper import Cache, TransactionMode, register_backend @@ -74,4 +76,6 @@ "Cache", "TransactionMode", "register_backend", + "key_context", + "register_key_context", ] diff --git a/cashews/contrib/fastapi.py b/cashews/contrib/fastapi.py index 3f498d0..85e0dfb 100644 --- a/cashews/contrib/fastapi.py +++ b/cashews/contrib/fastapi.py @@ -14,7 +14,7 @@ from cashews import Cache, Command, cache, invalidate_further from cashews._typing import TTL -_CACHE_MAX_AGE: ContextVar[int] = ContextVar("cache_control_max_age") +_cache_max_age: ContextVar[int] = ContextVar("cache_control_max_age") _CACHE_CONTROL_HEADER = "Cache-Control" _AGE_HEADER = "Age" @@ -35,7 +35,7 @@ def cache_control_ttl(default: TTL): def _ttl(*args, **kwargs): - return _CACHE_MAX_AGE.get(default) + return _cache_max_age.get(default) return _ttl @@ -78,12 +78,12 @@ def max_age(self, cache_control_value: str | None): _max_age = self._get_max_age(cache_control_value) reset_token = None if _max_age: - reset_token = _CACHE_MAX_AGE.set(_max_age) + reset_token = _cache_max_age.set(_max_age) try: yield finally: if reset_token: - _CACHE_MAX_AGE.reset(reset_token) + _cache_max_age.reset(reset_token) def _to_disable(self, cache_control_value: str | None) -> tuple[Command, ...]: if cache_control_value == _NO_CACHE: diff --git a/cashews/formatter.py b/cashews/formatter.py index 0fb2ae4..18f5a76 100644 --- a/cashews/formatter.py +++ b/cashews/formatter.py @@ -5,6 +5,7 @@ from string import Formatter from typing import Any, Callable, Dict, Iterable, Pattern, Tuple +from . import key_context from ._typing import KeyOrTemplate, KeyTemplate TemplateValue = str @@ -156,8 +157,10 @@ def _upper(value: TemplateValue) -> TemplateValue: return value.upper() -def template_to_pattern(template: KeyTemplate, _formatter=_ReplaceFormatter(), **values) -> KeyOrTemplate: - return _formatter.format(template, **values) +def default_format(template: KeyTemplate, **values) -> KeyOrTemplate: + _template_context = key_context.get() + _template_context.update(values) + return default_formatter.format(template, **_template_context) def _re_default(field_name): diff --git a/cashews/key.py b/cashews/key.py index ccd5a52..f80bac8 100644 --- a/cashews/key.py +++ b/cashews/key.py @@ -5,7 +5,8 @@ from typing import TYPE_CHECKING, Any, Callable, Container, Dict, Iterable, Tuple from .exceptions import WrongKeyError -from .formatter import _ReplaceFormatter, default_formatter, template_to_pattern +from .formatter import _ReplaceFormatter, default_format +from .key_context import get as get_key_context if TYPE_CHECKING: # pragma: no cover from ._typing import Key, KeyOrTemplate, KeyTemplate @@ -36,7 +37,7 @@ def get_cache_key( kwargs = kwargs or {} key_values = _get_call_values(func, args, kwargs) _key_template = template or get_cache_key_template(func) - return template_to_pattern(_key_template, _formatter=default_formatter, **key_values) + return default_format(_key_template, **key_values) def get_func_params(func: Callable) -> Iterable[str]: @@ -115,7 +116,7 @@ def _default(name): return "*" check = _ReplaceFormatter(default=_default) - check.format(key, **func_params) + check.format(key, **{**get_key_context(), **func_params}) if errors: raise WrongKeyError(f"Wrong parameter placeholder '{errors}' in the key ") diff --git a/cashews/key_context.py b/cashews/key_context.py new file mode 100644 index 0000000..c6019b2 --- /dev/null +++ b/cashews/key_context.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any, Iterator + +_template_context: ContextVar[dict[str, Any]] = ContextVar("template_context", default={}) + + +@contextmanager +def context(**values) -> Iterator[None]: + new_context = {**_template_context.get(), **values} + token = _template_context.set(new_context) + try: + yield + finally: + _template_context.reset(token) + + +def get(): + return {**_template_context.get()} + + +def register(*names: str) -> None: + new_names = {name: "" for name in names} + _template_context.set({**new_names, **_template_context.get()}) diff --git a/cashews/validation.py b/cashews/validation.py index bf149fd..d755eed 100644 --- a/cashews/validation.py +++ b/cashews/validation.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from contextvars import ContextVar from functools import wraps -from typing import Any, Dict, Optional +from typing import Any, Dict, Iterator, Optional from ._typing import AsyncCallable_T from .backends.interface import _BackendInterface @@ -41,7 +41,7 @@ async def _wrap(*args, **kwargs): @contextmanager -def invalidate_further(): +def invalidate_further() -> Iterator[None]: _INVALIDATE_FURTHER.set(True) try: yield diff --git a/cashews/wrapper/tags.py b/cashews/wrapper/tags.py index 91676be..9e8a199 100644 --- a/cashews/wrapper/tags.py +++ b/cashews/wrapper/tags.py @@ -4,7 +4,7 @@ from cashews._typing import TTL, Key, KeyOrTemplate, OnRemoveCallback, Tag, Tags, Value from cashews.backends.interface import Backend from cashews.exceptions import TagNotRegisteredError -from cashews.formatter import default_formatter, template_to_pattern, template_to_re_pattern +from cashews.formatter import default_format, template_to_re_pattern from .commands import CommandWrapper @@ -24,7 +24,7 @@ def get_key_tags(self, key: Key) -> Tags: match = self._match_patterns(key, patterns) if match: group_dict = {k: v if v is not None else "" for k, v in match.groupdict().items()} - tag = template_to_pattern(tag, _formatter=default_formatter, **group_dict) + tag = default_format(tag, **group_dict) tags.append(tag) return tags diff --git a/tests/test_key.py b/tests/test_key.py index f596956..7b5d6db 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -5,6 +5,8 @@ from cashews.exceptions import WrongKeyError from cashews.formatter import default_formatter from cashews.key import get_cache_key, get_cache_key_template +from cashews.key_context import context as key_context +from cashews.key_context import register as register_context from cashews.ttl import ttl_to_seconds @@ -161,13 +163,20 @@ async def func(user): "{kwarg1:jwt(user)}", "test", ), + ( + (), + {"kwarg": "1"}, + "{context_value:upper}:{kwarg}", + "CONTEXT:1", + ), ), ) def test_cache_key_args_kwargs(args, kwargs, template, key): async def func(arg1, arg2, *args, kwarg1=None, kwarg2=b"true", **kwargs): ... - assert get_cache_key(func, template, args=args, kwargs=kwargs) == key + with key_context(context_value="context", kwarg1="test"): + assert get_cache_key(func, template, args=args, kwargs=kwargs) == key @pytest.mark.parametrize( @@ -179,10 +188,11 @@ async def func(arg1, arg2, *args, kwarg1=None, kwarg2=b"true", **kwargs): (Klass.method, None, "tests.test_key:Klass.method:self:{self}:a:{a}:k:{k}"), (Klass.method, "key:{k}:{self.data.test}", "key:{k}:{self.data.test}"), (func2, "key:{k}", "key:{k}"), - (func3, "key:{k:len}:{k:hash(md5)}", "key:{k:len}:{k:hash(md5)}"), + (func3, "key:{k:len}:{k:hash(md5)}:{val}", "key:{k:len}:{k:hash(md5)}:{val}"), ), ) def test_get_key_template(func, key, template): + register_context("val", "k") assert get_cache_key_template(func, key) == template diff --git a/tests/test_tags_feature.py b/tests/test_tags_feature.py index a282fe3..61b2727 100644 --- a/tests/test_tags_feature.py +++ b/tests/test_tags_feature.py @@ -5,7 +5,7 @@ import pytest -from cashews import Cache +from cashews import Cache, key_context pytestmark = pytest.mark.asyncio @@ -13,10 +13,18 @@ def test_register_tags(cache: Cache): cache.register_tag("tag", "key") cache.register_tag("tag_template", "key{i}") - cache.register_tag("tag_test:{i}", "key{i}:test") + cache.register_tag("tag_test:{i}", "key{i:len}:test") + cache.register_tag("tag_func:{i:hash}", "key{i}:test") + cache.register_tag("tag_context:{val}", "key{i}:test") cache.register_tag("!@#$%^&*():{i}", "!@#$%^&*(){i}:test") - assert cache.get_key_tags("key1:test") == ["tag_template", "tag_test:1"] + with key_context(val=10): + assert cache.get_key_tags("key1:test") == [ + "tag_template", + "tag_test:1", + "tag_func:c4ca4238a0b923820dcc509a6f75849b", + "tag_context:10", + ] assert cache.get_key_tags("keyanytest") == ["tag_template"] assert cache.get_key_tags("keytest") == ["tag_template"] assert cache.get_key_tags("key") == ["tag", "tag_template"]