Repository: aio-libs/aiocache

Issue:
Block writing cache in `cached` decorator if custom condition is not satisfied
I m using the  `aiocache.cached` decorator with wraps the certain processing function. The setup is as following:

```python
import aiocache


aiocache.caches.add(
    "cache-alias",
    {
        "cache": "aiocache.RedisCache",
        {
            <cache-config-data>
        }
    },
)


@aiocache.cached(
    alias="cache-alias",
    key_builder=some_key_builder,
    ttl=ttl,
)
async def processing(...) -> int:
    ...
```

The problem is -- can i pass any argument to cached decorator in order to avoid cache writing for `processing(...)` return?
It seems like the only way to perform it is to throw exception inside the `processing(...)` function if the result is not appropriate one. I m looking for more generic way to do the same stuff.

For example, pass some `Callable` object to `cached.__call__()` returning the bool var meaning caching the results. Something similar to:

```python
def checker(value: Any) -> bool:
    ...

@aiocache.cached(
    alias="cache-alias",
    key_builder=some_key_builder,
    value_checker=checker,
    ttl=ttl,
)
async def processing(...) -> int:
    ...
```

Comments:

Patch:
diff --git a/.codecov.yml b/.codecov.yml
index 225b492..219845f 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -1,6 +1,5 @@
 codecov:
   notify:
-    require_ci_to_pass: yes
     after_n_builds: 4
 
 coverage:
diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml
index 6957de7..0fc389a 100644
--- a/.github/workflows/auto-merge.yml
+++ b/.github/workflows/auto-merge.yml
@@ -12,7 +12,7 @@ jobs:
     steps:
       - name: Dependabot metadata
         id: metadata
-        uses: dependabot/fetch-metadata@v1.3.5
+        uses: dependabot/fetch-metadata@v1.3.6
         with:
           github-token: "${{ secrets.GITHUB_TOKEN }}"
       - name: Enable auto-merge for Dependabot PRs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4859583..5e3a3b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -140,7 +140,7 @@ jobs:
      run: |
        python -m build
    - name: Make Release
-     uses: aio-libs/create-release@v1.3.3
+     uses: aio-libs/create-release@v1.6.6
      with:
        changes_file: CHANGES.rst
        name: aiocache
diff --git a/aiocache/decorators.py b/aiocache/decorators.py
index 92e4fe9..1892cf2 100644
--- a/aiocache/decorators.py
+++ b/aiocache/decorators.py
@@ -39,6 +39,10 @@ class cached:
     :param key_builder: Callable that allows to build the function dynamically. It receives
         the function plus same args and kwargs passed to the function.
         This behavior is necessarily different than ``BaseCache.build_key()``
+    :param skip_cache_func: Callable that receives the result after calling the
+        wrapped function and should return `True` if the value should skip the
+        cache (or `False` to store in the cache).
+        e.g. to avoid caching `None` results: `lambda r: r is None`
     :param cache: cache class to use when calling the ``set``/``get`` operations.
         Default is :class:`aiocache.SimpleMemoryCache`.
     :param serializer: serializer instance to use when calling the ``dumps``/``loads``.
@@ -58,6 +62,7 @@ class cached:
         ttl=SENTINEL,
         namespace=None,
         key_builder=None,
+        skip_cache_func=lambda x: False,
         cache=Cache.MEMORY,
         serializer=None,
         plugins=None,
@@ -67,6 +72,7 @@ class cached:
     ):
         self.ttl = ttl
         self.key_builder = key_builder
+        self.skip_cache_func = skip_cache_func
         self.noself = noself
         self.alias = alias
         self.cache = None
@@ -111,6 +117,9 @@ class cached:
 
         result = await f(*args, **kwargs)
 
+        if self.skip_cache_func(result):
+            return result
+
         if cache_write:
             if aiocache_wait_for_write:
                 await self.set_in_cache(key, result)
@@ -171,6 +180,10 @@ class cached_stampede(cached):
     :param key_builder: Callable that allows to build the function dynamically. It receives
         the function plus same args and kwargs passed to the function.
         This behavior is necessarily different than ``BaseCache.build_key()``
+    :param skip_cache_func: Callable that receives the result after calling the
+        wrapped function and should return `True` if the value should skip the
+        cache (or `False` to store in the cache).
+        e.g. to avoid caching `None` results: `lambda r: r is None`
     :param cache: cache class to use when calling the ``set``/``get`` operations.
         Default is :class:`aiocache.SimpleMemoryCache`.
     :param serializer: serializer instance to use when calling the ``dumps``/``loads``.
@@ -202,6 +215,9 @@ class cached_stampede(cached):
 
             result = await f(*args, **kwargs)
 
+            if self.skip_cache_func(result):
+                return result
+
             await self.set_in_cache(key, result)
 
         return result
@@ -268,6 +284,9 @@ class multi_cached:
         ``keys_from_attr``, the decorated callable, and the positional and keyword arguments
         that were passed to the decorated callable. This behavior is necessarily different than
         ``BaseCache.build_key()`` and the call signature differs from ``cached.key_builder``.
+    :param skip_cache_keys: Callable that receives both key and value and returns True
+        if that key-value pair should not be cached (or False to store in cache).
+        The keys and values to be passed are taken from the wrapped function result.
     :param ttl: int seconds to store the keys. Default is 0 which means no expiration.
     :param cache: cache class to use when calling the ``multi_set``/``multi_get`` operations.
         Default is :class:`aiocache.SimpleMemoryCache`.
@@ -286,6 +305,7 @@ class multi_cached:
         keys_from_attr,
         namespace=None,
         key_builder=None,
+        skip_cache_func=lambda k, v: False,
         ttl=SENTINEL,
         cache=Cache.MEMORY,
         serializer=None,
@@ -295,6 +315,7 @@ class multi_cached:
     ):
         self.keys_from_attr = keys_from_attr
         self.key_builder = key_builder or (lambda key, f, *args, **kwargs: key)
+        self.skip_cache_func = skip_cache_func
         self.ttl = ttl
         self.alias = alias
         self.cache = None
@@ -354,12 +375,17 @@ class multi_cached:
         result = await f(*new_args, **kwargs)
         result.update(partial)
 
+        to_cache = {k: v for k, v in result.items() if not self.skip_cache_func(k, v)}
+
+        if not to_cache:
+            return result
+
         if cache_write:
             if aiocache_wait_for_write:
-                await self.set_in_cache(result, f, args, kwargs)
+                await self.set_in_cache(to_cache, f, args, kwargs)
             else:
                 # TODO: Use aiojobs to avoid warnings.
-                asyncio.create_task(self.set_in_cache(result, f, args, kwargs))
+                asyncio.create_task(self.set_in_cache(to_cache, f, args, kwargs))
 
         return result
 
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 1a9e2df..04b8c87 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -4,7 +4,7 @@ flake8==6.0.0
 flake8-bandit==4.1.1
 flake8-bugbear==22.12.6
 flake8-import-order==0.18.2
-flake8-requirements==1.7.6
-mypy==0.991; implementation_name=="cpython"
-types-redis==4.4.0.3
+flake8-requirements==1.7.7
+mypy==1.0.0; implementation_name=="cpython"
+types-redis==4.4.0.6
 types-ujson==5.7.0.0
diff --git a/requirements.txt b/requirements.txt
index 18cc050..17deb39 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,4 +8,4 @@ pytest==7.2.1
 pytest-asyncio==0.20.3
 pytest-cov==4.0.0
 pytest-mock==3.10.0
-redis==4.4.2
+redis==4.5.1


Test patch:
diff --git a/tests/acceptance/test_decorators.py b/tests/acceptance/test_decorators.py
index f5be29b..f9608e5 100644
--- a/tests/acceptance/test_decorators.py
+++ b/tests/acceptance/test_decorators.py
@@ -49,6 +49,31 @@ class TestCached:
         await fn("self", 1, 3)
         assert await cache.exists(build_key(fn, "self", 1, 3)) is True
 
+    @pytest.mark.parametrize("decorator", (cached, cached_stampede))
+    async def test_cached_skip_cache_func(self, cache, decorator):
+        @decorator(skip_cache_func=lambda r: r is None)
+        async def sk_func(x):
+            return x if x > 0 else None
+
+        arg = 1
+        res = await sk_func(arg)
+        assert res
+
+        key = decorator().get_cache_key(sk_func, args=(1,), kwargs={})
+
+        assert key
+        assert await cache.exists(key)
+        assert await cache.get(key) == res
+
+        arg = -1
+
+        await sk_func(arg)
+
+        key = decorator().get_cache_key(sk_func, args=(-1,), kwargs={})
+
+        assert key
+        assert not await cache.exists(key)
+
     async def test_cached_without_namespace(self, cache):
         """Default cache key is created when no namespace is provided"""
         @cached(namespace=None)
@@ -149,6 +174,19 @@ class TestMultiCachedDecorator:
         assert await cache.exists("fn_" + _ensure_key(Keys.KEY) + "_ES") is True
         assert await cache.exists("fn_" + _ensure_key(Keys.KEY_1) + "_ES") is True
 
+    async def test_multi_cached_skip_keys(self, cache):
+        @multi_cached(keys_from_attr="keys", skip_cache_func=lambda _, v: v is None)
+        async def multi_sk_fn(keys, values):
+            return {k: v for k, v in zip(keys, values)}
+
+        res = await multi_sk_fn(keys=[Keys.KEY, Keys.KEY_1], values=[42, None])
+        assert res
+        assert Keys.KEY in res and Keys.KEY_1 in res
+
+        assert await cache.exists(Keys.KEY)
+        assert await cache.get(Keys.KEY) == res[Keys.KEY]
+        assert not await cache.exists(Keys.KEY_1)
+
     async def test_fn_with_args(self, cache):
         @multi_cached("keys")
         async def fn(keys, *args):