diff --git a/compressor/__init__.py b/compressor/__init__.py index e0bbcd9..a073fff 100644 --- a/compressor/__init__.py +++ b/compressor/__init__.py @@ -13,4 +13,4 @@ def get_version(): return version -__version__ = get_version() \ No newline at end of file +__version__ = get_version() diff --git a/compressor/base.py b/compressor/base.py index aae52b2..1345b29 100644 --- a/compressor/base.py +++ b/compressor/base.py @@ -1,29 +1,41 @@ +import fnmatch import os import socket from itertools import chain -from django.template.loader import render_to_string from django.core.files.base import ContentFile +from django.core.exceptions import ImproperlyConfigured +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 UncompressableFileError +from compressor.filters import CompilerFilter from compressor.storage import default_storage from compressor.utils import get_class, cached_property + class Compressor(object): + """ + Base compressor object to be subclassed for content type + depending implementations details. + """ + type = None def __init__(self, content=None, output_prefix="compressed"): self.content = content or "" - self.extra_context = {} - self.type = None self.output_prefix = output_prefix - self.split_content = [] self.charset = settings.DEFAULT_CHARSET + self.precompilers = settings.COMPRESS_PRECOMPILERS + self.split_content = [] + self.extra_context = {} def split_contents(self): - raise NotImplementedError( - "split_contents must be defined in a subclass") + """ + To be implemented in a subclass, should return an + iterable with three values: kind, value, element + """ + raise NotImplementedError def get_filename(self, url): try: @@ -68,11 +80,11 @@ class Compressor(object): @cached_property def hunks(self): for kind, value, elem in self.split_contents(): - attribs = self.parser.elem_attribs(elem) if kind == "hunk": # Let's cast BeautifulSoup element to unicode here since # it will try to encode using ascii internally later - yield unicode(self.filter(value, "input", elem=elem)) + yield unicode( + self.filter(value, method="input", elem=elem, kind=kind)) elif kind == "file": content = "" try: @@ -84,13 +96,56 @@ class Compressor(object): except IOError, e: raise UncompressableFileError( "IOError while processing '%s': %s" % (value, e)) - content = self.filter(content, "input", filename=value, elem=elem) - yield unicode(content, attribs.get("charset", self.charset)) + content = self.filter(content, + method="input", filename=value, elem=elem, kind=kind) + attribs = self.parser.elem_attribs(elem) + charset = attribs.get("charset", self.charset) + yield unicode(content, charset) def concat(self): return "\n".join((hunk.encode(self.charset) for hunk in self.hunks)) + def matches_patterns(self, path, patterns=[]): + """ + Return True or False depending on whether the ``path`` matches the + list of give the given patterns. + """ + if not isinstance(patterns, (list, tuple)): + patterns = (patterns,) + for pattern in patterns: + if fnmatch.fnmatchcase(path, pattern): + return True + return False + + def compiler_options(self, kind, filename, elem): + if kind == "file" and filename: + for patterns, options in self.precompilers.items(): + if self.matches_patterns(filename, patterns): + yield options + elif kind == "hunk" and elem is not None: + # get the mimetype of the file and handle "text/" cases + attrs = self.parser.elem_attribs(elem) + mimetype = attrs.get("type", "").split("/")[-1] + for options in self.precompilers.values(): + if options.get("mimetype", None) == mimetype: + yield options + + def precompile(self, content, kind=None, elem=None, filename=None, **kwargs): + if not kind: + return content + for options in self.compiler_options(kind, filename, elem): + command = options.get("command") + if command is None: + continue + content = CompilerFilter(content, + filter_type=self.type, command=command).output(**kwargs) + return content + def filter(self, content, method, **kwargs): + # run compiler + if method == "input": + content = self.precompile(content, **kwargs) + for filter_cls in self.cached_filters: filter_func = getattr( filter_cls(content, filter_type=self.type), method) @@ -103,7 +158,7 @@ class Compressor(object): @cached_property def combined(self): - return self.filter(self.concat(), 'output') + return self.filter(self.concat(), method="output") @cached_property def hash(self): @@ -112,7 +167,7 @@ class Compressor(object): @cached_property def new_filepath(self): return os.path.join(settings.COMPRESS_OUTPUT_DIR.strip(os.sep), - self.output_prefix, "%s.%s" % (self.hash, self.type)) + self.output_prefix, "%s.%s" % (self.hash, self.type)) def save_file(self): if self.storage.exists(self.new_filepath): diff --git a/compressor/cache.py b/compressor/cache.py index f2778a9..4b9ebb4 100644 --- a/compressor/cache.py +++ b/compressor/cache.py @@ -7,18 +7,22 @@ from django.utils.hashcompat import sha_constructor from compressor.conf import settings + def get_hexdigest(plaintext): return sha_constructor(plaintext).hexdigest() + def get_mtime_cachekey(filename): return "django_compressor.mtime.%s.%s" % (socket.gethostname(), get_hexdigest(filename)) + def get_offline_cachekey(source): return ("django_compressor.offline.%s.%s" % (socket.gethostname(), get_hexdigest("".join(smart_str(s) for s in source)))) + def get_mtime(filename): if settings.COMPRESS_MTIME_DELAY: key = get_mtime_cachekey(filename) @@ -29,9 +33,11 @@ def get_mtime(filename): return mtime return os.path.getmtime(filename) + def get_hashed_mtime(filename, length=12): filename = os.path.realpath(filename) mtime = str(int(get_mtime(filename))) return get_hexdigest(mtime)[:length] + cache = get_cache(settings.COMPRESS_CACHE_BACKEND) diff --git a/compressor/css.py b/compressor/css.py index deba219..3fab0cd 100644 --- a/compressor/css.py +++ b/compressor/css.py @@ -2,12 +2,13 @@ from compressor.conf import settings from compressor.base import Compressor from compressor.exceptions import UncompressableFileError + class CssCompressor(Compressor): + template_name = "compressor/css.html" + template_name_inline = "compressor/css_inline.html" def __init__(self, content=None, output_prefix="css"): super(CssCompressor, self).__init__(content, output_prefix) - self.template_name = "compressor/css.html" - self.template_name_inline = "compressor/css_inline.html" self.filters = list(settings.COMPRESS_CSS_FILTERS) self.type = 'css' @@ -21,8 +22,8 @@ class CssCompressor(Compressor): elem_attribs = self.parser.elem_attribs(elem) if elem_name == 'link' and elem_attribs['rel'] == 'stylesheet': try: - content = self.parser.elem_content(elem) - data = ('file', self.get_filename(elem_attribs['href']), elem) + data = ( + 'file', self.get_filename(elem_attribs['href']), elem) except UncompressableFileError: if settings.DEBUG: raise diff --git a/compressor/exceptions.py b/compressor/exceptions.py index c03b55c..42ae7c4 100644 --- a/compressor/exceptions.py +++ b/compressor/exceptions.py @@ -4,18 +4,21 @@ class UncompressableFileError(Exception): """ pass + class FilterError(Exception): """ This exception is raised when a filter fails """ pass + class ParserError(Exception): """ This exception is raised when the parser fails """ pass + class OfflineGenerationError(Exception): """ Offline compression generation related exceptions diff --git a/compressor/filters/__init__.py b/compressor/filters/__init__.py index e47a765..7fa05f2 100644 --- a/compressor/filters/__init__.py +++ b/compressor/filters/__init__.py @@ -1 +1 @@ -from compressor.filters.base import FilterBase, FilterError \ No newline at end of file +from compressor.filters.base import FilterBase, CompilerFilter, FilterError diff --git a/compressor/filters/base.py b/compressor/filters/base.py index b32db73..a3fb44a 100644 --- a/compressor/filters/base.py +++ b/compressor/filters/base.py @@ -1,6 +1,7 @@ +import os import logging - import subprocess +import tempfile from compressor.conf import settings from compressor.exceptions import FilterError @@ -8,6 +9,7 @@ from compressor.utils import cmd_split logger = logging.getLogger("compressor.filters") + class FilterBase(object): def __init__(self, content, filter_type=None, verbose=0): @@ -22,3 +24,58 @@ class FilterBase(object): def output(self, **kwargs): raise NotImplementedError + +class CompilerFilter(FilterBase): + """ + A filter subclass that is able to filter content via + external commands. + """ + def __init__(self, content, filter_type=None, verbose=0, command=None): + super(CompilerFilter, self).__init__(content, filter_type, verbose) + if command: + self.command = command + if not self.command: + raise FilterError("Required command attribute not set") + self.options = {} + self.stdout = subprocess.PIPE + self.stdin = subprocess.PIPE + self.stderr = subprocess.PIPE + + def output(self, **kwargs): + infile = outfile = "" + try: + if "%(infile)s" in self.command: + infile = tempfile.NamedTemporaryFile(mode='w') + infile.write(self.content) + infile.flush() + self.options["infile"] = infile.name + if "%(outfile)s" in self.command: + ext = ".%s" % self.type and self.type or "" + outfile = tempfile.NamedTemporaryFile(mode='w', suffix=ext) + self.options["outfile"] = outfile.name + proc = subprocess.Popen(cmd_split(self.command % self.options), + stdout=self.stdout, stdin=self.stdin, stderr=self.stderr) + if infile: + filtered, err = proc.communicate() + else: + filtered, err = proc.communicate(self.content) + except (IOError, OSError), e: + raise FilterError('Unable to apply %s (%r): %s' % ( + self.__class__.__name__, self.command, e)) + finally: + if infile: + infile.close() + if proc.wait() != 0: + if not err: + err = 'Unable to apply %s (%s)' % ( + self.__class__.__name__, self.command) + raise FilterError(err) + if self.verbose: + self.logger.debug(err) + if outfile: + try: + outfile_obj = open(outfile.name) + filtered = outfile_obj.read() + finally: + outfile_obj.close() + return filtered diff --git a/compressor/filters/closure.py b/compressor/filters/closure.py index 878e197..0740d19 100644 --- a/compressor/filters/closure.py +++ b/compressor/filters/closure.py @@ -1,31 +1,10 @@ -from subprocess import Popen, PIPE - from compressor.conf import settings -from compressor.filters import FilterBase, FilterError -from compressor.utils import cmd_split +from compressor.filters import CompilerFilter -class ClosureCompilerFilter(FilterBase): - - def output(self, **kwargs): - arguments = settings.COMPRESS_CLOSURE_COMPILER_ARGUMENTS - - command = '%s %s' % (settings.COMPRESS_CLOSURE_COMPILER_BINARY, arguments) - - try: - p = Popen(cmd_split(command), stdout=PIPE, stdin=PIPE, stderr=PIPE) - filtered, err = p.communicate(self.content) - - except IOError, e: - raise FilterError(e) - - if p.wait() != 0: - if not err: - err = 'Unable to apply Closure Compiler filter' - raise FilterError(err) - - if self.verbose: - print err - - return filtered - +class ClosureCompilerFilter(CompilerFilter): + command = "%(binary)s %(args)s" + options = { + "binary": settings.COMPRESS_CLOSURE_COMPILER_ARGUMENTS, + "args": settings.COMPRESS_CLOSURE_COMPILER_ARGUMENTS, + } diff --git a/compressor/filters/cssmin/cssmin.py b/compressor/filters/cssmin/cssmin.py index 42b0d84..1668355 100644 --- a/compressor/filters/cssmin/cssmin.py +++ b/compressor/filters/cssmin/cssmin.py @@ -2,9 +2,9 @@ # -*- 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 @@ -13,10 +13,10 @@ # 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 @@ -25,7 +25,7 @@ # 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.""" @@ -41,7 +41,7 @@ __version__ = '0.1.4' def remove_comments(css): """Remove all CSS comment blocks.""" - + iemac = False preserve = False comment_start = css.find("/*") @@ -49,7 +49,7 @@ def remove_comments(css): # 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: @@ -69,22 +69,22 @@ def remove_comments(css): 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: @@ -94,43 +94,43 @@ def remove_unnecessary_whitespace(css): 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: @@ -143,32 +143,32 @@ def normalize_rgb_colors_to_hex(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: @@ -184,19 +184,19 @@ def condense_hex_colors(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): @@ -204,7 +204,7 @@ def wrap_css_lines(css, line_length): 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) @@ -233,16 +233,16 @@ def cssmin(css, wrap=None): 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)) diff --git a/compressor/filters/csstidy.py b/compressor/filters/csstidy.py index c0600ab..3260753 100644 --- a/compressor/filters/csstidy.py +++ b/compressor/filters/csstidy.py @@ -1,31 +1,10 @@ -from subprocess import Popen, PIPE -import tempfile -import warnings - from compressor.conf import settings -from compressor.filters import FilterBase +from compressor.filters import CompilerFilter -warnings.simplefilter('ignore', RuntimeWarning) -class CSSTidyFilter(FilterBase): - - def output(self, **kwargs): - tmp_file = tempfile.NamedTemporaryFile(mode='w+b') - tmp_file.write(self.content) - tmp_file.flush() - - output_file = tempfile.NamedTemporaryFile(mode='w+b') - - command = '%s %s %s %s' % (settings.COMPRESS_CSSTIDY_BINARY, tmp_file.name, settings.COMPRESS_CSSTIDY_ARGUMENTS, output_file.name) - - command_output = Popen(command, shell=True, - stdout=PIPE, stdin=PIPE, stderr=PIPE).communicate() - - filtered_css = output_file.read() - output_file.close() - tmp_file.close() - - if self.verbose: - print command_output - - return filtered_css +class CSSTidyFilter(CompilerFilter): + command = "%(binary)s %(infile)s %(args)s %(outfile)s" + options = { + "binary": settings.COMPRESS_CSSTIDY_BINARY, + "args": settings.COMPRESS_CSSTIDY_ARGUMENTS, + } diff --git a/compressor/filters/datauri.py b/compressor/filters/datauri.py index a72cb57..82f50f7 100644 --- a/compressor/filters/datauri.py +++ b/compressor/filters/datauri.py @@ -6,6 +6,7 @@ from base64 import b64encode from compressor.conf import settings from compressor.filters import FilterBase + class DataUriFilter(FilterBase): """Filter for embedding media as data: URIs. @@ -28,7 +29,8 @@ class DataUriFilter(FilterBase): # strip query string of file paths if "?" in url: url = url.split("?")[0] - return os.path.join(settings.COMPRESS_ROOT, url[len(settings.COMPRESS_URL):]) + return os.path.join( + settings.COMPRESS_ROOT, url[len(settings.COMPRESS_URL):]) def data_uri_converter(self, matchobj): url = matchobj.group(1).strip(' \'"') @@ -36,7 +38,8 @@ class DataUriFilter(FilterBase): path = self.get_file_path(url) if os.stat(path).st_size <= settings.COMPRESS_DATA_URI_MIN_SIZE: data = b64encode(open(path, 'rb').read()) - return 'url("data:%s;base64,%s")' % (mimetypes.guess_type(path)[0], data) + return 'url("data:%s;base64,%s")' % ( + mimetypes.guess_type(path)[0], data) return 'url("%s")' % url diff --git a/compressor/filters/less.py b/compressor/filters/less.py deleted file mode 100644 index 7c75f6f..0000000 --- a/compressor/filters/less.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import warnings -import tempfile - -from compressor.conf import settings -from compressor.filters import FilterBase - -warnings.simplefilter('ignore', RuntimeWarning) - -class LessFilter(FilterBase): - - def output(self, **kwargs): - - tmp_file = tempfile.NamedTemporaryFile(mode='w+b') - tmp_file.write(self.content) - tmp_file.flush() - - output_file = tempfile.NamedTemporaryFile(mode='w+b') - - command = '%s %s %s' % (settings.COMPRESS_LESSC_BINARY, tmp_file.name, output_file.name) - - command_output = os.popen(command).read() - - filtered_css = output_file.read() - output_file.close() - tmp_file.close() - - if self.verbose: - print command_output - - return filtered_css diff --git a/compressor/filters/yui.py b/compressor/filters/yui.py index 25fbeaf..72cf7ab 100644 --- a/compressor/filters/yui.py +++ b/compressor/filters/yui.py @@ -1,48 +1,28 @@ -from subprocess import Popen, PIPE - from compressor.conf import settings -from compressor.filters import FilterBase, FilterError -from compressor.utils import cmd_split +from compressor.filters import CompilerFilter -class YUICompressorFilter(FilterBase): - - def output(self, **kwargs): - arguments = '' - if self.type == 'js': - arguments = settings.COMPRESS_YUI_JS_ARGUMENTS - elif self.type == 'css': - arguments = settings.COMPRESS_YUI_CSS_ARGUMENTS - - command = '%s --type=%s %s' % (settings.COMPRESS_YUI_BINARY, self.type, arguments) +class YUICompressorFilter(CompilerFilter): + command = "%(binary)s %(args)s" + def __init__(self, *args, **kwargs): + super(YUICompressorFilter, self).__init__(*args, **kwargs) + self.command += '--type=%s' % self.type if self.verbose: - command += ' --verbose' - - try: - p = Popen(cmd_split(command), stdin=PIPE, stdout=PIPE, stderr=PIPE) - filtered, err = p.communicate(self.content) - except IOError, e: - raise FilterError(e) - - if p.wait() != 0: - if not err: - err = 'Unable to apply YUI Compressor filter' - raise FilterError(err) - - if self.verbose: - print err - - return filtered + self.command += ' --verbose' class YUICSSFilter(YUICompressorFilter): - def __init__(self, *args, **kwargs): - super(YUICSSFilter, self).__init__(*args, **kwargs) - self.type = 'css' + type = 'css' + options = { + "binary": settings.COMPRESS_YUI_BINARY, + "args": settings.COMPRESS_YUI_CSS_ARGUMENTS, + } class YUIJSFilter(YUICompressorFilter): - def __init__(self, *args, **kwargs): - super(YUIJSFilter, self).__init__(*args, **kwargs) - self.type = 'js' + type = 'js' + options = { + "binary": settings.COMPRESS_YUI_BINARY, + "args": settings.COMPRESS_YUI_CSS_ARGUMENTS, + } diff --git a/compressor/finders.py b/compressor/finders.py index e49db97..fcb71c7 100644 --- a/compressor/finders.py +++ b/compressor/finders.py @@ -13,6 +13,7 @@ else: "standalone version django-staticfiles needs " "to be installed.") + class CompressorFinder(BaseStorageFinder): """ A staticfiles finder that looks in COMPRESS_ROOT diff --git a/compressor/js.py b/compressor/js.py index dddb9d0..4657f00 100644 --- a/compressor/js.py +++ b/compressor/js.py @@ -4,11 +4,11 @@ from compressor.exceptions import UncompressableFileError class JsCompressor(Compressor): + template_name = "compressor/js.html" + template_name_inline = "compressor/js_inline.html" def __init__(self, content=None, output_prefix="js"): super(JsCompressor, self).__init__(content, output_prefix) - self.template_name = "compressor/js.html" - self.template_name_inline = "compressor/js_inline.html" self.filters = list(settings.COMPRESS_JS_FILTERS) self.type = 'js' @@ -19,7 +19,8 @@ class JsCompressor(Compressor): attribs = self.parser.elem_attribs(elem) if 'src' in attribs: try: - self.split_content.append(('file', self.get_filename(attribs['src']), elem)) + self.split_content.append( + ('file', self.get_filename(attribs['src']), elem)) except UncompressableFileError: if settings.DEBUG: raise diff --git a/compressor/management/commands/compress.py b/compressor/management/commands/compress.py index 5414b3f..253cace 100644 --- a/compressor/management/commands/compress.py +++ b/compressor/management/commands/compress.py @@ -21,28 +21,30 @@ from compressor.utils import walk, any, import_module class Command(NoArgsCommand): - help = "Generate the compressor content outside of the request/response cycle" + help = "Compress content outside of the request/response cycle" option_list = NoArgsCommand.option_list + ( make_option('--extension', '-e', action='append', dest='extensions', help='The file extension(s) to examine (default: ".html", ' 'separate multiple extensions with commas, or use -e ' 'multiple times)'), - make_option('-f', '--force', default=False, action='store_true', dest='force', + make_option('-f', '--force', default=False, action='store_true', help="Force generation of compressor content even if " - "COMPRESS setting is not True."), - make_option('--follow-links', default=False, action='store_true', dest='follow_links', + "COMPRESS setting is not True.", dest='force'), + make_option('--follow-links', default=False, action='store_true', help="Follow symlinks when traversing the COMPRESS_ROOT " "(which defaults to MEDIA_ROOT). Be aware that using this " "can lead to infinite recursion if a link points to a parent " - "directory of itself."), + "directory of itself.", dest='follow_links'), ) def get_loaders(self): 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 + from django.template.loader import ( + find_template as finder_func) except ImportError: - from django.template.loader import find_template_source as finder_func + from django.template.loader import ( + find_template_source as finder_func) try: source, name = finder_func('test') except TemplateDoesNotExist: @@ -71,7 +73,8 @@ class Command(NoArgsCommand): for loader in self.get_loaders(): try: module = import_module(loader.__module__) - get_template_sources = getattr(module, 'get_template_sources', None) + 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(''))) @@ -89,7 +92,8 @@ class Command(NoArgsCommand): log.write("Considering paths:\n\t" + "\n\t".join(paths) + "\n") templates = set() for path in paths: - for root, dirs, files in walk(path, followlinks=options.get('followlinks', False)): + for root, dirs, files in walk(path, + followlinks=options.get('followlinks', False)): templates.update(os.path.join(root, name) for name in files if any(fnmatch(name, "*%s" % glob) for glob in extensions)) @@ -126,7 +130,8 @@ class Command(NoArgsCommand): compressor_nodes.setdefault(template_name, []).extend(nodes) if not compressor_nodes: - raise OfflineGenerationError("No 'compress' template tags found in templates.") + raise OfflineGenerationError( + "No 'compress' template tags found in templates.") if verbosity > 0: log.write("Found 'compress' tags in:\n\t" + @@ -175,18 +180,19 @@ class Command(NoArgsCommand): for i, ext in enumerate(ext_list): if not ext.startswith('.'): ext_list[i] = '.%s' % ext_list[i] - - # we don't want *.py files here because of the way non-*.py files - # are handled in make_messages() (they are copied to file.ext.py files to - # trick xgettext to parse them as Python files) - return set([x for x in ext_list if x != '.py']) + return set(ext_list) def handle_noargs(self, **options): if not settings.COMPRESS_ENABLED and not options.get("force"): - raise CommandError("Compressor is disabled. Set COMPRESS settting or use --force to override.") + raise CommandError( + "Compressor is disabled. Set COMPRESS " + "settting or use --force to override.") if not settings.COMPRESS_OFFLINE: if not options.get("force"): - raise CommandError("Offline compressiong is disabled. Set COMPRESS_OFFLINE or use the --force to override.") - warnings.warn("COMPRESS_OFFLINE is not set. Offline generated " - "cache will not be used.") + raise CommandError( + "Offline compressiong is disabled. Set " + "COMPRESS_OFFLINE or use the --force to override.") + warnings.warn( + "COMPRESS_OFFLINE is not set to True. " + "Offline generated cache will not be used.") self.compress(sys.stdout, **options) diff --git a/compressor/parser.py b/compressor/parser.py index 2052e5a..8ac09a6 100644 --- a/compressor/parser.py +++ b/compressor/parser.py @@ -2,8 +2,11 @@ from django.utils.encoding import smart_unicode from compressor.exceptions import ParserError -class ParserBase(object): +class ParserBase(object): + """ + Base parser to be subclassed when creating an own parser. + """ def __init__(self, content): self.content = content @@ -43,6 +46,7 @@ class ParserBase(object): """ raise NotImplementedError + class BeautifulSoupParser(ParserBase): _soup = None @@ -57,7 +61,7 @@ class BeautifulSoupParser(ParserBase): return self._soup def css_elems(self): - return self.soup.findAll({'link' : True, 'style' : True}) + return self.soup.findAll({'link': True, 'style': True}) def js_elems(self): return self.soup.findAll('script') @@ -74,6 +78,7 @@ class BeautifulSoupParser(ParserBase): def elem_str(self, elem): return smart_unicode(elem) + class LxmlParser(ParserBase): _tree = None @@ -110,4 +115,5 @@ class LxmlParser(ParserBase): def elem_str(self, elem): from lxml import etree - return smart_unicode(etree.tostring(elem, method='html', encoding=unicode)) + return smart_unicode( + etree.tostring(elem, method='html', encoding=unicode)) diff --git a/compressor/settings.py b/compressor/settings.py index 63e3c88..bfb9fd5 100644 --- a/compressor/settings.py +++ b/compressor/settings.py @@ -1,5 +1,3 @@ -import os - from django import VERSION as DJANGO_VERSION from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -24,29 +22,42 @@ class CompressorSettings(AppSettings): CSS_FILTERS = ['compressor.filters.css_default.CssAbsoluteFilter'] JS_FILTERS = ['compressor.filters.jsmin.JSMinFilter'] - - LESSC_BINARY = LESSC_BINARY = 'lessc' + PRECOMPILERS = { + # "*.coffee": { + # "command": "coffee --compile --stdio", + # "mimetype": "text/coffeescript", + # }, + # "*.less": { + # "command": "lessc %(infile)s %(outfile)s", + # "mimetype": "text/less", + # }, + # ("*.sass", "*.scss"): { + # "command": "sass %(infile)s %(outfile)s", + # "mimetype": "sass", + # }, + } 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 = 'COMPRESS_YUI_JS_ARGUMENTS' + YUI_JS_ARGUMENTS = '' DATA_URI_MIN_SIZE = 1024 + # the cache backend to use CACHE_BACKEND = None # rebuilds the cache every 30 days if nothing has changed. - REBUILD_TIMEOUT = 60 * 60 * 24 * 30 # 30 days + REBUILD_TIMEOUT = 60 * 60 * 24 * 30 # 30 days # the upper bound on how long any compression should take to be generated # (used against dog piling, should be a lot smaller than REBUILD_TIMEOUT - MINT_DELAY = 30 # seconds + MINT_DELAY = 30 # seconds # check for file changes only after a delay - MTIME_DELAY = 10 # seconds - # enables the offline cache -- a cache that is filled by the compress management command + MTIME_DELAY = 10 # seconds + # enables the offline cache -- also filled by the compress command OFFLINE = False # invalidates the offline cache after one year - OFFLINE_TIMEOUT = 60 * 60 * 24 * 365 # 1 year + OFFLINE_TIMEOUT = 60 * 60 * 24 * 365 # 1 year # The context to be used when compressing the files "offline" OFFLINE_CONTEXT = {} @@ -59,7 +70,8 @@ class CompressorSettings(AppSettings): if not value: value = settings.MEDIA_ROOT if not value: - raise ImproperlyConfigured("The COMPRESS_ROOT setting must be set.") + raise ImproperlyConfigured( + "The COMPRESS_ROOT setting must be set.") # In case staticfiles is used, make sure the FileSystemFinder is # installed, and if it is, check if COMPRESS_ROOT is listed in # STATICFILES_DIRS to allow finding compressed files @@ -78,14 +90,14 @@ class CompressorSettings(AppSettings): return value def configure_url(self, value): - # Falls back to the 1.3 STATIC_URL setting by default or falls back to MEDIA_URL + # Uses Django 1.3's STATIC_URL by default or falls back to MEDIA_URL if value is None: - value = getattr(settings, 'STATIC_URL', None) + value = getattr(settings, "STATIC_URL", None) if not value: value = settings.MEDIA_URL - if not value.endswith('/'): - raise ImproperlyConfigured('The URL settings (e.g. COMPRESS_URL) ' - 'must have a trailing slash.') + if not value.endswith("/"): + raise ImproperlyConfigured("The URL settings (e.g. COMPRESS_URL) " + "must have a trailing slash.") return value def configure_cache_backend(self, value): @@ -104,9 +116,23 @@ class CompressorSettings(AppSettings): def configure_offline_context(self, value): if not value: value = { - 'MEDIA_URL': settings.MEDIA_URL, + "MEDIA_URL": settings.MEDIA_URL, } # Adds the 1.3 STATIC_URL setting to the context if available - if getattr(settings, 'STATIC_URL', None): - value['STATIC_URL'] = settings.STATIC_URL + if getattr(settings, "STATIC_URL", None): + value["STATIC_URL"] = settings.STATIC_URL + return value + + def configure_precompilers(self, value): + for patterns, options in value.items(): + if options.get("command", None) is None: + raise ImproperlyConfigured("Please specify a command " + "for compiler with the pattern %r." % patterns) + mimetype = options.get("mimetype", None) + if mimetype is None: + raise ImproperlyConfigured("Please specify a mimetype " + "for compiler with the pattern %r." % patterns) + if mimetype.startswith("text/"): + options["mimetype"] = mimetype[5:] + value[patterns].update(options) return value diff --git a/compressor/storage.py b/compressor/storage.py index 8f0adea..755c51f 100644 --- a/compressor/storage.py +++ b/compressor/storage.py @@ -7,6 +7,7 @@ from django.utils.functional import LazyObject from compressor.conf import settings + class CompressorFileStorage(FileSystemStorage): """ Standard file system storage for files handled by django-compressor. @@ -40,6 +41,7 @@ class CompressorFileStorage(FileSystemStorage): self.delete(name) return name + class GzipCompressorFileStorage(CompressorFileStorage): """ The standard compressor file system storage that gzips storage files diff --git a/compressor/templatetags/compress.py b/compressor/templatetags/compress.py index c6a7038..6f74ca7 100644 --- a/compressor/templatetags/compress.py +++ b/compressor/templatetags/compress.py @@ -33,11 +33,13 @@ class CompressorNode(template.Node): if (time.time() > refresh_time) and not refreshed: # Store the stale value while the cache # revalidates for another MINT_DELAY seconds. - self.cache_set(key, val, timeout=settings.COMPRESS_MINT_DELAY, refreshed=True) + self.cache_set(key, val, refreshed=True, + timeout=settings.COMPRESS_MINT_DELAY) return None return val - def cache_set(self, key, val, timeout=settings.COMPRESS_REBUILD_TIMEOUT, refreshed=False): + def cache_set(self, key, val, refreshed=False, + timeout=settings.COMPRESS_REBUILD_TIMEOUT): refresh_time = timeout + time.time() real_timeout = timeout + settings.COMPRESS_MINT_DELAY packed_val = (val, refresh_time, refreshed) @@ -47,13 +49,15 @@ class CompressorNode(template.Node): return "%s.%s.%s" % (compressor.cachekey, self.mode, self.kind) def render(self, context, forced=False): - if (settings.COMPRESS_ENABLED and settings.COMPRESS_OFFLINE) and not forced: + if (settings.COMPRESS_ENABLED and + settings.COMPRESS_OFFLINE) and not forced: key = get_offline_cachekey(self.nodelist) content = cache.get(key) if content: return content content = self.nodelist.render(context) - if (not settings.COMPRESS_ENABLED or not len(content.strip())) and not forced: + if (not settings.COMPRESS_ENABLED or + not len(content.strip())) and not forced: return content compressor = self.compressor_cls(content) cachekey = self.cache_key(compressor) diff --git a/compressor/utils.py b/compressor/utils.py index de84cbe..539260e 100644 --- a/compressor/utils.py +++ b/compressor/utils.py @@ -9,13 +9,16 @@ from compressor.exceptions import FilterError try: any = any + except NameError: + def any(seq): for item in seq: if item: return True return False + def get_class(class_string, exception=FilterError): """ Convert a string version of a function name to the callable object. @@ -32,6 +35,7 @@ def get_class(class_string, exception=FilterError): return cls raise exception('Failed to import %s' % class_string) + def get_mod_func(callback): """ Converts 'django.views.news.stories.story_detail' to @@ -41,7 +45,8 @@ def get_mod_func(callback): dot = callback.rindex('.') except ValueError: return callback, '' - return callback[:dot], callback[dot+1:] + return callback[:dot], callback[dot + 1:] + def walk(root, topdown=True, onerror=None, followlinks=False): """ @@ -56,208 +61,210 @@ def walk(root, topdown=True, onerror=None, followlinks=False): for link_dirpath, link_dirnames, link_filenames in walk(p): yield (link_dirpath, link_dirnames, link_filenames) -# Taken from Django 1.3-beta1 and before that from Python 2.7 with permission from/by the original author. + +# Taken from Django 1.3 and before that from Python 2.7 +# with permission from the original author. def _resolve_name(name, package, level): - """Return the absolute name of the module to be imported.""" - if not hasattr(package, 'rindex'): - raise ValueError("'package' not set to a string") - dot = len(package) - for x in xrange(level, 1, -1): - try: - dot = package.rindex('.', 0, dot) - except ValueError: - raise ValueError("attempted relative import beyond top-level " - "package") - return "%s.%s" % (package[:dot], name) + """Return the absolute name of the module to be imported.""" + if not hasattr(package, 'rindex'): + raise ValueError("'package' not set to a string") + dot = len(package) + for x in xrange(level, 1, -1): + try: + dot = package.rindex('.', 0, dot) + except ValueError: + raise ValueError("attempted relative import beyond top-level " + "package") + return "%s.%s" % (package[:dot], name) + def import_module(name, package=None): - """Import a module. + """Import a module. - The 'package' argument is required when performing a relative import. It - specifies the package to use as the anchor point from which to resolve the - relative import to an absolute import. + The 'package' argument is required when performing a relative import. It + specifies the package to use as the anchor point from which to resolve the + relative import to an absolute import. - """ - if name.startswith('.'): - if not package: - raise TypeError("relative imports require the 'package' argument") - level = 0 - for character in name: - if character != '.': - break - level += 1 - name = _resolve_name(name[level:], package, level) - __import__(name) - return sys.modules[name] + """ + if name.startswith('.'): + if not package: + raise TypeError("relative imports require the 'package' argument") + level = 0 + for character in name: + if character != '.': + break + level += 1 + name = _resolve_name(name[level:], package, level) + __import__(name) + return sys.modules[name] class AppSettings(object): - """ - An app setting object to be used for handling app setting defaults - gracefully and providing a nice API for them. Say you have an app - called ``myapp`` and want to define a few defaults, and refer to the - defaults easily in the apps code. Add a ``settings.py`` to your app:: + """ + An app setting object to be used for handling app setting defaults + gracefully and providing a nice API for them. Say you have an app + called ``myapp`` and want to define a few defaults, and refer to the + defaults easily in the apps code. Add a ``settings.py`` to your app:: - from path.to.utils import AppSettings + from path.to.utils import AppSettings - class MyAppSettings(AppSettings): - SETTING_1 = "one" - SETTING_2 = ( - "two", - ) + class MyAppSettings(AppSettings): + SETTING_1 = "one" + SETTING_2 = ( + "two", + ) - Then initialize the setting with the correct prefix in the location of - of your choice, e.g. ``conf.py`` of the app module:: + Then initialize the setting with the correct prefix in the location of + of your choice, e.g. ``conf.py`` of the app module:: - settings = MyAppSettings(prefix="MYAPP") + settings = MyAppSettings(prefix="MYAPP") - The ``MyAppSettings`` instance will automatically look at Django's - global setting to determine each of the settings and respect the - provided ``prefix``. E.g. adding this to your site's ``settings.py`` - will set the ``SETTING_1`` setting accordingly:: + The ``MyAppSettings`` instance will automatically look at Django's + global setting to determine each of the settings and respect the + provided ``prefix``. E.g. adding this to your site's ``settings.py`` + will set the ``SETTING_1`` setting accordingly:: - MYAPP_SETTING_1 = "uno" + MYAPP_SETTING_1 = "uno" - Usage - ----- + Usage + ----- - Instead of using ``from django.conf import settings`` as you would - usually do, you can switch to using your apps own settings module - to access the app settings:: + Instead of using ``from django.conf import settings`` as you would + usually do, you can switch to using your apps own settings module + to access the app settings:: - from myapp.conf import settings + from myapp.conf import settings - print myapp_settings.MYAPP_SETTING_1 + print myapp_settings.MYAPP_SETTING_1 - ``AppSettings`` instances also work as pass-throughs for other - global settings that aren't related to the app. For example the - following code is perfectly valid:: + ``AppSettings`` instances also work as pass-throughs for other + global settings that aren't related to the app. For example the + following code is perfectly valid:: - from myapp.conf import settings + from myapp.conf import settings - if "myapp" in settings.INSTALLED_APPS: - print "yay, myapp is installed!" + if "myapp" in settings.INSTALLED_APPS: + print "yay, myapp is installed!" - Custom handling - --------------- + Custom handling + --------------- - Each of the settings can be individually configured with callbacks. - For example, in case a value of a setting depends on other settings - or other dependencies. The following example sets one setting to a - different value depending on a global setting:: + Each of the settings can be individually configured with callbacks. + For example, in case a value of a setting depends on other settings + or other dependencies. The following example sets one setting to a + different value depending on a global setting:: - from django.conf import settings + from django.conf import settings - class MyCustomAppSettings(AppSettings): - ENABLED = True + class MyCustomAppSettings(AppSettings): + ENABLED = True - def configure_enabled(self, value): - return value and not self.DEBUG + def configure_enabled(self, value): + return value and not self.DEBUG - custom_settings = MyCustomAppSettings("MYAPP") + custom_settings = MyCustomAppSettings("MYAPP") - The value of ``custom_settings.MYAPP_ENABLED`` will vary depending on the - value of the global ``DEBUG`` setting. + The value of ``custom_settings.MYAPP_ENABLED`` will vary depending on the + value of the global ``DEBUG`` setting. - Each of the app settings can be customized by providing - a method ``configure_`` that takes the default - value as defined in the class attributes as the only parameter. - The method needs to return the value to be use for the setting in - question. - """ - def __dir__(self): - return sorted(list(set(self.__dict__.keys() + dir(settings)))) + Each of the app settings can be customized by providing + a method ``configure_`` that takes the default + value as defined in the class attributes as the only parameter. + The method needs to return the value to be use for the setting in + question. + """ + def __dir__(self): + return sorted(list(set(self.__dict__.keys() + dir(settings)))) - __members__ = lambda self: self.__dir__() + __members__ = lambda self: self.__dir__() - def __getattr__(self, name): - if name.startswith(self._prefix): - raise AttributeError("%r object has no attribute %r" % - (self.__class__.__name__, name)) - return getattr(settings, name) + def __getattr__(self, name): + if name.startswith(self._prefix): + raise AttributeError("%r object has no attribute %r" % + (self.__class__.__name__, name)) + return getattr(settings, name) - def __setattr__(self, name, value): - super(AppSettings, self).__setattr__(name, value) - if name in dir(settings): - setattr(settings, name, value) + def __setattr__(self, name, value): + super(AppSettings, self).__setattr__(name, value) + if name in dir(settings): + setattr(settings, name, value) - def __init__(self, prefix): - super(AppSettings, self).__setattr__('_prefix', prefix) - for name, value in filter(self.issetting, getmembers(self.__class__)): - prefixed_name = "%s_%s" % (prefix.upper(), name.upper()) - value = getattr(settings, prefixed_name, value) - callback = getattr(self, "configure_%s" % name.lower(), None) - if callable(callback): - value = callback(value) - delattr(self.__class__, name) - setattr(self, prefixed_name, value) + def __init__(self, prefix): + super(AppSettings, self).__setattr__('_prefix', prefix) + for name, value in filter(self.issetting, getmembers(self.__class__)): + prefixed_name = "%s_%s" % (prefix.upper(), name.upper()) + value = getattr(settings, prefixed_name, value) + callback = getattr(self, "configure_%s" % name.lower(), None) + if callable(callback): + value = callback(value) + delattr(self.__class__, name) + setattr(self, prefixed_name, value) - def issetting(self, (name, value)): - return name == name.upper() + def issetting(self, (name, value)): + return name == name.upper() class cached_property(object): - """Property descriptor that caches the return value - of the get function. + """Property descriptor that caches the return value + of the get function. - *Examples* + *Examples* - .. code-block:: python + .. code-block:: python - @cached_property - def connection(self): - return Connection() + @cached_property + def connection(self): + return Connection() - @connection.setter # Prepares stored value - def connection(self, value): - if value is None: - raise TypeError("Connection must be a connection") - return value + @connection.setter # Prepares stored value + def connection(self, value): + if value is None: + raise TypeError("Connection must be a connection") + return value - @connection.deleter - def connection(self, value): - # Additional action to do at del(self.attr) - if value is not None: - print("Connection %r deleted" % (value, )) - """ + @connection.deleter + def connection(self, value): + # Additional action to do at del(self.attr) + if value is not None: + print("Connection %r deleted" % (value, )) + """ + def __init__(self, fget=None, fset=None, fdel=None, doc=None): + self.__get = fget + self.__set = fset + self.__del = fdel + self.__doc__ = doc or fget.__doc__ + self.__name__ = fget.__name__ + self.__module__ = fget.__module__ - def __init__(self, fget=None, fset=None, fdel=None, doc=None): - self.__get = fget - self.__set = fset - self.__del = fdel - self.__doc__ = doc or fget.__doc__ - self.__name__ = fget.__name__ - self.__module__ = fget.__module__ + def __get__(self, obj, type=None): + if obj is None: + return self + try: + return obj.__dict__[self.__name__] + except KeyError: + value = obj.__dict__[self.__name__] = self.__get(obj) + return value - def __get__(self, obj, type=None): - if obj is None: - return self - try: - return obj.__dict__[self.__name__] - except KeyError: - value = obj.__dict__[self.__name__] = self.__get(obj) - return value + def __set__(self, obj, value): + if obj is None: + return self + if self.__set is not None: + value = self.__set(obj, value) + obj.__dict__[self.__name__] = value - def __set__(self, obj, value): - if obj is None: - return self - if self.__set is not None: - value = self.__set(obj, value) - obj.__dict__[self.__name__] = value + def __delete__(self, obj): + if obj is None: + return self + try: + value = obj.__dict__.pop(self.__name__) + except KeyError: + pass + else: + if self.__del is not None: + self.__del(obj, value) - def __delete__(self, obj): - if obj is None: - return self - try: - value = obj.__dict__.pop(self.__name__) - except KeyError: - pass - else: - if self.__del is not None: - self.__del(obj, value) + def setter(self, fset): + return self.__class__(self.__get, fset, self.__del) - def setter(self, fset): - return self.__class__(self.__get, fset, self.__del) - - def deleter(self, fdel): - return self.__class__(self.__get, self.__set, fdel) + def deleter(self, fdel): + return self.__class__(self.__get, self.__set, fdel) diff --git a/docs/index.txt b/docs/index.txt index abe90c3..9085d49 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -285,6 +285,85 @@ COMPRESS_JS_FILTERS A list of filters that will be applied to javascript. +COMPRESS_PRECOMPILERS +^^^^^^^^^^^^^^^^^^^^^ + +:Default: ``{}`` + +A mapping of file pattern(s) to compiler options to be used on the +matching files. The options dictionary requires two items: + +* command + The command to call on each of the files. Standard Python string + formatting will be provided for the two variables ``%(infile)s`` and + ``%(outfile)s`` and will also trigger the actual creation of those + temporary files. If not given in the command string, django_compressor + will use ``stdin`` and ``stdout`` respectively instead. + +* mimetype + The mimetype of the file in case inline code should be compiled + (see below). + +Example:: + + COMPRESS_PRECOMPILERS = { + "*.coffee": { + "command": "coffee --compile --stdio", + "mimetype": "text/coffeescript", + }, + "*.less": { + "command": "lessc %(infile)s %(outfile)s", + "mimetype": "text/less", + }, + ("*.sass", "*.scss"): { + "command": "sass %(infile)s %(outfile)s", + "mimetype": "sass", + }, + } + +With that setting (and CoffeeScript_ installed), you could add the following +code to your templates: + +.. code-block:: django + + {% load compress %} + + {% compress js %} + + {% endcompress %} + +This would give you something like this:: + + + +The same works for less_, too: + +.. code-block:: django + + {% load compress %} + + {% compress css %} + + + {% endcompress %} + +Which would be rendered something like:: + + + +.. _less: http://lesscss.org/ +.. _CoffeeScript: http://jashkenas.github.com/coffee-script/ + COMPRESS_STORAGE ^^^^^^^^^^^^^^^^ @@ -437,3 +516,20 @@ Dependencies .. _BeautifulSoup: http://www.crummy.com/software/BeautifulSoup/ .. _lxml: http://codespeak.net/lxml/ .. _libxml2: http://xmlsoft.org/ + +Deprecation +----------- + +This section lists features and settings that are deprecated or removed +in newer versions of django_compressor. + +* ``COMPRESS_LESSC_BINARY`` + Superseded by the COMPRESS_PRECOMPILERS_ setting. Just add the following + to your settings:: + + COMPRESS_PRECOMPILERS = { + "*.less": { + "command": "lessc %(infile)s %(outfile)s", + "mimetype": "text/less", + }, + } diff --git a/tox.ini b/tox.ini index 9100989..73aa302 100644 --- a/tox.ini +++ b/tox.ini @@ -1,98 +1,57 @@ [tox] distribute = False envlist = - py24-1.0.X, py25-1.0.X, py26-1.0.X, py27-1.0.X, - py24-1.1.X, py25-1.1.X, py26-1.1.X, py27-1.1.X, - py24-1.2.X, py25-1.2.X, py26-1.2.X, py27-1.2.X, - py24-1.3.X, py25-1.3.X, py26-1.3.X, py27-1.3.X + py25-1.1.X, py26-1.1.X, py27-1.1.X, + py25-1.2.X, py26-1.2.X, py27-1.2.X, + py25-1.3.X, py26-1.3.X, py27-1.3.X [testenv] commands = python setup.py test -[testenv:py24-1.0.X] -basepython = python2.4 -deps = - pysqlite - django==1.0.4 - -[testenv:py25-1.0.X] -basepython = python2.5 -deps = - django==1.0.4 - -[testenv:py26-1.0.X] -basepython = python2.6 -deps = - django==1.0.4 - -[testenv:py27-1.0.X] -basepython = python2.7 -deps = - django==1.0.4 - - -[testenv:py24-1.1.X] -basepython = python2.4 -deps = - pysqlite - django==1.1.3 - [testenv:py25-1.1.X] basepython = python2.5 deps = - django==1.1.3 + django==1.1.4 [testenv:py26-1.1.X] basepython = python2.6 deps = - django==1.1.3 + django==1.1.4 [testenv:py27-1.1.X] basepython = python2.7 deps = - django==1.1.3 + django==1.1.4 -[testenv:py24-1.2.X] -basepython = python2.4 -deps = - pysqlite - django==1.2.4 - [testenv:py25-1.2.X] basepython = python2.5 deps = - django==1.2.4 + django==1.2.5 [testenv:py26-1.2.X] basepython = python2.6 deps = - django==1.2.4 + django==1.2.5 [testenv:py27-1.2.X] basepython = python2.7 deps = - django==1.2.4 + django==1.2.5 -[testenv:py24-1.3.X] -basepython = python2.4 -deps = - pysqlite - svn+http://code.djangoproject.com/svn/django/trunk/ - [testenv:py25-1.3.X] basepython = python2.5 deps = - svn+http://code.djangoproject.com/svn/django/trunk/ + django==1.3 [testenv:py26-1.3.X] basepython = python2.6 deps = - svn+http://code.djangoproject.com/svn/django/trunk/ + django==1.3 [testenv:py27-1.3.X] basepython = python2.7 deps = - svn+http://code.djangoproject.com/svn/django/trunk/ + django==1.3