diff options
| -rw-r--r-- | bot/utils/__init__.py | 3 | ||||
| -rw-r--r-- | bot/utils/redis_cache.py | 414 | ||||
| -rw-r--r-- | tests/bot/utils/test_redis_cache.py | 265 | 
3 files changed, 1 insertions, 681 deletions
| diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 3e93fcb06..60170a88f 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,4 @@  from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64 -from bot.utils.redis_cache import RedisCache  from bot.utils.services import send_to_paste_service -__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] +__all__ = ['CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py deleted file mode 100644 index 52b689b49..000000000 --- a/bot/utils/redis_cache.py +++ /dev/null @@ -1,414 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from functools import partialmethod -from typing import Any, Dict, ItemsView, Optional, Tuple, Union - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -# Type aliases -RedisKeyType = Union[str, int] -RedisValueType = Union[str, int, float, bool] -RedisKeyOrValue = Union[RedisKeyType, RedisValueType] - -# Prefix tuples -_PrefixTuple = Tuple[Tuple[str, Any], ...] -_VALUE_PREFIXES = ( -    ("f|", float), -    ("i|", int), -    ("s|", str), -    ("b|", bool), -) -_KEY_PREFIXES = ( -    ("i|", int), -    ("s|", str), -) - - -class NoBotInstanceError(RuntimeError): -    """Raised when RedisCache is created without an available bot instance on the owner class.""" - - -class NoNamespaceError(RuntimeError): -    """Raised when RedisCache has no namespace, for example if it is not assigned to a class attribute.""" - - -class NoParentInstanceError(RuntimeError): -    """Raised when the parent instance is available, for example if called by accessing the parent class directly.""" - - -class RedisCache: -    """ -    A simplified interface for a Redis connection. - -    We implement several convenient methods that are fairly similar to have a dict -    behaves, and should be familiar to Python users. The biggest difference is that -    all the public methods in this class are coroutines, and must be awaited. - -    Because of limitations in Redis, this cache will only accept strings and integers for keys, -    and strings, integers, floats and booleans for values. - -    Please note that this class MUST be created as a class attribute, and that that class -    must also contain an attribute with an instance of our Bot. See `__get__` and `__set_name__` -    for more information about how this works. - -    Simple example for how to use this: - -    class SomeCog(Cog): -        # To initialize a valid RedisCache, just add it as a class attribute here. -        # Do not add it to the __init__ method or anywhere else, it MUST be a class -        # attribute. Do not pass any parameters. -        cache = RedisCache() - -        async def my_method(self): - -            # Now we're ready to use the RedisCache. -            # One thing to note here is that this will not work unless -            # we access self.cache through an _instance_ of this class. -            # -            # For example, attempting to use SomeCog.cache will _not_ work, -            # you _must_ instantiate the class first and use that instance. -            # -            # Now we can store some stuff in the cache just by doing this. -            # This data will persist through restarts! -            await self.cache.set("key", "value") - -            # To get the data, simply do this. -            value = await self.cache.get("key") - -            # Other methods work more or less like a dictionary. -            # Checking if something is in the cache -            await self.cache.contains("key") - -            # iterating the cache -            async for key, value in self.cache.items(): -                print(value) - -            # We can even iterate in a comprehension! -            consumed = [value async for key, value in self.cache.items()] -    """ - -    _namespaces = [] - -    def __init__(self) -> None: -        """Initialize the RedisCache.""" -        self._namespace = None -        self.bot = None -        self._increment_lock = None - -    def _set_namespace(self, namespace: str) -> None: -        """Try to set the namespace, but do not permit collisions.""" -        log.trace(f"RedisCache setting namespace to {namespace}") -        self._namespaces.append(namespace) -        self._namespace = namespace - -    @staticmethod -    def _to_typestring(key_or_value: RedisKeyOrValue, prefixes: _PrefixTuple) -> str: -        """Turn a valid Redis type into a typestring.""" -        for prefix, _type in prefixes: -            # Convert bools into integers before storing them. -            if type(key_or_value) is bool: -                bool_int = int(key_or_value) -                return f"{prefix}{bool_int}" - -            # isinstance is a bad idea here, because isintance(False, int) == True. -            if type(key_or_value) is _type: -                return f"{prefix}{key_or_value}" - -        raise TypeError(f"RedisCache._to_typestring only supports the following: {prefixes}.") - -    @staticmethod -    def _from_typestring(key_or_value: Union[bytes, str], prefixes: _PrefixTuple) -> RedisKeyOrValue: -        """Deserialize a typestring into a valid Redis type.""" -        # Stuff that comes out of Redis will be bytestrings, so let's decode those. -        if isinstance(key_or_value, bytes): -            key_or_value = key_or_value.decode('utf-8') - -        # Now we convert our unicode string back into the type it originally was. -        for prefix, _type in prefixes: -            if key_or_value.startswith(prefix): - -                # For booleans, we need special handling because bool("False") is True. -                if prefix == "b|": -                    value = key_or_value[len(prefix):] -                    return bool(int(value)) - -                # Otherwise we can just convert normally. -                return _type(key_or_value[len(prefix):]) -        raise TypeError(f"RedisCache._from_typestring only supports the following: {prefixes}.") - -    # Add some nice partials to call our generic typestring converters. -    # These are basically methods that will fill in some of the parameters for you, so that -    # any call to _key_to_typestring will be like calling _to_typestring with the two parameters -    # at `prefixes` and `types_string` pre-filled. -    # -    # See https://docs.python.org/3/library/functools.html#functools.partialmethod -    _key_to_typestring = partialmethod(_to_typestring, prefixes=_KEY_PREFIXES) -    _value_to_typestring = partialmethod(_to_typestring, prefixes=_VALUE_PREFIXES) -    _key_from_typestring = partialmethod(_from_typestring, prefixes=_KEY_PREFIXES) -    _value_from_typestring = partialmethod(_from_typestring, prefixes=_VALUE_PREFIXES) - -    def _dict_from_typestring(self, dictionary: Dict) -> Dict: -        """Turns all contents of a dict into valid Redis types.""" -        return {self._key_from_typestring(key): self._value_from_typestring(value) for key, value in dictionary.items()} - -    def _dict_to_typestring(self, dictionary: Dict) -> Dict: -        """Turns all contents of a dict into typestrings.""" -        return {self._key_to_typestring(key): self._value_to_typestring(value) for key, value in dictionary.items()} - -    async def _validate_cache(self) -> None: -        """Validate that the RedisCache is ready to be used.""" -        if self._namespace is None: -            error_message = ( -                "Critical error: RedisCache has no namespace. " -                "This object must be initialized as a class attribute." -            ) -            log.error(error_message) -            raise NoNamespaceError(error_message) - -        if self.bot is None: -            error_message = ( -                "Critical error: RedisCache has no `Bot` instance. " -                "This happens when the class RedisCache was created in doesn't " -                "have a Bot instance. Please make sure that you're instantiating " -                "the RedisCache inside a class that has a Bot instance attribute." -            ) -            log.error(error_message) -            raise NoBotInstanceError(error_message) - -        if not self.bot.redis_closed: -            await self.bot.redis_ready.wait() - -    def __set_name__(self, owner: Any, attribute_name: str) -> None: -        """ -        Set the namespace to Class.attribute_name. - -        Called automatically when this class is constructed inside a class as an attribute. - -        This class MUST be created as a class attribute in a class, otherwise it will raise -        exceptions whenever a method is used. This is because it uses this method to create -        a namespace like `MyCog.my_class_attribute` which is used as a hash name when we store -        stuff in Redis, to prevent collisions. -        """ -        self._set_namespace(f"{owner.__name__}.{attribute_name}") - -    def __get__(self, instance: RedisCache, owner: Any) -> RedisCache: -        """ -        This is called if the RedisCache is a class attribute, and is accessed. - -        The class this object is instantiated in must contain an attribute with an -        instance of Bot. This is because Bot contains our redis_session, which is -        the mechanism by which we will communicate with the Redis server. - -        Any attempt to use RedisCache in a class that does not have a Bot instance -        will fail. It is mostly intended to be used inside of a Cog, although theoretically -        it should work in any class that has a Bot instance. -        """ -        if self.bot: -            return self - -        if self._namespace is None: -            error_message = "RedisCache must be a class attribute." -            log.error(error_message) -            raise NoNamespaceError(error_message) - -        if instance is None: -            error_message = ( -                "You must access the RedisCache instance through the cog instance " -                "before accessing it using the cog's class object." -            ) -            log.error(error_message) -            raise NoParentInstanceError(error_message) - -        for attribute in vars(instance).values(): -            if isinstance(attribute, Bot): -                self.bot = attribute -                return self -        else: -            error_message = ( -                "Critical error: RedisCache has no `Bot` instance. " -                "This happens when the class RedisCache was created in doesn't " -                "have a Bot instance. Please make sure that you're instantiating " -                "the RedisCache inside a class that has a Bot instance attribute." -            ) -            log.error(error_message) -            raise NoBotInstanceError(error_message) - -    def __repr__(self) -> str: -        """Return a beautiful representation of this object instance.""" -        return f"RedisCache(namespace={self._namespace!r})" - -    async def set(self, key: RedisKeyType, value: RedisValueType) -> None: -        """Store an item in the Redis cache.""" -        await self._validate_cache() - -        # Convert to a typestring and then set it -        key = self._key_to_typestring(key) -        value = self._value_to_typestring(value) - -        log.trace(f"Setting {key} to {value}.") -        await self.bot.redis_session.hset(self._namespace, key, value) - -    async def get(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> Optional[RedisValueType]: -        """Get an item from the Redis cache.""" -        await self._validate_cache() -        key = self._key_to_typestring(key) - -        log.trace(f"Attempting to retrieve {key}.") -        value = await self.bot.redis_session.hget(self._namespace, key) - -        if value is None: -            log.trace(f"Value not found, returning default value {default}") -            return default -        else: -            value = self._value_from_typestring(value) -            log.trace(f"Value found, returning value {value}") -            return value - -    async def delete(self, key: RedisKeyType) -> None: -        """ -        Delete an item from the Redis cache. - -        If we try to delete a key that does not exist, it will simply be ignored. - -        See https://redis.io/commands/hdel for more info on how this works. -        """ -        await self._validate_cache() -        key = self._key_to_typestring(key) - -        log.trace(f"Attempting to delete {key}.") -        return await self.bot.redis_session.hdel(self._namespace, key) - -    async def contains(self, key: RedisKeyType) -> bool: -        """ -        Check if a key exists in the Redis cache. - -        Return True if the key exists, otherwise False. -        """ -        await self._validate_cache() -        key = self._key_to_typestring(key) -        exists = await self.bot.redis_session.hexists(self._namespace, key) - -        log.trace(f"Testing if {key} exists in the RedisCache - Result is {exists}") -        return exists - -    async def items(self) -> ItemsView: -        """ -        Fetch all the key/value pairs in the cache. - -        Returns a normal ItemsView, like you would get from dict.items(). - -        Keep in mind that these items are just a _copy_ of the data in the -        RedisCache - any changes you make to them will not be reflected -        into the RedisCache itself. If you want to change these, you need -        to make a .set call. - -        Example: -        items = await my_cache.items() -        for key, value in items: -            # Iterate like a normal dictionary -        """ -        await self._validate_cache() -        items = self._dict_from_typestring( -            await self.bot.redis_session.hgetall(self._namespace) -        ).items() - -        log.trace(f"Retrieving all key/value pairs from cache, total of {len(items)} items.") -        return items - -    async def length(self) -> int: -        """Return the number of items in the Redis cache.""" -        await self._validate_cache() -        number_of_items = await self.bot.redis_session.hlen(self._namespace) -        log.trace(f"Returning length. Result is {number_of_items}.") -        return number_of_items - -    async def to_dict(self) -> Dict: -        """Convert to dict and return.""" -        return {key: value for key, value in await self.items()} - -    async def clear(self) -> None: -        """Deletes the entire hash from the Redis cache.""" -        await self._validate_cache() -        log.trace("Clearing the cache of all key/value pairs.") -        await self.bot.redis_session.delete(self._namespace) - -    async def pop(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> RedisValueType: -        """Get the item, remove it from the cache, and provide a default if not found.""" -        log.trace(f"Attempting to pop {key}.") -        value = await self.get(key, default) - -        log.trace( -            f"Attempting to delete item with key '{key}' from the cache. " -            "If this key doesn't exist, nothing will happen." -        ) -        await self.delete(key) - -        return value - -    async def update(self, items: Dict[RedisKeyType, RedisValueType]) -> None: -        """ -        Update the Redis cache with multiple values. - -        This works exactly like dict.update from a normal dictionary. You pass -        a dictionary with one or more key/value pairs into this method. If the keys -        do not exist in the RedisCache, they are created. If they do exist, the values -        are updated with the new ones from `items`. - -        Please note that keys and the values in the `items` dictionary -        must consist of valid RedisKeyTypes and RedisValueTypes. -        """ -        await self._validate_cache() -        log.trace(f"Updating the cache with the following items:\n{items}") -        await self.bot.redis_session.hmset_dict(self._namespace, self._dict_to_typestring(items)) - -    async def increment(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None: -        """ -        Increment the value by `amount`. - -        This works for both floats and ints, but will raise a TypeError -        if you try to do it for any other type of value. - -        This also supports negative amounts, although it would provide better -        readability to use .decrement() for that. -        """ -        log.trace(f"Attempting to increment/decrement the value with the key {key} by {amount}.") - -        # We initialize the lock here, because we need to ensure we get it -        # running on the same loop as the calling coroutine. -        # -        # If we initialized the lock in the __init__, the loop that the coroutine this method -        # would be called from might not exist yet, and so the lock would be on a different -        # loop, which would raise RuntimeErrors. -        if self._increment_lock is None: -            self._increment_lock = asyncio.Lock() - -        # Since this has several API calls, we need a lock to prevent race conditions -        async with self._increment_lock: -            value = await self.get(key) - -            # Can't increment a non-existing value -            if value is None: -                error_message = "The provided key does not exist!" -                log.error(error_message) -                raise KeyError(error_message) - -            # If it does exist, and it's an int or a float, increment and set it. -            if isinstance(value, int) or isinstance(value, float): -                value += amount -                await self.set(key, value) -            else: -                error_message = "You may only increment or decrement values that are integers or floats." -                log.error(error_message) -                raise TypeError(error_message) - -    async def decrement(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None: -        """ -        Decrement the value by `amount`. - -        Basically just does the opposite of .increment. -        """ -        await self.increment(key, -amount) diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py deleted file mode 100644 index a2f0fe55d..000000000 --- a/tests/bot/utils/test_redis_cache.py +++ /dev/null @@ -1,265 +0,0 @@ -import asyncio -import unittest - -import fakeredis.aioredis - -from bot.utils import RedisCache -from bot.utils.redis_cache import NoBotInstanceError, NoNamespaceError, NoParentInstanceError -from tests import helpers - - -class RedisCacheTests(unittest.IsolatedAsyncioTestCase): -    """Tests the RedisCache class from utils.redis_dict.py.""" - -    async def asyncSetUp(self):  # noqa: N802 -        """Sets up the objects that only have to be initialized once.""" -        self.bot = helpers.MockBot() -        self.bot.redis_session = await fakeredis.aioredis.create_redis_pool() - -        # Okay, so this is necessary so that we can create a clean new -        # class for every test method, and we want that because it will -        # ensure we get a fresh loop, which is necessary for test_increment_lock -        # to be able to pass. -        class DummyCog: -            """A dummy cog, for dummies.""" - -            redis = RedisCache() - -            def __init__(self, bot: helpers.MockBot): -                self.bot = bot - -        self.cog = DummyCog(self.bot) - -        await self.cog.redis.clear() - -    def test_class_attribute_namespace(self): -        """Test that RedisDict creates a namespace automatically for class attributes.""" -        self.assertEqual(self.cog.redis._namespace, "DummyCog.redis") - -    async def test_class_attribute_required(self): -        """Test that errors are raised when not assigned as a class attribute.""" -        bad_cache = RedisCache() -        self.assertIs(bad_cache._namespace, None) - -        with self.assertRaises(RuntimeError): -            await bad_cache.set("test", "me_up_deadman") - -    async def test_set_get_item(self): -        """Test that users can set and get items from the RedisDict.""" -        test_cases = ( -            ('favorite_fruit', 'melon'), -            ('favorite_number', 86), -            ('favorite_fraction', 86.54), -            ('favorite_boolean', False), -            ('other_boolean', True), -        ) - -        # Test that we can get and set different types. -        for test in test_cases: -            await self.cog.redis.set(*test) -            self.assertEqual(await self.cog.redis.get(test[0]), test[1]) - -        # Test that .get allows a default value -        self.assertEqual(await self.cog.redis.get('favorite_nothing', "bearclaw"), "bearclaw") - -    async def test_set_item_type(self): -        """Test that .set rejects keys and values that are not permitted.""" -        fruits = ["lemon", "melon", "apple"] - -        with self.assertRaises(TypeError): -            await self.cog.redis.set(fruits, "nice") - -        with self.assertRaises(TypeError): -            await self.cog.redis.set(4.23, "nice") - -    async def test_delete_item(self): -        """Test that .delete allows us to delete stuff from the RedisCache.""" -        # Add an item and verify that it gets added -        await self.cog.redis.set("internet", "firetruck") -        self.assertEqual(await self.cog.redis.get("internet"), "firetruck") - -        # Delete that item and verify that it gets deleted -        await self.cog.redis.delete("internet") -        self.assertIs(await self.cog.redis.get("internet"), None) - -    async def test_contains(self): -        """Test that we can check membership with .contains.""" -        await self.cog.redis.set('favorite_country', "Burkina Faso") - -        self.assertIs(await self.cog.redis.contains('favorite_country'), True) -        self.assertIs(await self.cog.redis.contains('favorite_dentist'), False) - -    async def test_items(self): -        """Test that the RedisDict can be iterated.""" -        # Set up our test cases in the Redis cache -        test_cases = [ -            ('favorite_turtle', 'Donatello'), -            ('second_favorite_turtle', 'Leonardo'), -            ('third_favorite_turtle', 'Raphael'), -        ] -        for key, value in test_cases: -            await self.cog.redis.set(key, value) - -        # Consume the AsyncIterator into a regular list, easier to compare that way. -        redis_items = [item for item in await self.cog.redis.items()] - -        # These sequences are probably in the same order now, but probably -        # isn't good enough for tests. Let's not rely on .hgetall always -        # returning things in sequence, and just sort both lists to be safe. -        redis_items = sorted(redis_items) -        test_cases = sorted(test_cases) - -        # If these are equal now, everything works fine. -        self.assertSequenceEqual(test_cases, redis_items) - -    async def test_length(self): -        """Test that we can get the correct .length from the RedisDict.""" -        await self.cog.redis.set('one', 1) -        await self.cog.redis.set('two', 2) -        await self.cog.redis.set('three', 3) -        self.assertEqual(await self.cog.redis.length(), 3) - -        await self.cog.redis.set('four', 4) -        self.assertEqual(await self.cog.redis.length(), 4) - -    async def test_to_dict(self): -        """Test that the .to_dict method returns a workable dictionary copy.""" -        copy = await self.cog.redis.to_dict() -        local_copy = {key: value for key, value in await self.cog.redis.items()} -        self.assertIs(type(copy), dict) -        self.assertDictEqual(copy, local_copy) - -    async def test_clear(self): -        """Test that the .clear method removes the entire hash.""" -        await self.cog.redis.set('teddy', 'with me') -        await self.cog.redis.set('in my dreams', 'you have a weird hat') -        self.assertEqual(await self.cog.redis.length(), 2) - -        await self.cog.redis.clear() -        self.assertEqual(await self.cog.redis.length(), 0) - -    async def test_pop(self): -        """Test that we can .pop an item from the RedisDict.""" -        await self.cog.redis.set('john', 'was afraid') - -        self.assertEqual(await self.cog.redis.pop('john'), 'was afraid') -        self.assertEqual(await self.cog.redis.pop('pete', 'breakneck'), 'breakneck') -        self.assertEqual(await self.cog.redis.length(), 0) - -    async def test_update(self): -        """Test that we can .update the RedisDict with multiple items.""" -        await self.cog.redis.set("reckfried", "lona") -        await self.cog.redis.set("bel air", "prince") -        await self.cog.redis.update({ -            "reckfried": "jona", -            "mega": "hungry, though", -        }) - -        result = { -            "reckfried": "jona", -            "bel air": "prince", -            "mega": "hungry, though", -        } -        self.assertDictEqual(await self.cog.redis.to_dict(), result) - -    def test_typestring_conversion(self): -        """Test the typestring-related helper functions.""" -        conversion_tests = ( -            (12, "i|12"), -            (12.4, "f|12.4"), -            ("cowabunga", "s|cowabunga"), -        ) - -        # Test conversion to typestring -        for _input, expected in conversion_tests: -            self.assertEqual(self.cog.redis._value_to_typestring(_input), expected) - -        # Test conversion from typestrings -        for _input, expected in conversion_tests: -            self.assertEqual(self.cog.redis._value_from_typestring(expected), _input) - -        # Test that exceptions are raised on invalid input -        with self.assertRaises(TypeError): -            self.cog.redis._value_to_typestring(["internet"]) -            self.cog.redis._value_from_typestring("o|firedog") - -    async def test_increment_decrement(self): -        """Test .increment and .decrement methods.""" -        await self.cog.redis.set("entropic", 5) -        await self.cog.redis.set("disentropic", 12.5) - -        # Test default increment -        await self.cog.redis.increment("entropic") -        self.assertEqual(await self.cog.redis.get("entropic"), 6) - -        # Test default decrement -        await self.cog.redis.decrement("entropic") -        self.assertEqual(await self.cog.redis.get("entropic"), 5) - -        # Test float increment with float -        await self.cog.redis.increment("disentropic", 2.0) -        self.assertEqual(await self.cog.redis.get("disentropic"), 14.5) - -        # Test float increment with int -        await self.cog.redis.increment("disentropic", 2) -        self.assertEqual(await self.cog.redis.get("disentropic"), 16.5) - -        # Test negative increments, because why not. -        await self.cog.redis.increment("entropic", -5) -        self.assertEqual(await self.cog.redis.get("entropic"), 0) - -        # Negative decrements? Sure. -        await self.cog.redis.decrement("entropic", -5) -        self.assertEqual(await self.cog.redis.get("entropic"), 5) - -        # What about if we use a negative float to decrement an int? -        # This should convert the type into a float. -        await self.cog.redis.decrement("entropic", -2.5) -        self.assertEqual(await self.cog.redis.get("entropic"), 7.5) - -        # Let's test that they raise the right errors -        with self.assertRaises(KeyError): -            await self.cog.redis.increment("doesn't_exist!") - -        await self.cog.redis.set("stringthing", "stringthing") -        with self.assertRaises(TypeError): -            await self.cog.redis.increment("stringthing") - -    async def test_increment_lock(self): -        """Test that we can't produce a race condition in .increment.""" -        await self.cog.redis.set("test_key", 0) -        tasks = [] - -        # Increment this a lot in different tasks -        for _ in range(100): -            task = asyncio.create_task( -                self.cog.redis.increment("test_key", 1) -            ) -            tasks.append(task) -        await asyncio.gather(*tasks) - -        # Confirm that the value has been incremented the exact right number of times. -        value = await self.cog.redis.get("test_key") -        self.assertEqual(value, 100) - -    async def test_exceptions_raised(self): -        """Testing that the various RuntimeErrors are reachable.""" -        class MyCog: -            cache = RedisCache() - -            def __init__(self): -                self.other_cache = RedisCache() - -        cog = MyCog() - -        # Raises "No Bot instance" -        with self.assertRaises(NoBotInstanceError): -            await cog.cache.get("john") - -        # Raises "RedisCache has no namespace" -        with self.assertRaises(NoNamespaceError): -            await cog.other_cache.get("was") - -        # Raises "You must access the RedisCache instance through the cog instance" -        with self.assertRaises(NoParentInstanceError): -            await MyCog.cache.get("afraid") | 
