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 Mehmet S. Catalbas
Michael van de Waeter Michael van de Waeter
Mike Yumatov Mike Yumatov
Nick Pope
Nicolas Charlot Nicolas Charlot
Niran Babalola Niran Babalola
Paul McMillan Paul McMillan

View File

@ -22,6 +22,7 @@ from compressor.conf import settings
from compressor.exceptions import (OfflineGenerationError, TemplateSyntaxError, from compressor.exceptions import (OfflineGenerationError, TemplateSyntaxError,
TemplateDoesNotExist) TemplateDoesNotExist)
from compressor.templatetags.compress import CompressorNode from compressor.templatetags.compress import CompressorNode
from compressor.utils import get_mod_func
if six.PY3: if six.PY3:
# there is an 'io' module in python 2.6+, but io.StringIO does not # there is an 'io' module in python 2.6+, but io.StringIO does not
@ -211,11 +212,25 @@ class Command(NoArgsCommand):
"\n\t".join((t.template_name "\n\t".join((t.template_name
for t in compressor_nodes.keys())) + "\n") 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... ") log.write("Compressing... ")
count = 0 block_count = context_count = 0
results = [] results = []
offline_manifest = SortedDict() offline_manifest = SortedDict()
init_context = parser.get_init_context(settings.COMPRESS_OFFLINE_CONTEXT)
for context_dict in contexts:
context_count += 1
init_context = parser.get_init_context(context_dict)
for template, nodes in compressor_nodes.items(): for template, nodes in compressor_nodes.items():
context = Context(init_context) context = Context(init_context)
@ -242,13 +257,13 @@ class Command(NoArgsCommand):
offline_manifest[key] = result offline_manifest[key] = result
context.pop() context.pop()
results.append(result) results.append(result)
count += 1 block_count += 1
write_offline_manifest(offline_manifest) write_offline_manifest(offline_manifest)
log.write("done\nCompressed %d block(s) from %d template(s).\n" % log.write("done\nCompressed %d block(s) from %d template(s) for %d context(s).\n" %
(count, len(compressor_nodes))) (block_count, len(compressor_nodes), context_count))
return count, results return block_count, results
def handle_extensions(self, extensions=('html',)): 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.template import Template, Context
from django.test import TestCase from django.test import TestCase
from django.utils import six, unittest 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.cache import flush_offline_manifest, get_offline_manifest
from compressor.conf import settings from compressor.conf import settings
from compressor.exceptions import OfflineGenerationError from compressor.exceptions import OfflineGenerationError
from compressor.management.commands.compress import Command as CompressCommand from compressor.management.commands.compress import Command as CompressCommand
from compressor.storage import default_storage from compressor.storage import default_storage
from compressor.utils import get_mod_func
if six.PY3: if six.PY3:
# there is an 'io' module in python 2.6+, but io.StringIO does not # 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) _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): class OfflineTestCaseMixin(object):
template_name = "test_compressor_offline.html" template_name = "test_compressor_offline.html"
verbosity = 0 verbosity = 0
@ -88,22 +95,30 @@ class OfflineTestCaseMixin(object):
if default_storage.exists(manifest_path): if default_storage.exists(manifest_path):
default_storage.delete(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): def _render_template(self, engine):
if engine == "django": contexts = self._prepare_contexts(engine)
return self.template.render(Context(settings.COMPRESS_OFFLINE_CONTEXT)) if engine == 'django':
elif engine == "jinja2": return ''.join(self.template.render(c) for c in contexts)
return self.template_jinja2.render(settings.COMPRESS_OFFLINE_CONTEXT) + "\n" if engine == 'jinja2':
else: return '\n'.join(self.template_jinja2.render(c) for c in contexts) + "\n"
return None return None
def _test_offline(self, engine): 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) count, result = CompressCommand().compress(log=self.log, verbosity=self.verbosity, engine=engine)
self.assertEqual(1, count) self.assertEqual(len(hashes), count)
self.assertEqual([ self.assertEqual(['<script type="text/javascript" src="/static/CACHE/js/%s.js"></script>' % h for h in hashes], result)
'<script type="text/javascript" src="/static/CACHE/js/%s.js"></script>' % (self.expected_hash, ),
], result)
rendered_template = self._render_template(engine) 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): def test_offline(self):
for engine in self.engines: for engine in self.engines:
@ -247,6 +262,84 @@ class OfflineGenerationTestCaseWithContext(OfflineTestCaseMixin, TestCase):
super(OfflineGenerationTestCaseWithContext, self).tearDown() 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): class OfflineGenerationTestCaseErrors(OfflineTestCaseMixin, TestCase):
templates_dir = "test_error_handling" 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. 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 .. attribute:: COMPRESS_OFFLINE_MANIFEST
:Default: ``manifest.json`` :Default: ``manifest.json``