From: dtrishkin Date: Mon, 14 Mar 2016 13:14:22 +0000 (+0000) Subject: Update to 2.0 X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=refs%2Fchanges%2F32%2F18032%2F1;p=packages%2Ftrusty%2Fpython-django-compressor.git Update to 2.0 * Update runtime requirements according to global requirements of Mitaka * Sources are from http://http.debian.net/debian/pool/main/p/python-django-compressor/python-django-compressor_2.0-1.dsc Change-Id: I6f97a83d967058275575160afaa60244d35f5d62 Related-Bug: #1545692 --- diff --git a/debian/changelog b/debian/changelog index 32e02f6..0c49873 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,41 @@ +python-django-compressor (2.0-1~u14.04+mos1) mos9.0; urgency=medium + + * Update to 2.0 + * Update runtime requirements according to global requirements of Mitaka + * Sources are from + http://http.debian.net/debian/pool/main/p/python-django-compressor/python-django-compressor_2.0-1.dsc + * Related-Bug: #1545692 + + -- Daniil Trishkin Mon, 14 Mar 2016 16:13:42 +0300 + +python-django-compressor (2.0-1) unstable; urgency=medium + + * New upstream release. + * Removed compressor/filters/jsmin/rjsmin.py from d/copyright. + * Removed compressor/utils/stringformat.py from d/copyright. + + -- Thomas Goirand Mon, 11 Jan 2016 02:57:22 +0000 + +python-django-compressor (1.6+2015.12.22.git.94755c5aa6-1) unstable; urgency=medium + + * New upstream release based on commit 94755c5aa6 (Closes: #807355). + * Added new (build-)dependencies: + - python-django-overextends + - python-django-sekizai + - python-rjsmin + - python-rcssmin + - python-csscompressor + * Upped the min required version for python-coffin. + + -- Thomas Goirand Tue, 15 Dec 2015 16:13:22 +0000 + +python-django-compressor (1.5-3) unstable; urgency=medium + + * Transition to python-django-compressor instead of python-compressor to + respect upstream egg-info (Closes: #799260). + + -- Thomas Goirand Fri, 16 Oct 2015 09:10:17 +0000 + python-django-compressor (1.5-2~u14.04+mos1) mos8.0; urgency=medium * Source: http://http.debian.net/debian/pool/main/p/python-django-compressor/python-django-compressor_1.5-2.dsc diff --git a/debian/control b/debian/control index d971149..ad4e30e 100644 --- a/debian/control +++ b/debian/control @@ -9,46 +9,63 @@ Build-Depends: debhelper (>= 9), python-all, python-setuptools, python3-all, - python3-setuptools + python3-setuptools, Build-Depends-Indep: csstidy, python-appconf, python-bs4, - python-coffin, + python-coffin (>= 2.0.1), python-coverage, + python-csscompressor, python-django, python-django-discover-runner, + python-django-overextends, + python-django-sekizai (>= 0.9.0), python-html5lib, python-jingo, python-jinja2, python-lxml, python-mock, python-nose, + python-rjsmin, + python-rcssmin, python-unittest2, python3-appconf, python3-bs4, - python3-coffin, + python3-coffin (>= 2.0.1), python3-coverage, + python3-csscompressor, python3-django, python3-django-discover-runner, + python3-django-overextends, + python3-django-sekizai (>= 0.9.0), python3-html5lib, python3-jingo, python3-jinja2, python3-lxml, python3-mock, python3-nose, - python3-unittest2 + python3-rjsmin, + python3-rcssmin, + python3-unittest2, Standards-Version: 3.9.6 Vcs-Browser: http://anonscm.debian.org/gitweb/?p=openstack/python-django-compressor.git;a=summary Vcs-Git: git://anonscm.debian.org/openstack/python-django-compressor.git Homepage: http://pypi.python.org/pypi/django_compressor/ -Package: python-compressor +Package: python-django-compressor Architecture: all -Depends: python-appconf, - python-django, +Depends: python-csscompressor, + python-django (>= 1.8), + python-django-appconf, + python-django-overextends, + python-rjsmin, + python-rcssmin, ${misc:Depends}, - ${python:Depends} -Provides: ${python:Provides} + ${python:Depends}, +Breaks: python-compressor (<= 1.5-2), +Replaces: python-compressor (<= 1.5-2), +Provides: python-compressor, + ${python:Provides}, Description: Compresses linked, inline JS or CSS into single cached files - Python 2.7 Django Compressor combines and compresses linked and inline Javascript or CSS in a Django templates into cacheable static files by using the compress @@ -56,16 +73,52 @@ Description: Compresses linked, inline JS or CSS into single cached files - Pyth . This package contains the Python 2.7 module. -Package: python3-compressor +Package: python3-django-compressor Architecture: all -Depends: python3-appconf, - python3-django, +Depends: python3-csscompressor, + python3-django (>= 1.8), + python3-django-appconf, + python3-django-overextends, + python3-rjsmin, + python3-rcssmin, ${misc:Depends}, - ${python3:Depends} -Provides: ${python:Provides} + ${python3:Depends}, +Breaks: python3-compressor (<= 1.5-2), +Replaces: python3-compressor (<= 1.5-2), +Provides: python3-compressor, + ${python3:Provides}, Description: Compresses linked, inline JS or CSS into single cached files - Python 3.x Django Compressor combines and compresses linked and inline Javascript or CSS in a Django templates into cacheable static files by using the compress template tag. . This package contains the Python 3.x module. + +Package: python-compressor +Section: oldlibs +Priority: extra +Architecture: all +Depends: python-django-compressor (>= 2.0), + ${misc:Depends}, +Provides: ${python:Provides}, +Description: Compresses linked, inline JS or CSS - Python 2.7 transition package + Django Compressor combines and compresses linked and inline Javascript or CSS + in a Django templates into cacheable static files by using the compress + template tag. + . + This is a transitional package to upgrade to python-django-compressor, and it + is safe to remove. + +Package: python3-compressor +Section: oldlibs +Priority: extra +Architecture: all +Depends: python3-django-compressor (>= 2.0), + ${misc:Depends}, +Description: Compresses linked, inline JS or CSS - Python 3.x transition package + Django Compressor combines and compresses linked and inline Javascript or CSS + in a Django templates into cacheable static files by using the compress + template tag. + . + This is a transitional package to upgrade to python3-django-compressor, and it + is safe to remove. diff --git a/debian/copyright b/debian/copyright index c573e42..d84bef8 100644 --- a/debian/copyright +++ b/debian/copyright @@ -6,10 +6,6 @@ Files: debian/* Copyright: (c) 2012, Thomas Goirand License: MIT -Files: compressor/filters/jsmin/rjsmin.py -Copyright: 2006-2011, André Malo -License: Apache-2.0 - Files: compressor/filters/cssmin/* Copyright: (c) 2010 Zachary Voase License: MIT @@ -18,10 +14,6 @@ Files: compressor/utils/decorators.py Copyright: 2009-2011, Ask Solem and contributors License: BSD-2-clauses -Files: compressor/utils/stringformat.py -Copyright: 2010, Florent Xicluna -License: BSD-3-clauses - Files: * Copyright: 2009-2011 django_compressor authors (see AUTHORS file) 2008 Andreas Pelme @@ -49,28 +41,6 @@ License: MIT OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -License: BSD-3-clauses - Redistribution and use in source and binary forms of the software as well as - documentation, with or without modification, are permitted provided that the - following conditions are met: - . - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * The names of the contributors may not be used to endorse or promote products - derived from this software without specific prior written permission. - . - THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND - CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR - CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR - BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - License: BSD-2-clauses Redistribution and use in source and binary forms of the software as well as documentation, with or without modification, are permitted provided that the @@ -90,19 +60,3 @@ License: BSD-2-clauses EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - -License: Apache-2.0 - Licensed under the Apache License, Version 2.0 (the "License"); you may not - use this file except in compliance with the License. You may obtain a copy of - the License at - . - http://www.apache.org/licenses/LICENSE-2.0 - . - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - License for the specific language governing permissions and limitations under - the License. - . - On Debian based systems, the full text of the Apache-2.0 license is available - in this file: /usr/share/common-licenses/Apache-2.0 diff --git a/debian/patches/remove-failed-test.patch b/debian/patches/remove-failed-test.patch deleted file mode 100644 index 6f835f3..0000000 --- a/debian/patches/remove-failed-test.patch +++ /dev/null @@ -1,43 +0,0 @@ -Description: Removes failed test - This unit test is failing, so removing it to build the package. -Author: Thomas Goirand -Forwarded: no -Last-Update: 2015-08-04 - ---- python-django-compressor-1.5.orig/compressor/tests/test_base.py -+++ python-django-compressor-1.5/compressor/tests/test_base.py -@@ -288,34 +288,6 @@ class CacheBackendTestCase(CompressorTes - self.assertEqual(cache.__class__, locmem.LocMemCache) - - --class JsAsyncDeferTestCase(SimpleTestCase): -- def setUp(self): -- self.js = """\ -- -- -- -- -- -- -- """ -- -- def test_js_output(self): -- def extract_attr(tag): -- if tag.has_attr('async'): -- return 'async' -- if tag.has_attr('defer'): -- return 'defer' -- js_node = JsCompressor(self.js) -- output = [None, 'async', 'defer', None, 'async', None] -- if six.PY3: -- scripts = make_soup(js_node.output()).find_all('script') -- attrs = [extract_attr(i) for i in scripts] -- else: -- scripts = make_soup(js_node.output()).findAll('script') -- attrs = [s.get('async') or s.get('defer') for s in scripts] -- self.assertEqual(output, attrs) -- -- - class CacheTestCase(SimpleTestCase): - - def setUp(self): diff --git a/debian/patches/series b/debian/patches/series deleted file mode 100644 index 5943615..0000000 --- a/debian/patches/series +++ /dev/null @@ -1 +0,0 @@ -remove-failed-test.patch diff --git a/debian/rules b/debian/rules index 46c1c96..38b16aa 100755 --- a/debian/rules +++ b/debian/rules @@ -3,7 +3,7 @@ PYTHONS:=$(shell pyversions -vr) PYTHON3S:=$(shell py3versions -vr) -UPSTREAM_GIT = git://github.com/jezdez/django_compressor.git +UPSTREAM_GIT = git://github.com/django-compressor/django-compressor.git include /usr/share/openstack-pkg-tools/pkgos.make @@ -28,11 +28,11 @@ override_dh_auto_build: override_dh_install: set -e ; for pyvers in $(PYTHONS); do \ python$$pyvers setup.py install --install-layout=deb \ - --root $(CURDIR)/debian/python-compressor; \ + --root $(CURDIR)/debian/python-django-compressor; \ done set -e ; for pyvers in $(PYTHON3S); do \ python$$pyvers setup.py install --install-layout=deb \ - --root $(CURDIR)/debian/python3-compressor; \ + --root $(CURDIR)/debian/python3-django-compressor; \ done rm -f $(CURDIR)/debian/usr/lib/python*/dist-packages/compressor/tests/static/CACHE/css/* rm -f $(CURDIR)/debian/usr/lib/python*/dist-packages/compressor/tests/static/CACHE/js/* diff --git a/python-django-compressor/.travis.yml b/python-django-compressor/.travis.yml index b350b18..5f1a39a 100644 --- a/python-django-compressor/.travis.yml +++ b/python-django-compressor/.travis.yml @@ -1,27 +1,27 @@ language: python -before_install: - - sudo apt-get update - - sudo apt-get install csstidy libxml2-dev libxslt-dev +sudo: false install: - pip install tox script: - tox env: - - TOXENV=py26-1.4.X - - TOXENV=py26-1.5.X - - TOXENV=py27-1.4.X - - TOXENV=py27-1.5.X - - TOXENV=py26-1.6.X - - TOXENV=py27-1.6.X - - TOXENV=py32-1.6.X - - TOXENV=py33-1.6.X - - TOXENV=py27-1.7.X - - TOXENV=py32-1.7.X - - TOXENV=py33-1.7.X - - TOXENV=py34-1.7.X - TOXENV=py27-1.8.X - TOXENV=py32-1.8.X - TOXENV=py33-1.8.X - TOXENV=py34-1.8.X + - TOXENV=py27-1.9.X + - TOXENV=py34-1.9.X +# https://github.com/travis-ci/travis-ci/issues/4794 +matrix: + include: + - python: 3.5 + env: + - TOXENV=py35-1.8.X + - python: 3.5 + env: + - TOXENV=py35-1.9.X notifications: irc: "irc.freenode.org#django-compressor" +after_success: + - pip install codecov + - codecov diff --git a/python-django-compressor/AUTHORS b/python-django-compressor/AUTHORS index 7b9a571..39e21d6 100644 --- a/python-django-compressor/AUTHORS +++ b/python-django-compressor/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/python-django-compressor/LICENSE b/python-django-compressor/LICENSE index d9432d5..43f0933 100644 --- a/python-django-compressor/LICENSE +++ b/python-django-compressor/LICENSE @@ -1,6 +1,6 @@ django_compressor ----------------- -Copyright (c) 2009-2014 Django Compressor authors (see AUTHORS file) +Copyright (c) 2009-2015 Django Compressor authors (see AUTHORS file) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/python-django-compressor/Makefile b/python-django-compressor/Makefile index 0c4c65f..6d4b2bf 100644 --- a/python-django-compressor/Makefile +++ b/python-django-compressor/Makefile @@ -3,9 +3,15 @@ testenv: pip install -r requirements/tests.txt pip install Django -test: +flake8: flake8 compressor --ignore=E501,E128,E701,E261,E301,E126,E127,E131 + +runtests: coverage run --branch --source=compressor `which django-admin.py` test --settings=compressor.test_settings compressor - coverage report --omit=compressor/test*,compressor/filters/jsmin/rjsmin*,compressor/filters/cssmin/cssmin*,compressor/utils/stringformat* -.PHONY: test +coveragereport: + coverage report --omit=compressor/test* + +test: flake8 runtests coveragereport + +.PHONY: test runtests flake8 coveragereport diff --git a/python-django-compressor/README.rst b/python-django-compressor/README.rst index 86ed3d7..4ebfcf8 100644 --- a/python-django-compressor/README.rst +++ b/python-django-compressor/README.rst @@ -1,8 +1,8 @@ Django Compressor ================= -.. image:: https://coveralls.io/repos/django-compressor/django-compressor/badge.png?branch=develop - :target: https://coveralls.io/r/django-compressor/django-compressor?branch=develop +.. 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://pypip.in/v/django_compressor/badge.svg :target: https://pypi.python.org/pypi/django_compressor @@ -50,10 +50,10 @@ 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 write a custom parser. -Django Compressor also comes with built-in support for `CSS Tidy`_, +Django Compressor also comes with built-in support for `YUI CSS and JS`_ compressor, `yUglify CSS and JS`_ compressor, the Google's `Closure Compiler`_, a Python port of Douglas Crockford's JSmin_, a Python port -of the YUI CSS Compressor cssmin_ and a filter to convert (some) images into +of the YUI CSS Compressor csscompressor_ and a filter to convert (some) images into `data URIs`_. If your setup requires a different compressor or other post-processing @@ -71,14 +71,12 @@ The in-development version of Django Compressor can be installed with .. _BeautifulSoup: http://www.crummy.com/software/BeautifulSoup/ .. _lxml: http://lxml.de/ -.. _html5lib: http://code.google.com/p/html5lib/ -.. _CSS Tidy: http://csstidy.sourceforge.net/ +.. _html5lib: https://github.com/html5lib/html5lib-python .. _YUI CSS and JS: http://developer.yahoo.com/yui/compressor/ .. _yUglify CSS and JS: https://github.com/yui/yuglify .. _Closure Compiler: http://code.google.com/closure/compiler/ .. _JSMin: http://www.crockford.com/javascript/jsmin.html -.. _cssmin: https://github.com/zacharyvoase/cssmin +.. _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/ .. _github.com/django-compressor/django-compressor: https://github.com/django-compressor/django-compressor - diff --git a/python-django-compressor/compressor/__init__.py b/python-django-compressor/compressor/__init__.py index 5e61781..5a1d40f 100644 --- a/python-django-compressor/compressor/__init__.py +++ b/python-django-compressor/compressor/__init__.py @@ -1,2 +1,2 @@ # following PEP 386 -__version__ = "1.5" +__version__ = "2.0" diff --git a/python-django-compressor/compressor/base.py b/python-django-compressor/compressor/base.py index 54b16f3..79ab0ac 100644 --- a/python-django-compressor/compressor/base.py +++ b/python-django-compressor/compressor/base.py @@ -1,26 +1,19 @@ from __future__ import with_statement, unicode_literals import os import codecs +from importlib import import_module from django.core.files.base import ContentFile -from django.template import Context -from django.template.loader import render_to_string -try: - from importlib import import_module -except: - from django.utils.importlib import import_module from django.utils.safestring import mark_safe - -try: - from urllib.request import url2pathname -except ImportError: - from urllib import url2pathname +from django.utils.six.moves.urllib.request import url2pathname +from django.template.loader import render_to_string from compressor.cache import get_hexdigest, get_mtime from compressor.conf import settings from compressor.exceptions import (CompressorError, UncompressableFileError, FilterDoesNotExist) -from compressor.filters import CompilerFilter +from compressor.filters import CachedCompilerFilter +from compressor.filters.css_default import CssAbsoluteFilter from compressor.storage import compressor_file_storage from compressor.signals import post_compress from compressor.utils import get_class, get_mod_func, staticfiles @@ -36,17 +29,19 @@ class Compressor(object): Base compressor object to be subclassed for content type depending implementations details. """ - type = None - def __init__(self, content=None, output_prefix=None, context=None, *args, **kwargs): + def __init__(self, content=None, output_prefix=None, + context=None, filters=None, *args, **kwargs): self.content = content or "" # rendered contents of {% compress %} tag self.output_prefix = output_prefix or "compressed" self.output_dir = settings.COMPRESS_OUTPUT_DIR.strip('/') self.charset = settings.DEFAULT_CHARSET self.split_content = [] self.context = context or {} + self.type = output_prefix or "" + self.filters = filters or [] self.extra_context = {} - self.all_mimetypes = dict(settings.COMPRESS_PRECOMPILERS) + self.precompiler_mimetypes = dict(settings.COMPRESS_PRECOMPILERS) self.finders = staticfiles.finders self._storage = None @@ -117,18 +112,21 @@ class Compressor(object): get_filename('css/one.css') -> '/full/path/to/static/css/one.css' """ filename = None - # first try finding the file in the root - try: - # call path first so remote storages don't make it to exists, - # which would cause network I/O - filename = self.storage.path(basename) - if not self.storage.exists(basename): - filename = None - except NotImplementedError: - # remote storages don't implement path, access the file locally - if compressor_file_storage.exists(basename): - filename = compressor_file_storage.path(basename) - # secondly try to find it with staticfiles (in debug mode) + # First try finding the file using the storage class. + # This is skipped in DEBUG mode as files might be outdated in + # compressor's final destination (COMPRESS_ROOT) during development + if not settings.DEBUG: + try: + # call path first so remote storages don't make it to exists, + # which would cause network I/O + filename = self.storage.path(basename) + if not self.storage.exists(basename): + filename = None + except NotImplementedError: + # remote storages don't implement path, access the file locally + if compressor_file_storage.exists(basename): + filename = compressor_file_storage.path(basename) + # secondly try to find it with staticfiles if not filename and self.finders: filename = self.finders.find(url2pathname(basename)) if filename: @@ -202,24 +200,28 @@ class Compressor(object): options = dict(options, filename=value) value = self.get_filecontent(value, charset) - if self.all_mimetypes: + if self.precompiler_mimetypes: precompiled, value = self.precompile(value, **options) if enabled: - yield self.filter(value, **options) + yield self.filter(value, self.cached_filters, **options) + elif precompiled: + # since precompiling moves files around, it breaks url() + # statements in css files. therefore we run the absolute filter + # on precompiled css files even if compression is disabled. + if CssAbsoluteFilter in self.cached_filters: + value = self.filter(value, [CssAbsoluteFilter], **options) + yield self.handle_output(kind, value, forced=True, + basename=basename) else: - if precompiled: - yield self.handle_output(kind, value, forced=True, - basename=basename) - else: - yield self.parser.elem_str(elem) + yield self.parser.elem_str(elem) def filter_output(self, content): """ Passes the concatenated content to the 'output' methods of the compressor filters. """ - return self.filter(content, method=METHOD_OUTPUT) + return self.filter(content, self.cached_filters, method=METHOD_OUTPUT) def filter_input(self, forced=False): """ @@ -242,37 +244,36 @@ class Compressor(object): return False, content attrs = self.parser.elem_attribs(elem) mimetype = attrs.get("type", None) - if mimetype: - filter_or_command = self.all_mimetypes.get(mimetype) - if filter_or_command is None: - if mimetype not in ("text/css", "text/javascript"): - raise CompressorError("Couldn't find any precompiler in " - "COMPRESS_PRECOMPILERS setting for " - "mimetype '%s'." % mimetype) - else: - mod_name, cls_name = get_mod_func(filter_or_command) - try: - mod = import_module(mod_name) - except (ImportError, TypeError): - filter = CompilerFilter( - content, filter_type=self.type, filename=filename, - charset=charset, command=filter_or_command) - return True, filter.input(**kwargs) - try: - precompiler_class = getattr(mod, cls_name) - except AttributeError: - raise FilterDoesNotExist('Could not find "%s".' % - filter_or_command) - else: - filter = precompiler_class( - content, attrs, filter_type=self.type, charset=charset, - filename=filename) - return True, filter.input(**kwargs) - - return False, content - - def filter(self, content, method, **kwargs): - for filter_cls in self.cached_filters: + if mimetype is None: + return False, content + + filter_or_command = self.precompiler_mimetypes.get(mimetype) + if filter_or_command is None: + if mimetype in ("text/css", "text/javascript"): + return False, content + raise CompressorError("Couldn't find any precompiler in " + "COMPRESS_PRECOMPILERS setting for " + "mimetype '%s'." % mimetype) + + mod_name, cls_name = get_mod_func(filter_or_command) + try: + mod = import_module(mod_name) + except (ImportError, TypeError): + filter = CachedCompilerFilter( + content=content, filter_type=self.type, filename=filename, + charset=charset, command=filter_or_command, mimetype=mimetype) + return True, filter.input(**kwargs) + try: + precompiler_class = getattr(mod, cls_name) + except AttributeError: + raise FilterDoesNotExist('Could not find "%s".' % filter_or_command) + filter = precompiler_class( + content, attrs=attrs, filter_type=self.type, charset=charset, + filename=filename) + return True, filter.input(**kwargs) + + def filter(self, content, filters, method, **kwargs): + for filter_cls in filters: filter_func = getattr( filter_cls(content, filter_type=self.type), method) try: @@ -338,8 +339,14 @@ class Compressor(object): self.context['compressed'].update(context or {}) self.context['compressed'].update(self.extra_context) - final_context = Context(self.context) + if hasattr(self.context, 'flatten'): + # Django 1.8 complains about Context being passed to its + # Template.render function. + final_context = self.context.flatten() + else: + final_context = self.context + post_compress.send(sender=self.__class__, type=self.type, mode=mode, context=final_context) template_name = self.get_template_name(mode) - return render_to_string(template_name, context_instance=final_context) + return render_to_string(template_name, context=final_context) diff --git a/python-django-compressor/compressor/cache.py b/python-django-compressor/compressor/cache.py index f80bf54..6cc7344 100644 --- a/python-django-compressor/compressor/cache.py +++ b/python-django-compressor/compressor/cache.py @@ -3,23 +3,13 @@ import hashlib import os import socket import time +from importlib import import_module -try: - from django.core.cache import caches - def get_cache(name): - return caches[name] -except ImportError: - from django.core.cache import get_cache - +from django.core.cache import caches from django.core.files.base import ContentFile from django.utils.encoding import force_text, smart_bytes from django.utils.functional import SimpleLazyObject -try: - from importlib import import_module -except: - from django.utils.importlib import import_module - from compressor.conf import settings from compressor.storage import default_storage from compressor.utils import get_mod_func @@ -135,6 +125,10 @@ def get_hashed_content(filename, length=12): return get_hexdigest(file.read(), length) +def get_precompiler_cachekey(command, contents): + return hashlib.sha1(smart_bytes('precompiler.%s.%s' % (command, contents))).hexdigest() + + def cache_get(key): packed_val = cache.get(key) if packed_val is None: @@ -158,4 +152,4 @@ def cache_set(key, val, refreshed=False, timeout=None): return cache.set(key, packed_val, real_timeout) -cache = SimpleLazyObject(lambda: get_cache(settings.COMPRESS_CACHE_BACKEND)) +cache = SimpleLazyObject(lambda: caches[settings.COMPRESS_CACHE_BACKEND]) diff --git a/python-django-compressor/compressor/conf.py b/python-django-compressor/compressor/conf.py index 87f9d69..c9dd953 100644 --- a/python-django-compressor/compressor/conf.py +++ b/python-django-compressor/compressor/conf.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import os from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.template.utils import InvalidTemplateEngineError from appconf import AppConf @@ -35,10 +36,9 @@ class CompressorConf(AppConf): # ('text/stylus', 'stylus < {infile} > {outfile}'), # ('text/x-scss', 'sass --scss {infile} {outfile}'), ) + CACHEABLE_PRECOMPILERS = () CLOSURE_COMPILER_BINARY = 'java -jar compiler.jar' CLOSURE_COMPILER_ARGUMENTS = '' - CSSTIDY_BINARY = 'csstidy' - CSSTIDY_ARGUMENTS = '--template=highest' YUI_BINARY = 'java -jar yuicompressor.jar' YUI_CSS_ARGUMENTS = '' YUI_JS_ARGUMENTS = '' @@ -70,11 +70,22 @@ class CompressorConf(AppConf): OFFLINE_MANIFEST = 'manifest.json' # The Context to be used when TemplateFilter is used TEMPLATE_FILTER_CONTEXT = {} - # Function that returns the Jinja2 environment to use in offline compression. + + # Returns the Jinja2 environment to use in offline compression. def JINJA2_GET_ENVIRONMENT(): + alias = 'Jinja2' try: - import jinja2 - return jinja2.Environment() + from django.template.loader import _engine_list + engines = _engine_list(alias) + if engines: + engine = engines[0] + return engine.env + except InvalidTemplateEngineError: + raise InvalidTemplateEngineError( + "Could not find config for '{}' " + "in settings.TEMPLATES. " + "COMPRESS_JINJA2_GET_ENVIRONMENT() may " + "need to be defined in settings".format(alias)) except ImportError: return None diff --git a/python-django-compressor/compressor/contrib/sekizai.py b/python-django-compressor/compressor/contrib/sekizai.py index 87966c5..38e4c01 100644 --- a/python-django-compressor/compressor/contrib/sekizai.py +++ b/python-django-compressor/compressor/contrib/sekizai.py @@ -6,7 +6,7 @@ and: https://github.com/ojii/django-sekizai.git@0.6 or later """ from compressor.templatetags.compress import CompressorNode -from django.template.base import Template +from django.template.base import TextNode def compress(context, data, name): @@ -15,4 +15,4 @@ def compress(context, data, name): Name is either 'js' or 'css' (the sekizai namespace) Basically passes the string through the {% compress 'js' %} template tag """ - return CompressorNode(nodelist=Template(data).nodelist, kind=name, mode='file').render(context=context) + return CompressorNode(nodelist=TextNode(data), kind=name, mode='file').render(context=context) diff --git a/python-django-compressor/compressor/css.py b/python-django-compressor/compressor/css.py index 45cdcd2..bd3e091 100644 --- a/python-django-compressor/compressor/css.py +++ b/python-django-compressor/compressor/css.py @@ -5,10 +5,8 @@ from compressor.conf import settings class CssCompressor(Compressor): def __init__(self, content=None, output_prefix="css", context=None): - super(CssCompressor, self).__init__(content=content, - output_prefix=output_prefix, context=context) - self.filters = list(settings.COMPRESS_CSS_FILTERS) - self.type = output_prefix + filters = list(settings.COMPRESS_CSS_FILTERS) + super(CssCompressor, self).__init__(content, output_prefix, context, filters) def split_contents(self): if self.split_content: diff --git a/python-django-compressor/compressor/filters/__init__.py b/python-django-compressor/compressor/filters/__init__.py index cd317fa..efeee86 100644 --- a/python-django-compressor/compressor/filters/__init__.py +++ b/python-django-compressor/compressor/filters/__init__.py @@ -1,3 +1,3 @@ # flake8: noqa from compressor.filters.base import (FilterBase, CallbackOutputFilter, - CompilerFilter, FilterError) + CompilerFilter, CachedCompilerFilter, FilterError) diff --git a/python-django-compressor/compressor/filters/base.py b/python-django-compressor/compressor/filters/base.py index ee14b82..dcdb96f 100644 --- a/python-django-compressor/compressor/filters/base.py +++ b/python-django-compressor/compressor/filters/base.py @@ -3,6 +3,7 @@ import io import logging import subprocess +from importlib import import_module from platform import system if system() != "Windows": @@ -19,14 +20,11 @@ else: from django.core.exceptions import ImproperlyConfigured from django.core.files.temp import NamedTemporaryFile -try: - from importlib import import_module -except ImportError: - from django.utils.importlib import import_module - from django.utils.encoding import smart_text from django.utils import six +from compressor.cache import cache, get_precompiler_cachekey + from compressor.conf import settings from compressor.exceptions import FilterError from compressor.utils import get_mod_func @@ -42,8 +40,8 @@ class FilterBase(object): Subclasses should implement `input` and/or `output` methods which must return a string (unicode under python 2) or raise a NotImplementedError. """ - def __init__(self, content, filter_type=None, filename=None, verbose=0, - charset=None): + def __init__(self, content, attrs=None, filter_type=None, filename=None, + verbose=0, charset=None, **kwargs): self.type = filter_type or getattr(self, 'type', None) self.content = content self.verbose = verbose or settings.COMPRESS_VERBOSE @@ -116,8 +114,8 @@ class CompilerFilter(FilterBase): options = () default_encoding = settings.FILE_CHARSET - def __init__(self, content, command=None, *args, **kwargs): - super(CompilerFilter, self).__init__(content, *args, **kwargs) + def __init__(self, content, command=None, **kwargs): + super(CompilerFilter, self).__init__(content, **kwargs) self.cwd = None if command: @@ -140,6 +138,7 @@ class CompilerFilter(FilterBase): self.infile = self.outfile = None def input(self, **kwargs): + encoding = self.default_encoding options = dict(self.options) @@ -208,5 +207,26 @@ class CompilerFilter(FilterBase): self.infile.close() if self.outfile is not None: self.outfile.close() - return smart_text(filtered) + + +class CachedCompilerFilter(CompilerFilter): + + def __init__(self, mimetype, *args, **kwargs): + self.mimetype = mimetype + super(CachedCompilerFilter, self).__init__(*args, **kwargs) + + def input(self, **kwargs): + if self.mimetype in settings.COMPRESS_CACHEABLE_PRECOMPILERS: + key = self.get_cache_key() + data = cache.get(key) + if data is not None: + return data + filtered = super(CachedCompilerFilter, self).input(**kwargs) + cache.set(key, filtered, settings.COMPRESS_REBUILD_TIMEOUT) + return filtered + else: + return super(CachedCompilerFilter, self).input(**kwargs) + + def get_cache_key(self): + return get_precompiler_cachekey(self.command, self.content) diff --git a/python-django-compressor/compressor/filters/cssmin/__init__.py b/python-django-compressor/compressor/filters/cssmin/__init__.py index 073303d..1f66b23 100644 --- a/python-django-compressor/compressor/filters/cssmin/__init__.py +++ b/python-django-compressor/compressor/filters/cssmin/__init__.py @@ -1,13 +1,21 @@ from compressor.filters import CallbackOutputFilter -class CSSMinFilter(CallbackOutputFilter): +class CSSCompressorFilter(CallbackOutputFilter): """ - A filter that utilizes Zachary Voase's Python port of - the YUI CSS compression algorithm: http://pypi.python.org/pypi/cssmin/ + A filter that utilizes Yury Selivanov's Python port of the YUI CSS + compression algorithm: https://pypi.python.org/pypi/csscompressor """ - callback = "compressor.filters.cssmin.cssmin.cssmin" + callback = "csscompressor.compress" + dependencies = ["csscompressor"] class rCSSMinFilter(CallbackOutputFilter): - callback = "compressor.filters.cssmin.rcssmin.cssmin" + callback = "rcssmin.cssmin" + dependencies = ["rcssmin"] + kwargs = { + "keep_bang_comments": True + } + +# This is for backwards compatibility. +CSSMinFilter = rCSSMinFilter diff --git a/python-django-compressor/compressor/filters/cssmin/cssmin.py b/python-django-compressor/compressor/filters/cssmin/cssmin.py deleted file mode 100644 index e8a02b0..0000000 --- a/python-django-compressor/compressor/filters/cssmin/cssmin.py +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# `cssmin.py` - A Python port of the YUI CSS compressor. -# -# Copyright (c) 2010 Zachary Voase -# -# Permission is hereby granted, free of charge, to any person -# obtaining a copy of this software and associated documentation -# files (the "Software"), to deal in the Software without -# restriction, including without limitation the rights to use, -# copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following -# conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -# OTHER DEALINGS IN THE SOFTWARE. -# -"""`cssmin` - A Python port of the YUI CSS compressor.""" - -import re - -__version__ = '0.1.4' - - -def remove_comments(css): - """Remove all CSS comment blocks.""" - - iemac = False - preserve = False - comment_start = css.find("/*") - while comment_start >= 0: - # Preserve comments that look like `/*!...*/`. - # Slicing is used to make sure we don"t get an IndexError. - preserve = css[comment_start + 2:comment_start + 3] == "!" - - comment_end = css.find("*/", comment_start + 2) - if comment_end < 0: - if not preserve: - css = css[:comment_start] - break - elif comment_end >= (comment_start + 2): - if css[comment_end - 1] == "\\": - # This is an IE Mac-specific comment; leave this one and the - # following one alone. - comment_start = comment_end + 2 - iemac = True - elif iemac: - comment_start = comment_end + 2 - iemac = False - elif not preserve: - css = css[:comment_start] + css[comment_end + 2:] - else: - comment_start = comment_end + 2 - comment_start = css.find("/*", comment_start) - - return css - - -def remove_unnecessary_whitespace(css): - """Remove unnecessary whitespace characters.""" - - def pseudoclasscolon(css): - - """ - Prevents 'p :link' from becoming 'p:link'. - - Translates 'p :link' into 'p ___PSEUDOCLASSCOLON___link'; this is - translated back again later. - """ - - regex = re.compile(r"(^|\})(([^\{\:])+\:)+([^\{]*\{)") - match = regex.search(css) - while match: - css = ''.join([ - css[:match.start()], - match.group().replace(":", "___PSEUDOCLASSCOLON___"), - css[match.end():]]) - match = regex.search(css) - return css - - css = pseudoclasscolon(css) - # Remove spaces from before things. - css = re.sub(r"\s+([!{};:>+\(\)\],])", r"\1", css) - - # If there is a `@charset`, then only allow one, and move to the beginning. - css = re.sub(r"^(.*)(@charset \"[^\"]*\";)", r"\2\1", css) - css = re.sub(r"^(\s*@charset [^;]+;\s*)+", r"\1", css) - - # Put the space back in for a few cases, such as `@media screen` and - # `(-webkit-min-device-pixel-ratio:0)`. - css = re.sub(r"\band\(", "and (", css) - - # Put the colons back. - css = css.replace('___PSEUDOCLASSCOLON___', ':') - - # Remove spaces from after things. - css = re.sub(r"([!{}:;>+\(\[,])\s+", r"\1", css) - - return css - - -def remove_unnecessary_semicolons(css): - """Remove unnecessary semicolons.""" - - return re.sub(r";+\}", "}", css) - - -def remove_empty_rules(css): - """Remove empty rules.""" - - return re.sub(r"[^\}\{]+\{\}", "", css) - - -def normalize_rgb_colors_to_hex(css): - """Convert `rgb(51,102,153)` to `#336699`.""" - - regex = re.compile(r"rgb\s*\(\s*([0-9,\s]+)\s*\)") - match = regex.search(css) - while match: - colors = map(lambda s: s.strip(), match.group(1).split(",")) - hexcolor = '#%.2x%.2x%.2x' % tuple(map(int, colors)) - css = css.replace(match.group(), hexcolor) - match = regex.search(css) - return css - - -def condense_zero_units(css): - """Replace `0(px, em, %, etc)` with `0`.""" - - return re.sub(r"([\s:])(0)(px|em|%|in|cm|mm|pc|pt|ex)", r"\1\2", css) - - -def condense_multidimensional_zeros(css): - """Replace `:0 0 0 0;`, `:0 0 0;` etc. with `:0;`.""" - - css = css.replace(":0 0 0 0;", ":0;") - css = css.replace(":0 0 0;", ":0;") - css = css.replace(":0 0;", ":0;") - - # Revert `background-position:0;` to the valid `background-position:0 0;`. - css = css.replace("background-position:0;", "background-position:0 0;") - - return css - - -def condense_floating_points(css): - """Replace `0.6` with `.6` where possible.""" - - return re.sub(r"(:|\s)0+\.(\d+)", r"\1.\2", css) - - -def condense_hex_colors(css): - """Shorten colors from #AABBCC to #ABC where possible.""" - - regex = re.compile(r"([^\"'=\s])(\s*)#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])") - match = regex.search(css) - while match: - first = match.group(3) + match.group(5) + match.group(7) - second = match.group(4) + match.group(6) + match.group(8) - if first.lower() == second.lower(): - css = css.replace(match.group(), match.group(1) + match.group(2) + '#' + first) - match = regex.search(css, match.end() - 3) - else: - match = regex.search(css, match.end()) - return css - - -def condense_whitespace(css): - """Condense multiple adjacent whitespace characters into one.""" - - return re.sub(r"\s+", " ", css) - - -def condense_semicolons(css): - """Condense multiple adjacent semicolon characters into one.""" - - return re.sub(r";;+", ";", css) - - -def wrap_css_lines(css, line_length): - """Wrap the lines of the given CSS to an approximate length.""" - - lines = [] - line_start = 0 - for i, char in enumerate(css): - # It's safe to break after `}` characters. - if char == '}' and (i - line_start >= line_length): - lines.append(css[line_start:i + 1]) - line_start = i + 1 - - if line_start < len(css): - lines.append(css[line_start:]) - return '\n'.join(lines) - - -def cssmin(css, wrap=None): - css = remove_comments(css) - css = condense_whitespace(css) - # A pseudo class for the Box Model Hack - # (see http://tantek.com/CSS/Examples/boxmodelhack.html) - css = css.replace('"\\"}\\""', "___PSEUDOCLASSBMH___") - css = remove_unnecessary_whitespace(css) - css = remove_unnecessary_semicolons(css) - css = condense_zero_units(css) - css = condense_multidimensional_zeros(css) - css = condense_floating_points(css) - css = normalize_rgb_colors_to_hex(css) - css = condense_hex_colors(css) - if wrap is not None: - css = wrap_css_lines(css, wrap) - css = css.replace("___PSEUDOCLASSBMH___", '"\\"}\\""') - css = condense_semicolons(css) - return css.strip() - - -def main(): - import optparse - import sys - - p = optparse.OptionParser( - prog="cssmin", version=__version__, - usage="%prog [--wrap N]", - description="""Reads raw CSS from stdin, and writes compressed CSS to stdout.""") - - p.add_option( - '-w', '--wrap', type='int', default=None, metavar='N', - help="Wrap output to approximately N chars per line.") - - options, args = p.parse_args() - sys.stdout.write(cssmin(sys.stdin.read(), wrap=options.wrap)) - - -if __name__ == '__main__': - main() diff --git a/python-django-compressor/compressor/filters/cssmin/rcssmin.py b/python-django-compressor/compressor/filters/cssmin/rcssmin.py deleted file mode 100644 index ff8e273..0000000 --- a/python-django-compressor/compressor/filters/cssmin/rcssmin.py +++ /dev/null @@ -1,360 +0,0 @@ -#!/usr/bin/env python -# -*- coding: ascii -*- -# -# Copyright 2011, 2012 -# Andr\xe9 Malo or his licensors, as applicable -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -r""" -============== - CSS Minifier -============== - -CSS Minifier. - -The minifier is based on the semantics of the `YUI compressor`_\, which itself -is based on `the rule list by Isaac Schlueter`_\. - -This module is a re-implementation aiming for speed instead of maximum -compression, so it can be used at runtime (rather than during a preprocessing -step). RCSSmin does syntactical compression only (removing spaces, comments -and possibly semicolons). It does not provide semantic compression (like -removing empty blocks, collapsing redundant properties etc). It does, however, -support various CSS hacks (by keeping them working as intended). - -Here's a feature list: - -- Strings are kept, except that escaped newlines are stripped -- Space/Comments before the very end or before various characters are - stripped: ``:{});=>+],!`` (The colon (``:``) is a special case, a single - space is kept if it's outside a ruleset.) -- Space/Comments at the very beginning or after various characters are - stripped: ``{}(=:>+[,!`` -- Optional space after unicode escapes is kept, resp. replaced by a simple - space -- whitespaces inside ``url()`` definitions are stripped -- Comments starting with an exclamation mark (``!``) can be kept optionally. -- All other comments and/or whitespace characters are replaced by a single - space. -- Multiple consecutive semicolons are reduced to one -- The last semicolon within a ruleset is stripped -- CSS Hacks supported: - - - IE7 hack (``>/**/``) - - Mac-IE5 hack (``/*\*/.../**/``) - - The boxmodelhack is supported naturally because it relies on valid CSS2 - strings - - Between ``:first-line`` and the following comma or curly brace a space is - inserted. (apparently it's needed for IE6) - - Same for ``:first-letter`` - -rcssmin.c is a reimplementation of rcssmin.py in C and improves runtime up to -factor 50 or so (depending on the input). - -Both python 2 (>= 2.4) and python 3 are supported. - -.. _YUI compressor: https://github.com/yui/yuicompressor/ - -.. _the rule list by Isaac Schlueter: https://github.com/isaacs/cssmin/tree/ -""" -__author__ = "Andr\xe9 Malo" -__author__ = getattr(__author__, 'decode', lambda x: __author__)('latin-1') -__docformat__ = "restructuredtext en" -__license__ = "Apache License, Version 2.0" -__version__ = '1.0.2' -__all__ = ['cssmin'] - -import re as _re - - -def _make_cssmin(python_only=False): - """ - Generate CSS minifier. - - :Parameters: - `python_only` : ``bool`` - Use only the python variant. If true, the c extension is not even - tried to be loaded. - - :Return: Minifier - :Rtype: ``callable`` - """ - # pylint: disable = W0612 - # ("unused" variables) - - # pylint: disable = R0911, R0912, R0914, R0915 - # (too many anything) - - if not python_only: - try: - import _rcssmin - except ImportError: - pass - else: - return _rcssmin.cssmin - - nl = r'(?:[\n\f]|\r\n?)' # pylint: disable = C0103 - spacechar = r'[\r\n\f\040\t]' - - unicoded = r'[0-9a-fA-F]{1,6}(?:[\040\n\t\f]|\r\n?)?' - escaped = r'[^\n\r\f0-9a-fA-F]' - escape = r'(?:\\(?:%(unicoded)s|%(escaped)s))' % locals() - - nmchar = r'[^\000-\054\056\057\072-\100\133-\136\140\173-\177]' - # nmstart = r'[^\000-\100\133-\136\140\173-\177]' - # ident = (r'(?:' - # r'-?(?:%(nmstart)s|%(escape)s)%(nmchar)s*(?:%(escape)s%(nmchar)s*)*' - # r')') % locals() - - comment = r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)' - - # only for specific purposes. The bang is grouped: - _bang_comment = r'(?:/\*(!?)[^*]*\*+(?:[^/*][^*]*\*+)*/)' - - string1 = \ - r'(?:\047[^\047\\\r\n\f]*(?:\\[^\r\n\f][^\047\\\r\n\f]*)*\047)' - string2 = r'(?:"[^"\\\r\n\f]*(?:\\[^\r\n\f][^"\\\r\n\f]*)*")' - strings = r'(?:%s|%s)' % (string1, string2) - - nl_string1 = \ - r'(?:\047[^\047\\\r\n\f]*(?:\\(?:[^\r]|\r\n?)[^\047\\\r\n\f]*)*\047)' - nl_string2 = r'(?:"[^"\\\r\n\f]*(?:\\(?:[^\r]|\r\n?)[^"\\\r\n\f]*)*")' - nl_strings = r'(?:%s|%s)' % (nl_string1, nl_string2) - - uri_nl_string1 = r'(?:\047[^\047\\]*(?:\\(?:[^\r]|\r\n?)[^\047\\]*)*\047)' - uri_nl_string2 = r'(?:"[^"\\]*(?:\\(?:[^\r]|\r\n?)[^"\\]*)*")' - uri_nl_strings = r'(?:%s|%s)' % (uri_nl_string1, uri_nl_string2) - - nl_escaped = r'(?:\\%(nl)s)' % locals() - - space = r'(?:%(spacechar)s|%(comment)s)' % locals() - - ie7hack = r'(?:>/\*\*/)' - - uri = (r'(?:' - r'(?:[^\000-\040"\047()\\\177]*' - r'(?:%(escape)s[^\000-\040"\047()\\\177]*)*)' - r'(?:' - r'(?:%(spacechar)s+|%(nl_escaped)s+)' - r'(?:' - r'(?:[^\000-\040"\047()\\\177]|%(escape)s|%(nl_escaped)s)' - r'[^\000-\040"\047()\\\177]*' - r'(?:%(escape)s[^\000-\040"\047()\\\177]*)*' - r')+' - r')*' - r')') % locals() - - nl_unesc_sub = _re.compile(nl_escaped).sub - - uri_space_sub = _re.compile(( - r'(%(escape)s+)|%(spacechar)s+|%(nl_escaped)s+' - ) % locals()).sub - uri_space_subber = lambda m: m.groups()[0] or '' - - space_sub_simple = _re.compile(( - r'[\r\n\f\040\t;]+|(%(comment)s+)' - ) % locals()).sub - space_sub_banged = _re.compile(( - r'[\r\n\f\040\t;]+|(%(_bang_comment)s+)' - ) % locals()).sub - - post_esc_sub = _re.compile(r'[\r\n\f\t]+').sub - - main_sub = _re.compile(( - r'([^\\"\047u>@\r\n\f\040\t/;:{}]+)' - r'|(?<=[{}(=:>+[,!])(%(space)s+)' - r'|^(%(space)s+)' - r'|(%(space)s+)(?=(([:{});=>+\],!])|$)?)' - r'|;(%(space)s*(?:;%(space)s*)*)(?=(\})?)' - r'|(\{)' - r'|(\})' - r'|(%(strings)s)' - r'|(?@\r\n\f\040\t/;:{}]*)' - ) % locals()).sub - - # print main_sub.__self__.pattern - - def main_subber(keep_bang_comments): - """ Make main subber """ - in_macie5, in_rule, at_media = [0], [0], [0] - - if keep_bang_comments: - space_sub = space_sub_banged - def space_subber(match): - """ Space|Comment subber """ - if match.lastindex: - group1, group2 = match.group(1, 2) - if group2: - if group1.endswith(r'\*/'): - in_macie5[0] = 1 - else: - in_macie5[0] = 0 - return group1 - elif group1: - if group1.endswith(r'\*/'): - if in_macie5[0]: - return '' - in_macie5[0] = 1 - return r'/*\*/' - elif in_macie5[0]: - in_macie5[0] = 0 - return '/**/' - return '' - else: - space_sub = space_sub_simple - def space_subber(match): - """ Space|Comment subber """ - if match.lastindex: - if match.group(1).endswith(r'\*/'): - if in_macie5[0]: - return '' - in_macie5[0] = 1 - return r'/*\*/' - elif in_macie5[0]: - in_macie5[0] = 0 - return '/**/' - return '' - - def fn_space_post(group): - """ space with token after """ - if group(5) is None or ( - group(6) == ':' and not in_rule[0] and not at_media[0]): - return ' ' + space_sub(space_subber, group(4)) - return space_sub(space_subber, group(4)) - - def fn_semicolon(group): - """ ; handler """ - return ';' + space_sub(space_subber, group(7)) - - def fn_semicolon2(group): - """ ; handler """ - if in_rule[0]: - return space_sub(space_subber, group(7)) - return ';' + space_sub(space_subber, group(7)) - - def fn_open(group): - """ { handler """ - # pylint: disable = W0613 - if at_media[0]: - at_media[0] -= 1 - else: - in_rule[0] = 1 - return '{' - - def fn_close(group): - """ } handler """ - # pylint: disable = W0613 - in_rule[0] = 0 - return '}' - - def fn_media(group): - """ @media handler """ - at_media[0] += 1 - return group(13) - - def fn_ie7hack(group): - """ IE7 Hack handler """ - if not in_rule[0] and not at_media[0]: - in_macie5[0] = 0 - return group(14) + space_sub(space_subber, group(15)) - return '>' + space_sub(space_subber, group(15)) - - table = ( - None, - None, - None, - None, - fn_space_post, # space with token after - fn_space_post, # space with token after - fn_space_post, # space with token after - fn_semicolon, # semicolon - fn_semicolon2, # semicolon - fn_open, # { - fn_close, # } - lambda g: g(11), # string - lambda g: 'url(%s)' % uri_space_sub(uri_space_subber, g(12)), - # url(...) - fn_media, # @media - None, - fn_ie7hack, # ie7hack - None, - lambda g: g(16) + ' ' + space_sub(space_subber, g(17)), - # :first-line|letter followed - # by [{,] (apparently space - # needed for IE6) - lambda g: nl_unesc_sub('', g(18)), # nl_string - lambda g: post_esc_sub(' ', g(19)), # escape - ) - - def func(match): - """ Main subber """ - idx, group = match.lastindex, match.group - if idx > 3: - return table[idx](group) - - # shortcuts for frequent operations below: - elif idx == 1: # not interesting - return group(1) - # else: # space with token before or at the beginning - return space_sub(space_subber, group(idx)) - - return func - - def cssmin(style, keep_bang_comments=False): # pylint: disable = W0621 - """ - Minify CSS. - - :Parameters: - `style` : ``str`` - CSS to minify - - `keep_bang_comments` : ``bool`` - Keep comments starting with an exclamation mark? (``/*!...*/``) - - :Return: Minified style - :Rtype: ``str`` - """ - return main_sub(main_subber(keep_bang_comments), style) - - return cssmin - -cssmin = _make_cssmin() - - -if __name__ == '__main__': - def main(): - """ Main """ - import sys as _sys - keep_bang_comments = ( - '-b' in _sys.argv[1:] - or '-bp' in _sys.argv[1:] - or '-pb' in _sys.argv[1:] - ) - if '-p' in _sys.argv[1:] or '-bp' in _sys.argv[1:] \ - or '-pb' in _sys.argv[1:]: - global cssmin # pylint: disable = W0603 - cssmin = _make_cssmin(python_only=True) - _sys.stdout.write(cssmin( - _sys.stdin.read(), keep_bang_comments=keep_bang_comments - )) - main() diff --git a/python-django-compressor/compressor/filters/csstidy.py b/python-django-compressor/compressor/filters/csstidy.py deleted file mode 100644 index 4b7e4c7..0000000 --- a/python-django-compressor/compressor/filters/csstidy.py +++ /dev/null @@ -1,10 +0,0 @@ -from compressor.conf import settings -from compressor.filters import CompilerFilter - - -class CSSTidyFilter(CompilerFilter): - command = "{binary} {infile} {args} {outfile}" - options = ( - ("binary", settings.COMPRESS_CSSTIDY_BINARY), - ("args", settings.COMPRESS_CSSTIDY_ARGUMENTS), - ) diff --git a/python-django-compressor/compressor/filters/jsmin/__init__.py b/python-django-compressor/compressor/filters/jsmin/__init__.py index 48d8007..f32e6a4 100644 --- a/python-django-compressor/compressor/filters/jsmin/__init__.py +++ b/python-django-compressor/compressor/filters/jsmin/__init__.py @@ -1,10 +1,21 @@ from __future__ import absolute_import from compressor.filters import CallbackOutputFilter -from compressor.filters.jsmin.slimit import SlimItFilter # noqa class rJSMinFilter(CallbackOutputFilter): - callback = "compressor.filters.jsmin.rjsmin.jsmin" + callback = "rjsmin.jsmin" + dependencies = ["rjsmin"] + kwargs = { + "keep_bang_comments": True + } # This is for backwards compatibility JSMinFilter = rJSMinFilter + + +class SlimItFilter(CallbackOutputFilter): + dependencies = ["slimit"] + callback = "slimit.minify" + kwargs = { + "mangle": True, + } diff --git a/python-django-compressor/compressor/filters/jsmin/rjsmin.py b/python-django-compressor/compressor/filters/jsmin/rjsmin.py deleted file mode 100755 index 6eedf2f..0000000 --- a/python-django-compressor/compressor/filters/jsmin/rjsmin.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python -# -*- coding: ascii -*- -# -# Copyright 2011 - 2013 -# Andr\xe9 Malo or his licensors, as applicable -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -r""" -===================== - Javascript Minifier -===================== - -rJSmin is a javascript minifier written in python. - -The minifier is based on the semantics of `jsmin.c by Douglas Crockford`_\. - -The module is a re-implementation aiming for speed, so it can be used at -runtime (rather than during a preprocessing step). Usually it produces the -same results as the original ``jsmin.c``. It differs in the following ways: - -- there is no error detection: unterminated string, regex and comment - literals are treated as regular javascript code and minified as such. -- Control characters inside string and regex literals are left untouched; they - are not converted to spaces (nor to \n) -- Newline characters are not allowed inside string and regex literals, except - for line continuations in string literals (ECMA-5). -- "return /regex/" is recognized correctly. -- "+ +" and "- -" sequences are not collapsed to '++' or '--' -- Newlines before ! operators are removed more sensibly -- rJSmin does not handle streams, but only complete strings. (However, the - module provides a "streamy" interface). - -Since most parts of the logic are handled by the regex engine it's way -faster than the original python port of ``jsmin.c`` by Baruch Even. The speed -factor varies between about 6 and 55 depending on input and python version -(it gets faster the more compressed the input already is). Compared to the -speed-refactored python port by Dave St.Germain the performance gain is less -dramatic but still between 1.2 and 7. See the docs/BENCHMARKS file for -details. - -rjsmin.c is a reimplementation of rjsmin.py in C and speeds it up even more. - -Both python 2 and python 3 are supported. - -.. _jsmin.c by Douglas Crockford: - http://www.crockford.com/javascript/jsmin.c -""" -__author__ = "Andr\xe9 Malo" -__author__ = getattr(__author__, 'decode', lambda x: __author__)('latin-1') -__docformat__ = "restructuredtext en" -__license__ = "Apache License, Version 2.0" -__version__ = '1.0.7' -__all__ = ['jsmin'] - -import re as _re - - -def _make_jsmin(python_only=False): - """ - Generate JS minifier based on `jsmin.c by Douglas Crockford`_ - - .. _jsmin.c by Douglas Crockford: - http://www.crockford.com/javascript/jsmin.c - - :Parameters: - `python_only` : ``bool`` - Use only the python variant. If true, the c extension is not even - tried to be loaded. - - :Return: Minifier - :Rtype: ``callable`` - """ - # pylint: disable = R0912, R0914, W0612 - if not python_only: - try: - import _rjsmin - except ImportError: - pass - else: - return _rjsmin.jsmin - try: - xrange - except NameError: - xrange = range # pylint: disable = W0622 - - space_chars = r'[\000-\011\013\014\016-\040]' - - line_comment = r'(?://[^\r\n]*)' - space_comment = r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)' - string1 = \ - r'(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|\r)[^\047\\\r\n]*)*\047)' - string2 = r'(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|\r)[^"\\\r\n]*)*")' - strings = r'(?:%s|%s)' % (string1, string2) - - charclass = r'(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]*)*\])' - nospecial = r'[^/\\\[\r\n]' - regex = r'(?:/(?![\r\n/*])%s*(?:(?:\\[^\r\n]|%s)%s*)*/)' % ( - nospecial, charclass, nospecial - ) - space = r'(?:%s|%s)' % (space_chars, space_comment) - newline = r'(?:%s?[\r\n])' % line_comment - - def fix_charclass(result): - """ Fixup string of chars to fit into a regex char class """ - pos = result.find('-') - if pos >= 0: - result = r'%s%s-' % (result[:pos], result[pos + 1:]) - - def sequentize(string): - """ - Notate consecutive characters as sequence - - (1-4 instead of 1234) - """ - first, last, result = None, None, [] - for char in map(ord, string): - if last is None: - first = last = char - elif last + 1 == char: - last = char - else: - result.append((first, last)) - first = last = char - if last is not None: - result.append((first, last)) - return ''.join(['%s%s%s' % ( - chr(first2), - last2 > first2 + 1 and '-' or '', - last2 != first2 and chr(last2) or '' - ) for first2, last2 in result]) - - return _re.sub(r'([\000-\040\047])', # for better portability - lambda m: '\\%03o' % ord(m.group(1)), (sequentize(result) - .replace('\\', '\\\\') - .replace('[', '\\[') - .replace(']', '\\]') - ) - ) - - def id_literal_(what): - """ Make id_literal like char class """ - match = _re.compile(what).match - result = ''.join([ - chr(c) for c in xrange(127) if not match(chr(c)) - ]) - return '[^%s]' % fix_charclass(result) - - def not_id_literal_(keep): - """ Make negated id_literal like char class """ - match = _re.compile(id_literal_(keep)).match - result = ''.join([ - chr(c) for c in xrange(127) if not match(chr(c)) - ]) - return r'[%s]' % fix_charclass(result) - - not_id_literal = not_id_literal_(r'[a-zA-Z0-9_$]') - preregex1 = r'[(,=:\[!&|?{};\r\n]' - preregex2 = r'%(not_id_literal)sreturn' % locals() - - id_literal = id_literal_(r'[a-zA-Z0-9_$]') - id_literal_open = id_literal_(r'[a-zA-Z0-9_${\[(!+-]') - id_literal_close = id_literal_(r'[a-zA-Z0-9_$}\])"\047+-]') - - dull = r'[^\047"/\000-\040]' - - space_sub = _re.compile(( - r'(%(dull)s+)' - r'|(%(strings)s%(dull)s*)' - r'|(?<=%(preregex1)s)' - r'%(space)s*(?:%(newline)s%(space)s*)*' - r'(%(regex)s%(dull)s*)' - r'|(?<=%(preregex2)s)' - r'%(space)s*(?:%(newline)s%(space)s)*' - r'(%(regex)s%(dull)s*)' - r'|(?<=%(id_literal_close)s)' - r'%(space)s*(?:(%(newline)s)%(space)s*)+' - r'(?=%(id_literal_open)s)' - r'|(?<=%(id_literal)s)(%(space)s)+(?=%(id_literal)s)' - r'|(?<=\+)(%(space)s)+(?=\+)' - r'|(?<=-)(%(space)s)+(?=-)' - r'|%(space)s+' - r'|(?:%(newline)s%(space)s*)+' - ) % locals()).sub - # print space_sub.__self__.pattern - - def space_subber(match): - """ Substitution callback """ - # pylint: disable = C0321, R0911 - groups = match.groups() - if groups[0]: return groups[0] - elif groups[1]: return groups[1] - elif groups[2]: return groups[2] - elif groups[3]: return groups[3] - elif groups[4]: return '\n' - elif groups[5] or groups[6] or groups[7]: return ' ' - else: return '' - - def jsmin(script): # pylint: disable = W0621 - r""" - Minify javascript based on `jsmin.c by Douglas Crockford`_\. - - Instead of parsing the stream char by char, it uses a regular - expression approach which minifies the whole script with one big - substitution regex. - - .. _jsmin.c by Douglas Crockford: - http://www.crockford.com/javascript/jsmin.c - - :Parameters: - `script` : ``str`` - Script to minify - - :Return: Minified script - :Rtype: ``str`` - """ - return space_sub(space_subber, '\n%s\n' % script).strip() - - return jsmin - -jsmin = _make_jsmin() - - -def jsmin_for_posers(script): - r""" - Minify javascript based on `jsmin.c by Douglas Crockford`_\. - - Instead of parsing the stream char by char, it uses a regular - expression approach which minifies the whole script with one big - substitution regex. - - .. _jsmin.c by Douglas Crockford: - http://www.crockford.com/javascript/jsmin.c - - :Warning: This function is the digest of a _make_jsmin() call. It just - utilizes the resulting regex. It's just for fun here and may - vanish any time. Use the `jsmin` function instead. - - :Parameters: - `script` : ``str`` - Script to minify - - :Return: Minified script - :Rtype: ``str`` - """ - def subber(match): - """ Substitution callback """ - groups = match.groups() - return ( - groups[0] or - groups[1] or - groups[2] or - groups[3] or - (groups[4] and '\n') or - (groups[5] and ' ') or - (groups[6] and ' ') or - (groups[7] and ' ') or - '' - ) - - return _re.sub( - r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?' - r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|' - r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?<=[(,=:\[!&|?{};\r\n])(?' - r':[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*' - r'(?:(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*' - r'[^*]*\*+(?:[^/*][^*]*\*+)*/))*)*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(' - r'?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[' - r'\r\n]*)*/)[^\047"/\000-\040]*)|(?<=[\000-#%-,./:-@\[-^`{-~-]return' - r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/' - r'))*(?:(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:' - r'/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?' - r':(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/' - r'\\\[\r\n]*)*/)[^\047"/\000-\040]*)|(?<=[^\000-!#%&(*,./:-@\[\\^`{|' - r'~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)' - r'*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\014\016-\040]' - r'|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#%-\047)*,./' - r':-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-\011\013\01' - r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^\000-#%-,./:' - r'-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*' - r'\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\013\014\016-' - r'\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\000-\011\013' - r'\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:(?:(?://[^' - r'\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^' - r'/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script - ).strip() - - -if __name__ == '__main__': - import sys as _sys - _sys.stdout.write(jsmin(_sys.stdin.read())) diff --git a/python-django-compressor/compressor/filters/jsmin/slimit.py b/python-django-compressor/compressor/filters/jsmin/slimit.py deleted file mode 100644 index 9ffc7f4..0000000 --- a/python-django-compressor/compressor/filters/jsmin/slimit.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import absolute_import -from compressor.filters import CallbackOutputFilter - - -class SlimItFilter(CallbackOutputFilter): - dependencies = ["slimit"] - callback = "slimit.minify" - kwargs = { - "mangle": True, - } diff --git a/python-django-compressor/compressor/js.py b/python-django-compressor/compressor/js.py index f60cc7e..651300f 100644 --- a/python-django-compressor/compressor/js.py +++ b/python-django-compressor/compressor/js.py @@ -5,9 +5,8 @@ from compressor.base import Compressor, SOURCE_HUNK, SOURCE_FILE class JsCompressor(Compressor): def __init__(self, content=None, output_prefix="js", context=None): - super(JsCompressor, self).__init__(content, output_prefix, context) - self.filters = list(settings.COMPRESS_JS_FILTERS) - self.type = output_prefix + filters = list(settings.COMPRESS_JS_FILTERS) + super(JsCompressor, self).__init__(content, output_prefix, context, filters) def split_contents(self): if self.split_content: diff --git a/python-django-compressor/compressor/management/commands/compress.py b/python-django-compressor/compressor/management/commands/compress.py index 8cf1926..a6fb1a1 100644 --- a/python-django-compressor/compressor/management/commands/compress.py +++ b/python-django-compressor/compressor/management/commands/compress.py @@ -2,23 +2,26 @@ import os import sys +from collections import OrderedDict from fnmatch import fnmatch from optparse import make_option +from importlib import import_module import django -from django.core.management.base import NoArgsCommand, CommandError +from django.core.management.base import BaseCommand, CommandError import django.template from django.template import Context from django.utils import six -from django.utils.datastructures import SortedDict -from django.utils.importlib import import_module 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: # there is an 'io' module in python 2.6+, but io.StringIO does not @@ -31,9 +34,9 @@ else: from StringIO import StringIO -class Command(NoArgsCommand): +class Command(BaseCommand): help = "Compress content outside of the request/response cycle" - option_list = NoArgsCommand.option_list + ( + 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 ' @@ -51,33 +54,12 @@ class Command(NoArgsCommand): dest="engine"), ) - requires_model_validation = False - def get_loaders(self): - if django.VERSION < (1, 8): - from django.template.loader import template_source_loaders - if template_source_loaders is None: - try: - from django.template.loader import ( - find_template as finder_func) - except ImportError: - from django.template.loader import ( - find_template_source as finder_func) # noqa - try: - # Force django to calculate template_source_loaders from - # TEMPLATE_LOADERS settings, by asking to find a dummy template - source, name = finder_func('test') - except django.template.TemplateDoesNotExist: - pass - # Reload template_source_loaders now that it has been calculated ; - # it should contain the list of valid, instanciated template loaders - # to use. - from django.template.loader import template_source_loaders - else: - from django.template import engines - template_source_loaders = [] - for e in engines.all(): - template_source_loaders.extend(e.engine.get_template_loaders(e.engine.loaders)) + template_source_loaders = [] + for e in engines.all(): + if hasattr(e, 'engine'): + template_source_loaders.extend( + e.engine.get_template_loaders(e.engine.loaders)) loaders = [] # If template loader is CachedTemplateLoader, return the loaders # that it wraps around. So if we have @@ -119,6 +101,7 @@ class Command(NoArgsCommand): The result is cached with a cache-key derived from the content of the compress nodes (not the content of the possibly linked files!). """ + engine = options.get("engine", "django") extensions = options.get('extensions') extensions = self.handle_extensions(extensions or ['html']) verbosity = int(options.get("verbosity", 0)) @@ -128,34 +111,44 @@ class Command(NoArgsCommand): raise OfflineGenerationError("No template loaders defined. You " "must set TEMPLATE_LOADERS in your " "settings.") - paths = set() - for loader in self.get_loaders(): - try: - module = import_module(loader.__module__) - get_template_sources = getattr(module, - 'get_template_sources', None) - if get_template_sources is None: - get_template_sources = loader.get_template_sources - paths.update(list(get_template_sources(''))) - except (ImportError, AttributeError, TypeError): - # Yeah, this didn't work out so well, let's move on - pass - if not paths: - raise OfflineGenerationError("No template paths found. None of " - "the configured template loaders " - "provided template paths. See " - "http://django.me/template-loaders " - "for more information on template " - "loaders.") - if verbosity > 1: - log.write("Considering paths:\n\t" + "\n\t".join(paths) + "\n") templates = set() - for path in paths: - for root, dirs, files in os.walk(path, - followlinks=options.get('followlinks', False)): - templates.update(os.path.join(root, name) - for name in files if not name.startswith('.') and - any(fnmatch(name, "*%s" % glob) for glob in extensions)) + if engine == 'django': + paths = set() + for loader in self.get_loaders(): + try: + module = import_module(loader.__module__) + get_template_sources = getattr(module, + 'get_template_sources', None) + if get_template_sources is None: + get_template_sources = loader.get_template_sources + paths.update(str(origin) for origin in get_template_sources('')) + except (ImportError, AttributeError, TypeError): + # Yeah, this didn't work out so well, let's move on + pass + + if not paths: + raise OfflineGenerationError("No template paths found. None of " + "the configured template loaders " + "provided template paths. See " + "https://docs.djangoproject.com/en/1.8/topics/templates/ " + "for more information on template " + "loaders.") + if verbosity > 1: + log.write("Considering paths:\n\t" + "\n\t".join(paths) + "\n") + + for path in paths: + for root, dirs, files in os.walk(path, + followlinks=options.get('followlinks', False)): + templates.update(os.path.join(root, name) + for name in files if not name.startswith('.') and + any(fnmatch(name, "*%s" % glob) for glob in extensions)) + elif engine == 'jinja2' and django.VERSION >= (1, 8): + env = settings.COMPRESS_JINJA2_GET_ENVIRONMENT() + if env and hasattr(env, 'list_templates'): + templates |= set([env.loader.get_source(env, template)[1] for template in + env.list_templates(filter_func=lambda _path: + os.path.splitext(_path)[-1] in extensions)]) + if not templates: raise OfflineGenerationError("No templates found. Make sure your " "TEMPLATE_LOADERS and TEMPLATE_DIRS " @@ -163,10 +156,8 @@ class Command(NoArgsCommand): if verbosity > 1: log.write("Found templates:\n\t" + "\n\t".join(templates) + "\n") - engine = options.get("engine", "django") parser = self.__get_parser(engine) - - compressor_nodes = SortedDict() + compressor_nodes = OrderedDict() for template_name in templates: try: template = parser.parse(template_name) @@ -208,44 +199,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 + offline_manifest = OrderedDict() - if not parser.process_template(template, context): - continue + for context_dict in contexts: + context_count += 1 + init_context = parser.get_init_context(context_dict) - for node in nodes: - context.push() - parser.process_node(template, context, node) - rendered = parser.render_nodelist(template, context, node) - key = get_offline_hexdigest(rendered) + for template, nodes in compressor_nodes.items(): + context = Context(init_context) + template._log = log + template._log_verbosity = verbosity - 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 occured 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',)): """ @@ -253,7 +258,7 @@ class Command(NoArgsCommand): passed by using --extension/-e multiple times. for example: running 'django-admin compress -e js,txt -e xhtml -a' - would result in a extension list: ['.js', '.txt', '.xhtml'] + would result in an extension list: ['.js', '.txt', '.xhtml'] >>> handle_extensions(['.html', 'html,js,py,py,py,.py', 'py,.py']) ['.html', '.js'] @@ -268,7 +273,7 @@ class Command(NoArgsCommand): ext_list[i] = '.%s' % ext_list[i] return set(ext_list) - def handle_noargs(self, **options): + def handle(self, **options): if not settings.COMPRESS_ENABLED and not options.get("force"): raise CommandError( "Compressor is disabled. Set the COMPRESS_ENABLED " @@ -279,3 +284,7 @@ class Command(NoArgsCommand): "Offline compression is disabled. Set " "COMPRESS_OFFLINE or use the --force to override.") self.compress(sys.stdout, **options) + + + +Command.requires_system_checks = False diff --git a/python-django-compressor/compressor/management/commands/mtime_cache.py b/python-django-compressor/compressor/management/commands/mtime_cache.py index e96f004..afd2390 100644 --- a/python-django-compressor/compressor/management/commands/mtime_cache.py +++ b/python-django-compressor/compressor/management/commands/mtime_cache.py @@ -1,34 +1,38 @@ import fnmatch import os -from optparse import make_option -from django.core.management.base import NoArgsCommand, CommandError +from django.core.management.base import BaseCommand, CommandError from compressor.conf import settings from compressor.cache import cache, get_mtime, get_mtime_cachekey -class Command(NoArgsCommand): +class Command(BaseCommand): help = "Add or remove all mtime values from the cache" - option_list = NoArgsCommand.option_list + ( - make_option('-i', '--ignore', action='append', default=[], + + def add_arguments(self, parser): + parser.add_argument( + '-i', '--ignore', action='append', default=[], dest='ignore_patterns', metavar='PATTERN', help="Ignore files or directories matching this glob-style " - "pattern. Use multiple times to ignore more."), - make_option('--no-default-ignore', action='store_false', + "pattern. Use multiple times to ignore more."), + parser.add_argument( + '--no-default-ignore', action='store_false', dest='use_default_ignore_patterns', default=True, help="Don't ignore the common private glob-style patterns 'CVS', " - "'.*' and '*~'."), - make_option('--follow-links', dest='follow_links', action='store_true', + "'.*' and '*~'."), + parser.add_argument( + '--follow-links', dest='follow_links', 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."), - make_option('-c', '--clean', dest='clean', action='store_true', + "(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."), + parser.add_argument( + '-c', '--clean', dest='clean', action='store_true', help="Remove all items"), - make_option('-a', '--add', dest='add', action='store_true', + parser.add_argument( + '-a', '--add', dest='add', action='store_true', help="Add all items"), - ) def is_ignored(self, path): """ @@ -40,24 +44,27 @@ class Command(NoArgsCommand): return True return False - def handle_noargs(self, **options): + def handle(self, **options): ignore_patterns = options['ignore_patterns'] if options['use_default_ignore_patterns']: ignore_patterns += ['CVS', '.*', '*~'] options['ignore_patterns'] = ignore_patterns self.ignore_patterns = ignore_patterns - if (options['add'] and options['clean']) or (not options['add'] and not options['clean']): + if ((options['add'] and options['clean']) or + (not options['add'] and not options['clean'])): raise CommandError('Please specify either "--add" or "--clean"') if not settings.COMPRESS_MTIME_DELAY: - raise CommandError('mtime caching is currently disabled. Please ' + raise CommandError( + 'mtime caching is currently disabled. Please ' 'set the COMPRESS_MTIME_DELAY setting to a number of seconds.') files_to_add = set() keys_to_delete = set() - for root, dirs, files in os.walk(settings.COMPRESS_ROOT, followlinks=options['follow_links']): + for root, dirs, files in os.walk(settings.COMPRESS_ROOT, + followlinks=options['follow_links']): for dir_ in dirs: if self.is_ignored(dir_): dirs.remove(dir_) @@ -74,9 +81,11 @@ class Command(NoArgsCommand): if keys_to_delete: cache.delete_many(list(keys_to_delete)) - print("Deleted mtimes of %d files from the cache." % len(keys_to_delete)) + self.stdout.write("Deleted mtimes of %d files from the cache." + % len(keys_to_delete)) if files_to_add: for filename in files_to_add: get_mtime(filename) - print("Added mtimes of %d files to cache." % len(files_to_add)) + self.stdout.write("Added mtimes of %d files to cache." + % len(files_to_add)) diff --git a/python-django-compressor/compressor/offline/django.py b/python-django-compressor/compressor/offline/django.py index 107c6e4..2c08885 100644 --- a/python-django-compressor/compressor/offline/django.py +++ b/python-django-compressor/compressor/offline/django.py @@ -1,9 +1,7 @@ from __future__ import absolute_import from copy import copy -import django from django import template -from django.conf import settings from django.template import Context from django.template.base import Node, VariableNode, TextNode, NodeList from django.template.defaulttags import IfNode @@ -26,7 +24,10 @@ def handle_extendsnode(extendsnode, block_context=None, original=None): extendsnode.nodelist.get_nodes_by_type(BlockNode)) block_context.add_blocks(blocks) - context = Context(settings.COMPRESS_OFFLINE_CONTEXT) + # 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 @@ -99,10 +100,7 @@ class DjangoParser(object): def parse(self, template_name): try: - if django.VERSION < (1, 8): - return get_template(template_name) - else: - return get_template(template_name).template + return get_template(template_name).template except template.TemplateSyntaxError as e: raise TemplateSyntaxError(str(e)) except template.TemplateDoesNotExist as e: @@ -118,8 +116,7 @@ class DjangoParser(object): pass def render_nodelist(self, template, context, node): - if django.VERSION >= (1, 8): - context.template = template + context.template = template return node.nodelist.render(context) def render_node(self, template, context, node): @@ -144,7 +141,7 @@ class DjangoParser(object): return nodelist def walk_nodes(self, node, original=None): - if django.VERSION >= (1, 8) and original is 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): diff --git a/python-django-compressor/compressor/parser/__init__.py b/python-django-compressor/compressor/parser/__init__.py index 19beb01..a53f2a4 100644 --- a/python-django-compressor/compressor/parser/__init__.py +++ b/python-django-compressor/compressor/parser/__init__.py @@ -1,9 +1,7 @@ +from importlib import import_module + from django.utils import six from django.utils.functional import LazyObject -try: - from importlib import import_module -except ImportError: - from django.utils.importlib import import_module # support legacy parser module usage from compressor.parser.base import ParserBase # noqa diff --git a/python-django-compressor/compressor/parser/beautifulsoup.py b/python-django-compressor/compressor/parser/beautifulsoup.py index d143df4..1068abe 100644 --- a/python-django-compressor/compressor/parser/beautifulsoup.py +++ b/python-django-compressor/compressor/parser/beautifulsoup.py @@ -1,42 +1,34 @@ from __future__ import absolute_import from django.core.exceptions import ImproperlyConfigured -from django.utils import six from django.utils.encoding import smart_text -from compressor.exceptions import ParserError from compressor.parser import ParserBase -from compressor.utils.decorators import cached_property class BeautifulSoupParser(ParserBase): - @cached_property - def soup(self): + def __init__(self, content): + super(BeautifulSoupParser, self).__init__(content) try: - if six.PY3: - from bs4 import BeautifulSoup - else: - from BeautifulSoup import BeautifulSoup - return BeautifulSoup(self.content) + from bs4 import BeautifulSoup + self.soup = BeautifulSoup(self.content, "html.parser") except ImportError as err: raise ImproperlyConfigured("Error while importing BeautifulSoup: %s" % err) - except Exception as err: - raise ParserError("Error while initializing Parser: %s" % err) def css_elems(self): - if six.PY3: - return self.soup.find_all({'link': True, 'style': True}) - else: - return self.soup.findAll({'link': True, 'style': True}) + return self.soup.find_all({'link': True, 'style': True}) def js_elems(self): - if six.PY3: - return self.soup.find_all('script') - else: - return self.soup.findAll('script') + return self.soup.find_all('script') def elem_attribs(self, elem): - return dict(elem.attrs) + attrs = dict(elem.attrs) + # hack around changed behaviour in bs4, it returns lists now instead of one string, see + # http://www.crummy.com/software/BeautifulSoup/bs4/doc/#multi-valued-attributes + for key, value in attrs.items(): + if type(value) is list: + attrs[key] = " ".join(value) + return attrs def elem_content(self, elem): return elem.string diff --git a/python-django-compressor/compressor/parser/default_htmlparser.py b/python-django-compressor/compressor/parser/default_htmlparser.py index 80272cb..825808b 100644 --- a/python-django-compressor/compressor/parser/default_htmlparser.py +++ b/python-django-compressor/compressor/parser/default_htmlparser.py @@ -1,3 +1,5 @@ +import sys + from django.utils import six from django.utils.encoding import smart_text @@ -5,9 +7,26 @@ from compressor.exceptions import ParserError from compressor.parser import ParserBase +# Starting in Python 3.2, the HTMLParser constructor takes a 'strict' +# argument which default to True (which we don't want). +# In Python 3.3, it defaults to False. +# In Python 3.4, passing it at all raises a deprecation warning. +# So we only pass it for 3.2. +# In Python 3.4, it also takes a 'convert_charrefs' argument +# which raises a warning if we don't pass it. +major, minor, release = sys.version_info[:3] +CONSTRUCTOR_TAKES_STRICT = major == 3 and minor == 2 +CONSTRUCTOR_TAKES_CONVERT_CHARREFS = major == 3 and minor >= 4 +HTML_PARSER_ARGS = {} +if CONSTRUCTOR_TAKES_STRICT: + HTML_PARSER_ARGS['strict'] = False +if CONSTRUCTOR_TAKES_CONVERT_CHARREFS: + HTML_PARSER_ARGS['convert_charrefs'] = False + + class DefaultHtmlParser(ParserBase, six.moves.html_parser.HTMLParser): def __init__(self, content): - six.moves.html_parser.HTMLParser.__init__(self) + six.moves.html_parser.HTMLParser.__init__(self, **HTML_PARSER_ARGS) self.content = content self._css_elems = [] self._js_elems = [] diff --git a/python-django-compressor/compressor/storage.py b/python-django-compressor/compressor/storage.py index 16419a8..6fe994e 100644 --- a/python-django-compressor/compressor/storage.py +++ b/python-django-compressor/compressor/storage.py @@ -36,7 +36,7 @@ class CompressorFileStorage(FileSystemStorage): def modified_time(self, name): return datetime.fromtimestamp(os.path.getmtime(self.path(name))) - def get_available_name(self, name): + def get_available_name(self, name, max_length=None): """ Deletes the given file if it exists. """ diff --git a/python-django-compressor/compressor/templatetags/compress.py b/python-django-compressor/compressor/templatetags/compress.py index a45f454..f92d2c7 100644 --- a/python-django-compressor/compressor/templatetags/compress.py +++ b/python-django-compressor/compressor/templatetags/compress.py @@ -40,8 +40,7 @@ class CompressorMixin(object): def debug_mode(self, context): if settings.COMPRESS_DEBUG_TOGGLE: - # Only check for the debug parameter - # if a RequestContext was used + # Only check for the debug parameter if a RequestContext was used request = context.get('request', None) if request is not None: return settings.COMPRESS_DEBUG_TOGGLE in request.GET @@ -57,68 +56,55 @@ class CompressorMixin(object): return (settings.COMPRESS_ENABLED and settings.COMPRESS_OFFLINE) or forced - def render_offline(self, context, forced): + def render_offline(self, context): """ If enabled and in offline mode, and not forced check the offline cache and return the result if given """ - if self.is_offline_compression_enabled(forced) and not forced: - key = get_offline_hexdigest(self.get_original_content(context)) - offline_manifest = get_offline_manifest() - if key in offline_manifest: - return offline_manifest[key] - else: - raise OfflineGenerationError('You have offline compression ' - 'enabled but key "%s" is missing from offline manifest. ' - 'You may need to run "python manage.py compress".' % key) - - def render_cached(self, compressor, kind, mode, forced=False): + key = get_offline_hexdigest(self.get_original_content(context)) + offline_manifest = get_offline_manifest() + if key in offline_manifest: + return offline_manifest[key] + else: + raise OfflineGenerationError('You have offline compression ' + 'enabled but key "%s" is missing from offline manifest. ' + 'You may need to run "python manage.py compress".' % key) + + def render_cached(self, compressor, kind, mode): """ If enabled checks the cache for the given compressor's cache key and return a tuple of cache key and output """ - if settings.COMPRESS_ENABLED and not forced: - cache_key = get_templatetag_cachekey(compressor, mode, kind) - cache_content = cache_get(cache_key) - return cache_key, cache_content - return None, None + cache_key = get_templatetag_cachekey(compressor, mode, kind) + cache_content = cache_get(cache_key) + return cache_key, cache_content def render_compressed(self, context, kind, mode, forced=False): # See if it has been rendered offline - cached_offline = self.render_offline(context, forced=forced) - if cached_offline: - return cached_offline + if self.is_offline_compression_enabled(forced) and not forced: + return self.render_offline(context) # Take a shortcut if we really don't have anything to do - if ((not settings.COMPRESS_ENABLED and - not settings.COMPRESS_PRECOMPILERS) and not forced): + if (not settings.COMPRESS_ENABLED and + not settings.COMPRESS_PRECOMPILERS and not forced): return self.get_original_content(context) context['compressed'] = {'name': getattr(self, 'name', None)} compressor = self.get_compressor(context, kind) - # Prepare the actual compressor and check cache - cache_key, cache_content = self.render_cached(compressor, kind, mode, forced=forced) - if cache_content is not None: - return cache_content - - # call compressor output method and handle exceptions - try: - rendered_output = self.render_output(compressor, mode, forced=forced) - if cache_key: - cache_set(cache_key, rendered_output) - assert isinstance(rendered_output, six.string_types) - return rendered_output - except Exception: - if settings.DEBUG or forced: - raise - - # Or don't do anything in production - return self.get_original_content(context) + # Check cache + cache_key = None + if settings.COMPRESS_ENABLED and not forced: + cache_key, cache_content = self.render_cached(compressor, kind, mode) + if cache_content is not None: + return cache_content - def render_output(self, compressor, mode, forced=False): - return compressor.output(mode, forced=forced) + rendered_output = compressor.output(mode, forced=forced) + assert isinstance(rendered_output, six.string_types) + if cache_key: + cache_set(cache_key, rendered_output) + return rendered_output class CompressorNode(CompressorMixin, template.Node): @@ -132,14 +118,6 @@ class CompressorNode(CompressorMixin, template.Node): def get_original_content(self, context): return self.nodelist.render(context) - def debug_mode(self, context): - if settings.COMPRESS_DEBUG_TOGGLE: - # Only check for the debug parameter - # if a RequestContext was used - request = context.get('request', None) - if request is not None: - return settings.COMPRESS_DEBUG_TOGGLE in request.GET - def render(self, context, forced=False): # Check if in debug mode diff --git a/python-django-compressor/compressor/test_settings.py b/python-django-compressor/compressor/test_settings.py index 7fb021c..facf66a 100644 --- a/python-django-compressor/compressor/test_settings.py +++ b/python-django-compressor/compressor/test_settings.py @@ -1,5 +1,4 @@ import os -import django TEST_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'tests') @@ -21,10 +20,8 @@ DATABASES = { INSTALLED_APPS = [ 'django.contrib.staticfiles', 'compressor', - 'coffin', + 'sekizai', ] -if django.VERSION < (1, 8): - INSTALLED_APPS.append('jingo') STATICFILES_FINDERS = [ 'django.contrib.staticfiles.finders.FileSystemFinder', @@ -37,15 +34,25 @@ STATIC_URL = '/static/' STATIC_ROOT = os.path.join(TEST_DIR, 'static') -TEMPLATE_DIRS = ( - # Specifically choose a name that will not be considered - # by app_directories loader, to make sure each test uses - # a specific template without considering the others. - os.path.join(TEST_DIR, 'test_templates'), -) - -if django.VERSION[:2] < (1, 6): - TEST_RUNNER = 'discover_runner.DiscoverRunner' +TEMPLATES = [{ + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'APP_DIRS': True, + 'DIRS': [ + # Specifically choose a name that will not be considered + # by app_directories loader, to make sure each test uses + # a specific template without considering the others. + os.path.join(TEST_DIR, 'test_templates'), + ], +}, { + 'BACKEND': 'django.template.backends.jinja2.Jinja2', + 'APP_DIRS': True, + 'DIRS': [ + # Specifically choose a name that will not be considered + # by app_directories loader, to make sure each test uses + # a specific template without considering the others. + os.path.join(TEST_DIR, 'test_templates_jinja2'), + ], +}] SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" diff --git a/python-django-compressor/compressor/tests/static/css/relative_url.css b/python-django-compressor/compressor/tests/static/css/relative_url.css new file mode 100644 index 0000000..c690cde --- /dev/null +++ b/python-django-compressor/compressor/tests/static/css/relative_url.css @@ -0,0 +1 @@ +p { background: url('../img/python.png'); } \ No newline at end of file diff --git a/python-django-compressor/compressor/tests/test_base.py b/python-django-compressor/compressor/tests/test_base.py index e8255db..dc6d2e9 100644 --- a/python-django-compressor/compressor/tests/test_base.py +++ b/python-django-compressor/compressor/tests/test_base.py @@ -1,32 +1,27 @@ from __future__ import with_statement, unicode_literals import os import re +from tempfile import mkdtemp +from shutil import rmtree, copytree -try: - from bs4 import BeautifulSoup -except ImportError: - from BeautifulSoup import BeautifulSoup +from bs4 import BeautifulSoup -from django.utils import six from django.core.cache.backends import locmem from django.test import SimpleTestCase from django.test.utils import override_settings from compressor import cache as cachemod from compressor.base import SOURCE_FILE, SOURCE_HUNK -from compressor.cache import get_cachekey +from compressor.cache import get_cachekey, get_precompiler_cachekey, get_hexdigest from compressor.conf import settings from compressor.css import CssCompressor from compressor.exceptions import FilterDoesNotExist, FilterError from compressor.js import JsCompressor +from compressor.storage import DefaultStorage def make_soup(markup): - # we use html.parser instead of lxml because it doesn't work on python 3.3 - if six.PY3: - return BeautifulSoup(markup, 'html.parser') - else: - return BeautifulSoup(markup) + return BeautifulSoup(markup, "html.parser") def css_tag(href, **kwargs): @@ -45,15 +40,63 @@ class TestPrecompiler(object): return 'OUTPUT' +class PassthroughPrecompiler(object): + """A filter whose outputs the input unmodified """ + def __init__(self, content, attrs, filter_type=None, filename=None, + charset=None): + self.content = content + + def input(self, **kwargs): + return self.content + + test_dir = os.path.abspath(os.path.join(os.path.dirname(__file__))) +class PrecompilerAndAbsoluteFilterTestCase(SimpleTestCase): + + def setUp(self): + self.html_orig = '' + self.html_link_to_precompiled_css = '' + self.html_link_to_absolutized_css = '' + self.css_orig = "p { background: url('../img/python.png'); }" # content of relative_url.css + self.css_absolutized = "p { background: url('/static/img/python.png?c2281c83670e'); }" + + def helper(self, enabled, use_precompiler, use_absolute_filter, expected_output): + precompiler = (('text/css', 'compressor.tests.test_base.PassthroughPrecompiler'),) if use_precompiler else () + filters = ('compressor.filters.css_default.CssAbsoluteFilter',) if use_absolute_filter else () + + with self.settings(COMPRESS_ENABLED=enabled, COMPRESS_PRECOMPILERS=precompiler, COMPRESS_CSS_FILTERS=filters): + css_node = CssCompressor(self.html_orig) + output = list(css_node.hunks())[0] + self.assertEqual(output, expected_output) + + @override_settings(COMPRESS_CSS_HASHING_METHOD="content") + def test_precompiler_enables_absolute(self): + """ + Tests whether specifying a precompiler also runs the CssAbsoluteFilter even if + compression is disabled, but only if the CssAbsoluteFilter is actually contained + in the filters setting. + While at it, ensure that everything runs as expected when compression is enabled. + """ + self.helper(enabled=False, use_precompiler=False, use_absolute_filter=False, expected_output=self.html_orig) + self.helper(enabled=False, use_precompiler=False, use_absolute_filter=True, expected_output=self.html_orig) + self.helper(enabled=False, use_precompiler=True, use_absolute_filter=False, expected_output=self.html_link_to_precompiled_css) + self.helper(enabled=False, use_precompiler=True, use_absolute_filter=True, expected_output=self.html_link_to_absolutized_css) + self.helper(enabled=True, use_precompiler=False, use_absolute_filter=False, expected_output=self.css_orig) + self.helper(enabled=True, use_precompiler=False, use_absolute_filter=True, expected_output=self.css_absolutized) + self.helper(enabled=True, use_precompiler=True, use_absolute_filter=False, expected_output=self.css_orig) + self.helper(enabled=True, use_precompiler=True, use_absolute_filter=True, expected_output=self.css_absolutized) + + +@override_settings( + COMPRESS_ENABLED=True, + COMPRESS_PRECOMPILERS=(), + COMPRESS_DEBUG_TOGGLE='nocompress', +) class CompressorTestCase(SimpleTestCase): def setUp(self): - settings.COMPRESS_ENABLED = True - settings.COMPRESS_PRECOMPILERS = () - settings.COMPRESS_DEBUG_TOGGLE = 'nocompress' self.css = """\ @@ -128,8 +171,8 @@ class CompressorTestCase(SimpleTestCase): self.assertTrue(is_date.match(str(float(date))), "mtimes is returning something that doesn't look like a date: %s" % date) + @override_settings(COMPRESS_ENABLED=False) def test_css_return_if_off(self): - settings.COMPRESS_ENABLED = False self.assertEqualCollapsed(self.css, self.css_node.output()) def test_cachekey(self): @@ -226,6 +269,15 @@ class CompressorTestCase(SimpleTestCase): css_node = CssCompressor(css) self.assertRaises(FilterError, css_node.output, 'inline') + @override_settings(COMPRESS_PRECOMPILERS=( + ('text/django', 'compressor.filters.template.TemplateFilter'), + ), COMPRESS_ENABLED=True) + def test_template_precompiler(self): + css = '' + css_node = CssCompressor(css) + output = make_soup(css_node.output('inline')) + self.assertEqual(output.text, 'p { border:10px solid green;}') + class CssMediaTestCase(SimpleTestCase): def setUp(self): @@ -237,10 +289,7 @@ class CssMediaTestCase(SimpleTestCase): def test_css_output(self): css_node = CssCompressor(self.css) - if six.PY3: - links = make_soup(css_node.output()).find_all('link') - else: - links = make_soup(css_node.output()).findAll('link') + links = make_soup(css_node.output()).find_all('link') media = ['screen', 'print', 'all', None] self.assertEqual(len(links), 4) self.assertEqual(media, [l.get('media', None) for l in links]) @@ -249,10 +298,7 @@ class CssMediaTestCase(SimpleTestCase): css = self.css + '' css_node = CssCompressor(css) media = ['screen', 'print', 'all', None, 'print'] - if six.PY3: - links = make_soup(css_node.output()).find_all('link') - else: - links = make_soup(css_node.output()).findAll('link') + links = make_soup(css_node.output()).find_all('link') self.assertEqual(media, [l.get('media', None) for l in links]) @override_settings(COMPRESS_PRECOMPILERS=( @@ -264,21 +310,16 @@ class CssMediaTestCase(SimpleTestCase): """ css_node = CssCompressor(css) - if six.PY3: - output = make_soup(css_node.output()).find_all(['link', 'style']) - else: - output = make_soup(css_node.output()).findAll(['link', 'style']) + output = make_soup(css_node.output()).find_all(['link', 'style']) self.assertEqual(['/static/css/one.css', '/static/css/two.css', None], [l.get('href', None) for l in output]) self.assertEqual(['screen', 'screen', 'screen'], [l.get('media', None) for l in output]) +@override_settings(COMPRESS_VERBOSE=True) class VerboseTestCase(CompressorTestCase): - - def setUp(self): - super(VerboseTestCase, self).setUp() - settings.COMPRESS_VERBOSE = True + pass class CacheBackendTestCase(CompressorTestCase): @@ -307,12 +348,8 @@ class JsAsyncDeferTestCase(SimpleTestCase): return 'defer' js_node = JsCompressor(self.js) output = [None, 'async', 'defer', None, 'async', None] - if six.PY3: - scripts = make_soup(js_node.output()).find_all('script') - attrs = [extract_attr(i) for i in scripts] - else: - scripts = make_soup(js_node.output()).findAll('script') - attrs = [s.get('async') or s.get('defer') for s in scripts] + scripts = make_soup(js_node.output()).find_all('script') + attrs = [extract_attr(s) for s in scripts] self.assertEqual(output, attrs) @@ -331,3 +368,57 @@ class CacheTestCase(SimpleTestCase): @override_settings(COMPRESS_CACHE_KEY_FUNCTION='invalid.module') def test_get_cachekey_invalid_mod(self): self.assertRaises(ImportError, lambda: get_cachekey("foo")) + + def test_get_precompiler_cachekey(self): + try: + get_precompiler_cachekey("asdf", "asdf") + except TypeError: + self.fail("get_precompiler_cachekey raised TypeError unexpectedly") + + +class CompressorInDebugModeTestCase(SimpleTestCase): + + def setUp(self): + self.css = '' + self.tmpdir = mkdtemp() + new_static_root = os.path.join(self.tmpdir, "static") + copytree(settings.STATIC_ROOT, new_static_root) + + self.override_settings = self.settings( + COMPRESS_ENABLED=True, + COMPRESS_PRECOMPILERS=(), + COMPRESS_DEBUG_TOGGLE='nocompress', + DEBUG=True, + STATIC_ROOT=new_static_root, + COMPRESS_ROOT=new_static_root, + STATICFILES_DIRS=[settings.COMPRESS_ROOT] + ) + self.override_settings.__enter__() + + def tearDown(self): + rmtree(self.tmpdir) + self.override_settings.__exit__(None, None, None) + + def test_filename_in_debug_mode(self): + # In debug mode, compressor should look for files using staticfiles + # finders only, and not look into the global static directory, where + # 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() + hashed = get_hexdigest(css_content, 12) + # Now modify the file in the STATIC_ROOT + test_css_content = "p { font-family: 'test' }" + with open(css_filename, "a") as css: + css.write("\n") + css.write(test_css_content) + # We should generate a link with the hash of the original content, not + # the modified one + expected = '' % hashed + compressor = CssCompressor(self.css) + 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() + self.assertTrue(test_css_content not in result) diff --git a/python-django-compressor/compressor/tests/test_filters.py b/python-django-compressor/compressor/tests/test_filters.py index 784d89a..fb75146 100644 --- a/python-django-compressor/compressor/tests/test_filters.py +++ b/python-django-compressor/compressor/tests/test_filters.py @@ -3,23 +3,20 @@ from collections import defaultdict import io import os import sys -import textwrap from django.utils import six from django.test import TestCase -from django.utils import unittest from django.test.utils import override_settings -from compressor.cache import get_hashed_mtime, get_hashed_content +from compressor.cache import cache, get_hashed_mtime, get_hashed_content from compressor.conf import settings from compressor.css import CssCompressor -from compressor.utils import find_command -from compressor.filters.base import CompilerFilter -from compressor.filters.cssmin import CSSMinFilter +from compressor.filters.base import CompilerFilter, CachedCompilerFilter +from compressor.filters.cssmin import CSSCompressorFilter, rCSSMinFilter from compressor.filters.css_default import CssAbsoluteFilter +from compressor.filters.jsmin import JSMinFilter from compressor.filters.template import TemplateFilter from compressor.filters.closure import ClosureCompilerFilter -from compressor.filters.csstidy import CSSTidyFilter from compressor.filters.yuglify import YUglifyCSSFilter, YUglifyJSFilter from compressor.filters.yui import YUICSSFilter, YUIJSFilter from compressor.filters.cleancss import CleanCSSFilter @@ -30,26 +27,14 @@ def blankdict(*args, **kwargs): return defaultdict(lambda: '', *args, **kwargs) -@unittest.skipIf(find_command(settings.COMPRESS_CSSTIDY_BINARY) is None, - 'CSStidy binary %r not found' % settings.COMPRESS_CSSTIDY_BINARY) -class CssTidyTestCase(TestCase): - def test_tidy(self): - content = textwrap.dedent("""\ - /* Some comment */ - font,th,td,p{ - color: black; - } - """) - ret = CSSTidyFilter(content).input() - self.assertIsInstance(ret, six.text_type) - self.assertEqual( - "font,th,td,p{color:#000;}", CSSTidyFilter(content).input()) - - +@override_settings(COMPRESS_CACHEABLE_PRECOMPILERS=('text/css',)) class PrecompilerTestCase(TestCase): def setUp(self): self.test_precompiler = os.path.join(test_dir, 'precompiler.py') self.setup_infile() + self.cached_precompiler_args = dict( + content=self.content, charset=settings.FILE_CHARSET, + filename=self.filename, mimetype='text/css') def setup_infile(self, filename='static/css/one.css'): self.filename = os.path.join(test_dir, filename) @@ -101,10 +86,51 @@ class PrecompilerTestCase(TestCase): compiler = CompilerFilter(content=self.content, filename=self.filename, command=command) self.assertEqual(type(compiler.input()), six.text_type) + def test_precompiler_cache(self): + 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()) + # We tell whether the precompiler actually ran by inspecting compiler.infile. If not None, the compiler had to + # write the input out to the file for the external command. If None, it was in the cache and thus skipped. + self.assertIsNotNone(compiler.infile) # Not cached + + compiler = CachedCompilerFilter(command=command, **self.cached_precompiler_args) + self.assertEqual("body { color:#990; }", compiler.input()) + self.assertIsNone(compiler.infile) # Cached + + self.cached_precompiler_args['content'] += ' ' # Invalidate cache by slightly changing content + compiler = CachedCompilerFilter(command=command, **self.cached_precompiler_args) + self.assertEqual("body { color:#990; }", compiler.input()) + self.assertIsNotNone(compiler.infile) # Not cached + + 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' + compiler = CachedCompilerFilter(command=command, **self.cached_precompiler_args) + self.assertEqual("body { color:#990; }", compiler.input()) + self.assertIsNotNone(compiler.infile) # Not cached -class CssMinTestCase(TestCase): - def test_cssmin_filter(self): - content = """p { + compiler = CachedCompilerFilter(command=command, **self.cached_precompiler_args) + self.assertEqual("body { color:#990; }", compiler.input()) + self.assertIsNotNone(compiler.infile) # Not cached + + def test_precompiler_caches_empty_files(self): + 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()) + + cache.set(compiler.get_cache_key(), "") + compiler = CachedCompilerFilter(command=command, **self.cached_precompiler_args) + self.assertEqual("", compiler.input()) + + +class CSSCompressorTestCase(TestCase): + def test_csscompressor_filter(self): + content = """/*! + * django-compressor + * Copyright (c) 2009-2014 Django Compressor authors + */ + p { background: rgb(51,102,153) url('../../images/image.gif'); @@ -112,10 +138,52 @@ class CssMinTestCase(TestCase): } """ - output = "p{background:#369 url('../../images/image.gif')}" - self.assertEqual(output, CSSMinFilter(content).output()) + output = """/*! + * django-compressor + * Copyright (c) 2009-2014 Django Compressor authors + */p{background:#369 url('../../images/image.gif')}""" + self.assertEqual(output, CSSCompressorFilter(content).output()) + + +class rCssMinTestCase(TestCase): + def test_rcssmin_filter(self): + content = """/*! + * django-compressor + * Copyright (c) 2009-2014 Django Compressor authors + */ + p { + + + background: rgb(51,102,153) url('../../images/image.gif'); + } + """ + output = """/*! + * django-compressor + * Copyright (c) 2009-2014 Django Compressor authors + */p{background:rgb(51,102,153) url('../../images/image.gif')}""" + self.assertEqual(output, rCSSMinFilter(content).output()) + + +class JsMinTestCase(TestCase): + def test_jsmin_filter(self): + content = """/*! + * django-compressor + * Copyright (c) 2009-2014 Django Compressor authors + */ + var foo = "bar";""" + output = """/*! + * django-compressor + * Copyright (c) 2009-2014 Django Compressor authors + */var foo="bar";""" + self.assertEqual(output, JSMinFilter(content).output()) + + +@override_settings( + COMPRESS_ENABLED=True, + COMPRESS_URL='/static/', +) class CssAbsolutizingTestCase(TestCase): hashing_method = 'mtime' hashing_func = staticmethod(get_hashed_mtime) @@ -123,12 +191,9 @@ class CssAbsolutizingTestCase(TestCase): "p { filter: Alpha(src='%(url)simg/python.png%(query)s%(hash)s%(frag)s') }") def setUp(self): - self.old_enabled = settings.COMPRESS_ENABLED - self.old_url = settings.COMPRESS_URL - self.old_hashing_method = settings.COMPRESS_CSS_HASHING_METHOD - settings.COMPRESS_ENABLED = True - settings.COMPRESS_URL = '/static/' - settings.COMPRESS_CSS_HASHING_METHOD = self.hashing_method + self.override_settings = self.settings(COMPRESS_CSS_HASHING_METHOD=self.hashing_method) + self.override_settings.__enter__() + self.css = """ @@ -136,12 +201,10 @@ class CssAbsolutizingTestCase(TestCase): self.css_node = CssCompressor(self.css) def tearDown(self): - settings.COMPRESS_ENABLED = self.old_enabled - settings.COMPRESS_URL = self.old_url - settings.COMPRESS_CSS_HASHING_METHOD = self.old_hashing_method + self.override_settings.__exit__(None, None, None) + @override_settings(COMPRESS_CSS_HASHING_METHOD=None) def test_css_no_hash(self): - settings.COMPRESS_CSS_HASHING_METHOD = None filename = os.path.join(settings.COMPRESS_ROOT, 'css/url/test.css') content = self.template % blankdict(url='../../') params = blankdict({ @@ -151,10 +214,11 @@ class CssAbsolutizingTestCase(TestCase): filter = CssAbsoluteFilter(content) self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) - settings.COMPRESS_URL = params['url'] = 'http://static.example.com/' - output = self.template % params - filter = CssAbsoluteFilter(content) - self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) + with self.settings(COMPRESS_URL='http://static.example.com/'): + params['url'] = settings.COMPRESS_URL + output = self.template % params + filter = CssAbsoluteFilter(content) + self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) def test_css_absolute_filter(self): filename = os.path.join(settings.COMPRESS_ROOT, 'css/url/test.css') @@ -168,10 +232,11 @@ class CssAbsolutizingTestCase(TestCase): filter = CssAbsoluteFilter(content) self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) - settings.COMPRESS_URL = params['url'] = 'http://static.example.com/' - output = self.template % params - filter = CssAbsoluteFilter(content) - self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) + with self.settings(COMPRESS_URL='http://static.example.com/'): + params['url'] = settings.COMPRESS_URL + output = self.template % params + filter = CssAbsoluteFilter(content) + self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) def test_css_absolute_filter_url_fragment(self): filename = os.path.join(settings.COMPRESS_ROOT, 'css/url/test.css') @@ -186,10 +251,11 @@ class CssAbsolutizingTestCase(TestCase): filter = CssAbsoluteFilter(content) self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) - settings.COMPRESS_URL = params['url'] = 'http://media.example.com/' - output = self.template % params - filter = CssAbsoluteFilter(content) - self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) + with self.settings(COMPRESS_URL='http://media.example.com/'): + params['url'] = settings.COMPRESS_URL + output = self.template % params + filter = CssAbsoluteFilter(content) + self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) def test_css_absolute_filter_only_url_fragment(self): filename = os.path.join(settings.COMPRESS_ROOT, 'css/url/test.css') @@ -197,9 +263,9 @@ class CssAbsolutizingTestCase(TestCase): filter = CssAbsoluteFilter(content) self.assertEqual(content, filter.input(filename=filename, basename='css/url/test.css')) - settings.COMPRESS_URL = 'http://media.example.com/' - 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') @@ -214,10 +280,11 @@ class CssAbsolutizingTestCase(TestCase): filter = CssAbsoluteFilter(content) self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) - settings.COMPRESS_URL = params['url'] = 'http://media.example.com/' - output = self.template % params - filter = CssAbsoluteFilter(content) - self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) + with self.settings(COMPRESS_URL='http://media.example.com/'): + params['url'] = settings.COMPRESS_URL + output = self.template % params + filter = CssAbsoluteFilter(content) + self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) def test_css_absolute_filter_https(self): filename = os.path.join(settings.COMPRESS_ROOT, 'css/url/test.css') @@ -231,10 +298,11 @@ class CssAbsolutizingTestCase(TestCase): filter = CssAbsoluteFilter(content) self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) - settings.COMPRESS_URL = params['url'] = 'https://static.example.com/' - output = self.template % params - filter = CssAbsoluteFilter(content) - self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) + with self.settings(COMPRESS_URL='https://static.example.com/'): + params['url'] = settings.COMPRESS_URL + output = self.template % params + filter = CssAbsoluteFilter(content) + self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) def test_css_absolute_filter_relative_path(self): filename = os.path.join(settings.TEST_DIR, 'whatever', '..', 'static', 'whatever/../css/url/test.css') @@ -248,10 +316,11 @@ class CssAbsolutizingTestCase(TestCase): filter = CssAbsoluteFilter(content) self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) - settings.COMPRESS_URL = params['url'] = 'https://static.example.com/' - output = self.template % params - filter = CssAbsoluteFilter(content) - self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) + with self.settings(COMPRESS_URL='https://static.example.com/'): + params['url'] = settings.COMPRESS_URL + output = self.template % params + filter = CssAbsoluteFilter(content) + self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css')) def test_css_absolute_filter_filename_outside_compress_root(self): filename = '/foo/bar/baz/test.css' @@ -262,11 +331,12 @@ class CssAbsolutizingTestCase(TestCase): output = self.template % params filter = CssAbsoluteFilter(content) self.assertEqual(output, filter.input(filename=filename, basename='bar/baz/test.css')) - settings.COMPRESS_URL = 'https://static.example.com/' - params['url'] = settings.COMPRESS_URL + 'bar/qux/' - output = self.template % params - filter = CssAbsoluteFilter(content) - self.assertEqual(output, filter.input(filename=filename, basename='bar/baz/test.css')) + + with self.settings(COMPRESS_URL='https://static.example.com/'): + params['url'] = settings.COMPRESS_URL + 'bar/qux/' + output = self.template % params + filter = CssAbsoluteFilter(content) + self.assertEqual(output, filter.input(filename=filename, basename='bar/baz/test.css')) def test_css_hunks(self): hash_dict = { @@ -290,12 +360,12 @@ p { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='/static/img/ def test_guess_filename(self): for base_url in ('/static/', 'http://static.example.com/'): - settings.COMPRESS_URL = base_url - url = '%s/img/python.png' % settings.COMPRESS_URL.rstrip('/') - path = os.path.join(settings.COMPRESS_ROOT, 'img/python.png') - content = "p { background: url('%s') }" % url - filter = CssAbsoluteFilter(content) - self.assertEqual(path, filter.guess_filename(url)) + with self.settings(COMPRESS_URL=base_url): + url = '%s/img/python.png' % settings.COMPRESS_URL.rstrip('/') + path = os.path.join(settings.COMPRESS_ROOT, 'img/python.png') + content = "p { background: url('%s') }" % url + filter = CssAbsoluteFilter(content) + self.assertEqual(path, filter.guess_filename(url)) class CssAbsolutizingTestCaseWithHash(CssAbsolutizingTestCase): @@ -303,15 +373,17 @@ class CssAbsolutizingTestCaseWithHash(CssAbsolutizingTestCase): hashing_func = staticmethod(get_hashed_content) +@override_settings( + COMPRESS_ENABLED=True, + COMPRESS_CSS_FILTERS=[ + 'compressor.filters.css_default.CssAbsoluteFilter', + 'compressor.filters.datauri.CssDataUriFilter', + ], + COMPRESS_URL='/static/', + COMPRESS_CSS_HASHING_METHOD='mtime' +) class CssDataUriTestCase(TestCase): def setUp(self): - settings.COMPRESS_ENABLED = True - settings.COMPRESS_CSS_FILTERS = [ - 'compressor.filters.css_default.CssAbsoluteFilter', - 'compressor.filters.datauri.CssDataUriFilter', - ] - settings.COMPRESS_URL = '/static/' - settings.COMPRESS_CSS_HASHING_METHOD = 'mtime' self.css = """ """ @@ -352,10 +424,6 @@ class SpecializedFiltersTest(TestCase): filter = ClosureCompilerFilter('') self.assertEqual(filter.options, (('binary', six.text_type('java -jar compiler.jar')), ('args', six.text_type('')))) - def test_csstidy_filter(self): - filter = CSSTidyFilter('') - self.assertEqual(filter.options, (('binary', six.text_type('csstidy')), ('args', six.text_type('--template=highest')))) - def test_yuglify_filters(self): filter = YUglifyCSSFilter('') self.assertEqual(filter.command, '{binary} {args} --type=css') diff --git a/python-django-compressor/compressor/tests/test_finder.py b/python-django-compressor/compressor/tests/test_finder.py new file mode 100644 index 0000000..0420cd4 --- /dev/null +++ b/python-django-compressor/compressor/tests/test_finder.py @@ -0,0 +1,15 @@ +from django.test import TestCase + +from compressor.finders import CompressorFinder +from compressor.storage import CompressorFileStorage + + +class FinderTestCase(TestCase): + + def test_has_correct_storage(self): + finder = CompressorFinder() + self.assertTrue(type(finder.storage) is CompressorFileStorage) + + def test_list_returns_empty_list(self): + finder = CompressorFinder() + self.assertEqual(finder.list([]), []) diff --git a/python-django-compressor/compressor/tests/test_jinja2ext.py b/python-django-compressor/compressor/tests/test_jinja2ext.py index 04adb9a..fc334da 100644 --- a/python-django-compressor/compressor/tests/test_jinja2ext.py +++ b/python-django-compressor/compressor/tests/test_jinja2ext.py @@ -2,16 +2,17 @@ from __future__ import with_statement, unicode_literals import sys +import unittest from django.test import TestCase -from django.utils import unittest, six +from django.utils import six from django.test.utils import override_settings from compressor.conf import settings from compressor.tests.test_base import css_tag -@unittest.skipUnless(not six.PY3 or sys.version_info[:2] >= (3, 3), +@unittest.skipIf(six.PY3 and sys.version_info[:2] < (3, 3), 'Jinja can only run on Python < 3 and >= 3.3') class TestJinja2CompressorExtension(TestCase): """ @@ -143,13 +144,11 @@ class TestJinja2CompressorExtension(TestCase): self.assertEqual(out, template.render(context)) def test_nonascii_inline_css(self): - org_COMPRESS_ENABLED = settings.COMPRESS_ENABLED - settings.COMPRESS_ENABLED = False - template = self.env.from_string('{% compress css %}' - '{% endcompress %}') + with self.settings(COMPRESS_ENABLED=False): + template = self.env.from_string('{% compress css %}' + '{% endcompress %}') out = '' - settings.COMPRESS_ENABLED = org_COMPRESS_ENABLED context = {'STATIC_URL': settings.COMPRESS_URL} self.assertEqual(out, template.render(context)) diff --git a/python-django-compressor/compressor/tests/test_mtime_cache.py b/python-django-compressor/compressor/tests/test_mtime_cache.py new file mode 100644 index 0000000..fd0006c --- /dev/null +++ b/python-django-compressor/compressor/tests/test_mtime_cache.py @@ -0,0 +1,37 @@ +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase +from django.utils.six import StringIO + + +class TestMtimeCacheCommand(TestCase): + # FIXME: add actual tests, improve the existing ones. + + exclusion_patterns = [ + '*CACHE*', '*custom*', '*066cd253eada.js', 'test.txt*' + ] + + def default_ignore(self): + return ['--ignore=%s' % pattern for pattern in self.exclusion_patterns] + + def test_handle_no_args(self): + with self.assertRaises(CommandError): + call_command('mtime_cache') + + def test_handle_add(self): + out = StringIO() + with self.settings(CACHES={}): + call_command( + 'mtime_cache', '--add', *self.default_ignore(), stdout=out) + output = out.getvalue() + self.assertIn('Deleted mtimes of 19 files from the cache.', output) + self.assertIn('Added mtimes of 19 files to cache.', output) + + def test_handle_clean(self): + out = StringIO() + with self.settings(CACHES={}): + call_command( + 'mtime_cache', '--clean', *self.default_ignore(), stdout=out) + output = out.getvalue() + self.assertIn('Deleted mtimes of 19 files from the cache.', output) + self.assertNotIn('Added mtimes of 19 files to cache.', output) diff --git a/python-django-compressor/compressor/tests/test_offline.py b/python-django-compressor/compressor/tests/test_offline.py index 59a3d62..6433911 100644 --- a/python-django-compressor/compressor/tests/test_offline.py +++ b/python-django-compressor/compressor/tests/test_offline.py @@ -1,19 +1,25 @@ from __future__ import with_statement, unicode_literals +import copy import io import os import sys +import unittest +from importlib import import_module + +from mock import patch +from unittest import SkipTest -import django 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 import six 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 @@ -28,58 +34,81 @@ else: # The Jinja2 tests fail on Python 3.2 due to the following: # The line in compressor/management/commands/compress.py: # compressor_nodes.setdefault(template, []).extend(nodes) -# causes the error "unhashable type: 'Template'" +# causes the error 'unhashable type: 'Template'' _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" + template_name = 'test_compressor_offline.html' verbosity = 0 # Change this for each test class - templates_dir = "" - expected_hash = "" + templates_dir = '' + expected_hash = '' # Engines to test if _TEST_JINJA2: - engines = ("django", "jinja2") + engines = ('django', 'jinja2') else: - engines = ("django",) + engines = ('django',) + additional_test_settings = None def setUp(self): self.log = StringIO() # Reset template dirs, because it enables us to force compress to # consider only a specific directory (helps us make true, - # independant unit tests). - # Specify both Jinja2 and Django template locations. When the wrong engine - # is used to parse a template, the TemplateSyntaxError will cause the - # template to be skipped over. - django_template_dir = os.path.join(settings.TEST_DIR, 'test_templates', self.templates_dir) - jinja2_template_dir = os.path.join(settings.TEST_DIR, 'test_templates_jinja2', self.templates_dir) + # independent unit tests). + # Specify both Jinja2 and Django template locations. When the wrong + # engine is used to parse a template, the TemplateSyntaxError will + # cause the template to be skipped over. + # We've hardcoded TEMPLATES[0] to be Django templates backend and + # TEMPLATES[1] to be Jinja2 templates backend in test_settings. + TEMPLATES = copy.deepcopy(settings.TEMPLATES) + + django_template_dir = os.path.join( + TEMPLATES[0]['DIRS'][0], self.templates_dir) + jinja2_template_dir = os.path.join( + TEMPLATES[1]['DIRS'][0], self.templates_dir) + + TEMPLATES[0]['DIRS'] = [django_template_dir] + TEMPLATES[1]['DIRS'] = [jinja2_template_dir] override_settings = { - 'TEMPLATE_DIRS': (django_template_dir, jinja2_template_dir,), + 'TEMPLATES': TEMPLATES, 'COMPRESS_ENABLED': True, 'COMPRESS_OFFLINE': True } - if "jinja2" in self.engines: - override_settings["COMPRESS_JINJA2_GET_ENVIRONMENT"] = lambda: self._get_jinja2_env() + if 'jinja2' in self.engines: + override_settings['COMPRESS_JINJA2_GET_ENVIRONMENT'] = ( + lambda: self._get_jinja2_env()) + + if self.additional_test_settings is not None: + override_settings.update(self.additional_test_settings) self.override_settings = self.settings(**override_settings) self.override_settings.__enter__() - if "django" in self.engines: - self.template_path = os.path.join(django_template_dir, self.template_name) + if 'django' in self.engines: + self.template_path = os.path.join( + django_template_dir, self.template_name) - with io.open(self.template_path, encoding=settings.FILE_CHARSET) as file: - self.template = Template(file.read()) + with io.open(self.template_path, + encoding=settings.FILE_CHARSET) as file_: + self.template = Template(file_.read()) - if "jinja2" in self.engines: - jinja2_env = override_settings["COMPRESS_JINJA2_GET_ENVIRONMENT"]() - self.template_path_jinja2 = os.path.join(jinja2_template_dir, self.template_name) + if 'jinja2' in self.engines: + self.template_path_jinja2 = os.path.join( + jinja2_template_dir, self.template_name) + jinja2_env = override_settings['COMPRESS_JINJA2_GET_ENVIRONMENT']() - with io.open(self.template_path_jinja2, encoding=settings.FILE_CHARSET) as file: - self.template_jinja2 = jinja2_env.from_string(file.read()) + with io.open(self.template_path_jinja2, + encoding=settings.FILE_CHARSET) as file_: + self.template_jinja2 = jinja2_env.from_string(file_.read()) def tearDown(self): self.override_settings.__exit__(None, None, None) @@ -88,26 +117,44 @@ 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): - count, result = CompressCommand().compress(log=self.log, verbosity=self.verbosity, engine=engine) - self.assertEqual(1, count) + 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(len(hashes), count) self.assertEqual([ - '' % (self.expected_hash, ), - ], result) + '' % 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: - self._test_offline(engine=engine) + def test_offline_django(self): + if 'django' not in self.engines: + raise SkipTest('This test class does not support django engine.') + self._test_offline(engine='django') + + def test_offline_jinja2(self): + if 'jinja2' not in self.engines: + raise SkipTest('This test class does not support jinja2 engine.') + self._test_offline(engine='jinja2') def _get_jinja2_env(self): import jinja2 @@ -131,151 +178,338 @@ class OfflineTestCaseMixin(object): def _get_jinja2_loader(self): import jinja2 - loader = jinja2.FileSystemLoader(settings.TEMPLATE_DIRS, encoding=settings.FILE_CHARSET) + loader = jinja2.FileSystemLoader( + settings.TEMPLATES[1]['DIRS'], encoding=settings.FILE_CHARSET) return loader -class OfflineGenerationSkipDuplicatesTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_duplicate" +class OfflineCompressBasicTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'basic' + expected_hash = 'f5e179b8eca4' + + @patch.object(CompressCommand, 'compress') + def test_handle_no_args(self, compress_mock): + CompressCommand().handle() + self.assertEqual(compress_mock.call_count, 1) + + @patch.object(CompressCommand, 'compress') + def test_handle_compress_disabled(self, compress_mock): + with self.settings(COMPRESS_ENABLED=False): + with self.assertRaises(CommandError): + CompressCommand().handle() + self.assertEqual(compress_mock.call_count, 0) + + @patch.object(CompressCommand, 'compress') + def test_handle_compress_offline_disabled(self, compress_mock): + with self.settings(COMPRESS_OFFLINE=False): + with self.assertRaises(CommandError): + CompressCommand().handle() + self.assertEqual(compress_mock.call_count, 0) + + @patch.object(CompressCommand, 'compress') + def test_handle_compress_offline_disabled_force(self, compress_mock): + with self.settings(COMPRESS_OFFLINE=False): + CompressCommand().handle(force=True) + self.assertEqual(compress_mock.call_count, 1) + + def test_rendering_without_manifest_raises_exception(self): + # flush cached manifest + flush_offline_manifest() + self.assertRaises(OfflineGenerationError, + self.template.render, Context({})) + + @unittest.skipIf(not _TEST_JINJA2, 'No Jinja2 testing') + def test_rendering_without_manifest_raises_exception_jinja2(self): + # flush cached manifest + flush_offline_manifest() + self.assertRaises(OfflineGenerationError, + self.template_jinja2.render, {}) + + def _test_deleting_manifest_does_not_affect_rendering(self, engine): + count, result = CompressCommand().compress( + log=self.log, verbosity=self.verbosity, engine=engine) + get_offline_manifest() + manifest_path = os.path.join('CACHE', 'manifest.json') + if default_storage.exists(manifest_path): + default_storage.delete(manifest_path) + self.assertEqual(1, count) + self.assertEqual([ + '' % (self.expected_hash, )], result) + rendered_template = self._render_template(engine) + self.assertEqual(rendered_template, ''.join(result) + '\n') + + def test_deleting_manifest_does_not_affect_rendering(self): + for engine in self.engines: + self._test_deleting_manifest_does_not_affect_rendering(engine) + + def test_get_loaders(self): + TEMPLATE_LOADERS = ( + ('django.template.loaders.cached.Loader', ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + )), + ) + with self.settings(TEMPLATE_LOADERS=TEMPLATE_LOADERS): + from django.template.loaders.filesystem import ( + Loader as FileSystemLoader) + from django.template.loaders.app_directories import ( + Loader as AppDirectoriesLoader) + loaders = CompressCommand().get_loaders() + self.assertTrue(isinstance(loaders[0], FileSystemLoader)) + self.assertTrue(isinstance(loaders[1], AppDirectoriesLoader)) + + +class OfflineCompressSkipDuplicatesTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_duplicate' # We don't need to test multiples engines here. - engines = ("django",) + engines = ('django',) def _test_offline(self, engine): - count, result = CompressCommand().compress(log=self.log, verbosity=self.verbosity, engine=engine) + count, result = CompressCommand().compress( + log=self.log, verbosity=self.verbosity, engine=engine) # Only one block compressed, the second identical one was skipped. self.assertEqual(1, count) # Only 1 ', + '', ], result) rendered_template = self._render_template(engine) # But rendering the template returns both (identical) scripts. - self.assertEqual(rendered_template, "".join(result * 2) + "\n") + self.assertEqual(rendered_template, ''.join(result * 2) + '\n') -class OfflineGenerationBlockSuperTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_block_super" - expected_hash = "7c02d201f69d" +class OfflineCompressBlockSuperTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_block_super' + expected_hash = '7c02d201f69d' # Block.super not supported for Jinja2 yet. - engines = ("django",) + engines = ('django',) -class OfflineGenerationBlockSuperMultipleTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_block_super_multiple" - expected_hash = "f8891c416981" +class OfflineCompressBlockSuperMultipleTestCase( + OfflineTestCaseMixin, TestCase): + templates_dir = 'test_block_super_multiple' + expected_hash = 'f8891c416981' # Block.super not supported for Jinja2 yet. - engines = ("django",) + engines = ('django',) -class OfflineGenerationBlockSuperMultipleWithCachedLoaderTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_block_super_multiple_cached" - expected_hash = "2f6ef61c488e" +class OfflineCompressBlockSuperMultipleCachedLoaderTestCase( + OfflineTestCaseMixin, TestCase): + templates_dir = 'test_block_super_multiple_cached' + expected_hash = '2f6ef61c488e' # Block.super not supported for Jinja2 yet. - engines = ("django",) - - def setUp(self): - self._old_template_loaders = settings.TEMPLATE_LOADERS - settings.TEMPLATE_LOADERS = ( + engines = ('django',) + additional_test_settings = { + 'TEMPLATE_LOADERS': ( ('django.template.loaders.cached.Loader', ( 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', )), ) - super(OfflineGenerationBlockSuperMultipleWithCachedLoaderTestCase, self).setUp() - - def tearDown(self): - super(OfflineGenerationBlockSuperMultipleWithCachedLoaderTestCase, self).tearDown() - settings.TEMPLATE_LOADERS = self._old_template_loaders + } -class OfflineGenerationBlockSuperTestCaseWithExtraContent(OfflineTestCaseMixin, TestCase): - templates_dir = "test_block_super_extra" +class OfflineCompressBlockSuperTestCaseWithExtraContent( + OfflineTestCaseMixin, TestCase): + templates_dir = 'test_block_super_extra' # Block.super not supported for Jinja2 yet. - engines = ("django",) + engines = ('django',) def _test_offline(self, engine): - 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(2, count) self.assertEqual([ - '', - '' + '', + '', ], result) rendered_template = self._render_template(engine) - self.assertEqual(rendered_template, "".join(result) + "\n") - + self.assertEqual(rendered_template, ''.join(result) + '\n') -class OfflineGenerationConditionTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_condition" - expected_hash = "4e3758d50224" - def setUp(self): - self.old_offline_context = settings.COMPRESS_OFFLINE_CONTEXT - settings.COMPRESS_OFFLINE_CONTEXT = { +class OfflineCompressConditionTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_condition' + expected_hash = '4e3758d50224' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { 'condition': 'red', } - super(OfflineGenerationConditionTestCase, self).setUp() + } - def tearDown(self): - self.COMPRESS_OFFLINE_CONTEXT = self.old_offline_context - super(OfflineGenerationConditionTestCase, self).tearDown() +class OfflineCompressTemplateTagTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_templatetag' + expected_hash = 'a27e1d3a619a' -class OfflineGenerationTemplateTagTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_templatetag" - expected_hash = "a27e1d3a619a" +class OfflineCompressStaticTemplateTagTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_static_templatetag' + expected_hash = 'dfa2bb387fa8' -class OfflineGenerationStaticTemplateTagTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_static_templatetag" - expected_hash = "dfa2bb387fa8" +class OfflineCompressTestCaseWithContext(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context' + expected_hash = '5838e2fd66af' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { + 'content': 'OK!', + } + } -class OfflineGenerationTestCaseWithContext(OfflineTestCaseMixin, TestCase): - templates_dir = "test_with_context" - expected_hash = "5838e2fd66af" - def setUp(self): - self.old_offline_context = settings.COMPRESS_OFFLINE_CONTEXT - settings.COMPRESS_OFFLINE_CONTEXT = { +class OfflineCompressTestCaseWithContextSuper(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context_super' + expected_hash = 'b1d0a333a4ef' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { 'content': 'OK!', } - super(OfflineGenerationTestCaseWithContext, self).setUp() + } + # Block.super not supported for Jinja2 yet. + engines = ('django',) + + +class OfflineCompressTestCaseWithContextList(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context' + expected_hash = ['f8bcaea049b3', 'db12749b1e80', 'e9f4a0054a06'] + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': list(offline_context_generator()) + } + + 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 OfflineCompressTestCaseWithContextListSuper( + OfflineCompressTestCaseWithContextList): + templates_dir = 'test_with_context_super' + expected_hash = ['b11543f1e174', 'aedf6d2a7ec7', '0dbb8c29f23a'] + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': list(offline_context_generator()) + } + # Block.super not supported for Jinja2 yet. + engines = ('django',) + + +class OfflineCompressTestCaseWithContextGenerator( + OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context' + expected_hash = ['f8bcaea049b3', 'db12749b1e80', 'e9f4a0054a06'] + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': 'compressor.tests.test_offline.' + 'offline_context_generator' + } + + 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 OfflineCompressTestCaseWithContextGeneratorSuper( + OfflineCompressTestCaseWithContextGenerator): + templates_dir = 'test_with_context_super' + expected_hash = ['b11543f1e174', 'aedf6d2a7ec7', '0dbb8c29f23a'] + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': 'compressor.tests.test_offline.' + 'offline_context_generator' + } + # Block.super not supported for Jinja2 yet. + engines = ('django',) - def tearDown(self): - settings.COMPRESS_OFFLINE_CONTEXT = self.old_offline_context - super(OfflineGenerationTestCaseWithContext, self).tearDown() +class OfflineCompressTestCaseWithContextGeneratorImportError( + OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context' -class OfflineGenerationTestCaseErrors(OfflineTestCaseMixin, TestCase): - templates_dir = "test_error_handling" + def _test_offline(self, engine): + # Test that we are properly generating ImportError when + # COMPRESS_OFFLINE_CONTEXT looks like a function but can't be imported + # for whatever reason. + + with self.settings( + COMPRESS_OFFLINE_CONTEXT='invalid_mod.invalid_func'): + # Path with invalid module name -- ImportError: + self.assertRaises( + ImportError, CompressCommand().compress, engine=engine) + + with self.settings(COMPRESS_OFFLINE_CONTEXT='compressor'): + # Valid module name only without function -- AttributeError: + self.assertRaises( + ImportError, CompressCommand().compress, engine=engine) + + with self.settings( + COMPRESS_OFFLINE_CONTEXT='compressor.tests.invalid_function'): + # Path with invalid function name -- AttributeError: + self.assertRaises( + ImportError, CompressCommand().compress, engine=engine) + + with self.settings( + COMPRESS_OFFLINE_CONTEXT='compressor.tests.test_offline'): + # Path without function attempts call on module -- TypeError: + self.assertRaises( + ImportError, CompressCommand().compress, engine=engine) + + valid_path = 'compressor.tests.test_offline.offline_context_generator' + with self.settings(COMPRESS_OFFLINE_CONTEXT=valid_path): + # Valid path to generator function -- no ImportError: + + try: + CompressCommand().compress(engine=engine) + except ImportError: + self.fail('Valid path to offline context generator must' + ' not raise ImportError.') + + +class OfflineCompressTestCaseErrors(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_error_handling' def _test_offline(self, engine): - count, result = CompressCommand().compress(log=self.log, verbosity=self.verbosity, engine=engine) + count, result = CompressCommand().compress( + log=self.log, verbosity=self.verbosity, engine=engine) - if engine == "django": + if engine == 'django': self.assertEqual(2, count) else: # Because we use env.parse in Jinja2Parser, the engine does not - # actually load the "extends" and "includes" templates, and so - # it is unable to detect that they are missing. So all the "compress" - # nodes are processed correctly. + # actually load the 'extends' and 'includes' templates, and so + # it is unable to detect that they are missing. So all the + # 'compress' nodes are processed correctly. self.assertEqual(4, count) - self.assertEqual(engine, "jinja2") - self.assertIn('', result) - self.assertIn('', result) - - self.assertIn('', result) - self.assertIn('', result) - - -class OfflineGenerationTestCaseWithError(OfflineTestCaseMixin, TestCase): + self.assertEqual(engine, 'jinja2') + self.assertIn( + '', result) + self.assertIn( + '', result) + + self.assertIn( + '', result) + self.assertIn( + '', result) + + +class OfflineCompressTestCaseWithError(OfflineTestCaseMixin, TestCase): templates_dir = 'test_error_handling' - - def setUp(self): - self._old_compress_precompilers = settings.COMPRESS_PRECOMPILERS - settings.COMPRESS_PRECOMPILERS = (('text/coffeescript', 'non-existing-binary'),) - super(OfflineGenerationTestCaseWithError, self).setUp() + additional_test_settings = { + 'COMPRESS_PRECOMPILERS': (('text/coffeescript', 'nonexisting-binary'),) + } def _test_offline(self, engine): """ @@ -283,219 +517,115 @@ class OfflineGenerationTestCaseWithError(OfflineTestCaseMixin, TestCase): True, as otherwise errors in configuration will never show in production. """ - self._old_debug = settings.DEBUG - - try: - settings.DEBUG = True - self.assertRaises(CommandError, CompressCommand().compress, engine=engine) - - settings.DEBUG = False - self.assertRaises(CommandError, CompressCommand().compress, engine=engine) - - finally: - settings.DEBUG = self._old_debug - - def tearDown(self): - settings.COMPRESS_PRECOMPILERS = self._old_compress_precompilers - super(OfflineGenerationTestCaseWithError, self).tearDown() - - -class OfflineGenerationTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "basic" - expected_hash = "f5e179b8eca4" - - def test_rendering_without_manifest_raises_exception(self): - # flush cached manifest - flush_offline_manifest() - self.assertRaises(OfflineGenerationError, - self.template.render, Context({})) - - @unittest.skipIf(not _TEST_JINJA2, "No Jinja2 testing") - def test_rendering_without_manifest_raises_exception_jinja2(self): - # flush cached manifest - flush_offline_manifest() - self.assertRaises(OfflineGenerationError, - self.template_jinja2.render, {}) - - def _test_deleting_manifest_does_not_affect_rendering(self, engine): - count, result = CompressCommand().compress(log=self.log, verbosity=self.verbosity, engine=engine) - get_offline_manifest() - manifest_path = os.path.join('CACHE', 'manifest.json') - if default_storage.exists(manifest_path): - default_storage.delete(manifest_path) - self.assertEqual(1, count) - self.assertEqual([ - '' % (self.expected_hash, ), - ], result) - rendered_template = self._render_template(engine) - self.assertEqual(rendered_template, "".join(result) + "\n") + with self.settings(DEBUG=True): + self.assertRaises( + CommandError, CompressCommand().compress, engine=engine) + + with self.settings(DEBUG=False): + self.assertRaises( + CommandError, CompressCommand().compress, engine=engine) + + +class OfflineCompressEmptyTag(OfflineTestCaseMixin, TestCase): + """ + In case of a compress template tag with no content, an entry + will be added to the manifest with an empty string as value. + This test makes sure there is no recompression happening when + compressor encounters such an emptystring in the manifest. + """ + templates_dir = 'basic' + expected_hash = 'f5e179b8eca4' + engines = ('django',) - def test_deleting_manifest_does_not_affect_rendering(self): - for engine in self.engines: - self._test_deleting_manifest_does_not_affect_rendering(engine) - - def test_requires_model_validation(self): - self.assertFalse(CompressCommand.requires_model_validation) - - def test_get_loaders(self): - old_loaders = settings.TEMPLATE_LOADERS - settings.TEMPLATE_LOADERS = ( - ('django.template.loaders.cached.Loader', ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - )), - ) - try: - from django.template.loaders.filesystem import Loader as FileSystemLoader - from django.template.loaders.app_directories import Loader as AppDirectoriesLoader - except ImportError: - pass - else: - loaders = CompressCommand().get_loaders() - self.assertTrue(isinstance(loaders[0], FileSystemLoader)) - self.assertTrue(isinstance(loaders[1], AppDirectoriesLoader)) - finally: - settings.TEMPLATE_LOADERS = old_loaders + def _test_offline(self, engine): + count, result = CompressCommand().compress( + log=self.log, verbosity=self.verbosity, engine=engine) + manifest = get_offline_manifest() + manifest[list(manifest)[0]] = '' + self.assertEqual(self._render_template(engine), '\n') -class OfflineGenerationBlockSuperBaseCompressed(OfflineTestCaseMixin, TestCase): - template_names = ["base.html", "base2.html", "test_compressor_offline.html"] +class OfflineCompressBlockSuperBaseCompressed(OfflineTestCaseMixin, TestCase): + template_names = ['base.html', 'base2.html', + 'test_compressor_offline.html'] templates_dir = 'test_block_super_base_compressed' expected_hash = ['028c3fc42232', '2e9d3f5545a6', 'f8891c416981'] # Block.super not supported for Jinja2 yet. - engines = ("django",) + engines = ('django',) def setUp(self): - super(OfflineGenerationBlockSuperBaseCompressed, self).setUp() + super(OfflineCompressBlockSuperBaseCompressed, self).setUp() self.template_paths = [] self.templates = [] for template_name in self.template_names: - template_path = os.path.join(settings.TEMPLATE_DIRS[0], template_name) + template_path = os.path.join( + settings.TEMPLATES[0]['DIRS'][0], template_name) self.template_paths.append(template_path) - with io.open(template_path, encoding=settings.FILE_CHARSET) as file: - template = Template(file.read()) + with io.open(template_path, + encoding=settings.FILE_CHARSET) as file_: + template = Template(file_.read()) self.templates.append(template) def _render_template(self, template, engine): - if engine == "django": + if engine == 'django': return template.render(Context(settings.COMPRESS_OFFLINE_CONTEXT)) - elif engine == "jinja2": - return template.render(settings.COMPRESS_OFFLINE_CONTEXT) + "\n" + elif engine == 'jinja2': + return template.render(settings.COMPRESS_OFFLINE_CONTEXT) + '\n' else: return None def _test_offline(self, engine): - 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(len(self.expected_hash), count) for expected_hash, template in zip(self.expected_hash, self.templates): - expected_output = '' % (expected_hash, ) - self.assertIn(expected_output, result) + expected = ('' % (expected_hash, )) + self.assertIn(expected, result) rendered_template = self._render_template(template, engine) - self.assertEqual(rendered_template, expected_output + '\n') - + self.assertEqual(rendered_template, expected + '\n') -class OfflineGenerationInlineNonAsciiTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_inline_non_ascii" - def setUp(self): - self.old_offline_context = settings.COMPRESS_OFFLINE_CONTEXT - settings.COMPRESS_OFFLINE_CONTEXT = { +class OfflineCompressInlineNonAsciiTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_inline_non_ascii' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { 'test_non_ascii_value': '\u2014', } - super(OfflineGenerationInlineNonAsciiTestCase, self).setUp() - - def tearDown(self): - self.COMPRESS_OFFLINE_CONTEXT = self.old_offline_context - super(OfflineGenerationInlineNonAsciiTestCase, self).tearDown() + } def _test_offline(self, engine): - count, result = CompressCommand().compress(log=self.log, verbosity=self.verbosity, engine=engine) + count, result = CompressCommand().compress( + log=self.log, verbosity=self.verbosity, engine=engine) rendered_template = self._render_template(engine) - self.assertEqual(rendered_template, "".join(result) + "\n") + self.assertEqual(rendered_template, ''.join(result) + '\n') -class OfflineGenerationComplexTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_complex" - - def setUp(self): - self.old_offline_context = settings.COMPRESS_OFFLINE_CONTEXT - settings.COMPRESS_OFFLINE_CONTEXT = { +class OfflineCompressComplexTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_complex' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { 'condition': 'OK!', # Django templating does not allow definition of tuples in the - # templates. Make sure this is same as test_templates_jinja2/test_complex. - 'my_names': ("js/one.js", "js/nonasc.js"), + # templates. + # Make sure this is same as test_templates_jinja2/test_complex. + 'my_names': ('js/one.js', 'js/nonasc.js'), } - super(OfflineGenerationComplexTestCase, self).setUp() - - def tearDown(self): - self.COMPRESS_OFFLINE_CONTEXT = self.old_offline_context - super(OfflineGenerationComplexTestCase, self).tearDown() + } def _test_offline(self, engine): - 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(3, count) self.assertEqual([ - '', - '', - '', + '', + '', + '', ], result) rendered_template = self._render_template(engine) result = (result[0], result[2]) - self.assertEqual(rendered_template, "".join(result) + "\n") - - -# Coffin does not work on Python 3.2+ due to: -# The line at coffin/template/__init__.py:15 -# from library import * -# causing 'ImportError: No module named library'. -# It seems there is no evidence nor indicated support for Python 3+. -@unittest.skipIf(sys.version_info >= (3, 2), - "Coffin does not support 3.2+") -@unittest.skipIf(django.VERSION >= (1, 8), - "Import error on 1.8") -class OfflineGenerationCoffinTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_coffin" - expected_hash = "32c8281e3346" - engines = ("jinja2",) - - def _get_jinja2_env(self): - import jinja2 - from coffin.common import env - from compressor.contrib.jinja2ext import CompressorExtension - - # Could have used the env.add_extension method, but it's only available - # in Jinja2 v2.5 - new_env = jinja2.Environment(extensions=[CompressorExtension]) - env.extensions.update(new_env.extensions) - - return env - - -# Jingo does not work when using Python 3.2 due to the use of Unicode string -# prefix (and possibly other stuff), but it actually works when using Python 3.3 -# since it tolerates the use of the Unicode string prefix. Python 3.3 support -# is also evident in its tox.ini file. -@unittest.skipIf(sys.version_info >= (3, 2) and sys.version_info < (3, 3), - "Jingo does not support 3.2") -@unittest.skipIf(django.VERSION >= (1, 8), - "Import error on 1.8") -class OfflineGenerationJingoTestCase(OfflineTestCaseMixin, TestCase): - templates_dir = "test_jingo" - expected_hash = "61ec584468eb" - engines = ("jinja2",) - - def _get_jinja2_env(self): - import jinja2 - import jinja2.ext - from jingo import env - from compressor.contrib.jinja2ext import CompressorExtension - from compressor.offline.jinja2 import SpacelessExtension, url_for - - # Could have used the env.add_extension method, but it's only available - # in Jinja2 v2.5 - new_env = jinja2.Environment(extensions=[CompressorExtension, SpacelessExtension, jinja2.ext.with_]) - env.extensions.update(new_env.extensions) - env.globals['url_for'] = url_for - - return env + self.assertEqual(rendered_template, ''.join(result) + '\n') diff --git a/python-django-compressor/compressor/tests/test_parsers.py b/python-django-compressor/compressor/tests/test_parsers.py index d9b4dd6..6b37503 100644 --- a/python-django-compressor/compressor/tests/test_parsers.py +++ b/python-django-compressor/compressor/tests/test_parsers.py @@ -1,5 +1,6 @@ from __future__ import with_statement import os +import unittest try: import lxml @@ -11,12 +12,6 @@ try: except ImportError: html5lib = None -try: - from BeautifulSoup import BeautifulSoup -except ImportError: - BeautifulSoup = None - -from django.utils import unittest from django.test.utils import override_settings from compressor.base import SOURCE_HUNK, SOURCE_FILE @@ -26,12 +21,12 @@ from compressor.tests.test_base import CompressorTestCase class ParserTestCase(object): def setUp(self): - self.old_parser = settings.COMPRESS_PARSER - settings.COMPRESS_PARSER = self.parser_cls + self.override_settings = self.settings(COMPRESS_PARSER=self.parser_cls) + self.override_settings.__enter__() super(ParserTestCase, self).setUp() def tearDown(self): - settings.COMPRESS_PARSER = self.old_parser + self.override_settings.__exit__(None, None, None) @unittest.skipIf(lxml is None, 'lxml not found') @@ -101,8 +96,8 @@ class Html5LibParserTests(ParserTestCase, CompressorTestCase): split[1][3].attrib, split[1][3].text)) + @override_settings(COMPRESS_ENABLED=False) def test_css_return_if_off(self): - settings.COMPRESS_ENABLED = False # Yes, they are semantically equal but attributes might be # scrambled in unpredictable order. A more elaborate check # would require parsing both arguments with a different parser @@ -116,9 +111,46 @@ class Html5LibParserTests(ParserTestCase, CompressorTestCase): self.assertEqual(len(self.js), len(self.js_node.output())) -@unittest.skipIf(BeautifulSoup is None, 'BeautifulSoup not found') class BeautifulSoupParserTests(ParserTestCase, CompressorTestCase): parser_cls = 'compressor.parser.BeautifulSoupParser' + # just like in the Html5LibParserTests, provide special tests because + # in bs4 attributes are held in dictionaries + + def test_css_split(self): + split = self.css_node.split_contents() + out0 = ( + SOURCE_FILE, + os.path.join(settings.COMPRESS_ROOT, 'css', 'one.css'), + 'css/one.css', + None, + None, + ) + self.assertEqual(out0, split[0][:3] + (split[0][3].tag, + split[0][3].attrib)) + out1 = ( + SOURCE_HUNK, + 'p { border:5px solid green;}', + None, + '', + ) + self.assertEqual(out1, split[1][:3] + + (self.css_node.parser.elem_str(split[1][3]),)) + out2 = ( + SOURCE_FILE, + os.path.join(settings.COMPRESS_ROOT, 'css', 'two.css'), + 'css/two.css', + None, + None, + ) + self.assertEqual(out2, split[2][:3] + (split[2][3].tag, + split[2][3].attrib)) + + @override_settings(COMPRESS_ENABLED=False) + def test_css_return_if_off(self): + # in addition to unspecified attribute order, + # bs4 output doesn't have the extra space, so we add that here + fixed_output = self.css_node.output().replace('"/>', '" />') + self.assertEqual(len(self.css), len(fixed_output)) class HtmlParserTests(ParserTestCase, CompressorTestCase): diff --git a/python-django-compressor/compressor/tests/test_signals.py b/python-django-compressor/compressor/tests/test_signals.py index 13d5eed..e3645b8 100644 --- a/python-django-compressor/compressor/tests/test_signals.py +++ b/python-django-compressor/compressor/tests/test_signals.py @@ -1,18 +1,20 @@ from django.test import TestCase +from django.test.utils import override_settings from mock import Mock -from compressor.conf import settings from compressor.css import CssCompressor from compressor.js import JsCompressor from compressor.signals import post_compress +@override_settings( + COMPRESS_ENABLED=True, + COMPRESS_PRECOMPILERS=(), + COMPRESS_DEBUG_TOGGLE='nocompress' +) class PostCompressSignalTestCase(TestCase): def setUp(self): - settings.COMPRESS_ENABLED = True - settings.COMPRESS_PRECOMPILERS = () - settings.COMPRESS_DEBUG_TOGGLE = 'nocompress' self.css = """\ diff --git a/python-django-compressor/compressor/tests/test_storages.py b/python-django-compressor/compressor/tests/test_storages.py index 91a36f2..9bbc026 100644 --- a/python-django-compressor/compressor/tests/test_storages.py +++ b/python-django-compressor/compressor/tests/test_storages.py @@ -5,6 +5,7 @@ import os from django.core.files.base import ContentFile from django.core.files.storage import get_storage_class from django.test import TestCase +from django.test.utils import override_settings from django.utils.functional import LazyObject from compressor import storage @@ -18,16 +19,14 @@ class GzipStorage(LazyObject): self._wrapped = get_storage_class('compressor.storage.GzipCompressorFileStorage')() +@override_settings(COMPRESS_ENABLED=True) class StorageTestCase(TestCase): def setUp(self): - self.old_enabled = settings.COMPRESS_ENABLED - settings.COMPRESS_ENABLED = True self.default_storage = storage.default_storage storage.default_storage = GzipStorage() def tearDown(self): storage.default_storage = self.default_storage - settings.COMPRESS_ENABLED = self.old_enabled def test_gzip_storage(self): storage.default_storage.save('test.txt', ContentFile('yeah yeah')) diff --git a/python-django-compressor/compressor/tests/test_templates/test_with_context_super/base.html b/python-django-compressor/compressor/tests/test_templates/test_with_context_super/base.html new file mode 100644 index 0000000..4de1806 --- /dev/null +++ b/python-django-compressor/compressor/tests/test_templates/test_with_context_super/base.html @@ -0,0 +1,7 @@ +{% spaceless %} +{% block js %} + +{% endblock %} +{% endspaceless %} diff --git a/python-django-compressor/compressor/tests/test_templates/test_with_context_super/test_compressor_offline.html b/python-django-compressor/compressor/tests/test_templates/test_with_context_super/test_compressor_offline.html new file mode 100644 index 0000000..9a144e7 --- /dev/null +++ b/python-django-compressor/compressor/tests/test_templates/test_with_context_super/test_compressor_offline.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% load compress %} + +{% block js %}{% spaceless %} + {% compress js %} + {{ block.super }} + + {% endcompress %} +{% endspaceless %}{% endblock %} + +{% block css %}{% endblock %} diff --git a/python-django-compressor/compressor/tests/test_templatetags.py b/python-django-compressor/compressor/tests/test_templatetags.py index db0d1b7..f41194a 100644 --- a/python-django-compressor/compressor/tests/test_templatetags.py +++ b/python-django-compressor/compressor/tests/test_templatetags.py @@ -13,27 +13,27 @@ from compressor.conf import settings from compressor.signals import post_compress from compressor.tests.test_base import css_tag, test_dir +from sekizai.context import SekizaiContext -def render(template_string, context_dict=None): + +def render(template_string, context_dict=None, context=None): """ A shortcut for testing template output. """ if context_dict is None: context_dict = {} - c = Context(context_dict) + if context is None: + context = Context + c = context(context_dict) t = Template(template_string) return t.render(c).strip() +@override_settings(COMPRESS_ENABLED=True) class TemplatetagTestCase(TestCase): def setUp(self): - self.old_enabled = settings.COMPRESS_ENABLED - settings.COMPRESS_ENABLED = True self.context = {'STATIC_URL': settings.COMPRESS_URL} - def tearDown(self): - settings.COMPRESS_ENABLED = self.old_enabled - def test_empty_tag(self): template = """{% load compress %}{% compress js %}{% block js %} {% endblock %}{% endcompress %}""" @@ -130,25 +130,34 @@ class TemplatetagTestCase(TestCase): context = kwargs['context'] self.assertEqual('foo', context['compressed']['name']) + def test_sekizai_only_once(self): + template = """{% load sekizai_tags %}{% addtoblock "js" %} + + {% endaddtoblock %}{% render_block "js" postprocessor "compressor.contrib.sekizai.compress" %} + """ + out = '' + self.assertEqual(out, render(template, self.context, SekizaiContext)) + class PrecompilerTemplatetagTestCase(TestCase): def setUp(self): - self.old_enabled = settings.COMPRESS_ENABLED - self.old_precompilers = settings.COMPRESS_PRECOMPILERS - precompiler = os.path.join(test_dir, 'precompiler.py') python = sys.executable - settings.COMPRESS_ENABLED = True - settings.COMPRESS_PRECOMPILERS = ( - ('text/coffeescript', '%s %s' % (python, precompiler)), - ('text/less', '%s %s' % (python, precompiler)), - ) + override_settings = { + 'COMPRESS_ENABLED': True, + 'COMPRESS_PRECOMPILERS': ( + ('text/coffeescript', '%s %s' % (python, precompiler)), + ('text/less', '%s %s' % (python, precompiler)), + ) + } + self.override_settings = self.settings(**override_settings) + self.override_settings.__enter__() + self.context = {'STATIC_URL': settings.COMPRESS_URL} def tearDown(self): - settings.COMPRESS_ENABLED = self.old_enabled - settings.COMPRESS_PRECOMPILERS = self.old_precompilers + self.override_settings.__exit__(None, None, None) def test_compress_coffeescript_tag(self): template = """{% load compress %}{% compress js %} diff --git a/python-django-compressor/compressor/tests/test_utils.py b/python-django-compressor/compressor/tests/test_utils.py new file mode 100644 index 0000000..a275f0e --- /dev/null +++ b/python-django-compressor/compressor/tests/test_utils.py @@ -0,0 +1,46 @@ +from django.test import TestCase +from django.test.utils import override_settings +from django.conf import settings +import django.contrib.staticfiles.finders +import django + +import compressor.utils.staticfiles + +from imp import reload + + +def get_apps_without_staticfiles(apps): + return [x for x in apps if x != 'django.contrib.staticfiles'] + + +def get_apps_with_staticfiles_using_appconfig(apps): + return get_apps_without_staticfiles(apps) + [ + 'django.contrib.staticfiles.apps.StaticFilesConfig', + ] + + +class StaticFilesTestCase(TestCase): + + def test_has_finders_from_staticfiles(self): + self.assertTrue(compressor.utils.staticfiles.finders is + django.contrib.staticfiles.finders) + + def test_has_finders_from_staticfiles_if_configured_per_appconfig(self): + apps = get_apps_with_staticfiles_using_appconfig( + settings.INSTALLED_APPS) + try: + with override_settings(INSTALLED_APPS=apps): + reload(compressor.utils.staticfiles) + self.assertTrue(compressor.utils.staticfiles.finders is + django.contrib.staticfiles.finders) + finally: + reload(compressor.utils.staticfiles) + + def test_finders_is_none_if_staticfiles_is_not_installed(self): + apps = get_apps_without_staticfiles(settings.INSTALLED_APPS) + try: + with override_settings(INSTALLED_APPS=apps): + reload(compressor.utils.staticfiles) + self.assertTrue(compressor.utils.staticfiles.finders is None) + finally: + reload(compressor.utils.staticfiles) diff --git a/python-django-compressor/compressor/utils/staticfiles.py b/python-django-compressor/compressor/utils/staticfiles.py index 2d9ed00..09a8a9b 100644 --- a/python-django-compressor/compressor/utils/staticfiles.py +++ b/python-django-compressor/compressor/utils/staticfiles.py @@ -1,10 +1,12 @@ from __future__ import absolute_import, unicode_literals +from django.apps import apps from django.core.exceptions import ImproperlyConfigured from compressor.conf import settings -if "django.contrib.staticfiles" in settings.INSTALLED_APPS: + +if apps.is_installed("django.contrib.staticfiles"): from django.contrib.staticfiles import finders # noqa if ("compressor.finders.CompressorFinder" diff --git a/python-django-compressor/compressor/utils/stringformat.py b/python-django-compressor/compressor/utils/stringformat.py deleted file mode 100644 index 9311e78..0000000 --- a/python-django-compressor/compressor/utils/stringformat.py +++ /dev/null @@ -1,260 +0,0 @@ -# -*- coding: utf-8 -*- -"""Advanced string formatting for Python >= 2.4. - -An implementation of the advanced string formatting (PEP 3101). - -Author: Florent Xicluna -""" - -from __future__ import unicode_literals - -import re - -from django.utils import six - -_format_str_re = re.compile( - r'((?=^])?)' # alignment - r'([-+ ]?)' # sign - r'(#?)' r'(\d*)' r'(,?)' # base prefix, minimal width, thousands sep - r'((?:\.\d+)?)' # precision - r'(.?)$' # type -) -_field_part_re = re.compile( - r'(?:(\[)|\.|^)' # start or '.' or '[' - r'((?(1)[^]]*|[^.[]*))' # part - r'(?(1)(?:\]|$)([^.[]+)?)' # ']' and invalid tail -) - -_format_str_sub = _format_str_re.sub - - -def _is_integer(value): - return hasattr(value, '__index__') - - -def _strformat(value, format_spec=""): - """Internal string formatter. - - It implements the Format Specification Mini-Language. - """ - m = _format_spec_re.match(str(format_spec)) - if not m: - raise ValueError('Invalid conversion specification') - align, sign, prefix, width, comma, precision, conversion = m.groups() - is_numeric = hasattr(value, '__float__') - is_integer = is_numeric and _is_integer(value) - if prefix and not is_integer: - raise ValueError('Alternate form (#) not allowed in %s format ' - 'specifier' % (is_numeric and 'float' or 'string')) - if is_numeric and conversion == 'n': - # Default to 'd' for ints and 'g' for floats - conversion = is_integer and 'd' or 'g' - elif sign: - if not is_numeric: - raise ValueError("Sign not allowed in string format specifier") - if conversion == 'c': - raise ValueError("Sign not allowed with integer " - "format specifier 'c'") - if comma: - # TODO: thousand separator - pass - try: - if ((is_numeric and conversion == 's') or (not is_integer and conversion in set('cdoxX'))): - raise ValueError - if conversion == 'c': - conversion = 's' - value = chr(value % 256) - rv = ('%' + prefix + precision + (conversion or 's')) % (value,) - except ValueError: - raise ValueError("Unknown format code %r for object of type %r" % - (conversion, value.__class__.__name__)) - if sign not in '-' and value >= 0: - # sign in (' ', '+') - rv = sign + rv - if width: - zero = (width[0] == '0') - width = int(width) - else: - zero = False - width = 0 - # Fastpath when alignment is not required - if width <= len(rv): - if not is_numeric and (align == '=' or (zero and not align)): - raise ValueError("'=' alignment not allowed in string format " - "specifier") - return rv - fill, align = align[:-1], align[-1:] - if not fill: - fill = zero and '0' or ' ' - if align == '^': - padding = width - len(rv) - # tweak the formatting if the padding is odd - if padding % 2: - rv += fill - rv = rv.center(width, fill) - elif align == '=' or (zero and not align): - if not is_numeric: - raise ValueError("'=' alignment not allowed in string format " - "specifier") - if value < 0 or sign not in '-': - rv = rv[0] + rv[1:].rjust(width - 1, fill) - else: - rv = rv.rjust(width, fill) - elif align in ('>', '=') or (is_numeric and not align): - # numeric value right aligned by default - rv = rv.rjust(width, fill) - else: - rv = rv.ljust(width, fill) - return rv - - -def _format_field(value, parts, conv, spec, want_bytes=False): - """Format a replacement field.""" - for k, part, _ in parts: - if k: - if part.isdigit(): - value = value[int(part)] - else: - value = value[part] - else: - value = getattr(value, part) - if conv: - value = ((conv == 'r') and '%r' or '%s') % (value,) - if hasattr(value, '__format__'): - value = value.__format__(spec) - elif hasattr(value, 'strftime') and spec: - value = value.strftime(str(spec)) - else: - value = _strformat(value, spec) - if want_bytes and isinstance(value, six.text_type): - return str(value) - return value - - -class FormattableString(object): - """Class which implements method format(). - - The method format() behaves like str.format() in python 2.6+. - - >>> FormattableString('{a:5}').format(a=42) - ... # Same as '{a:5}'.format(a=42) - ' 42' - - """ - - __slots__ = '_index', '_kwords', '_nested', '_string', 'format_string' - - def __init__(self, format_string): - self._index = 0 - self._kwords = {} - self._nested = {} - - self.format_string = format_string - self._string = _format_str_sub(self._prepare, format_string) - - def __eq__(self, other): - if isinstance(other, FormattableString): - return self.format_string == other.format_string - # Compare equal with the original string. - return self.format_string == other - - def _prepare(self, match): - # Called for each replacement field. - part = match.group(0) - if part[0] == part[-1]: - # '{{' or '}}' - assert part == part[0] * len(part) - return part[:len(part) // 2] - repl = part[1:-1] - field, _, format_spec = repl.partition(':') - literal, sep, conversion = field.partition('!') - if sep and not conversion: - raise ValueError("end of format while looking for " - "conversion specifier") - if len(conversion) > 1: - raise ValueError("expected ':' after format specifier") - if conversion not in 'rsa': - raise ValueError("Unknown conversion specifier %s" % - str(conversion)) - name_parts = _field_part_re.findall(literal) - if literal[:1] in '.[': - # Auto-numbering - if self._index is None: - raise ValueError("cannot switch from manual field " - "specification to automatic field numbering") - name = str(self._index) - self._index += 1 - if not literal: - del name_parts[0] - else: - name = name_parts.pop(0)[1] - if name.isdigit() and self._index is not None: - # Manual specification - if self._index: - raise ValueError("cannot switch from automatic field " - "numbering to manual field specification") - self._index = None - empty_attribute = False - for k, v, tail in name_parts: - if not v: - empty_attribute = True - if tail: - raise ValueError("Only '.' or '[' may follow ']' " - "in format field specifier") - if name_parts and k == '[' and not literal[-1] == ']': - raise ValueError("Missing ']' in format string") - if empty_attribute: - raise ValueError("Empty attribute in format string") - if '{' in format_spec: - format_spec = _format_sub_re.sub(self._prepare, format_spec) - rv = (name_parts, conversion, format_spec) - self._nested.setdefault(name, []).append(rv) - else: - rv = (name_parts, conversion, format_spec) - self._kwords.setdefault(name, []).append(rv) - return r'%%(%s)s' % id(rv) - - def format(self, *args, **kwargs): - """Same as str.format() and unicode.format() in Python 2.6+.""" - if args: - kwargs.update(dict((str(i), value) - for (i, value) in enumerate(args))) - # Encode arguments to ASCII, if format string is bytes - want_bytes = isinstance(self._string, str) - params = {} - for name, items in self._kwords.items(): - value = kwargs[name] - for item in items: - parts, conv, spec = item - params[str(id(item))] = _format_field(value, parts, conv, spec, - want_bytes) - for name, items in self._nested.items(): - value = kwargs[name] - for item in items: - parts, conv, spec = item - spec = spec % params - params[str(id(item))] = _format_field(value, parts, conv, spec, - want_bytes) - return self._string % params - - -def selftest(): - import datetime - F = FormattableString - - assert F("{0:{width}.{precision}s}").format('hello world', - width=8, precision=5) == 'hello ' - - d = datetime.date(2010, 9, 7) - assert F("The year is {0.year}").format(d) == "The year is 2010" - assert F("Tested on {0:%Y-%m-%d}").format(d) == "Tested on 2010-09-07" - print('Test successful') - -if __name__ == '__main__': - selftest() diff --git a/python-django-compressor/docs/changelog.txt b/python-django-compressor/docs/changelog.txt index 07864df..bf40c16 100644 --- a/python-django-compressor/docs/changelog.txt +++ b/python-django-compressor/docs/changelog.txt @@ -1,10 +1,58 @@ Changelog ========= +v2.0 (01/07/2015) +----------------- + +`Full Changelog `_ + +- Add Django 1.9 compatibility + +- Remove official support for Django 1.4 and 1.7 + +- Add official support for Python 3.5 + +- Remove official support for Python 2.6 + +- Remove support for coffin and jingo + +- Fix Jinja2 compatibility for Django 1.8+ + +- Stop bundling vendored versions of rcssmin and rjsmin, make them proper dependencies + +- Remove support for CSSTidy + +- Remove support for beautifulsoup 3. + +- Replace cssmin by csscompressor (cssmin is still available for backwards-compatibility but points to rcssmin) + + +v1.6 (11/19/2015) +----------------- + +`Full Changelog `_ + +- Upgrade rcssmin and rjsmin + +- Apply CssAbsoluteFilter to precompiled css even when compression is disabled + +- Add optional caching to CompilerFilter to avoid re-compiling unchanged files + +- Fix various deprecation warnings on Django 1.7 / 1.8 + +- Fix TemplateFilter + +- Fix double-rendering bug with sekizai extension + +- Fix debug mode using destination directory instead of staticfiles finders first + +- Removed some silent exception catching in compress command + + v1.5 (03/27/2015) ----------------- -`Full Changelog `_ +`Full Changelog `_ - Fix compress command and run automated tests for Django 1.8 @@ -57,7 +105,7 @@ v1.4 (06/20/2014) - Made ``CssCompressor`` class easier to extend. -- Added support for explictly stating the block being ended. +- Added support for explicitly stating the block being ended. - Added rcssmin and updated rjsmin. @@ -368,7 +416,7 @@ v0.6.1 ------ - Fixed staticfiles support to also use its finder API to find files during - developement -- when the static files haven't been collected in + development -- when the static files haven't been collected in ``STATIC_ROOT``. - Fixed regression with the ``COMPRESS`` setting, pre-compilation and @@ -394,7 +442,7 @@ Major improvements and a lot of bugfixes, some of which are: compress template tag does. See the :ref:`pre-compression ` docs for more information. -- Various perfomance improvements by better caching and mtime cheking. +- Various performance improvements by better caching and mtime cheking. - Deprecated ``COMPRESS_LESSC_BINARY`` setting because it's now superseded by the :attr:`~django.conf.settings.COMPRESS_PRECOMPILERS` diff --git a/python-django-compressor/docs/contributing.txt b/python-django-compressor/docs/contributing.txt index 8f0cd50..fa33dad 100644 --- a/python-django-compressor/docs/contributing.txt +++ b/python-django-compressor/docs/contributing.txt @@ -11,9 +11,9 @@ Community People interested in developing for the Django Compressor should: -1. Head over to #django-compressor on the `freenode`_ IRC network for help and to -discuss the development. -2. Open an issue on GitHub explaining your ideas. +#. Head over to #django-compressor on the `freenode`_ IRC network for help and to + discuss the development. +#. Open an issue on GitHub explaining your ideas. In a nutshell diff --git a/python-django-compressor/docs/index.txt b/python-django-compressor/docs/index.txt index f1adfa4..7cb58f2 100644 --- a/python-django-compressor/docs/index.txt +++ b/python-django-compressor/docs/index.txt @@ -43,5 +43,6 @@ Contents behind-the-scenes jinja2 django-sekizai + reactjs contributing changelog diff --git a/python-django-compressor/docs/jinja2.txt b/python-django-compressor/docs/jinja2.txt index f492279..c352da1 100644 --- a/python-django-compressor/docs/jinja2.txt +++ b/python-django-compressor/docs/jinja2.txt @@ -24,19 +24,6 @@ From now on, you can use same code you'd normally use within Django templates:: ])) template.render({'STATIC_URL': settings.STATIC_URL}) -For coffin users ----------------- - -Coffin_ makes it very easy to include additional Jinja2_ extensions as it -only requires to add extension to ``JINJA2_EXTENSIONS`` at main settings -module:: - - JINJA2_EXTENSIONS = [ - 'compressor.contrib.jinja2ext.CompressorExtension', - ] - -And that's it - our extension is loaded and ready to be used. - Jinja2 Offline Compression Support ================================== @@ -85,13 +72,7 @@ method, and is in the ``TEMPLATE_LOADERS`` setting. If you're using Jinja2, you're likely to have a Jinja2 template loader in the ``TEMPLATE_LOADERS`` setting, otherwise Django won't know how to load Jinja2 -templates. You could use Jingo_ or your own custom loader. Coffin_ works -differently by providing a custom rendering method instead of a custom loader. - -Unfortunately, Jingo_ does not implement such a method in its loader; -Coffin_ does not seem to have a template loader in the first place. -Read on to understand how to make Compressor work nicely with Jingo_ -and Coffin_. +templates. By default, if you don't override the ``TEMPLATE_LOADERS`` setting, it will include the app directories loader that searches for templates under @@ -104,51 +85,6 @@ the filesystem loader (``django.template.loaders.filesystem.Loader``) in the ``TEMPLATE_LOADERS`` setting and specify the custom location in the ``TEMPLATE_DIRS`` setting. -For Jingo users ---------------- -You should configure ``TEMPLATE_LOADERS`` as such:: - - TEMPLATE_LOADERS = ( - 'jingo.Loader', - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ) - - def COMPRESS_JINJA2_GET_ENVIRONMENT(): - # TODO: ensure the CompressorExtension is installed with Jingo via - # Jingo's JINJA_CONFIG setting. - # Additional globals, filters, tests, - # and extensions used within {%compress%} blocks must be configured - # with Jingo. - from jingo import env - - return env - -This will enable the Jingo_ loader to load Jinja2 templates and the other -loaders to report the templates location(s). - -For Coffin users ----------------- -You might want to configure ``TEMPLATE_LOADERS`` as such:: - - TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ) - - def COMPRESS_JINJA2_GET_ENVIRONMENT(): - # TODO: ensure the CompressorExtension is installed with Coffin - # as described in the "In-Request Support" section above. - # Additional globals, filters, tests, - # and extensions used within {%compress%} blocks must be configured - # with Coffin. - from coffin.common import env - - return env - -Again, if you have the Jinja2 templates in the app template directories, you're -done here. Otherwise, specify the location in ``TEMPLATE_DIRS``. - Using your custom loader ------------------------ You should configure ``TEMPLATE_LOADERS`` as such:: @@ -161,14 +97,6 @@ You should configure ``TEMPLATE_LOADERS`` as such:: You could implement the `get_template_sources` method in your loader or make use of the Django's builtin loaders to report the Jinja2 template location(s). -Python 3 Support ----------------- -Jingo with Jinja2 are tested and work on Python 2.6, 2.7, and 3.3. -Coffin with Jinja2 are tested and work on Python 2.6 and 2.7 only. -Jinja2 alone (with custom loader) are tested and work on Python 2.6, 2.7 and -3.3 only. .. _Jinja2: http://jinja.pocoo.org/docs/ -.. _Coffin: http://pypi.python.org/pypi/Coffin -.. _Jingo: https://jingo.readthedocs.org/en/latest/ diff --git a/python-django-compressor/docs/quickstart.txt b/python-django-compressor/docs/quickstart.txt index 00931ea..9dc58ee 100644 --- a/python-django-compressor/docs/quickstart.txt +++ b/python-django-compressor/docs/quickstart.txt @@ -31,7 +31,7 @@ Installation ) * Define :attr:`COMPRESS_ROOT ` in settings - if you don't have already ``STATIC_ROOT`` or if you want it in a different + if you don't have already ``STATIC_ROOT`` or if you want it in a different folder. .. _staticfiles: http://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/ @@ -39,34 +39,16 @@ Installation .. _dependencies: -Dependencies +Optional Dependencies ------------ -Required -^^^^^^^^ - -In case you're installing Django Compressor differently -(e.g. from the Git repo), make sure to install the following -dependencies. - -- django-appconf_ - - Used internally to handle Django's settings, this is - automatically installed when following the above - installation instructions. - - pip install django-appconf - -Optional -^^^^^^^^ - - BeautifulSoup_ For the :attr:`parser ` ``compressor.parser.BeautifulSoupParser`` and ``compressor.parser.LxmlParser``:: - pip install "BeautifulSoup<4.0" + pip install beautifulsoup4 - lxml_ @@ -89,10 +71,17 @@ Optional pip install slimit +- `csscompressor`_ + + For the :ref:`csscompressor filter ` + ``compressor.filters.cssmin.CSSCompressorFilter``:: + + pip install csscompressor + .. _BeautifulSoup: http://www.crummy.com/software/BeautifulSoup/ -.. _lxml: http://codespeak.net/lxml/ +.. _lxml: http://lxml.de/ .. _libxml2: http://xmlsoft.org/ -.. _html5lib: http://code.google.com/p/html5lib/ +.. _html5lib: https://github.com/html5lib/html5lib-python .. _`Slim It`: https://github.com/rspivak/slimit .. _django-appconf: http://pypi.python.org/pypi/django-appconf/ .. _versiontools: http://pypi.python.org/pypi/versiontools/ diff --git a/python-django-compressor/docs/reactjs.txt b/python-django-compressor/docs/reactjs.txt new file mode 100644 index 0000000..c1dfc8b --- /dev/null +++ b/python-django-compressor/docs/reactjs.txt @@ -0,0 +1,76 @@ +.. _reactjs_support: + +Facebook React Support +====================== + +Assuming you have `npm` available, you can install `babel` via `npm install -g babel` and integrate React with +Django Compressor by following the `react-tools installation instructions`_ and adding an appropriate +``COMPRESS_PRECOMPILERS`` setting: + +.. code-block:: django + + COMPRESS_PRECOMPILERS = ( + ('text/jsx', 'cat {infile} | babel > {outfile}'), + ) + + +.. _react-tools installation instructions: http://facebook.github.io/react/docs/tooling-integration.html#productionizing-precompiled-jsx + +If the above approach is not suitable for you, compiling React's jsx files can be done by creating +a custom precompressor. + +Requirements +------------ +* PyReact>=0.5.2 for compiling jsx files +* PyExecJS>=1.1.0 required by PyReact (automatically installed when using pip) +* A Javascript runtime : options include PyV8, Node.js, PhantomJS among others + + +The full list of supported javascript engines can be found here: + https://github.com/doloopwhile/PyExecJS + + +Installation +------------ +1. Place the following code in a Python file (e.g. + ``third_party/react_compressor.py``). Also make sure that + ``third_party/__init__.py`` exists so the directory is recognized as a + Python package. + +.. code-block:: django + + from compressor.filters import FilterBase + from react import jsx + + + class ReactFilter(FilterBase): + + def __init__(self, content, *args, **kwargs): + self.content = content + kwargs.pop('filter_type') + super(ReactFilter, self).__init__(content, *args, **kwargs) + + def input(self, **kwargs): + return jsx.transform_string(self.content) + + +2. In your Django settings, add the following line: + +.. code-block:: django + + COMPRESS_PRECOMPILERS = ( + ('text/jsx', 'third_party.react_compressor.ReactFilter'), + ) + +Where ``third_party.react_compressor.ReactFilter`` is the full name of your ``ReactFilter`` class. + + +Troubleshooting +--------------- +If you get "file not found" errors, open your Python command line and make sure +you are able to import your ``ReactFilter`` class: + +.. code-block:: django + + __import__('third_party.react_compressor.ReactFilter') + diff --git a/python-django-compressor/docs/remote-storages.txt b/python-django-compressor/docs/remote-storages.txt index 8af6934..30d6fb3 100644 --- a/python-django-compressor/docs/remote-storages.txt +++ b/python-django-compressor/docs/remote-storages.txt @@ -17,7 +17,7 @@ django-storages ^^^^^^^^^^^^^^^ So assuming your CDN is `Amazon S3`_, you can use the boto_ storage backend -from the 3rd party app `django-storages`_. Some required settings are:: +from the 3rd party app `django-storages-redux`_. Some required settings are:: AWS_ACCESS_KEY_ID = 'XXXXXXXXXXXXXXXXXXXXX' AWS_SECRET_ACCESS_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' @@ -51,7 +51,7 @@ apps can be integrated. #. You need to create a subclass of the remote storage backend you want to use; below is an example of the boto S3 storage backend from - django-storages_:: + django-storages-redux_:: from django.core.files.storage import get_storage_class from storages.backends.s3boto import S3BotoStorage @@ -82,7 +82,7 @@ apps can be integrated. .. _CDN: http://en.wikipedia.org/wiki/Content_delivery_network .. _Amazon S3: https://s3.amazonaws.com/ .. _boto: http://boto.cloudhackers.com/ -.. _django-storages: http://code.welldev.org/django-storages/ +.. _django-storages-redux: http://github.com/jschneier/django-storages .. _staticfiles: http://docs.djangoproject.com/en/dev/howto/static-files/ .. _STATIC_ROOT: http://docs.djangoproject.com/en/dev/ref/settings/#static-root .. _STATIC_URL: http://docs.djangoproject.com/en/dev/ref/settings/#static-url diff --git a/python-django-compressor/docs/settings.txt b/python-django-compressor/docs/settings.txt index 268f645..b175bff 100644 --- a/python-django-compressor/docs/settings.txt +++ b/python-django-compressor/docs/settings.txt @@ -87,18 +87,6 @@ Backend settings feature, and the ``'content'`` in case you're using multiple servers to serve your content. - - ``compressor.filters.csstidy.CSSTidyFilter`` - - A filter that passes the CSS content to the CSSTidy_ tool. - - .. attribute:: COMPRESS_CSSTIDY_BINARY - - The CSSTidy binary filesystem path. - - .. attribute:: COMPRESS_CSSTIDY_ARGUMENTS - - The arguments passed to CSSTidy. - - ``compressor.filters.datauri.CssDataUriFilter`` A filter for embedding media as `data: URIs`_ in the CSS. @@ -132,27 +120,34 @@ Backend settings The arguments passed to the compressor. Defaults to --terminal. - - ``compressor.filters.cssmin.CSSMinFilter`` + .. _csscompressor_filter: + + - ``compressor.filters.cssmin.CSSCompressorFilter`` - A filter that uses Zachary Voase's Python port of the YUI CSS compression - algorithm cssmin_. + A filter that uses Yury Selivanov's Python port of the YUI CSS compression + algorithm csscompressor_. + + - ``compressor.filters.cssmin.rCSSMinFilter`` + + A filter that uses the cssmin implementation rCSSmin_ to compress CSS + (installed by default). - ``compressor.filters.cleancss.CleanCSSFilter`` A filter that passes the CSS content to the `clean-css`_ tool. - .. attribute:: CLEAN_CSS_BINARY + .. attribute:: COMPRESS_CLEAN_CSS_BINARY The clean-css binary filesystem path. - .. attribute:: CLEAN_CSS_ARGUMENTS + .. attribute:: COMPRESS_CLEAN_CSS_ARGUMENTS The arguments passed to clean-css. - .. _CSSTidy: http://csstidy.sourceforge.net/ .. _`data: URIs`: http://en.wikipedia.org/wiki/Data_URI_scheme - .. _cssmin: http://pypi.python.org/pypi/cssmin/ + .. _csscompressor: http://pypi.python.org/pypi/csscompressor/ + .. _rCSSmin: http://opensource.perlig.de/rcssmin/ .. _`clean-css`: https://github.com/GoalSmashers/clean-css/ @@ -178,7 +173,7 @@ Backend settings - ``compressor.filters.jsmin.JSMinFilter`` A filter that uses the jsmin implementation rJSmin_ to compress - JavaScript code. + JavaScript code (installed by default). .. _slimit_filter: @@ -274,8 +269,8 @@ Backend settings .. note:: Depending on the implementation, some precompilers might not support outputting to something else than ``stdout``, so you'll need to omit the - ``{outfile}`` parameter when working with those. For instance, if you - are using the Ruby version of lessc, you'll need to set up the + ``{outfile}`` parameter when working with those. For instance, if you + are using the Ruby version of lessc, you'll need to set up the precompiler like this:: ('text/less', 'lessc {infile}'), @@ -412,6 +407,19 @@ Caching settings :attr:`~django.conf.settings.COMPRESS_REBUILD_TIMEOUT` and :attr:`~django.conf.settings.COMPRESS_MINT_DELAY`. +.. attribute:: COMPRESS_CACHEABLE_PRECOMPILERS + + :Default: ``()`` + + An iterable of precompiler mimetypes as defined in :attr:`~django.conf.settings.COMPRESS_PRECOMPILERS` + for which the compiler output can be cached based solely on the contents + of the input file. This lets Django Compressor avoid recompiling unchanged + files. Caching is appropriate for compilers such as CoffeeScript where files + are compiled one-to-one, but not for compilers such as SASS that have an + ``import`` mechanism for including one file from another. If caching is enabled + for such a compiler, Django Compressor will not know to recompile files when a file + they import is modified. + .. attribute:: COMPRESS_DEBUG_TOGGLE :Default: None @@ -434,11 +442,11 @@ Caching settings and the ``django.core.context_processors.request`` context processor. .. _RequestContext: http://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.RequestContext - + .. attribute:: COMPRESS_CACHE_KEY_FUNCTION :Default: ``'compressor.cache.simple_cachekey'`` - + The function to use when generating the cache key. The function must take one argument which is the partial key based on the source's hex digest. It must return the full key as a string. @@ -473,6 +481,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`` diff --git a/python-django-compressor/docs/usage.txt b/python-django-compressor/docs/usage.txt index 5bf665e..aea8fcd 100644 --- a/python-django-compressor/docs/usage.txt +++ b/python-django-compressor/docs/usage.txt @@ -105,7 +105,7 @@ of that particular compress tag. This is then added to the context so you can access it in the `post_compress signal `. .. _memcached: http://memcached.org/ -.. _caching documentation: http://docs.djangoproject.com/en/1.2/topics/cache/#memcached +.. _caching documentation: https://docs.djangoproject.com/en/1.8/topics/cache/#memcached .. _pre-compression: @@ -153,7 +153,7 @@ the commonly used setting to refer to saved files ``STATIC_URL``. The result of running the ``compress`` management command will be cached in a file called ``manifest.json`` using the :attr:`configured storage -` to be able to be transfered from your developement +` to be able to be transferred from your development computer to the server easily. .. _TEMPLATE_LOADERS: http://docs.djangoproject.com/en/dev/ref/settings/#template-loaders diff --git a/python-django-compressor/requirements/tests.txt b/python-django-compressor/requirements/tests.txt index 775874f..6cd420e 100644 --- a/python-django-compressor/requirements/tests.txt +++ b/python-django-compressor/requirements/tests.txt @@ -1,10 +1,11 @@ -flake8 -coverage -html5lib -mock -jinja2 -lxml -BeautifulSoup -unittest2 -coffin -jingo +flake8==2.4.0 +coverage==3.7.1 +html5lib==0.9999999 +mock==1.0.1 +Jinja2==2.7.3 +lxml==3.4.2 +beautifulsoup4==4.4.0 +django-sekizai==0.9.0 +csscompressor==0.9.4 +rcssmin==1.0.6 +rjsmin==1.0.12 diff --git a/python-django-compressor/setup.py b/python-django-compressor/setup.py index 81f5dde..f3e116f 100644 --- a/python-django-compressor/setup.py +++ b/python-django-compressor/setup.py @@ -131,15 +131,18 @@ setup( 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Topic :: Internet :: WWW/HTTP', ], zip_safe=False, install_requires=[ 'django-appconf >= 0.4', + 'rcssmin == 1.0.6', + 'rjsmin == 1.0.12', ], ) diff --git a/python-django-compressor/tox.ini b/python-django-compressor/tox.ini index 78d6039..2ca649b 100644 --- a/python-django-compressor/tox.ini +++ b/python-django-compressor/tox.ini @@ -1,49 +1,51 @@ [deps] two = - flake8 - coverage - html5lib - mock - jinja2 - lxml - BeautifulSoup - unittest2 - jingo - coffin + flake8==2.4.0 + coverage==3.7.1 + html5lib==0.9999999 + mock==1.0.1 + Jinja2==2.7.3 + lxml==3.4.2 + beautifulsoup4==4.4.0 + django-sekizai==0.9.0 + csscompressor==0.9.4 + rcssmin==1.0.6 + rjsmin==1.0.12 three = - flake8 - coverage - html5lib - mock - jinja2 - lxml - BeautifulSoup4 - jingo - coffin + flake8==2.4.0 + coverage==3.7.1 + html5lib==0.9999999 + mock==1.0.1 + Jinja2==2.7.3 + lxml==3.4.2 + beautifulsoup4==4.4.0 + django-sekizai==0.9.0 + csscompressor==0.9.4 + rcssmin==1.0.6 + rjsmin==1.0.12 three_two = - flake8 - coverage - html5lib - mock - jinja2==2.6 - lxml - BeautifulSoup4 - jingo - coffin - + flake8==2.4.0 + coverage==3.7.1 + html5lib==0.9999999 + mock==1.0.1 + Jinja2==2.6 + lxml==3.4.2 + beautifulsoup4==4.4.0 + django-sekizai==0.9.0 + csscompressor==0.9.4 + rcssmin==1.0.6 + rjsmin==1.0.12 [tox] envlist = - {py26,py27}-{1.4.X,1.5.X}, - {py26,py27,py32,py33}-{1.6.X}, - {py27,py32,py33,py34}-{1.7.X}, - {py27,py32,py33,py34}-{1.8.X} + {py27,py32,py33,py34,py35}-{1.8.X} + {py27,py34,py35}-{1.9.X} [testenv] basepython = - py26: python2.6 py27: python2.7 py32: python3.2 py33: python3.3 py34: python3.4 + py35: python3.5 usedevelop = true setenv = CPPFLAGS=-O0 @@ -53,14 +55,11 @@ commands = django-admin.py --version make test deps = - 1.4.X: Django>=1.4,<1.5 - 1.5.X: Django>=1.5,<1.6 - 1.6.X: Django>=1.6,<1.7 - 1.7.X: Django>=1.7,<1.8 1.8.X: Django>=1.8,<1.9 - py26: {[deps]two} + 1.9.X: Django>=1.9,<1.10 py27: {[deps]two} py32: {[deps]three_two} py33: {[deps]three} py34: {[deps]three} + py35: {[deps]three} django-discover-runner