Commits

Mikhail Korobov committed 1312ac9

Massive refactoring, more tests and .invalidate method for wrapped functions.

Comments (0)

Files changed (6)

 
 django-cache-utils provides some utils for make cache-related work easier:
 
-* django memcached cache backend with group O(1)
-  invalidation ability, dog-pile effect prevention using MintCache algorythm
+* ``cached`` decorator. It can be applied to function, method or classmethod.
+
+  Supports bulk cache invalidation and fine-grained invalidation for
+  exact parameter set. Cache keys are human-readable because they are
+  constructed from callable's full name and arguments and then sanitized
+  to make memcached happy.
+
+  Wrapped callable gets `invalidate` methods. Call `invalidate` with
+  same arguments as function and the cached result for these arguments will be
+  invalidated.
+
+* django memcached cache backend with group O(1) invalidation ability,
+  dog-pile effect prevention using MintCache algorythm
   and project version support to allow gracefull updates and multiple django
   projects on same memcached instance.
-  Long keys (>250) are truncated and appended with md5 hash.
-* ``cached`` decorator. Can be applied to function, method or classmethod.
-  Supports bulk O(1) cache invalidation and meaningful cache keys.
-  Takes function's arguments and full name into account while
-  constructing cache key.
+  Long keys (>250) are auto-truncated and appended with md5 hash.
 
 Installation
 ============
 
     pip install django-cache-utils
 
-and then::
+and then (optional)::
 
     # settings.py
     CACHE_BACKEND = 'cache_utils.group_backend://localhost:11211/'
 Usage
 =====
 
-::
+Without ``group_backend``::
+
+    from cache_utils.decorators import cached
+
+    @cached(60)
+    def foo(x, y=0):
+        print 'foo is called'
+        return x+y
+
+    foo(1,2) # foo is called
+    foo(1,2)
+    foo(5,6) # foo is called
+    foo(5,6)
+    foo.invalidate(1,2)
+    foo(1,2) # foo is called
+    foo(5,6)
+    foo(x=2) # foo is called
+    foo(x=2)
+
+    class Foo(object):
+        @cached(60)
+        def foo(self, x,y):
+            print "foo is called"
+            return x+y
+
+    obj = Foo()
+    obj.foo(1,2) # foo is called
+    obj.foo(1,2)
+
+
+With ``group_backend``::
 
     from django.db import models
     from cache_utils.decorators import cached
 Notes
 =====
 
+If decorated function returns None cache will be bypassed.
+
 django-cache-utils use 2 reads from memcached to get a value if 'group'
 argument is passed to 'cached' decorator::
 
     @cached(60)
-    def my_func(param)
+    def foo(param)
         return ..
 
     @cached(60, 'my_group')
-    def my_func2(param)
+    def bar(param)
         return ..
 
     # 1 read from memcached
-    value1 = my_func(1)
+    value1 = foo(1)
 
     # 2 reads from memcached + ability to invalidate all values at once
-    value2 = my_func2(2)
+    value2 = bar(1)
+
 
 Running tests
 =============
 
-Add ``'cache_utils'`` to ``INSTALLED_APPS`` and run ``./manage test cache_utils``.
+::
+
+    cd test_project
+    ./runtests.py

cache_utils/decorators.py

 #coding: utf-8
 from django.core.cache import cache
 from django.utils.functional import wraps
-from cache_utils.utils import get_args_string
+from cache_utils.utils import readable_cache_key
 
 def cached(timeout, group=None):
     """ Caching decorator. Can be applied to function, method or classmethod.
-        Supports bulk O(1) cache invalidation and meaningful cache keys.
-        Takes function's arguments and full name into account while
-        constructing cache key.
+    Supports bulk cache invalidation and invalidation for exact parameter
+    set. Cache keys are human-readable because they are constructed from
+    callable's full name and arguments and then sanitized to make
+    memcached happy.
+
+    It can be used with or without group_backend. Without group_backend
+    bulk invalidation is not supported.
+
+    Wrapped callable gets `invalidate` methods. Call `invalidate` with
+    same arguments as function and the result for these arguments will be
+    invalidated.
     """
 
     backend_kwargs = {'group': group} if group else {}
 
     def _cached(func):
 
-        # check if decorator is applied to function, method or classmethod
-        argnames = func.func_code.co_varnames[:func.func_code.co_argcount]
-        static = method = False
-        if len(argnames) > 0:
-            if argnames[0] == 'self' or argnames[0] == 'cls':
-                method = True
-                if argnames[0] == 'cls':
-                    static = True
+        def cache_key(*args, **kwargs):
+            ''' returns cache key for given arguments '''
+            return readable_cache_key(func, args, kwargs)
+
+        def invalidate(*args, **kwargs):
+            ''' invalidates cache result for function called with passed arguments '''
+            key = cache_key(*args, **kwargs)
+            cache.delete(key, **backend_kwargs)
 
         @wraps(func)
         def wrapper(*args, **kwargs):
 
-            # introspect function's or method's full name
-            if method:
-                if static:
-                    class_name = args[0].__name__
-                else:
-                    class_name = args[0].__class__.__name__
-                func_name = ".".join([func.__module__, class_name, func.__name__])
-                key_args = args[1:]
-            else:
-                func_name = ".".join([func.__module__, func.__name__])
-                key_args = args
-
-            # construct the key using function's (method's) full name and
-            # passed parameters
-            key = '[cached]%s(%s)' % (func_name, get_args_string(key_args, kwargs))
-
             # try to get the value from cache
+            key = cache_key(*args, **kwargs)
             value = cache.get(key, **backend_kwargs)
 
             # in case of cache miss recalculate the value and put it to the cache
                 value = func(*args, **kwargs)
                 cache.set(key, value, timeout, **backend_kwargs)
             return value
+
+        wrapper.cache_key = cache_key
+        wrapper.invalidate = invalidate
+
         return wrapper
     return _cached

cache_utils/group_backend.py

 
     def set(self, key, value, timeout=0, group=None, refreshed=False):
         key = self._make_key(group, key)
-
         refresh_time = timeout + time.time()
         real_timeout = timeout + MINT_DELAY
         packed_value = (value, refresh_time, refreshed)

cache_utils/tests.py

 
 from django.core.cache import cache
 from cache_utils.decorators import cached
-from cache_utils.utils import sanitize_memcached_key
+from cache_utils.utils import sanitize_memcached_key, _func_type, _func_info,\
+    readable_cache_key
+
+def foo(a,b):
+    pass
+
+class Foo(object):
+    def foo(self, a, b):
+        pass
+    @classmethod
+    def bar(cls, x):
+        pass
+
+class FuncTypeTest(TestCase):
+    def assertFuncType(self, func, tp):
+        self.assertEqual(_func_type(func), tp)
+
+    def test_func(self):
+        self.assertFuncType(foo, 'function')
+
+    def test_method(self):
+        self.assertFuncType(Foo.foo, 'method')
+
+    def test_classmethod(self):
+        self.assertFuncType(Foo.bar, 'classmethod')
+
+
+class FuncInfoTest(TestCase):
+    def assertFuncInfo(self, func, args_in, name, args_out):
+        info = _func_info(func, args_in)
+        self.assertEqual(info[0], name)
+        self.assertEqual(info[1], args_out)
+
+    def test_func(self):
+        self.assertFuncInfo(foo, [1,2], 'cache_utils.tests.foo', [1,2])
+
+    def test_method(self):
+        foo_obj = Foo()
+        self.assertFuncInfo(Foo.foo, [foo_obj, 1, 2],
+                            'cache_utils.tests.Foo.foo', [1,2])
+
+    def test_classmethod(self):
+        self.assertFuncInfo(Foo.bar, [Foo, 1],
+                            'cache_utils.tests.Foo.bar', [1])
+
+
+class SanitizeTest(TestCase):
+    def test_sanitize_keys(self):
+        key = u"12345678901234567890123456789012345678901234567890"
+        self.assertTrue(len(key) >= 40)
+        key = sanitize_memcached_key(key, 40)
+        self.assertTrue(len(key) <= 40)
+
+
+class ReadableKeysTest(TestCase):
+
+    def test_key_is_readable(self):
+        key = readable_cache_key(foo, [1], {'b': 'foo'})
+        self.assertEqual(key, u"[cached]cache_utils.tests.foo([1]{'b': 'foo'})")
+
 
 class ClearMemcachedTest(TestCase):
     def tearDown(self):
     def setUp(self):
         cache._cache.flush_all()
 
+
 class InvalidationTest(ClearMemcachedTest):
 
-    def test_invalidation(self):
+    def test_group_invalidation(self):
         cache.set('vasia', 'foo', 60, group='names')
         cache.set('petya', 'bar', 60, group='names')
         cache.set('red', 'good', 60, group='colors')
         cache.set('vasia', 'foo', 60, group='names')
         self.assertEqual(cache.get('vasia', group='names'), 'foo')
 
+    def test_decorator_invalidation(self):
+        self.call_count = 0
 
-class UtilsTest(ClearMemcachedTest):
+        @cached(60)
+        def my_func(a, b):
+            self.call_count += 1
+            return self.call_count
 
-    def test_sanitize_keys(self):
-        key = u"12345678901234567890123456789012345678901234567890"
-        self.assertTrue(len(key) >= 40)
-        key = sanitize_memcached_key(key, 40)
-        self.assertTrue(len(key) <= 40)
+        self.assertEqual(my_func(1,2), 1)
+        self.assertEqual(my_func(1,2), 1)
+        self.assertEqual(my_func(3,2), 2)
+        self.assertEqual(my_func(3,2), 2)
+        my_func.invalidate(3,2)
+        self.assertEqual(my_func(1,2), 1)
+        self.assertEqual(my_func(3,2), 3)
+        self.assertEqual(my_func(3,2), 3)
+
+
+class DecoratorTest(ClearMemcachedTest):
 
     def test_decorator(self):
         self._x = 0

cache_utils/utils.py

         key = key[:max_length-33]+'-'+hash
     return key
 
-def get_args_string(args, kwargs):
+def _args_to_unicode(args, kwargs):
     key = ""
     if args:
         key += unicode(args)
     return key
 
 
+def _func_type(func):
+    """ returns if callable is a function, method or a classmethod """
+    argnames = func.func_code.co_varnames[:func.func_code.co_argcount]
+    if len(argnames) > 0:
+        if argnames[0] == 'self':
+            return 'method'
+        if argnames[0] == 'cls':
+            return 'classmethod'
+    return 'function'
+
+
+def _func_info(func, args):
+    ''' introspect function's or method's full name.
+    Returns a tuple (name, normalized_args,) with
+    'cls' and 'self' removed from normalized_args '''
+
+    func_type = _func_type(func)
+
+    if func_type == 'function':
+        return ".".join([func.__module__, func.__name__]), args
+
+    class_name = args[0].__class__.__name__
+    if func_type == 'classmethod':
+        class_name = args[0].__name__
+
+    return ".".join([func.__module__, class_name, func.__name__]), args[1:]
+
+
+def readable_cache_key(func, args, kwargs):
+    """ Construct readable cache key using callable's full name and
+    passed parameters """
+    func_name, normalized_args = _func_info(func, args)
+    args_string = _args_to_unicode(normalized_args, kwargs)
+    return '[cached]%s(%s)' % (func_name, args_string,)
+
 #!/usr/bin/env python
 from distutils.core import setup
 
-version='0.5.1'
+version='0.6.0'
 
 setup(
     name='django-cache-utils',