Repository: Krukov/cashews

Issue:
Templated tags with None values throw TagNotRegisteredError
Hi, first thanks for the great library! Love the cutting edge features and performance.

## What is the Issue

When we register a tag using the `@cache` decorator, and that tag is a template, and then we call the cached function with a `None` value for something that is templated, it fails with `TagNotRegisteredError`.

## Reproduction

```python
import asyncio

from cashews import cache

cache.setup("mem://")


@cache(
    ttl=None,
    tags=["a:{a};b:{b}"],
)
async def cached(
    a: int,
    b: int | None = None,
) -> str:
    return f"{a}{b}"


if __name__ == "__main__":
    asyncio.run(cached(1))
```

This throws:

```
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "/home/nick/repos/txn-labelling-app/src/temp.py", line 22, in <module>
    asyncio.run(cached(1))
  File "/home/nick/.asdf/installs/python/3.11.3/lib/python3.11/asyncio/runners.py", line 190, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "/home/nick/.asdf/installs/python/3.11.3/lib/python3.11/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/nick/.asdf/installs/python/3.11.3/lib/python3.11/asyncio/base_events.py", line 653, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "/home/nick/repos/txn-labelling-app/.venv/lib/python3.11/site-packages/cashews/wrapper/decorators.py", line 57, in _call
    return await thunder_protection(decorator)(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/nick/repos/txn-labelling-app/.venv/lib/python3.11/site-packages/cashews/decorators/locked.py", line 96, in _wrapper
    return await task
           ^^^^^^^^^^
  File "/home/nick/repos/txn-labelling-app/.venv/lib/python3.11/site-packages/cashews/decorators/cache/simple.py", line 68, in _wrap
    await backend.set(_cache_key, result, expire=_ttl, tags=_tags)
  File "/home/nick/repos/txn-labelling-app/.venv/lib/python3.11/site-packages/cashews/wrapper/tags.py", line 109, in set
    self._tags_registry.check_tags_registered(key, tags)
  File "/home/nick/repos/txn-labelling-app/.venv/lib/python3.11/site-packages/cashews/wrapper/tags.py", line 43, in check_tags_registered
    raise TagNotRegisteredError(f"tag: {difference} not registered: call cache.register_tag before using tags")
cashews.exceptions.TagNotRegisteredError: tag: {'a:1;b:'} not registered: call cache.register_tag before using tags
```

## Other Details

I started digging into this a bit and it seems like the handling of `None` is different across the functions that build the templated tags. I can see in `TagsRegistry.check_tags_registered` that we get `key_tags={'a:1;b:None'}` while `tags={'a:1;b:'}`.


Discussion:

Patch:
diff --git a/cashews/wrapper/tags.py b/cashews/wrapper/tags.py
index a317a04..d4b23eb 100644
--- a/cashews/wrapper/tags.py
+++ b/cashews/wrapper/tags.py
@@ -23,7 +23,8 @@ class TagsRegistry:
         for tag, patterns in self._registry_template.items():
             match = self._match_patterns(key, patterns)
             if match:
-                tags.append(tag.format(**match.groupdict()))
+                group_dict = {k: v if v is not None else "" for k, v in match.groupdict().items()}
+                tags.append(tag.format(**group_dict))
         return tags
 
     @staticmethod


Test patch:
diff --git a/tests/test_tags_feature.py b/tests/test_tags_feature.py
index 129010f..923f597 100644
--- a/tests/test_tags_feature.py
+++ b/tests/test_tags_feature.py
@@ -1,5 +1,6 @@
 import asyncio
 from random import random
+from typing import Union
 from unittest.mock import AsyncMock
 
 import pytest
@@ -213,3 +214,17 @@ async def test_delete_tags_separate_backend(cache: Cache, redis_dsn: str):
 
     tag_backend.set_pop.assert_awaited_with(key="_tag:tag", count=100)
     tag_backend.init.assert_awaited_once()
+
+
+async def test_templated_tag_with_none_value(cache: Cache):
+    @cache(
+        ttl=None,
+        tags=["a:{a};b:{b}"],
+    )
+    async def cached(
+        a: int,
+        b: Union[int, None] = None,
+    ) -> str:
+        return f"{a}{b}"
+
+    assert await cached(1) == "1None"