Skip to content

Commit 6804f05

Browse files
committed
Created new module helpers.cache for easy caching
**Caching:** - The main `privex.helpers.cache` module exposes a `cached` attribute (`from privex.helpers import cached`) which acts as a singleton adapter wrapper, so the global cache adapter can easily be switched out without any risk of other parts of your code using the "old" adapter. - Created `CacheAdapter` which is an abstract base class designed to set the standard for the cache API - Created `MemoryCache` which is a simple caching layer with expiration support that simply stores cache items in memory using a static `dict` attribute. - Created `RedisCache` which as it sounds, is a cache layer that uses Redis for it's backend. It uses the global Redis from `privex.helpers.plugin` by default, however you can pass a `redis.Redis` class instance to it's constructor to use a custom instance instead. *Unit testing:** - Created unit tests for `MemoryCache` - Created unit tests for `RedisCache` - baseed on the unit tests for `MemoryCache` to avoid code duplication **Other Updates:** - Added two new functions to `privex.helpers.plugin` - `configure_redis` for updating the global redis settings and automatically replacing the Redis instance - `reset_redis` to close the current Redis connection, then delete and re-instantiate the Redis connector class. - Added two new exceptions `CacheNotFound` and `NotConfigured` - Possibly some other small changes
1 parent a8019aa commit 6804f05

9 files changed

Lines changed: 842 additions & 3 deletions

File tree

privex/helpers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def _setup_logging(level=logging.WARNING):
6666

6767
VERSION = '1.3.0.post3'
6868

69+
6970
class ImproperlyConfigured(Exception):
7071
"""Placeholder in-case this fails to import from django.core.exceptions"""
7172
pass
@@ -97,6 +98,7 @@ class AppRegistryNotReady(Exception):
9798
from privex.helpers.net import *
9899
from privex.helpers.exceptions import *
99100
from privex.helpers.plugin import *
101+
from privex.helpers.cache import CacheNotFound, CacheAdapter, CacheWrapper, MemoryCache, RedisCache, cached
100102

101103
try:
102104
from privex.helpers.asyncx import *
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any, Optional, Union
3+
4+
from privex.helpers.exceptions import CacheNotFound
5+
from privex.helpers.settings import DEFAULT_CACHE_TIMEOUT
6+
7+
8+
class CacheAdapter(ABC):
9+
"""
10+
**CacheAdapter** is an abstract base class which scaffolds methods for implementing a Cache, allowing for
11+
consistent methods and method signatures across all child classes which implement it.
12+
13+
This class cannot be instantiated by itself, only child classes which extend :class:`.CacheAdapter` and implement
14+
all methods marked with ``@abstractmethod`` can be instantiated.
15+
16+
For an example implementation of CacheAdapter, see :class:`privex.helpers.cache.MemoryCache`
17+
18+
"""
19+
20+
def __init__(self, *args, **kwargs):
21+
pass
22+
23+
@abstractmethod
24+
def get(self, key: str, default: Any = None, fail: bool = False) -> Any:
25+
"""
26+
Return the value of cache key ``key``. If the key wasn't found, or it was expired, then ``default`` will be
27+
returned.
28+
29+
Optionally, you may choose to pass ``fail=True``, which will cause this method to raise :class:`.CacheNotFound`
30+
instead of returning ``default`` when a key is non-existent / expired.
31+
32+
:param str key: The cache key (as a string) to get the value for, e.g. ``example:test``
33+
:param Any default: If the cache key ``key`` isn't found / is expired, return this value (Default: ``None``)
34+
:param bool fail: If set to ``True``, will raise :class:`.CacheNotFound` instead of returning ``default``
35+
when a key is non-existent / expired.
36+
37+
:raises CacheNotFound: Raised when ``fail=True`` and ``key`` was not found in cache / expired.
38+
39+
:return Any value: The value of the cache key ``key``, or ``default`` if it wasn't found.
40+
"""
41+
raise NotImplemented(f'{self.__class__.__name__} must implement .get()')
42+
43+
@abstractmethod
44+
def set(self, key: str, value: Any, timeout: Optional[int] = DEFAULT_CACHE_TIMEOUT):
45+
"""
46+
Set the cache key ``key`` to the value ``value``, and automatically expire the key after ``timeout`` seconds
47+
from now.
48+
49+
If ``timeout`` is ``None``, then the key will never expire (unless the cache implementation loses it's
50+
persistence, e.g. memory caches with no disk writes).
51+
52+
:param str key: The cache key (as a string) to set the value for, e.g. ``example:test``
53+
:param Any value: The value to store in the cache key ``key``
54+
:param int timeout: The amount of seconds to keep the data in cache. Pass ``None`` to disable expiration.
55+
"""
56+
raise NotImplemented(f'{self.__class__.__name__} must implement .set()')
57+
58+
59+
@abstractmethod
60+
def remove(self, key: str) -> bool:
61+
"""
62+
Remove a key from the cache.
63+
64+
If the cache key existed before removal, ``True`` will be returned. If it didn't exist (and thus couldn't
65+
remove), then ``False`` will be returned.
66+
67+
:param str key: The cache key to remove
68+
:return bool removed: ``True`` if ``key`` existed and was removed
69+
:return bool removed: ``False`` if ``key`` didn't exist, and no action was taken.
70+
"""
71+
raise NotImplemented(f'{self.__class__.__name__} must implement .remove()')
72+
73+
@abstractmethod
74+
def update_timeout(self, key: str, timeout: int = DEFAULT_CACHE_TIMEOUT) -> Any:
75+
"""
76+
Update the timeout for a given ``key`` to ``datetime.utcnow() + timedelta(seconds=timeout)``
77+
78+
This method should accept keys which are already expired, allowing expired cache keys to have their timeout
79+
extended **after** expiry.
80+
81+
**Example**::
82+
83+
>>> c = CacheAdapter()
84+
>>> c.set('example', 'test', timeout=60)
85+
>>> sleep(70)
86+
>>> c.update_timeout('example', timeout=60) # Reset the timeout for ``'example'`` to ``now + 60 seconds``
87+
>>> c.get('example')
88+
'test'
89+
90+
:param str key: The cache key to update the timeout for
91+
:param int timeout: Reset the timeout to this many seconds from ``datetime.utcnow()``
92+
:raises CacheNotFound: Raised when ``key`` was not found in cache (thus cannot extend timeout)
93+
:return Any value: The value of the cache key
94+
"""
95+
raise NotImplemented(f'{self.__class__.__name__} must implement .extend_timeout()')
96+
97+
def get_or_set(self, key: str, value: Union[Any, callable], timeout: int = DEFAULT_CACHE_TIMEOUT) -> Any:
98+
"""
99+
Attempt to return the value of ``key`` in the cache. If ``key`` doesn't exist or is expired, then it will be
100+
set to ``value``, and ``value`` will be returned.
101+
102+
The ``value`` parameter can be any standard type such as ``str`` or ``dict`` - or it can be a callable
103+
function / method which returns the value to set and return.
104+
105+
**Basic Usage**::
106+
107+
>>> c = CacheAdapter()
108+
>>> c.get('testing')
109+
None
110+
>>> c.get_or_set('testing', 'hello world')
111+
'hello world'
112+
>>> c.get('testing')
113+
'hello world'
114+
115+
**Set and get the value from a function if ``key`` didn't exist / was expired**::
116+
117+
>>> def my_func(key): return "hello {} world".format(key)
118+
>>> c = CacheAdapter()
119+
>>> c.get_or_set('example', my_func)
120+
'hello example world'
121+
>>> c.get('example')
122+
'hello example world'
123+
124+
:param str key: The cache key (as a string) to get/set the value for, e.g. ``example:test``
125+
:param Any value: The value to store in the cache key ``key``. Can be a standard type, or a callable function.
126+
:param int timeout: The amount of seconds to keep the data in cache. Pass ``None`` to disable expiration.
127+
:return Any value: The value of the cache key ``key``, or ``value`` if it wasn't found.
128+
"""
129+
key, timeout = str(key), int(timeout)
130+
try:
131+
k = self.get(key, fail=True)
132+
except CacheNotFound:
133+
k = value(key) if callable(value) else value
134+
self.set(key=key, value=k, timeout=timeout)
135+
return k
136+
137+
def __getitem__(self, item):
138+
try:
139+
return self.get(key=item, fail=True)
140+
except CacheNotFound:
141+
raise KeyError(f'Key "{item}" not found in cache.')
142+
143+
def __setitem__(self, key, value):
144+
return self.set(key=key, value=value)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import logging
2+
from datetime import datetime, timedelta
3+
from typing import Any, Optional, Union
4+
5+
from privex.helpers.exceptions import CacheNotFound
6+
from privex.helpers.settings import DEFAULT_CACHE_TIMEOUT
7+
from privex.helpers.cache.CacheAdapter import CacheAdapter
8+
9+
log = logging.getLogger(__name__)
10+
11+
12+
class MemoryCache(CacheAdapter):
13+
"""
14+
A very basic cache adapter which implements :class:`.CacheAdapter` - stores the cache in memory using
15+
the static attribute :py:attr:`.__CACHE`
16+
17+
As the cache is simply stored in memory, any python object can be cached without needing any form of serialization.
18+
19+
Fully supports cache expiration.
20+
21+
**Basic Usage**::
22+
23+
>>> from time import sleep
24+
>>> c = MemoryCache()
25+
>>> c.set('test:example', 'hello world', timeout=60)
26+
>>> c.get('test:example')
27+
'hello world'
28+
>>> sleep(60)
29+
>>> c.get('test:example', 'NOT FOUND')
30+
'NOT FOUND'
31+
32+
"""
33+
__CACHE = {}
34+
35+
def get(self, key: str, default: Any = None, fail: bool = False) -> Any:
36+
key = str(key)
37+
c = self.__CACHE
38+
if key in c:
39+
vc = c[key]
40+
if str(vc['timeout']) != 'never' and vc['timeout'] < datetime.utcnow():
41+
log.debug('Cache key "%s" has expired. Removing from cache.')
42+
del c[key]
43+
if fail:
44+
raise CacheNotFound(f'Cache key "{key}" was expired.')
45+
return default
46+
return vc['value']
47+
if fail:
48+
raise CacheNotFound(f'Cache key "{key}" was not found.')
49+
return default
50+
51+
def set(self, key: str, value: Any, timeout: Optional[int] = DEFAULT_CACHE_TIMEOUT):
52+
key, timeout = str(key), int(timeout)
53+
c = self.__CACHE
54+
c[key] = dict(value=value, timeout=datetime.utcnow() + timedelta(seconds=timeout))
55+
return c[key]
56+
57+
def get_or_set(self, key: str, value: Union[Any, callable], timeout: int = DEFAULT_CACHE_TIMEOUT) -> Any:
58+
key, timeout = str(key), int(timeout)
59+
try:
60+
k = self.get(key, fail=True)
61+
except CacheNotFound:
62+
k = value(key) if callable(value) else value
63+
self.set(key=key, value=k, timeout=timeout)
64+
return k
65+
66+
def remove(self, key: str) -> bool:
67+
key = str(key)
68+
if key in self.__CACHE:
69+
del self.__CACHE[key]
70+
return True
71+
return False
72+
73+
def update_timeout(self, key: str, timeout: int = DEFAULT_CACHE_TIMEOUT) -> Any:
74+
key, timeout = str(key), int(timeout)
75+
v = self.get(key=key, fail=True)
76+
self.__CACHE[key]['timeout'] = datetime.utcnow() + timedelta(seconds=timeout)
77+
return v

privex/helpers/cache/RedisCache.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import pickle
2+
from typing import Any, Union, Optional
3+
4+
from privex.helpers.common import empty
5+
6+
from privex.helpers import plugin
7+
from privex.helpers.cache.CacheAdapter import CacheAdapter
8+
from privex.helpers.exceptions import CacheNotFound
9+
from privex.helpers.settings import DEFAULT_CACHE_TIMEOUT
10+
11+
12+
if plugin.HAS_REDIS:
13+
from privex.helpers.plugin import get_redis
14+
import redis
15+
16+
class RedisCache(CacheAdapter):
17+
"""
18+
An Redis backed implementation of :class:`.CacheAdapter`. Uses the global Redis instance from
19+
:py:mod:`privex.helpers.plugin` by default, however custom Redis instances can be passed in via
20+
the constructor argument ``redis_instance``.
21+
22+
To allow for a wide variety of Python objects to be safely stored and retrieved from Redis, this class
23+
uses the :py:mod:`pickle` module for serialising + un-serialising values to/from Redis.
24+
25+
**Basic Usage**::
26+
27+
>>> from privex.helpers import RedisCache
28+
>>> rc = RedisCache()
29+
>>> rc.set('hello', 'world')
30+
>>> rc['hello']
31+
'world'
32+
33+
**Disabling Pickling**::
34+
35+
In some cases, you may need interoperable caching with other languages. The :py:mod:`pickle` serialisation
36+
technique is extremely specific to Python and is largely unsupported outside of Python. Thus if you need
37+
to share Redis cache data with applications in other languages, then you must disable pickling.
38+
39+
**WARNING:** If you disable pickling, then you must perform your own serialisation + de-serialization on
40+
complex objects such as ``dict``, ``list``, ``Decimal``, or arbitrary classes/functions after getting
41+
or setting cache keys.
42+
43+
**Disabling Pickle per instance**::
44+
45+
Pass ``use_pickle=False`` to the constructor, or access the attribute directly to disable pickling for a
46+
single instance of RedisCache (not globally)
47+
48+
>>> rc = RedisCache(use_pickle=False) # Opt 1. Disable pickle in constructor
49+
>>> rc.use_pickle = False # Opt 2. Disable pickle on an existing instance
50+
51+
**Disabling Pickle by default on any new instances**::
52+
53+
Change the static attribute :py:attr:`.pickle_default` to ``False`` to disable the use of pickle by default
54+
across any new instances of RedisCache.
55+
56+
>>> RedisCache.pickle_default = False
57+
58+
"""
59+
60+
pickle_default: bool = True
61+
"""
62+
Change this to ``False`` to disable the use of :py:mod:`pickle` by default for any new instances
63+
of this class.
64+
"""
65+
66+
use_pickle: bool
67+
"""If ``True``, will use :py:mod:`pickle` for serializing objects before inserting into Redis, and
68+
un-serialising objects retrieved from Redis. This attribute is set in :py:meth:`.__init__`.
69+
70+
Change this to ``False`` to disable the use of :py:mod:`pickle` - instead values will be passed to / returned
71+
from Redis as-is, with no serialisation (this may require you to manually serialize complex types such
72+
as ``dict`` and ``Decimal`` before insertion, and un-serialise after retrieval).
73+
"""
74+
75+
def __init__(self, use_pickle: bool = None, redis_instance: redis.Redis = None, *args, **kwargs):
76+
"""
77+
RedisCache by default uses the global Redis instance from :py:mod:`privex.helpers.plugin`.
78+
79+
It's recommended to use :py:func:`privex.helpers.plugin.configure_redis` if you need to change any
80+
Redis settings, as this will adjust the global settings and re-instantiate the global instance if required.
81+
82+
Alternatively, you may pass an instance of :class:`redis.Redis` as ``redis_instance``, then that will
83+
be used instead of the global instance from :py:func:`.get_redis`
84+
85+
:param bool use_pickle: (Default: ``True``) Use the built-in ``pickle`` to serialise values before
86+
storing in Redis, and un-serialise when loading from Redis
87+
88+
:param redis.Redis redis_instance: If this isn't ``None`` / ``False``, then this Redis instance will be
89+
used instead of the global one from :py:func:`.get_redis`
90+
91+
"""
92+
super().__init__(*args, **kwargs)
93+
self.redis = get_redis() if not redis_instance else redis_instance
94+
self.use_pickle = self.pickle_default if use_pickle is None else use_pickle
95+
96+
def get(self, key: str, default: Any = None, fail: bool = False) -> Any:
97+
key = str(key)
98+
res = self.redis.get(key)
99+
if empty(res):
100+
if fail: raise CacheNotFound(f'Cache key "{key}" was not found.')
101+
return default
102+
return pickle.loads(res) if self.use_pickle else res
103+
104+
def set(self, key: str, value: Any, timeout: Optional[int] = DEFAULT_CACHE_TIMEOUT):
105+
v = pickle.dumps(value) if self.use_pickle else value
106+
return self.redis.set(str(key), v, ex=timeout)
107+
108+
def remove(self, key: str) -> bool:
109+
try:
110+
self.get(key, fail=True)
111+
self.redis.delete(key)
112+
return True
113+
except CacheNotFound:
114+
return False
115+
116+
def update_timeout(self, key: str, timeout: int = DEFAULT_CACHE_TIMEOUT) -> Any:
117+
key, timeout = str(key), int(timeout)
118+
v = self.get(key=key, fail=True)
119+
self.set(key=key, value=v, timeout=timeout)
120+
return v

0 commit comments

Comments
 (0)