From 693464a1c278855747c1b5c96c48dcdd4c5f9cab Mon Sep 17 00:00:00 2001 From: Nick Pope Date: Thu, 23 Oct 2014 17:36:02 +0100 Subject: [PATCH] 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. --- AUTHORS | 1 + compressor/management/commands/compress.py | 69 ++++++++----- compressor/tests/test_offline.py | 115 +++++++++++++++++++-- docs/settings.txt | 31 ++++++ 4 files changed, 178 insertions(+), 38 deletions(-) diff --git a/AUTHORS b/AUTHORS index 7b9a571..39e21d6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -70,6 +70,7 @@ Matthew Tretter Mehmet S. Catalbas Michael van de Waeter Mike Yumatov +Nick Pope Nicolas Charlot Niran Babalola Paul McMillan diff --git a/compressor/management/commands/compress.py b/compressor/management/commands/compress.py index ac555e9..f2061d1 100644 --- a/compressor/management/commands/compress.py +++ b/compressor/management/commands/compress.py @@ -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',)): """ diff --git a/compressor/tests/test_offline.py b/compressor/tests/test_offline.py index 71f91c0..09bc3bd 100644 --- a/compressor/tests/test_offline.py +++ b/compressor/tests/test_offline.py @@ -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([ - '' % (self.expected_hash, ), - ], result) + self.assertEqual(len(hashes), count) + self.assertEqual(['' % 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" diff --git a/docs/settings.txt b/docs/settings.txt index 4acf418..e0cb6f2 100644 --- a/docs/settings.txt +++ b/docs/settings.txt @@ -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``