diff --git a/cachalot/jinja2.py b/cachalot/jinja2.py new file mode 100644 index 0000000..16a0511 --- /dev/null +++ b/cachalot/jinja2.py @@ -0,0 +1,75 @@ +from django.core.cache import caches, DEFAULT_CACHE_ALIAS +from django.core.cache.utils import make_template_fragment_key +from jinja2.nodes import Keyword, Const, CallBlock +from jinja2.ext import Extension + +from .api import get_last_invalidation + + +class CachalotExtension(Extension): + tags = {'cache'} + allowed_kwargs = ('cache_key', 'timeout', 'cache_alias') + + def __init__(self, environment): + super(CachalotExtension, self).__init__(environment) + + self.environment.globals.update( + get_last_invalidation=get_last_invalidation) + + def parse_args(self, parser): + args = [] + kwargs = [] + + stream = parser.stream + + while stream.current.type != 'block_end': + if stream.current.type == 'name' \ + and stream.look().type == 'assign': + key = stream.current.value + if key not in self.allowed_kwargs: + parser.fail( + "'%s' is not a valid keyword argument " + "for {%% cache %%}" % key, + stream.current.lineno) + stream.skip(2) + value = parser.parse_expression() + kwargs.append(Keyword(key, value, lineno=value.lineno)) + else: + args.append(parser.parse_expression()) + + if stream.current.type == 'block_end': + break + + parser.stream.expect('comma') + + return args, kwargs + + def parse(self, parser): + tag = parser.stream.current.value + lineno = next(parser.stream).lineno + args, kwargs = self.parse_args(parser) + default_cache_key = (None if parser.filename is None + else '%s:%d' % (parser.filename, lineno)) + kwargs.append(Keyword('default_cache_key', Const(default_cache_key), + lineno=lineno)) + body = parser.parse_statements(['name:end' + tag], drop_needle=True) + + return CallBlock(self.call_method('cache', args, kwargs), + [], [], body).set_lineno(lineno) + + def cache(self, *args, **kwargs): + cache_alias = kwargs.get('cache_alias', DEFAULT_CACHE_ALIAS) + cache_key = kwargs.get('cache_key', kwargs['default_cache_key']) + if cache_key is None: + raise ValueError( + 'You must set `cache_key` when the template is not a file.') + cache_key = make_template_fragment_key(cache_key, args) + + out = caches[cache_alias].get(cache_key) + if out is None: + out = kwargs['caller']() + caches[cache_alias].set(cache_key, out, kwargs.get('timeout')) + return out + + +ext = CachalotExtension diff --git a/cachalot/tests/api.py b/cachalot/tests/api.py index 1a1d846..036eb02 100644 --- a/cachalot/tests/api.py +++ b/cachalot/tests/api.py @@ -6,11 +6,12 @@ from unittest import skipIf from django.conf import settings from django.contrib.auth.models import User -from django.core.cache import DEFAULT_CACHE_ALIAS +from django.core.cache import DEFAULT_CACHE_ALIAS, caches from django.core.management import call_command from django.db import connection, transaction, DEFAULT_DB_ALIAS -from django.template import Template, Context +from django.template import engines, Context from django.test import TransactionTestCase +from jinja2.exceptions import TemplateSyntaxError from ..api import * from .models import Test @@ -20,6 +21,8 @@ class APITestCase(TransactionTestCase): def setUp(self): self.t1 = Test.objects.create(name='test1') self.is_sqlite = connection.vendor == 'sqlite' + self.cache_alias2 = next(alias for alias in settings.CACHES + if alias != DEFAULT_CACHE_ALIAS) def test_invalidate_tables(self): with self.assertNumQueries(1): @@ -109,11 +112,33 @@ class APITestCase(TransactionTestCase): self.assertAlmostEqual(timestamp, time(), delta=0.1) def test_get_last_invalidation_template_tag(self): - original_timestamp = Template("{{ timestamp }}").render(Context({ - 'timestamp': get_last_invalidation('auth.Group', 'cachalot_test') + # Without arguments + original_timestamp = engines['django'].from_string( + "{{ timestamp }}" + ).render(Context({ + 'timestamp': get_last_invalidation(), })) - template = Template(""" + template = engines['django'].from_string(""" + {% load cachalot %} + {% get_last_invalidation as timestamp %} + {{ timestamp }} + """) + timestamp = template.render(Context()).strip() + + self.assertNotEqual(timestamp, '') + self.assertNotEqual(timestamp, '0.0') + self.assertAlmostEqual(float(timestamp), float(original_timestamp), + delta=0.1) + + # With arguments + original_timestamp = engines['django'].from_string( + "{{ timestamp }}" + ).render(Context({ + 'timestamp': get_last_invalidation('auth.Group', 'cachalot_test'), + })) + + template = engines['django'].from_string(""" {% load cachalot %} {% get_last_invalidation 'auth.Group' 'cachalot_test' as timestamp %} {{ timestamp }} @@ -125,7 +150,8 @@ class APITestCase(TransactionTestCase): self.assertAlmostEqual(float(timestamp), float(original_timestamp), delta=0.1) - template = Template(""" + # While using the `cache` template tag, with invalidation + template = engines['django'].from_string(""" {% load cachalot cache %} {% get_last_invalidation 'auth.Group' 'cachalot_test' as timestamp %} {% cache 10 cache_key_name timestamp %} @@ -140,6 +166,76 @@ class APITestCase(TransactionTestCase): content = template.render(Context({'content': 'yet another'})).strip() self.assertEqual(content, 'yet another') + def test_get_last_invalidation_jinja2(self): + original_timestamp = engines['jinja2'].from_string( + "{{ timestamp }}" + ).render({ + 'timestamp': get_last_invalidation('auth.Group', 'cachalot_test'), + }) + template = engines['jinja2'].from_string( + "{{ get_last_invalidation('auth.Group', 'cachalot_test') }}") + timestamp = template.render({}) + + self.assertNotEqual(timestamp, '') + self.assertNotEqual(timestamp, '0.0') + self.assertAlmostEqual(float(timestamp), float(original_timestamp), + delta=0.1) + + def test_cache_jinja2(self): + # Invalid arguments + with self.assertRaises(TemplateSyntaxError, + msg="'invalid' is not a valid keyword argument " + "for {% cache %}"): + engines['jinja2'].from_string(""" + {% cache cache_key='anything', invalid='what?' %}{% endcache %} + """) + with self.assertRaises(ValueError, msg='You must set `cache_key` when ' + 'the template is not a file.'): + engines['jinja2'].from_string( + '{% cache %} broken {% endcache %}').render() + + # With the minimum number of arguments + template = engines['jinja2'].from_string(""" + {%- cache cache_key='first' -%} + {{ content1 }} + {%- endcache -%} + {%- cache cache_key='second' -%} + {{ content2 }} + {%- endcache -%} + """) + content = template.render({'content1': 'abc', 'content2': 'def'}) + self.assertEqual(content, 'abcdef') + invalidate() + content = template.render({'content1': 'ghi', 'content2': 'jkl'}) + self.assertEqual(content, 'abcdef') + + # With the maximum number of arguments + template = engines['jinja2'].from_string(""" + {%- cache get_last_invalidation('auth.Group', 'cachalot_test', + cache_alias=cache), + timeout=10, cache_key='cache_key_name', cache_alias=cache -%} + {{ content }} + {%- endcache -%} + """) + content = template.render({'content': 'something', + 'cache': self.cache_alias2}) + self.assertEqual(content, 'something') + content = template.render({'content': 'anything', + 'cache': self.cache_alias2}) + self.assertEqual(content, 'something') + invalidate('cachalot_test', cache_alias=DEFAULT_CACHE_ALIAS) + content = template.render({'content': 'yet another', + 'cache': self.cache_alias2}) + self.assertEqual(content, 'something') + invalidate('cachalot_test') + content = template.render({'content': 'will you change?', + 'cache': self.cache_alias2}) + self.assertEqual(content, 'will you change?') + caches[self.cache_alias2].clear() + content = template.render({'content': 'better!', + 'cache': self.cache_alias2}) + self.assertEqual(content, 'better!') + class CommandTestCase(TransactionTestCase): multi_db = True diff --git a/docs/introduction.rst b/docs/introduction.rst index 5dddc3c..4b4d184 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -53,7 +53,7 @@ Features - A few bonus features like :ref:`a signal triggered at each database change ` (including bulk changes) and - :ref:`a template tag for a better template fragment caching