Merge branch 'feature/preprocessing' into develop

This commit is contained in:
Jannis Leidel
2011-03-30 13:17:49 +02:00
23 changed files with 579 additions and 439 deletions

View File

@@ -13,4 +13,4 @@ def get_version():
return version
__version__ = get_version()
__version__ = get_version()

View File

@@ -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/<type>" 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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -1 +1 @@
from compressor.filters.base import FilterBase, FilterError
from compressor.filters.base import FilterBase, CompilerFilter, FilterError

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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))

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -13,6 +13,7 @@ else:
"standalone version django-staticfiles needs "
"to be installed.")
class CompressorFinder(BaseStorageFinder):
"""
A staticfiles finder that looks in COMPRESS_ROOT

View File

@@ -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

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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_<lower_setting_name>`` 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_<lower_setting_name>`` 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)

View File

@@ -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 %}
<script type="text/coffeescript" charset="utf-8" src="/static/js/awesome.coffee" />
<script type="text/coffeescript" charset="utf-8">
# Functions:
square = (x) -> x * x
</script>
{% endcompress %}
This would give you something like this::
<script type="text/javascript" src="/static/CACHE/js/8dd1a2872443.js" charset="utf-8"></script>
The same works for less_, too:
.. code-block:: django
{% load compress %}
{% compress css %}
<link rel="stylesheet" href="/static/css/styles.less" charset="utf-8">
<style type="text/less">
@color: #4D926F;
#header {
color: @color;
}
</style>
{% endcompress %}
Which would be rendered something like::
<link rel="stylesheet" href="/static/CACHE/css/8ccf8d877f18.css" type="text/css" charset="utf-8">
.. _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",
},
}

65
tox.ini
View File

@@ -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