Skip to content

Commit 1a1407f

Browse files
authored
Merge pull request #6 from Jakeway/check_for_obj_get_cache_key
Add CacheHelperCacheable Interface; Clean up existing code
2 parents c036ed3 + 3ce3fd0 commit 1a1407f

File tree

5 files changed

+427
-151
lines changed

5 files changed

+427
-151
lines changed

cache_helper/decorators.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,32 @@
33
except ImportError:
44
from cache_helper.exceptions import CacheHelperException as CacheSetError
55

6+
67
from django.core.cache import cache
78
from django.utils.functional import wraps
9+
810
from cache_helper import utils
11+
from cache_helper.exceptions import CacheHelperFunctionError
912

1013

1114
def cached(timeout):
12-
def get_key(*args, **kwargs):
13-
return utils.sanitize_key(utils._cache_key(*args, **kwargs))
1415

15-
def _cached(func, *args):
16-
func_type = utils._func_type(func)
16+
def _cached(func):
17+
func_type = utils.get_function_type(func)
18+
if func_type is None:
19+
raise CacheHelperFunctionError('Error determining function type of {func}'.format(func=func))
20+
21+
func_name = utils.get_function_name(func)
22+
if func_name is None:
23+
raise CacheHelperFunctionError('Error determining function name of {func}'.format(func=func))
1724

1825
@wraps(func)
1926
def wrapper(*args, **kwargs):
20-
name = utils._func_info(func, args)
21-
key = get_key(name, func_type, args, kwargs)
27+
function_cache_key = utils.get_function_cache_key(func_type, func_name, args, kwargs)
28+
cache_key = utils.get_hashed_cache_key(function_cache_key)
2229

2330
try:
24-
value = cache.get(key)
31+
value = cache.get(cache_key)
2532
except Exception:
2633
value = None
2734

@@ -31,16 +38,11 @@ def wrapper(*args, **kwargs):
3138
# But if it fails on an error from the underlying
3239
# cache system, handle it.
3340
try:
34-
cache.set(key, value, timeout)
41+
cache.set(cache_key, value, timeout)
3542
except CacheSetError:
3643
pass
3744

3845
return value
3946

40-
def invalidate(*args, **kwargs):
41-
name = utils._func_info(func, args)
42-
key = get_key(name, func_type, args, kwargs)
43-
cache.delete(key)
44-
wrapper.invalidate = invalidate
4547
return wrapper
46-
return _cached
48+
return _cached

cache_helper/exceptions.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
class CacheHelperException(Exception):
2-
pass
2+
pass
3+
34

45
class CacheKeyCreationError(CacheHelperException):
5-
pass
6+
pass
7+
8+
9+
class CacheHelperFunctionError(CacheHelperException):
10+
pass

cache_helper/interfaces.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import abc
2+
3+
4+
class CacheHelperCacheable(abc.ABC):
5+
@abc.abstractmethod
6+
def get_cache_helper_key(self):
7+
"""
8+
For any two objects of the same class which are considered equal in your application,
9+
get_cache_helper_key should return the same key. This key should be unique to all objects
10+
considered equal. This key will be used as a component to the final cache key to get/set
11+
values from the cache. The key should be a string.
12+
"""
13+
pass

cache_helper/utils.py

Lines changed: 62 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,68 @@
1-
import unicodedata
21
from hashlib import sha256
3-
4-
from django.core.cache import cache
2+
import inspect
53

64
from cache_helper import settings
75
from cache_helper.exceptions import CacheKeyCreationError
6+
from cache_helper.interfaces import CacheHelperCacheable
7+
8+
9+
def get_function_cache_key(func_type, func_name, func_args, func_kwargs):
10+
if func_type in ['method', 'function']:
11+
args_string = _sanitize_args(*func_args, **func_kwargs)
12+
elif func_type == 'class_method':
13+
# In this case, since we are dealing with a class method, the first arg to the function
14+
# will be the class. Since the name of the class and function is already built in to the
15+
# cache key, we can bypass the class variable and instead slice from the first index.
16+
args_string = _sanitize_args(*func_args[1:], **func_kwargs)
17+
key = '{func_name}{args_string}'.format(func_name=func_name, args_string=args_string)
18+
return key
819

9-
# List of Control Characters not useable by memcached
10-
CONTROL_CHARACTERS = set([chr(i) for i in range(0, 33)])
11-
CONTROL_CHARACTERS.add(chr(127))
1220

13-
def sanitize_key(key, max_length=250):
21+
def get_hashed_cache_key(key):
1422
"""
15-
Truncates key to keep it under memcached char limit. Replaces with hash.
16-
Remove control characters b/c of memcached restriction on control chars.
23+
Given the intermediate key produced by a function call along with its args + kwargs,
24+
performs a sha256 hash on the utf-8 encoded version of the key, and returns the result
1725
"""
18-
key = ''.join([c for c in key if c not in CONTROL_CHARACTERS])
19-
key_length = len(key)
20-
# django memcached backend will, by default, add a prefix. Account for this in max
21-
# key length. '%s:%s:%s'.format()
22-
version_length = len(str(getattr(cache, 'version', '')))
23-
prefix_length = len(settings.CACHE_MIDDLEWARE_KEY_PREFIX)
24-
# +2 for the colons
25-
max_length -= (version_length + prefix_length + 2)
26-
if key_length > max_length:
27-
the_hash = sha256(key.encode('utf-8')).hexdigest()
28-
# sha256 always 64 chars.
29-
key = key[:max_length - 64] + the_hash
30-
return key
26+
key_hash = sha256(key.encode('utf-8', errors='ignore')).hexdigest()
27+
return key_hash
3128

3229

33-
def _sanitize_args(args=[], kwargs={}):
30+
def _sanitize_args(*args, **kwargs):
3431
"""
3532
Creates unicode key from all kwargs/args
36-
-Note: comma separate args in order to prevent poo(1,2), poo(12, None) corner-case collisions...
33+
-Note: comma separate args in order to prevent foo(1,2), foo(12, None) corner-case collisions...
3734
"""
38-
key = ";{0};{1}"
39-
kwargs_key = ""
35+
key = ";{args_key};{kwargs_key}"
4036
args_key = _plumb_collections(args)
4137
kwargs_key = _plumb_collections(kwargs)
42-
return key.format(args_key, kwargs_key)
38+
return key.format(args_key=args_key, kwargs_key=kwargs_key)
4339

4440

45-
def _func_type(func):
46-
argnames = func.__code__.co_varnames[:func.__code__.co_argcount]
47-
if len(argnames) > 0:
48-
if argnames[0] == 'self':
49-
return 'method'
50-
elif argnames[0] == 'cls':
51-
return 'class_method'
52-
return 'function'
41+
def get_function_type(func):
42+
"""
43+
Gets the type of the given function
44+
"""
45+
if 'self' in inspect.getargspec(func).args:
46+
return 'method'
47+
if 'cls' in inspect.getargspec(func).args:
48+
return 'class_method'
5349

50+
if inspect.isfunction(func):
51+
return 'function'
5452

55-
def get_normalized_term(term, dash_replacement=''):
56-
term = str(term)
57-
if isinstance(term, bytes):
58-
term = term.decode('utf-8')
59-
term = unicodedata.normalize('NFKD', term).encode('ascii', 'ignore').decode('utf-8')
60-
term = term.lower()
61-
term = term.strip()
62-
return term
53+
return None
6354

6455

65-
def _func_info(func, args):
66-
func_type = _func_type(func)
67-
lineno = ":%s" % func.__code__.co_firstlineno
56+
def get_function_name(func):
57+
func_type = get_function_type(func)
6858

69-
if func_type == 'function':
70-
name = ".".join([func.__module__, func.__name__]) + lineno
59+
if func_type in ['method', 'class_method', 'function']:
60+
name = '{func_module}.{qualified_name}'\
61+
.format(func_module=func.__module__, qualified_name=func.__qualname__)
7162
return name
72-
elif func_type == 'class_method':
73-
class_name = args[0].__name__
74-
else:
75-
class_name = args[0].__class__.__name__
76-
name = ".".join([func.__module__, class_name, func.__name__]) + lineno
77-
return name
7863

64+
return None
7965

80-
def _cache_key(func_name, func_type, args, kwargs):
81-
if func_type in ['method', 'function']:
82-
args_string = _sanitize_args(args, kwargs)
83-
elif func_type == 'class_method':
84-
args_string = _sanitize_args(args[1:], kwargs)
85-
key = '%s%s' % (func_name, args_string)
86-
return key
8766

8867
def _plumb_collections(input_item):
8968
"""
@@ -96,18 +75,19 @@ def _plumb_collections(input_item):
9675
if hasattr(input_item, '__iter__'):
9776
if isinstance(input_item, dict):
9877
# Py3k Compatibility nonsense...
99-
remains = [[(k,v) for k, v in input_item.items()].__iter__()]
78+
remains = [[(k, v) for k, v in input_item.items()].__iter__()]
10079
# because dictionary iterators yield tuples, it would appear
10180
# to be 2 levels per dictionary, but that seems unexpected.
10281
level -= 1
10382
else:
10483
remains = [input_item.__iter__()]
10584
else:
106-
return get_normalized_term(input_item)
85+
return _get_object_cache_key(input_item)
10786

10887
while len(remains) > 0:
10988
if settings.MAX_DEPTH is not None and level > settings.MAX_DEPTH:
110-
raise CacheKeyCreationError('Function args or kwargs have too many nested collections for current MAX_DEPTH')
89+
raise CacheKeyCreationError(
90+
'Function args or kwargs have too many nested collections for current MAX_DEPTH')
11191
current_iterator = remains.pop()
11292
level += 1
11393
while True:
@@ -127,7 +107,8 @@ def _plumb_collections(input_item):
127107
hashed_list = []
128108

129109
for k, v in current_item.items():
130-
hashed_list.append((sha256(str(k).encode('utf-8')).hexdigest(), v))
110+
item_cache_key = _get_object_cache_key(k)
111+
hashed_list.append((sha256(item_cache_key.encode('utf-8')).hexdigest(), v))
131112

132113
hashed_list = sorted(hashed_list, key=lambda t: t[0])
133114
remains.append(current_iterator)
@@ -139,7 +120,8 @@ def _plumb_collections(input_item):
139120
hashed_list = []
140121

141122
for item in current_item:
142-
hashed_list.append(sha256(str(item).encode('utf-8')).hexdigest())
123+
item_cache_key = _get_object_cache_key(item)
124+
hashed_list.append(sha256(item_cache_key.encode('utf-8')).hexdigest())
143125

144126
hashed_list = sorted(hashed_list)
145127
remains.append(current_iterator)
@@ -150,10 +132,23 @@ def _plumb_collections(input_item):
150132
remains.append(current_item.__iter__())
151133
break
152134
else:
153-
current_item_string = '{0},'.format(get_normalized_term(current_item))
135+
current_item_string = '{0},'.format(_get_object_cache_key(current_item))
154136
return_list.append(current_item_string)
155137
continue
156138
# trim trailing comma
157139
return_string = ''.join(return_list)
158140
# trim last ',' because it lacks significant meaning.
159141
return return_string[:-1]
142+
143+
144+
def _get_object_cache_key(obj):
145+
"""
146+
Function used to get the individual cache key for objects. Checks if the
147+
object is an instance of CacheHelperCacheable, which means it will have a
148+
get_cache_helper_key function defined for it which will be used as the key.
149+
Otherwise, just uses the string representation of the object.
150+
"""
151+
if isinstance(obj, CacheHelperCacheable):
152+
return obj.get_cache_helper_key()
153+
else:
154+
return str(obj)

0 commit comments

Comments
 (0)