Adds a Jinja2 extension.

This commit is contained in:
Bertrand Bordage 2016-09-13 20:15:30 +02:00
parent 62cb9c5c78
commit 04afa3f439
7 changed files with 240 additions and 15 deletions

75
cachalot/jinja2.py Normal file
View File

@ -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

View File

@ -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

View File

@ -53,7 +53,7 @@ Features
- A few bonus features like
:ref:`a signal triggered at each database change <Signal>`
(including bulk changes) and
:ref:`a template tag for a better template fragment caching <Template tag>`.
:ref:`a template tag for a better template fragment caching <Template utils>`.
Comparison with similar tools
.............................

View File

@ -166,10 +166,10 @@ For example:
settings.CACHALOT_ENABLED = False
.. _Template tag:
.. _Template utils:
Template tag
............
Template utils
..............
`Caching template fragments <https://docs.djangoproject.com/en/1.8/topics/cache/#template-fragment-caching>`_
can be extremely powerful to speedup a Django application. However, it often
@ -190,6 +190,9 @@ or tables. The API function
and we provided a ``get_last_invalidation`` template tag to directly
use it in templates. It works exactly the same as the API function.
Django template tag
~~~~~~~~~~~~~~~~~~~
Example of a quite heavy nested loop with a lot of SQL queries
(considering no prefetch has been done)::
@ -215,6 +218,45 @@ are also available (see
:meth:`cachalot.api.get_last_invalidation`).
Jinja2 statement and function
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A Jinja2 extension for django-cachalot can be used, simply add
``''cachalot.jinja2.ext','`` to the ``'extensions'`` list of the ``OPTIONS``
dict in the Django ``TEMPLATES`` settings.
It provides:
- The API function
:meth:`get_last_invalidation <cachalot.api.get_last_invalidation>` directly
available as a function anywhere in Jinja2.
- An Jinja2 statement equivalent to the ``cache`` template tag of Django.
The ``cache`` does the same thing as its Django template equivalent,
except that ``cache_key`` and ``timeout`` are optional keyword arguments, and
you need to add commas between arguments. When unspecified, ``cache_key`` is
generated from the template filename plus the statement line number, and
``timeout`` defaults to infinite. To specify which cache should store the
saved content, use the ``cache_alias`` keyword argument.
Same example than above, but for Jinja2::
{% cache get_last_invalidation('auth.User', 'library.Book', 'library.Author'),
cache_key='short_user_profile', timeout=3600 %}
{{ user }} has borrowed these books:
{% for book in user.borrowed_books.all() %}
<div class="book">
{{ book }} ({{ book.pages.count() }} pages)
<span class="authors">
{% for author in book.authors.all() %}
{{ author }}{% if not loop.last %},{% endif %}
{% endfor %}
</span>
</div>
{% endfor %}
{% endcache %}
.. _Signal:
Signal

View File

@ -6,3 +6,4 @@ django-redis
python-memcached
pylibmc
pytz
Jinja2

View File

@ -24,8 +24,9 @@ DATABASES = {
if django_version[:2] == (1, 8):
DATABASES['postgresql']['ENGINE'] = 'django.db.backends.postgresql_psycopg2'
for alias in DATABASES:
test_db_name = 'test_' + DATABASES[alias]['NAME']
DATABASES[alias]['TEST'] = {'NAME': test_db_name}
if 'TEST' not in DATABASES[alias]:
test_db_name = 'test_' + DATABASES[alias]['NAME']
DATABASES[alias]['TEST'] = {'NAME': test_db_name}
DATABASES['default'] = DATABASES.pop(os.environ.get('DB_ENGINE', 'sqlite3'))
@ -105,12 +106,21 @@ TEMPLATES = [
'DIRS': [],
'APP_DIRS': True,
},
{
'BACKEND': 'django.template.backends.jinja2.Jinja2',
'APP_DIRS': True,
'OPTIONS': {
'extensions': [
'cachalot.jinja2.ext',
],
},
}
]
MIDDLEWARE_CLASSES = ()
PASSWORD_HASHERS = ('django.contrib.auth.hashers.MD5PasswordHasher',)
SECRET_KEY = 'its not important but we have to set it'
SECRET_KEY = 'its not important in tests but we have to set it'
USE_TZ = False # Time zones are not supported by MySQL,

View File

@ -14,13 +14,14 @@ deps =
django1.9: Django>=1.9,<1.10
django1.10: Django>=1.10,<1.11
psycopg2
mysqlclient
django-redis
python-memcached
# FIXME: Remove the version when this is fixed: https://github.com/lericson/pylibmc/issues/216
pylibmc==1.5.0
pytz
py{2.7,3.3,3.4,3.5}: coverage
py{2.7,3.3,3.4,3.5}: mysqlclient
jinja2
coverage
setenv =
sqlite3: DB_ENGINE=sqlite3
postgresql: DB_ENGINE=postgresql