Add support for multiple offline contexts.

Offline compression now supports generation for multiple contexts by
providing a list or tuple of dictionaries, or by providing a dotted
string pointing to a generator function.

This makes it easier to generate multiple contexts dynamically for
situations where a user might be able to select or be served a different
theme for a website, etc.
This commit is contained in:
Nick Pope 2014-10-23 17:36:02 +01:00
parent e6ade966d5
commit 693464a1c2
4 changed files with 178 additions and 38 deletions

View File

@ -70,6 +70,7 @@ Matthew Tretter
Mehmet S. Catalbas
Michael van de Waeter
Mike Yumatov
Nick Pope
Nicolas Charlot
Niran Babalola
Paul McMillan

View File

@ -22,6 +22,7 @@ from compressor.conf import settings
from compressor.exceptions import (OfflineGenerationError, TemplateSyntaxError,
TemplateDoesNotExist)
from compressor.templatetags.compress import CompressorNode
from compressor.utils import get_mod_func
if six.PY3:
# there is an 'io' module in python 2.6+, but io.StringIO does not
@ -211,44 +212,58 @@ class Command(NoArgsCommand):
"\n\t".join((t.template_name
for t in compressor_nodes.keys())) + "\n")
contexts = settings.COMPRESS_OFFLINE_CONTEXT
if isinstance(contexts, six.string_types):
try:
module, function = get_mod_func(contexts)
contexts = getattr(import_module(module), function)()
except (AttributeError, ImportError, TypeError) as e:
raise ImportError("Couldn't import offline context function %s: %s" %
(settings.COMPRESS_OFFLINE_CONTEXT, e))
elif not isinstance(contexts, (list, tuple)):
contexts = [contexts]
log.write("Compressing... ")
count = 0
block_count = context_count = 0
results = []
offline_manifest = SortedDict()
init_context = parser.get_init_context(settings.COMPRESS_OFFLINE_CONTEXT)
for template, nodes in compressor_nodes.items():
context = Context(init_context)
template._log = log
template._log_verbosity = verbosity
for context_dict in contexts:
context_count += 1
init_context = parser.get_init_context(context_dict)
if not parser.process_template(template, context):
continue
for template, nodes in compressor_nodes.items():
context = Context(init_context)
template._log = log
template._log_verbosity = verbosity
for node in nodes:
context.push()
parser.process_node(template, context, node)
rendered = parser.render_nodelist(template, context, node)
key = get_offline_hexdigest(rendered)
if key in offline_manifest:
if not parser.process_template(template, context):
continue
try:
result = parser.render_node(template, context, node)
except Exception as e:
raise CommandError("An error occurred during rendering %s: "
"%s" % (template.template_name, e))
offline_manifest[key] = result
context.pop()
results.append(result)
count += 1
for node in nodes:
context.push()
parser.process_node(template, context, node)
rendered = parser.render_nodelist(template, context, node)
key = get_offline_hexdigest(rendered)
if key in offline_manifest:
continue
try:
result = parser.render_node(template, context, node)
except Exception as e:
raise CommandError("An error occurred during rendering %s: "
"%s" % (template.template_name, e))
offline_manifest[key] = result
context.pop()
results.append(result)
block_count += 1
write_offline_manifest(offline_manifest)
log.write("done\nCompressed %d block(s) from %d template(s).\n" %
(count, len(compressor_nodes)))
return count, results
log.write("done\nCompressed %d block(s) from %d template(s) for %d context(s).\n" %
(block_count, len(compressor_nodes), context_count))
return block_count, results
def handle_extensions(self, extensions=('html',)):
"""

View File

@ -8,12 +8,14 @@ from django.core.management.base import CommandError
from django.template import Template, Context
from django.test import TestCase
from django.utils import six, unittest
from django.utils.importlib import import_module
from compressor.cache import flush_offline_manifest, get_offline_manifest
from compressor.conf import settings
from compressor.exceptions import OfflineGenerationError
from compressor.management.commands.compress import Command as CompressCommand
from compressor.storage import default_storage
from compressor.utils import get_mod_func
if six.PY3:
# there is an 'io' module in python 2.6+, but io.StringIO does not
@ -32,6 +34,11 @@ else:
_TEST_JINJA2 = not(sys.version_info[0] == 3 and sys.version_info[1] == 2)
def offline_context_generator():
for i in range(1, 4):
yield {'content': 'OK %d!' % i}
class OfflineTestCaseMixin(object):
template_name = "test_compressor_offline.html"
verbosity = 0
@ -88,22 +95,30 @@ class OfflineTestCaseMixin(object):
if default_storage.exists(manifest_path):
default_storage.delete(manifest_path)
def _prepare_contexts(self, engine):
if engine == 'django':
return [Context(settings.COMPRESS_OFFLINE_CONTEXT)]
if engine == 'jinja2':
return [settings.COMPRESS_OFFLINE_CONTEXT]
return None
def _render_template(self, engine):
if engine == "django":
return self.template.render(Context(settings.COMPRESS_OFFLINE_CONTEXT))
elif engine == "jinja2":
return self.template_jinja2.render(settings.COMPRESS_OFFLINE_CONTEXT) + "\n"
else:
return None
contexts = self._prepare_contexts(engine)
if engine == 'django':
return ''.join(self.template.render(c) for c in contexts)
if engine == 'jinja2':
return '\n'.join(self.template_jinja2.render(c) for c in contexts) + "\n"
return None
def _test_offline(self, engine):
hashes = self.expected_hash
if not isinstance(hashes, (list, tuple)):
hashes = [hashes]
count, result = CompressCommand().compress(log=self.log, verbosity=self.verbosity, engine=engine)
self.assertEqual(1, count)
self.assertEqual([
'<script type="text/javascript" src="/static/CACHE/js/%s.js"></script>' % (self.expected_hash, ),
], result)
self.assertEqual(len(hashes), count)
self.assertEqual(['<script type="text/javascript" src="/static/CACHE/js/%s.js"></script>' % h for h in hashes], result)
rendered_template = self._render_template(engine)
self.assertEqual(rendered_template, "".join(result) + "\n")
self.assertEqual(rendered_template, '\n'.join(result) + '\n')
def test_offline(self):
for engine in self.engines:
@ -247,6 +262,84 @@ class OfflineGenerationTestCaseWithContext(OfflineTestCaseMixin, TestCase):
super(OfflineGenerationTestCaseWithContext, self).tearDown()
class OfflineGenerationTestCaseWithContextList(OfflineTestCaseMixin, TestCase):
templates_dir = 'test_with_context'
expected_hash = ['f8bcaea049b3', 'db12749b1e80', 'e9f4a0054a06']
def setUp(self):
self.old_offline_context = settings.COMPRESS_OFFLINE_CONTEXT
settings.COMPRESS_OFFLINE_CONTEXT = [{'content': 'OK %d!' % i} for i in range(1, 4)]
super(OfflineGenerationTestCaseWithContextList, self).setUp()
def tearDown(self):
settings.COMPRESS_OFFLINE_CONTEXT = self.old_offline_context
super(OfflineGenerationTestCaseWithContextList, self).tearDown()
def _prepare_contexts(self, engine):
if engine == 'django':
return [Context(c) for c in settings.COMPRESS_OFFLINE_CONTEXT]
if engine == 'jinja2':
return settings.COMPRESS_OFFLINE_CONTEXT
return None
class OfflineGenerationTestCaseWithContextGenerator(OfflineTestCaseMixin, TestCase):
templates_dir = 'test_with_context'
expected_hash = ['f8bcaea049b3', 'db12749b1e80', 'e9f4a0054a06']
def setUp(self):
self.old_offline_context = settings.COMPRESS_OFFLINE_CONTEXT
settings.COMPRESS_OFFLINE_CONTEXT = 'compressor.tests.test_offline.offline_context_generator'
super(OfflineGenerationTestCaseWithContextGenerator, self).setUp()
def tearDown(self):
settings.COMPRESS_OFFLINE_CONTEXT = self.old_offline_context
super(OfflineGenerationTestCaseWithContextGenerator, self).tearDown()
def _prepare_contexts(self, engine):
module, function = get_mod_func(settings.COMPRESS_OFFLINE_CONTEXT)
contexts = getattr(import_module(module), function)()
if engine == 'django':
return (Context(c) for c in contexts)
if engine == 'jinja2':
return contexts
return None
class OfflineGenerationTestCaseWithContextGeneratorImportError(OfflineTestCaseMixin, TestCase):
templates_dir = 'test_with_context'
def _test_offline(self, engine):
old_offline_context = settings.COMPRESS_OFFLINE_CONTEXT
try:
# Path with invalid module name -- ImportError:
settings.COMPRESS_OFFLINE_CONTEXT = 'invalid_module.invalid_function'
self.assertRaises(ImportError, CompressCommand().compress, engine=engine)
# Module name only without function -- AttributeError:
settings.COMPRESS_OFFLINE_CONTEXT = 'compressor'
self.assertRaises(ImportError, CompressCommand().compress, engine=engine)
# Path with invalid function name -- AttributeError:
settings.COMPRESS_OFFLINE_CONTEXT = 'compressor.tests.invalid_function'
self.assertRaises(ImportError, CompressCommand().compress, engine=engine)
# Path without function attempts call on module -- TypeError:
settings.COMPRESS_OFFLINE_CONTEXT = 'compressor.tests.test_offline'
self.assertRaises(ImportError, CompressCommand().compress, engine=engine)
# Valid path to generator function -- no ImportError:
settings.COMPRESS_OFFLINE_CONTEXT = 'compressor.tests.test_offline.offline_context_generator'
try:
CompressCommand().compress(engine=engine)
except ImportError:
self.fail("Valid path to offline context generator mustn't raise ImportError.")
finally:
settings.COMPRESS_OFFLINE_CONTEXT = old_offline_context
class OfflineGenerationTestCaseErrors(OfflineTestCaseMixin, TestCase):
templates_dir = "test_error_handling"

View File

@ -492,6 +492,37 @@ Offline settings
If available, the ``STATIC_URL`` setting is also added to the context.
.. note::
It is also possible to perform offline compression for multiple
contexts by providing a list or tuple of dictionaries, or by providing
a dotted string pointing to a generator function.
This makes it easier to generate contexts dynamically for situations
where a user might be able to select a different theme in their user
profile, or be served different stylesheets based on other criteria.
An example of multiple offline contexts by providing a list or tuple::
# project/settings.py:
COMPRESS_OFFLINE_CONTEXT = [
{'THEME': 'plain', 'STATIC_URL': STATIC_URL},
{'THEME': 'fancy', 'STATIC_URL': STATIC_URL},
# ...
]
An example of multiple offline contexts generated dynamically::
# project/settings.py:
COMPRESS_OFFLINE_CONTEXT = 'project.module.offline_context'
# project/module.py:
from django.conf import settings
def offline_context():
from project.models import Company
for theme in set(Company.objects.values_list('theme', flat=True)):
yield {'THEME': theme, 'STATIC_URL': settings.STATIC_URL}
.. attribute:: COMPRESS_OFFLINE_MANIFEST
:Default: ``manifest.json``