Repository: aio-libs/aiocache

Issue:
Ensure that underlying value of enum key is used by BaseCache._build_key() across Python versions
Key builders and tests currently cast key values (notably `Enum` values) to `str` to maintain compatibility with Python 3.11 and older versions.  ~~This requirement can be eliminated by using f-strings rather than `str.__format__`.~~

EDIT: Whoops, that erroneous statement was based on testing in the wrong python environment.  Instead of using `str()`, perhaps we should use a simple guard that check for an enum key.  Something like:

```
def ensure_key(key):
    if isinstance(key, Enum):
        return key.value
    else:
        return key
```

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c584b9c..0e66ae1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,8 @@
 * Add ``async with`` support to ``BaseCache``.
 * Remove deprecated ``loop`` parameters.
 * Remove deprecated ``cache`` parameter from ``create()``.
-* Use ``str()`` in ``_build_key()`` to ensure consistency of enum keys between different Python versions (if using enum keys, upgrading to 0.12 may invalidate existing caches due to key values changing).
+* Use ``base._ensure_key()`` in ``_build_key()`` to ensure consistency of enum
+keys between different Python versions. [#633](https://github.com/aio-libs/aiocache/issues/633) -- Padraic Shafer
 * Improved support for ``build_key(key, namespace)`` [#569](https://github.com/aio-libs/aiocache/issues/569) - Padraic Shafer
     * `BaseCache.build_key` uses `namespace` argument if provided,
     otherwise it uses `self.namespace`.
diff --git a/aiocache/backends/redis.py b/aiocache/backends/redis.py
index d569ec2..407efec 100644
--- a/aiocache/backends/redis.py
+++ b/aiocache/backends/redis.py
@@ -4,7 +4,7 @@ import warnings
 import redis.asyncio as redis
 from redis.exceptions import ResponseError as IncrbyException
 
-from aiocache.base import BaseCache
+from aiocache.base import BaseCache, _ensure_key
 from aiocache.serializers import JsonSerializer
 
 
@@ -219,11 +219,12 @@ class RedisCache(RedisBackend):
         return options
 
     def _build_key(self, key, namespace=None):
-        # TODO(PY311): Remove str()
         if namespace is not None:
-            return "{}{}{}".format(namespace, ":" if namespace else "", str(key))
+            return "{}{}{}".format(
+                namespace, ":" if namespace else "", _ensure_key(key))
         if self.namespace is not None:
-            return "{}{}{}".format(self.namespace, ":" if self.namespace else "", str(key))
+            return "{}{}{}".format(
+                self.namespace, ":" if self.namespace else "", _ensure_key(key))
         return key
 
     def __repr__(self):  # pragma: no cover
diff --git a/aiocache/base.py b/aiocache/base.py
index bc4df81..54e9d18 100644
--- a/aiocache/base.py
+++ b/aiocache/base.py
@@ -3,6 +3,7 @@ import functools
 import logging
 import os
 import time
+from enum import Enum
 from types import TracebackType
 from typing import Callable, Optional, Set, Type
 
@@ -498,14 +499,10 @@ class BaseCache:
         pass
 
     def _build_key(self, key, namespace=None):
-        # TODO(PY311): Remove str() calls.
-        # str() is needed to ensure consistent results when using enums between
-        # Python 3.11+ and older releases due to changed __format__() method:
-        # https://docs.python.org/3/whatsnew/3.11.html#enum
         if namespace is not None:
-            return "{}{}".format(namespace, str(key))
+            return "{}{}".format(namespace, _ensure_key(key))
         if self.namespace is not None:
-            return "{}{}".format(self.namespace, str(key))
+            return "{}{}".format(self.namespace, _ensure_key(key))
         return key
 
     def _get_ttl(self, ttl):
@@ -553,5 +550,12 @@ class _Conn:
         return _do_inject_conn
 
 
+def _ensure_key(key):
+    if isinstance(key, Enum):
+        return key.value
+    else:
+        return key
+
+
 for cmd in API.CMDS:
     setattr(_Conn, cmd.__name__, _Conn._inject_conn(cmd.__name__))


Test patch:
diff --git a/tests/acceptance/test_decorators.py b/tests/acceptance/test_decorators.py
index 13783db..2f755a1 100644
--- a/tests/acceptance/test_decorators.py
+++ b/tests/acceptance/test_decorators.py
@@ -5,6 +5,7 @@ from unittest import mock
 import pytest
 
 from aiocache import cached, cached_stampede, multi_cached
+from aiocache.base import _ensure_key
 from ..utils import Keys
 
 
@@ -137,17 +138,16 @@ class TestMultiCachedDecorator:
         assert await cache.exists(Keys.KEY) is True
 
     async def test_multi_cached_key_builder(self, cache):
-        # TODO(PY311): Remove str() calls
         def build_key(key, f, self, keys, market="ES"):
-            return "{}_{}_{}".format(f.__name__, str(key), market)
+            return "{}_{}_{}".format(f.__name__, _ensure_key(key), market)
 
         @multi_cached(keys_from_attr="keys", key_builder=build_key)
         async def fn(self, keys, market="ES"):
             return {Keys.KEY: 1, Keys.KEY_1: 2}
 
         await fn("self", keys=[Keys.KEY, Keys.KEY_1])
-        assert await cache.exists("fn_" + str(Keys.KEY) + "_ES") is True
-        assert await cache.exists("fn_" + str(Keys.KEY_1) + "_ES") is True
+        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_fn_with_args(self, cache):
         @multi_cached("keys")
diff --git a/tests/ut/backends/test_memcached.py b/tests/ut/backends/test_memcached.py
index 5f856b4..7e01161 100644
--- a/tests/ut/backends/test_memcached.py
+++ b/tests/ut/backends/test_memcached.py
@@ -4,7 +4,7 @@ import aiomcache
 import pytest
 
 from aiocache.backends.memcached import MemcachedBackend, MemcachedCache
-from aiocache.base import BaseCache
+from aiocache.base import BaseCache, _ensure_key
 from aiocache.serializers import JsonSerializer
 from ...utils import Keys
 
@@ -249,8 +249,7 @@ class TestMemcachedCache:
 
     @pytest.mark.parametrize(
         "namespace, expected",
-        # TODO(PY311): Remove str()
-        ([None, "test" + str(Keys.KEY)], ["", str(Keys.KEY)], ["my_ns", "my_ns" + str(Keys.KEY)]),  # type: ignore[attr-defined]  # noqa: B950
+        ([None, "test" + _ensure_key(Keys.KEY)], ["", _ensure_key(Keys.KEY)], ["my_ns", "my_ns" + _ensure_key(Keys.KEY)]),  # type: ignore[attr-defined]  # noqa: B950
     )
     def test_build_key_bytes(self, set_test_namespace, memcached_cache, namespace, expected):
         assert memcached_cache.build_key(Keys.KEY, namespace=namespace) == expected.encode()
diff --git a/tests/ut/backends/test_redis.py b/tests/ut/backends/test_redis.py
index dd41522..2470584 100644
--- a/tests/ut/backends/test_redis.py
+++ b/tests/ut/backends/test_redis.py
@@ -5,7 +5,7 @@ from redis.asyncio.client import Pipeline
 from redis.exceptions import ResponseError
 
 from aiocache.backends.redis import RedisBackend, RedisCache
-from aiocache.base import BaseCache
+from aiocache.base import BaseCache, _ensure_key
 from aiocache.serializers import JsonSerializer
 from ...utils import Keys
 
@@ -253,8 +253,7 @@ class TestRedisCache:
 
     @pytest.mark.parametrize(
         "namespace, expected",
-        # TODO(PY311): Remove str()
-        ([None, "test:" + str(Keys.KEY)], ["", str(Keys.KEY)], ["my_ns", "my_ns:" + str(Keys.KEY)]),  # noqa: B950
+        ([None, "test:" + _ensure_key(Keys.KEY)], ["", _ensure_key(Keys.KEY)], ["my_ns", "my_ns:" + _ensure_key(Keys.KEY)]),  # noqa: B950
     )
     def test_build_key_double_dot(self, set_test_namespace, redis_cache, namespace, expected):
         assert redis_cache.build_key(Keys.KEY, namespace=namespace) == expected
diff --git a/tests/ut/test_base.py b/tests/ut/test_base.py
index 09b36dd..569b524 100644
--- a/tests/ut/test_base.py
+++ b/tests/ut/test_base.py
@@ -4,7 +4,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch
 
 import pytest
 
-from aiocache.base import API, BaseCache, _Conn
+from aiocache.base import API, BaseCache, _Conn, _ensure_key
 from ..utils import Keys
 
 
@@ -205,8 +205,7 @@ class TestBaseCache:
 
     @pytest.mark.parametrize(
         "namespace, expected",
-        # TODO(PY311): Remove str()
-        ([None, "test" + str(Keys.KEY)], ["", str(Keys.KEY)], ["my_ns", "my_ns" + str(Keys.KEY)]),  # type: ignore[attr-defined]  # noqa: B950
+        ([None, "test" + _ensure_key(Keys.KEY)], ["", _ensure_key(Keys.KEY)], ["my_ns", "my_ns" + _ensure_key(Keys.KEY)]),  # type: ignore[attr-defined]  # noqa: B950
     )
     def test_build_key(self, set_test_namespace, base_cache, namespace, expected):
         assert base_cache.build_key(Keys.KEY, namespace=namespace) == expected
@@ -219,18 +218,16 @@ class TestBaseCache:
     def alt_base_cache(self, init_namespace="test"):
         """Custom key_builder for cache"""
         def build_key(key, namespace=None):
-            # TODO(PY311): Remove str()
             ns = namespace if namespace is not None else ""
             sep = ":" if namespace else ""
-            return f"{ns}{sep}{str(key)}"
+            return f"{ns}{sep}{_ensure_key(key)}"
 
         cache = BaseCache(key_builder=build_key, namespace=init_namespace)
         return cache
 
     @pytest.mark.parametrize(
         "namespace, expected",
-        # TODO(PY311): Remove str()
-        ([None, str(Keys.KEY)], ["", str(Keys.KEY)], ["my_ns", "my_ns:" + str(Keys.KEY)]),  # type: ignore[attr-defined]  # noqa: B950
+        ([None, _ensure_key(Keys.KEY)], ["", _ensure_key(Keys.KEY)], ["my_ns", "my_ns:" + _ensure_key(Keys.KEY)]),  # type: ignore[attr-defined]  # noqa: B950
     )
     def test_alt_build_key_override_namespace(self, alt_base_cache, namespace, expected):
         """Custom key_builder overrides namespace of cache"""
@@ -239,8 +236,7 @@ class TestBaseCache:
 
     @pytest.mark.parametrize(
         "init_namespace, expected",
-        # TODO(PY311): Remove str()
-        ([None, str(Keys.KEY)], ["", str(Keys.KEY)], ["test", "test:" + str(Keys.KEY)]),  # type: ignore[attr-defined]  # noqa: B950
+        ([None, _ensure_key(Keys.KEY)], ["", _ensure_key(Keys.KEY)], ["test", "test:" + _ensure_key(Keys.KEY)]),  # type: ignore[attr-defined]  # noqa: B950
     )
     async def test_alt_build_key_default_namespace(
             self, init_namespace, alt_base_cache, expected):