Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make BaseCache an ABC #683

Merged
merged 13 commits into from
Mar 5, 2023
Merged
2 changes: 1 addition & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ There are a number of backwards-incompatible changes. These points should help w
* The ``key_builder`` parameter for caches now expects a callback which accepts 2 strings and returns a string in all cache implementations, making the builders simpler and interchangeable.
* The ``key`` parameter has been removed from the ``cached`` decorator. The behaviour can be easily reimplemented with ``key_builder=lambda *a, **kw: "foo"``
* When using the ``key_builder`` parameter in ``@multicached``, the function will now return the original, unmodified keys, only using the transformed keys in the cache (this has always been the documented behaviour, but not the implemented behaviour).
* ``BaseSerializer`` is now an ``ABC``, so cannot be instantiated directly.
* ``BaseCache`` and ``BaseSerializer`` are now ``ABC``s, so cannot be instantiated directly.
* If subclassing ``BaseCache`` to implement a custom backend:

* The cache key type used by the backend must now be specified when inheriting (e.g. ``BaseCache[str]`` typically).
Expand Down
8 changes: 4 additions & 4 deletions aiocache/backends/memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ async def _redlock_release(self, key, _):
async def _close(self, *args, _conn=None, **kwargs):
await self.client.close()

def build_key(self, key: str, namespace: Optional[str] = None) -> bytes:
ns_key = self._str_build_key(key, namespace).replace(" ", "_")
return str.encode(ns_key)


class MemcachedCache(MemcachedBackend):
"""
Expand Down Expand Up @@ -148,9 +152,5 @@ def __init__(self, serializer=None, **kwargs):
def parse_uri_path(cls, path):
return {}

def build_key(self, key: str, namespace: Optional[str] = None) -> bytes:
ns_key = self._str_build_key(key, namespace).replace(" ", "_")
return str.encode(ns_key)

def __repr__(self): # pragma: no cover
return "MemcachedCache ({}:{})".format(self.endpoint, self.port)
6 changes: 3 additions & 3 deletions aiocache/backends/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ def __delete(self, key):

return 0

def build_key(self, key: str, namespace: Optional[str] = None) -> str:
return self._str_build_key(key, namespace)


class SimpleMemoryCache(SimpleMemoryBackend):
"""
Expand All @@ -132,6 +135,3 @@ def __init__(self, serializer=None, **kwargs):
@classmethod
def parse_uri_path(cls, path):
return {}

def build_key(self, key: str, namespace: Optional[str] = None) -> str:
return self._str_build_key(key, namespace)
6 changes: 3 additions & 3 deletions aiocache/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ async def _redlock_release(self, key, value):
async def _close(self, *args, _conn=None, **kwargs):
await self.client.close()

def build_key(self, key: str, namespace: Optional[str] = None) -> str:
return self._str_build_key(key, namespace)


class RedisCache(RedisBackend):
"""
Expand Down Expand Up @@ -235,6 +238,3 @@ def parse_uri_path(cls, path):

def __repr__(self): # pragma: no cover
return "RedisCache ({}:{})".format(self.endpoint, self.port)

def build_key(self, key: str, namespace: Optional[str] = None) -> str:
return self._str_build_key(key, namespace)
17 changes: 15 additions & 2 deletions aiocache/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
import os
import time
from abc import abstractmethod
from abc import ABC, abstractmethod
from enum import Enum
from types import TracebackType
from typing import Callable, Generic, List, Optional, Set, TYPE_CHECKING, Type, TypeVar
Expand Down Expand Up @@ -93,7 +93,7 @@ async def _plugins(self, *args, **kwargs):
return _plugins


class BaseCache(Generic[CacheKeyType]):
class BaseCache(Generic[CacheKeyType], ABC):
"""
Base class that agregates the common logic for the different caches that may exist. Cache
related available options are:
Expand Down Expand Up @@ -180,6 +180,7 @@ async def add(self, key, value, ttl=SENTINEL, dumps_fn=None, namespace=None, _co
logger.debug("ADD %s %s (%.4f)s", ns_key, True, time.monotonic() - start)
return True

@abstractmethod
async def _add(self, key, value, ttl, _conn=None):
raise NotImplementedError()

Expand Down Expand Up @@ -209,9 +210,11 @@ async def get(self, key, default=None, loads_fn=None, namespace=None, _conn=None
logger.debug("GET %s %s (%.4f)s", ns_key, value is not None, time.monotonic() - start)
return value if value is not None else default

@abstractmethod
async def _get(self, key, encoding, _conn=None):
raise NotImplementedError()

@abstractmethod
async def _gets(self, key, encoding="utf-8", _conn=None):
raise NotImplementedError()

Expand Down Expand Up @@ -250,6 +253,7 @@ async def multi_get(self, keys, loads_fn=None, namespace=None, _conn=None):
)
return values

@abstractmethod
async def _multi_get(self, keys, encoding, _conn=None):
raise NotImplementedError()

Expand Down Expand Up @@ -286,6 +290,7 @@ async def set(
logger.debug("SET %s %d (%.4f)s", ns_key, True, time.monotonic() - start)
return res

@abstractmethod
async def _set(self, key, value, ttl, _cas_token=None, _conn=None):
raise NotImplementedError()

Expand Down Expand Up @@ -325,6 +330,7 @@ async def multi_set(self, pairs, ttl=SENTINEL, dumps_fn=None, namespace=None, _c
)
return True

@abstractmethod
async def _multi_set(self, pairs, ttl, _conn=None):
raise NotImplementedError()

Expand All @@ -349,6 +355,7 @@ async def delete(self, key, namespace=None, _conn=None):
logger.debug("DELETE %s %d (%.4f)s", ns_key, ret, time.monotonic() - start)
return ret

@abstractmethod
async def _delete(self, key, _conn=None):
raise NotImplementedError()

Expand All @@ -373,6 +380,7 @@ async def exists(self, key, namespace=None, _conn=None):
logger.debug("EXISTS %s %d (%.4f)s", ns_key, ret, time.monotonic() - start)
return ret

@abstractmethod
async def _exists(self, key, _conn=None):
raise NotImplementedError()

Expand Down Expand Up @@ -400,6 +408,7 @@ async def increment(self, key, delta=1, namespace=None, _conn=None):
logger.debug("INCREMENT %s %d (%.4f)s", ns_key, ret, time.monotonic() - start)
return ret

@abstractmethod
async def _increment(self, key, delta, _conn=None):
raise NotImplementedError()

Expand All @@ -425,6 +434,7 @@ async def expire(self, key, ttl, namespace=None, _conn=None):
logger.debug("EXPIRE %s %d (%.4f)s", ns_key, ret, time.monotonic() - start)
return ret

@abstractmethod
async def _expire(self, key, ttl, _conn=None):
raise NotImplementedError()

Expand All @@ -448,6 +458,7 @@ async def clear(self, namespace=None, _conn=None):
logger.debug("CLEAR %s %d (%.4f)s", namespace, ret, time.monotonic() - start)
return ret

@abstractmethod
async def _clear(self, namespace, _conn=None):
raise NotImplementedError()

Expand Down Expand Up @@ -476,9 +487,11 @@ async def raw(self, command, *args, _conn=None, **kwargs):
logger.debug("%s (%.4f)s", command, time.monotonic() - start)
return ret

@abstractmethod
async def _raw(self, command, *args, **kwargs):
raise NotImplementedError()

@abstractmethod
async def _redlock_release(self, key, value):
raise NotImplementedError()

Expand Down
13 changes: 5 additions & 8 deletions tests/ut/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from aiocache import caches
from aiocache.backends.memcached import MemcachedCache
from aiocache.backends.redis import RedisCache
from aiocache.base import BaseCache
from aiocache.plugins import BasePlugin
from ..utils import AbstractBaseCache, ConcreteBaseCache

if sys.version_info < (3, 8):
# Missing AsyncMock on 3.7
Expand All @@ -29,14 +29,14 @@ def reset_caches():

@pytest.fixture
def mock_cache(mocker):
return create_autospec(BaseCache[str]())
return create_autospec(ConcreteBaseCache())


@pytest.fixture
def mock_base_cache():
"""Return BaseCache instance with unimplemented methods mocked out."""
plugin = create_autospec(BasePlugin, instance=True)
cache = BaseCache[str](timeout=0.002, plugins=(plugin,))
cache = ConcreteBaseCache(timeout=0.002, plugins=(plugin,))
methods = ("_add", "_get", "_gets", "_set", "_multi_get", "_multi_set", "_delete",
"_exists", "_increment", "_expire", "_clear", "_raw", "_close",
"_redlock_release", "acquire_conn", "release_conn")
Expand All @@ -50,15 +50,12 @@ def mock_base_cache():

@pytest.fixture
def abstract_base_cache():
# TODO: Is there need for a separate BaseCache[bytes] fixture?
return BaseCache[str]()
return AbstractBaseCache()


@pytest.fixture
def base_cache():
# TODO: Is there need for a separate BaseCache[bytes] fixture?
cache = BaseCache[str]()
cache.build_key = cache._str_build_key
cache = ConcreteBaseCache()
return cache


Expand Down
24 changes: 8 additions & 16 deletions tests/ut/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

import pytest

from aiocache.base import API, BaseCache, _Conn
from ..utils import Keys, ensure_key
from aiocache.base import API, _Conn
from ..utils import AbstractBaseCache, ConcreteBaseCache, Keys, ensure_key


class TestAPI:
Expand Down Expand Up @@ -137,11 +137,11 @@ async def dummy(self, *args, **kwargs):

class TestBaseCache:
def test_str_ttl(self):
cache = BaseCache[str](ttl="1.5")
cache = AbstractBaseCache(ttl="1.5")
assert cache.ttl == 1.5

def test_str_timeout(self):
cache = BaseCache[str](timeout="1.5")
cache = AbstractBaseCache(timeout="1.5")
assert cache.timeout == 1.5

async def test_add(self, base_cache):
Expand Down Expand Up @@ -213,7 +213,7 @@ def set_test_namespace(self, base_cache):
)
def test_str_build_key(self, set_test_namespace, namespace, expected):
# TODO: Runtime check for namespace=None: Raise ValueError or replace with ""?
cache = BaseCache[str](namespace=namespace)
cache = AbstractBaseCache(namespace=namespace)
assert cache._str_build_key(Keys.KEY) == expected

@pytest.mark.parametrize(
Expand All @@ -223,14 +223,8 @@ def test_str_build_key(self, set_test_namespace, namespace, expected):
def test_build_key(self, set_test_namespace, base_cache, namespace, expected):
assert base_cache.build_key(Keys.KEY, namespace) == expected

def patch_str_build_key(self, cache: BaseCache[str]) -> None:
"""Implement build_key() on BaseCache[str] as if it were subclassed"""
cache.build_key = cache._str_build_key # type: ignore[assignment]
return

def test_alt_build_key(self):
cache = BaseCache[str](key_builder=lambda key, namespace: "x")
self.patch_str_build_key(cache)
cache = ConcreteBaseCache(key_builder=lambda key, namespace: "x")
assert cache.build_key(Keys.KEY, "namespace") == "x"

def alt_build_key(self, key, namespace):
Expand All @@ -244,8 +238,7 @@ def alt_build_key(self, key, namespace):
)
def test_alt_build_key_override_namespace(self, namespace, expected):
"""Custom key_builder overrides namespace of cache"""
cache = BaseCache[str](key_builder=self.alt_build_key, namespace="test")
self.patch_str_build_key(cache)
cache = ConcreteBaseCache(key_builder=self.alt_build_key, namespace="test")
assert cache.build_key(Keys.KEY, namespace) == expected

@pytest.mark.parametrize(
Expand All @@ -264,8 +257,7 @@ async def test_alt_build_key_default_namespace(self, namespace, expected):
even when that cache is supplied to a lock or to a decorator
using the ``alias`` argument.
"""
cache = BaseCache[str](key_builder=self.alt_build_key, namespace=namespace)
self.patch_str_build_key(cache)
cache = ConcreteBaseCache(key_builder=self.alt_build_key, namespace=namespace)

# Verify that private members are called with the correct ns_key
await self._assert_add__alt_build_key_default_namespace(cache, expected)
Expand Down
7 changes: 4 additions & 3 deletions tests/ut/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

from aiocache import cached, cached_stampede, multi_cached
from aiocache.backends.memory import SimpleMemoryCache
from aiocache.base import BaseCache, SENTINEL
from aiocache.base import SENTINEL
from aiocache.decorators import _get_args_dict
from aiocache.lock import RedLock
from ..utils import AbstractBaseCache


async def stub(*args, value=None, seconds=0, **kwargs):
Expand Down Expand Up @@ -208,7 +209,7 @@ async def what(self, a, b):

async def test_reuses_cache_instance(self):
with patch("aiocache.decorators._get_cache", autospec=True) as get_c:
cache = create_autospec(BaseCache, instance=True)
cache = create_autospec(AbstractBaseCache, instance=True)
get_c.side_effect = [cache, None]

@cached()
Expand Down Expand Up @@ -561,7 +562,7 @@ async def what(self, keys=None, what=1):

async def test_reuses_cache_instance(self):
with patch("aiocache.decorators._get_cache", autospec=True) as get_c:
cache = create_autospec(BaseCache, instance=True)
cache = create_autospec(AbstractBaseCache, instance=True)
cache.multi_get.return_value = [None]
get_c.side_effect = [cache, None]

Expand Down
62 changes: 61 additions & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from enum import Enum
from typing import Optional, Union

from aiocache.base import BaseCache


class Keys(str, Enum):
Expand All @@ -9,8 +12,65 @@ class Keys(str, Enum):
KEY_LOCK = Keys.KEY + "-lock"


def ensure_key(key):
def ensure_key(key: Union[str, Enum]) -> str:
if isinstance(key, Enum):
return key.value
else:
return key


class AbstractBaseCache(BaseCache[str]):
"""BaseCache that can be mocked for NotImplementedError tests"""
Copy link
Member

@Dreamsorcerer Dreamsorcerer Mar 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like these 2 classes are rather awkward, but I'll merge it for now.

I don't think there's any value in testing the logic of abstract methods, and I'm wondering if the other tests would still be testing the same thing if we just use the memory cache instead.. If so, we could then drop both of these classes.

Copy link
Member

@Dreamsorcerer Dreamsorcerer Mar 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also introduces a new mypy error:
https://github.com/aio-libs/aiocache/actions/runs/4333749520/jobs/7567135931

Curiously, only on the first method, though it looks like it should happen on all of them. Maybe because it doesn't have an await... Because it's the only annotated method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also introduces a new mypy error: https://github.com/aio-libs/aiocache/actions/runs/4333749520/jobs/7567135931

Because this class exists purely for tests, to allow mocks without throwing ABC TypeErrors, I think adding # type: ignore[safe-super] would be appropriate here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realise that, it's just a side note to the first comment about whether we can just remove them if they are not testing anything useful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like these 2 classes are rather awkward, but I'll merge it for now.

Yeah, it's a bit awkward because the tests aren't being performed directly on BaseCache, and because of how many abstract methods need to be defined on the derived class. However the definitions of AbstractBaseCache and ConcreteBaseCache are trivial and transparent, so at least no new complexity is added.

I don't think there's any value in testing the logic of abstract methods, and I'm wondering if the other tests would still be testing the same thing if we just use the memory cache instead.. If so, we could then drop both of these classes.

On the contrary, I really like how the logic of what's defined in BaseCache gets tested independently with the (abstract) base class.

The derived classes used for the backend implementations use more resources and add enough "side effects" that proper testing of the underlying logic would require repeating these tests with all 3 backends. And even then, it would perhaps be an open question of whether the BaseCache functionality extends to new backends.

Keeping these tests of BaseCache separate from tests of the backends seems much cleaner and more robust. Previously this was a bit simpler because BaseCache was not formally abstract--it had unimplemented methods intended to be override, but instantiating BaseCache did not raise an exception.

Well, in any case, I'm open to finding a cleaner test solution, but I favor keeping the BaseCache tests separate from the backend tests of the same methods.

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def build_key(self, key: str, namespace: Optional[str] = None) -> str:
return super().build_key(key, namespace)

async def _add(self, key, value, ttl, _conn=None):
return await super()._add(key, value, ttl, _conn)

async def _get(self, key, encoding, _conn=None):
return await super()._get(key, encoding, _conn)

async def _gets(self, key, encoding="utf-8", _conn=None):
return await super()._gets(key, encoding, _conn)

async def _multi_get(self, keys, encoding, _conn=None):
return await super()._multi_get(keys, encoding, _conn)

async def _set(self, key, value, ttl, _cas_token=None, _conn=None):
return await super()._set(key, value, ttl, _cas_token, _conn)

async def _multi_set(self, pairs, ttl, _conn=None):
return await super()._multi_set(pairs, ttl, _conn)

async def _delete(self, key, _conn=None):
return await super()._delete(key, _conn)

async def _exists(self, key, _conn=None):
return await super()._exists(key, _conn)

async def _increment(self, key, delta, _conn=None):
return await super()._increment(key, delta, _conn)

async def _expire(self, key, ttl, _conn=None):
return await super()._expire(key, ttl, _conn)

async def _clear(self, namespace, _conn=None):
return await super()._clear(namespace, _conn)

async def _raw(self, command, *args, **kwargs):
return await super()._raw(command, *args, **kwargs)

async def _redlock_release(self, key, value):
return await super()._redlock_release(key, value)


class ConcreteBaseCache(AbstractBaseCache):
"""BaseCache that can be mocked for tests"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def build_key(self, key: str, namespace: Optional[str] = None) -> str:
return self._str_build_key(key, namespace)