Commit aaa23876 authored by Thomas Goirand's avatar Thomas Goirand

Merge tag '2.1.1' into debian/pike

parents eb26054a e2b7a3ef
......@@ -13,3 +13,4 @@ docs/_build/
.sass-cache
.coverage
.tox
.eggs
language: python
sudo: false
install:
- if [[ $TOXENV == py32-1.8.X ]]; then pip install pip\<8.0.0 virtualenv\<14.0.0; fi
- pip install tox
script:
- tox
......@@ -11,15 +12,17 @@ env:
- TOXENV=py34-1.8.X
- TOXENV=py27-1.9.X
- TOXENV=py34-1.9.X
- TOXENV=py27-1.10.X
- TOXENV=py34-1.10.X
# https://github.com/travis-ci/travis-ci/issues/4794
matrix:
include:
- python: 3.5
env:
- TOXENV=py35-1.8.X
env: TOXENV=py35-1.8.X
- python: 3.5
env:
- TOXENV=py35-1.9.X
env: TOXENV=py35-1.9.X
- python: 3.5
env: TOXENV=py35-1.10.X
notifications:
irc: "irc.freenode.org#django-compressor"
after_success:
......
Django Compressor
=================
.. image:: http://codecov.io/github/django-compressor/django-compressor/coverage.svg?branch=develop
:target: http://codecov.io/github/django-compressor/django-compressor?branch=develop
.. image:: https://codecov.io/github/django-compressor/django-compressor/coverage.svg?branch=develop
:target: https://codecov.io/github/django-compressor/django-compressor?branch=develop
.. image:: https://pypip.in/v/django_compressor/badge.svg
:target: https://pypi.python.org/pypi/django_compressor
.. image:: https://pypip.in/d/django_compressor/badge.svg
.. image:: https://img.shields.io/pypi/v/django_compressor.svg
:target: https://pypi.python.org/pypi/django_compressor
.. image:: https://secure.travis-ci.org/django-compressor/django-compressor.svg?branch=develop
......@@ -17,34 +14,39 @@ Django Compressor
.. image:: https://caniusepython3.com/project/django_compressor.svg
:target: https://caniusepython3.com/project/django_compressor
Django Compressor combines and compresses linked and inline Javascript
or CSS in a Django template into cacheable static files by using the
``compress`` template tag.
Django Compressor processes, combines and minifies linked and inline
Javascript or CSS in a Django template into cacheable static files.
It supports compilers such as coffeescript, LESS and SASS and is
extensible by custom processing steps.
Django Compressor is compatible with Django 1.8 and newer.
HTML in between ``{% compress js/css %}`` and ``{% endcompress %}`` is
parsed and searched for CSS or JS. These styles and scripts are subsequently
processed with optional, configurable compilers and filters.
How it works
------------
In your templates, all HTML code between the tags ``{% compress js/css %}`` and
``{% endcompress %}`` is parsed and searched for CSS or JS. These styles and
scripts are subsequently processed with optional, configurable compilers and
filters.
The default filter for CSS rewrites paths to static files to be absolute
and adds a cache busting timestamp. For Javascript the default filter
compresses it using ``jsmin``.
The default filter for CSS rewrites paths to static files to be absolute.
Both Javascript and CSS files are by default concatenated and minified.
As the final result the template tag outputs a ``<script>`` or ``<link>``
tag pointing to the optimized file. These files are stored inside a folder
and given a unique name based on their content. Alternatively it can also
return the resulting content to the original template directly.
As the final step the template tag outputs a ``<script>`` or ``<link>``
tag pointing to the optimized file. Alternatively it can also
inline the resulting content into the original template directly.
Since the file name is dependent on the content these files can be given
Since the file name is dependent on the content, these files can be given
a far future expiration date without worrying about stale browser caches.
The concatenation and compressing process can also be jump started outside
of the request/response cycle by using the Django management command
``manage.py compress``.
For increased performance, the concatenation and compressing process
can also be run once manually outside of the request/response cycle by using
the Django management command ``manage.py compress``.
Configurability & Extendibility
Configurability & Extensibility
-------------------------------
Django Compressor is highly configurable and extendible. The HTML parsing
Django Compressor is highly configurable and extensible. The HTML parsing
is done using lxml_ or if it's not available Python's built-in HTMLParser by
default. As an alternative Django Compressor provides a BeautifulSoup_ and a
html5lib_ based parser, as well as an abstract base class that makes it easy to
......@@ -78,5 +80,5 @@ The in-development version of Django Compressor can be installed with
.. _JSMin: http://www.crockford.com/javascript/jsmin.html
.. _csscompressor: https://github.com/sprymix/csscompressor
.. _data URIs: http://en.wikipedia.org/wiki/Data_URI_scheme
.. _django-compressor.readthedocs.org: http://django-compressor.readthedocs.org/en/latest/
.. _django-compressor.readthedocs.org: https://django-compressor.readthedocs.io/en/latest/
.. _github.com/django-compressor/django-compressor: https://github.com/django-compressor/django-compressor
# following PEP 386
__version__ = "2.0"
__version__ = "2.1.1"
......@@ -73,13 +73,10 @@ class CompressorConf(AppConf):
# Returns the Jinja2 environment to use in offline compression.
def JINJA2_GET_ENVIRONMENT():
alias = 'Jinja2'
alias = 'jinja2'
try:
from django.template.loader import _engine_list
engines = _engine_list(alias)
if engines:
engine = engines[0]
return engine.env
from django.template import engines
return engines[alias].env
except InvalidTemplateEngineError:
raise InvalidTemplateEngineError(
"Could not find config for '{}' "
......
......@@ -126,7 +126,7 @@ class CompilerFilter(FilterBase):
if isinstance(self.options, dict):
# turn dict into a tuple
new_options = ()
for item in kwargs.items():
for item in self.options.items():
new_options += (item,)
self.options = new_options
......@@ -221,7 +221,7 @@ class CachedCompilerFilter(CompilerFilter):
key = self.get_cache_key()
data = cache.get(key)
if data is not None:
return data
return smart_text(data)
filtered = super(CachedCompilerFilter, self).input(**kwargs)
cache.set(key, filtered, settings.COMPRESS_REBUILD_TIMEOUT)
return filtered
......
......@@ -84,19 +84,23 @@ class CssAbsoluteFilter(FilterBase):
def _converter(self, matchobj, group, template):
url = matchobj.group(group)
url = url.strip(' \'"')
url = url.strip()
wrap = '"' if url[0] == '"' else "'"
url = url.strip('\'"')
if url.startswith('#'):
return "url('%s')" % url
return template % (wrap, url, wrap)
elif url.startswith(SCHEMES):
return "url('%s')" % self.add_suffix(url)
return template % (wrap, self.add_suffix(url), wrap)
full_url = posixpath.normpath('/'.join([str(self.directory_name),
url]))
if self.has_scheme:
full_url = "%s%s" % (self.protocol, full_url)
return template % self.add_suffix(full_url)
return template % (wrap, self.add_suffix(full_url), wrap)
def url_converter(self, matchobj):
return self._converter(matchobj, 1, "url('%s')")
return self._converter(matchobj, 1, "url(%s%s%s)")
def src_converter(self, matchobj):
return self._converter(matchobj, 2, "src='%s'")
return self._converter(matchobj, 2, "src=%s%s%s")
......@@ -2,9 +2,8 @@
import os
import sys
from collections import OrderedDict
from collections import OrderedDict, defaultdict
from fnmatch import fnmatch
from optparse import make_option
from importlib import import_module
import django
......@@ -13,14 +12,12 @@ import django.template
from django.template import Context
from django.utils import six
from django.template.loader import get_template # noqa Leave this in to preload template locations
from django.template.utils import InvalidTemplateEngineError
from django.template import engines
from compressor.cache import get_offline_hexdigest, write_offline_manifest
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:
......@@ -36,23 +33,23 @@ else:
class Command(BaseCommand):
help = "Compress content outside of the request/response cycle"
option_list = BaseCommand.option_list + (
make_option('--extension', '-e', action='append', dest='extensions',
help='The file extension(s) to examine (default: ".html", '
'separate multiple extensions with commas, or use -e '
'multiple times)'),
make_option('-f', '--force', default=False, action='store_true',
help="Force the generation of compressed content even if the "
"COMPRESS_ENABLED setting is not True.", dest='force'),
make_option('--follow-links', default=False, action='store_true',
help="Follow symlinks when traversing the COMPRESS_ROOT "
"(which defaults to STATIC_ROOT). Be aware that using this "
"can lead to infinite recursion if a link points to a parent "
"directory of itself.", dest='follow_links'),
make_option('--engine', default="django", action="store",
help="Specifies the templating engine. jinja2 or django",
dest="engine"),
)
def add_arguments(self, parser):
parser.add_argument('--extension', '-e', action='append', dest='extensions',
help='The file extension(s) to examine (default: ".html", '
'separate multiple extensions with commas, or use -e '
'multiple times)')
parser.add_argument('-f', '--force', default=False, action='store_true',
help="Force the generation of compressed content even if the "
"COMPRESS_ENABLED setting is not True.", dest='force')
parser.add_argument('--follow-links', default=False, action='store_true',
help="Follow symlinks when traversing the COMPRESS_ROOT "
"(which defaults to STATIC_ROOT). Be aware that using this "
"can lead to infinite recursion if a link points to a parent "
"directory of itself.", dest='follow_links')
parser.add_argument('--engine', default="django", action="store",
help="Specifies the templating engine. jinja2 or django",
dest="engine")
def get_loaders(self):
template_source_loaders = []
......@@ -107,10 +104,11 @@ class Command(BaseCommand):
verbosity = int(options.get("verbosity", 0))
if not log:
log = StringIO()
if not settings.TEMPLATE_LOADERS:
if not self.get_loaders():
raise OfflineGenerationError("No template loaders defined. You "
"must set TEMPLATE_LOADERS in your "
"settings.")
"settings or set 'loaders' in your "
"TEMPLATES dictionary.")
templates = set()
if engine == 'django':
paths = set()
......@@ -156,6 +154,18 @@ class Command(BaseCommand):
if verbosity > 1:
log.write("Found templates:\n\t" + "\n\t".join(templates) + "\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]
contexts = list(contexts) # evaluate generator
parser = self.__get_parser(engine)
compressor_nodes = OrderedDict()
for template_name in templates:
......@@ -177,16 +187,23 @@ class Command(BaseCommand):
if verbosity > 0:
log.write("UnicodeDecodeError while trying to read "
"template %s\n" % template_name)
try:
nodes = list(parser.walk_nodes(template))
except (TemplateDoesNotExist, TemplateSyntaxError) as e:
# Could be an error in some base template
if verbosity > 0:
log.write("Error parsing template %s: %s\n" % (template_name, e))
continue
if nodes:
template.template_name = template_name
compressor_nodes.setdefault(template, []).extend(nodes)
for context_dict in contexts:
context = parser.get_init_context(context_dict)
context = Context(context)
try:
nodes = list(parser.walk_nodes(template, context=context))
except (TemplateDoesNotExist, TemplateSyntaxError) as e:
# Could be an error in some base template
if verbosity > 0:
log.write("Error parsing template %s: %s\n" % (template_name, e))
continue
if nodes:
template.template_name = template_name
template_nodes = compressor_nodes.setdefault(template, OrderedDict())
for node in nodes:
template_nodes.setdefault(node, []).append(context)
if not compressor_nodes:
raise OfflineGenerationError(
......@@ -199,36 +216,23 @@ class Command(BaseCommand):
"\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... ")
block_count = context_count = 0
block_count = 0
compressed_contexts = []
results = []
offline_manifest = OrderedDict()
for context_dict in contexts:
context_count += 1
init_context = parser.get_init_context(context_dict)
for template, nodes in compressor_nodes.items():
context = Context(init_context)
template._log = log
template._log_verbosity = verbosity
if not parser.process_template(template, context):
continue
for node in nodes:
for template, nodes in compressor_nodes.items():
template._log = log
template._log_verbosity = verbosity
for node, contexts in nodes.items():
for context in contexts:
if context not in compressed_contexts:
compressed_contexts.append(context)
context.push()
if not parser.process_template(template, context):
continue
parser.process_node(template, context, node)
rendered = parser.render_nodelist(template, context, node)
key = get_offline_hexdigest(rendered)
......@@ -248,6 +252,7 @@ class Command(BaseCommand):
write_offline_manifest(offline_manifest)
context_count = len(compressed_contexts)
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
......
......@@ -6,31 +6,25 @@ from django.template import Context
from django.template.base import Node, VariableNode, TextNode, NodeList
from django.template.defaulttags import IfNode
from django.template.loader import get_template
from django.template.loader_tags import ExtendsNode, BlockNode, BlockContext
from django.template.loader_tags import BLOCK_CONTEXT_KEY, ExtendsNode, BlockNode, BlockContext
from compressor.exceptions import TemplateSyntaxError, TemplateDoesNotExist
from compressor.templatetags.compress import CompressorNode
def handle_extendsnode(extendsnode, block_context=None, original=None):
def handle_extendsnode(extendsnode, context):
"""Create a copy of Node tree of a derived template replacing
all blocks tags with the nodes of appropriate blocks.
Also handles {{ block.super }} tags.
"""
if block_context is None:
block_context = BlockContext()
if BLOCK_CONTEXT_KEY not in context.render_context:
context.render_context[BLOCK_CONTEXT_KEY] = BlockContext()
block_context = context.render_context[BLOCK_CONTEXT_KEY]
blocks = dict((n.name, n) for n in
extendsnode.nodelist.get_nodes_by_type(BlockNode))
block_context.add_blocks(blocks)
# Note: we pass an empty context when we find the parent, this breaks
# inheritance using variables ({% extends template_var %}) but a refactor
# will be needed to support that use-case with multiple offline contexts.
context = Context()
if original is not None:
context.template = original
compiled_parent = extendsnode.get_parent(context)
parent_nodelist = compiled_parent.nodelist
# If the parent template has an ExtendsNode it is not the root.
......@@ -38,7 +32,7 @@ def handle_extendsnode(extendsnode, block_context=None, original=None):
# The ExtendsNode has to be the first non-text node.
if not isinstance(node, TextNode):
if isinstance(node, ExtendsNode):
return handle_extendsnode(node, block_context, original)
return handle_extendsnode(node, context)
break
# Add blocks of the root template to block context.
blocks = dict((n.name, n) for n in
......@@ -122,10 +116,13 @@ class DjangoParser(object):
def render_node(self, template, context, node):
return node.render(context, forced=True)
def get_nodelist(self, node, original=None):
def get_nodelist(self, node, original, context):
if isinstance(node, ExtendsNode):
try:
return handle_extendsnode(node, block_context=None, original=original)
if context is None:
context = Context()
context.template = original
return handle_extendsnode(node, context)
except template.TemplateSyntaxError as e:
raise TemplateSyntaxError(str(e))
except template.TemplateDoesNotExist as e:
......@@ -140,12 +137,13 @@ class DjangoParser(object):
nodelist = getattr(node, 'nodelist', [])
return nodelist
def walk_nodes(self, node, original=None):
def walk_nodes(self, node, original=None, context=None):
if original is None:
original = node
for node in self.get_nodelist(node, original):
if isinstance(node, CompressorNode) and node.is_offline_compression_enabled(forced=True):
for node in self.get_nodelist(node, original, context):
if isinstance(node, CompressorNode) \
and node.is_offline_compression_enabled(forced=True):
yield node
else:
for node in self.walk_nodes(node, original):
for node in self.walk_nodes(node, original, context):
yield node
......@@ -112,7 +112,7 @@ class Jinja2Parser(object):
return body
def walk_nodes(self, node, block_name=None):
def walk_nodes(self, node, block_name=None, context=None):
for node in self.get_nodelist(node):
if (isinstance(node, CallBlock) and
isinstance(node.call, Call) and
......
......@@ -70,21 +70,17 @@ class GzipCompressorFileStorage(CompressorFileStorage):
orig_path = self.path(filename)
compressed_path = '%s.gz' % orig_path
f_in = open(orig_path, 'rb')
f_out = open(compressed_path, 'wb')
try:
f_out = gzip.GzipFile(fileobj=f_out)
f_out.write(f_in.read())
finally:
f_out.close()
f_in.close()
# Ensure the file timestamps match.
# os.stat() returns nanosecond resolution on Linux, but os.utime()
# only sets microsecond resolution. Set times on both files to
# ensure they are equal.
stamp = time.time()
os.utime(orig_path, (stamp, stamp))
os.utime(compressed_path, (stamp, stamp))
with open(orig_path, 'rb') as f_in, open(compressed_path, 'wb') as f_out:
with gzip.GzipFile(fileobj=f_out) as gz_out:
gz_out.write(f_in.read())
# Ensure the file timestamps match.
# os.stat() returns nanosecond resolution on Linux, but os.utime()
# only sets microsecond resolution. Set times on both files to
# ensure they are equal.
stamp = time.time()
os.utime(orig_path, (stamp, stamp))
os.utime(compressed_path, (stamp, stamp))
return filename
......
......@@ -16,9 +16,8 @@ def main():
options, arguments = p.parse_args()
if options.filename:
f = open(options.filename)
content = f.read()
f.close()
with open(options.filename) as f:
content = f.read()
else:
content = sys.stdin.read()
......
......@@ -405,7 +405,8 @@ class CompressorInDebugModeTestCase(SimpleTestCase):
# files can be outdated
css_filename = os.path.join(settings.COMPRESS_ROOT, "css", "one.css")
# Store the hash of the original file's content
css_content = open(css_filename).read()
with open(css_filename) as f:
css_content = f.read()
hashed = get_hexdigest(css_content, 12)
# Now modify the file in the STATIC_ROOT
test_css_content = "p { font-family: 'test' }"
......@@ -419,6 +420,7 @@ class CompressorInDebugModeTestCase(SimpleTestCase):
compressor.storage = DefaultStorage()
output = compressor.output()
self.assertEqual(expected, output)
result = open(os.path.join(settings.COMPRESS_ROOT, "CACHE", "css",
"%s.css" % hashed), "r").read()
with open(os.path.join(settings.COMPRESS_ROOT, "CACHE", "css",
"%s.css" % hashed), "r") as f:
result = f.read()
self.assertTrue(test_css_content not in result)
......@@ -3,8 +3,10 @@ from collections import defaultdict
import io
import os
import sys
import mock
from django.utils import six
from django.utils.encoding import smart_text
from django.test import TestCase
from django.test.utils import override_settings
......@@ -41,6 +43,15 @@ class PrecompilerTestCase(TestCase):
with io.open(self.filename, encoding=settings.FILE_CHARSET) as file:
self.content = file.read()
def test_precompiler_dict_options(self):
command = "%s %s {option}" % (sys.executable, self.test_precompiler)
option = ("option", "option",)
CompilerFilter.options = dict([option])
compiler = CompilerFilter(
content=self.content, filename=self.filename,
charset=settings.FILE_CHARSET, command=command)
self.assertIn(option, compiler.options)
def test_precompiler_infile_outfile(self):
command = '%s %s -f {infile} -o {outfile}' % (sys.executable, self.test_precompiler)
compiler = CompilerFilter(
......@@ -103,6 +114,15 @@ class PrecompilerTestCase(TestCase):
self.assertEqual("body { color:#990; }", compiler.input())
self.assertIsNotNone(compiler.infile) # Not cached
@mock.patch('django.core.cache.backends.locmem.LocMemCache.get')
def test_precompiler_cache_issue750(self, mock_cache):
# emulate memcached and return string
mock_cache.side_effect = (lambda key: str("body { color:#990; }"))
command = '%s %s -f {infile} -o {outfile}' % (sys.executable, self.test_precompiler)
compiler = CachedCompilerFilter(command=command, **self.cached_precompiler_args)
self.assertEqual("body { color:#990; }", compiler.input())
self.assertEqual(type(compiler.input()), type(smart_text("body { color:#990; }")))
def test_precompiler_not_cacheable(self):
command = '%s %s -f {infile} -o {outfile}' % (sys.executable, self.test_precompiler)
self.cached_precompiler_args['mimetype'] = 'text/different'
......@@ -267,6 +287,16 @@ class CssAbsolutizingTestCase(TestCase):
filter = CssAbsoluteFilter(content)
self.assertEqual(content, filter.input(filename=filename, basename='css/url/test.css'))
def test_css_absolute_filter_only_url_fragment_wrap_double_quotes(self):
filename = os.path.join(settings.COMPRESS_ROOT, 'css/url/test.css')
content = 'p { background: url("#foo") }'
filter = CssAbsoluteFilter(content)
self.assertEqual(content, filter.input(filename=filename, basename='css/url/test.css'))
with self.settings(COMPRESS_URL='http://media.example.com/'):
filter = CssAbsoluteFilter(content)
self.assertEqual(content, filter.input(filename=filename, basename='css/url/test.css'))
def test_css_absolute_filter_querystring(self):
filename = os.path.join(settings.COMPRESS_ROOT, 'css/url/test.css')
imagefilename = os.path.join(settings.COMPRESS_ROOT, 'img/python.png')
......
from __future__ import with_statement, unicode_literals
import copy
import django
import io
import os
import sys
......@@ -7,11 +8,12 @@ import unittest
from importlib import import_module
from mock import patch
from unittest import SkipTest
from unittest import SkipTest, skipIf
from django.core.management.base import CommandError
from django.template import Template, Context
from django.test import TestCase
from django.test.utils import override_settings
from django.utils import six
from compressor.cache import flush_offline_manifest, get_offline_manifest
......@@ -118,10 +120,14 @@ class OfflineTestCaseMixin(object):
default_storage.delete(manifest_path)
def _prepare_contexts(self, engine):
contexts = settings.COMPRESS_OFFLINE_CONTEXT
if not isinstance(contexts, (list, tuple)):
contexts = [contexts]
if engine == 'django':
return [Context(settings.COMPRESS_OFFLINE_CONTEXT)]
return [Context(c) for c in contexts]
if engine == 'jinja2':
return [settings.COMPRESS_OFFLINE_CONTEXT]
return contexts
return None
def _render_template(self, engine):
......@@ -431,6 +437,40 @@ class OfflineCompressTestCaseWithContextGeneratorSuper(
engines = ('django',)
class OfflineCompressTestCaseWithContextVariableInheritance(
OfflineTestCaseMixin, TestCase):
templates_dir = 'test_with_context_variable_inheritance'
additional_test_settings = {
'COMPRESS_OFFLINE_CONTEXT': {
'parent_template': 'base.html',
}
}
def _test_offline(self, engine):
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/'
'ea3267f3e9dd.js"></script>'], result)
rendered_template = self._render_template(engine)
self.assertEqual(rendered_template, '\n' + result[0] + '\n')
class OfflineCompressTestCaseWithContextVariableInheritanceSuper(
OfflineTestCaseMixin, TestCase):
templates_dir = 'test_with_context_variable_inheritance_super'
additional_test_settings = {
'COMPRESS_OFFLINE_CONTEXT': [{
'parent_template': 'base1.html',
}, {
'parent_template': 'base2.html',
}]
}
expected_hash = ['7d1416cab12e', 'a31eb23d0157']
# Block.super not supported for Jinja2 yet.
engines = ('django',)
class OfflineCompressTestCaseWithContextGeneratorImportError(
OfflineTestCaseMixin, TestCase):
templates_dir = 'test_with_context'
......@@ -629,3 +669,27 @@ class OfflineCompressComplexTestCase(OfflineTestCaseMixin, TestCase):
rendered_template = self._render_template(engine)
result = (result[0], result[2])
self.assertEqual(rendered_template, ''.join(result) + '\n')
@skipIf(django.VERSION < (1, 9), "Needs Django >= 1.9, recursive templates were fixed in Django 1.9")
class OfflineCompressExtendsRecursionTestCase(OfflineTestCaseMixin, TestCase):
"""
Test that templates extending templates with the same name
(e.g. admin/index.html) don't cause an infinite test_extends_recursion
"""
templates_dir = 'test_extends_recursion'
engines = ('django',)
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.staticfiles',
'compressor',
]
@override_settings(INSTALLED_APPS=INSTALLED_APPS)
def _test_offline(self, engine):
count, result = CompressCommand().compress(
log=self.log, verbosity=self.verbosity, engine=engine)
self.assertEqual(count, 1)