diff options
44 files changed, 4373 insertions, 114 deletions
diff --git a/compressor/base.py b/compressor/base.py index 7200479..9d79dab 100644 --- a/compressor/base.py +++ b/compressor/base.py @@ -3,12 +3,12 @@ import os import codecs from importlib import import_module +import six from django.core.files.base import ContentFile -from django.utils import six from django.utils.safestring import mark_safe -from django.utils.six.moves.urllib.request import url2pathname from django.template.loader import render_to_string from django.utils.functional import cached_property +from six.moves.urllib.request import url2pathname from compressor.cache import get_hexdigest, get_mtime from compressor.conf import settings @@ -349,6 +349,13 @@ class Compressor(object): """ return self.render_output(mode, {"content": content}) + def output_preload(self, mode, content, forced=False, basename=None): + """ + The output method that returns <link> with rel="preload" and + proper href attribute for given file. + """ + return self.output_file(mode, content, forced, basename) + def render_output(self, mode, context=None): """ Renders the compressor output with the appropriate template for diff --git a/compressor/base.py.orig b/compressor/base.py.orig new file mode 100644 index 0000000..c04fd22 --- /dev/null +++ b/compressor/base.py.orig @@ -0,0 +1,384 @@ +from __future__ import with_statement, unicode_literals +import os +import codecs +from importlib import import_module + +<<<<<<< HEAD +======= +import six +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b +from django.core.files.base import ContentFile +from django.utils.safestring import mark_safe +from django.template.loader import render_to_string +from django.utils.functional import cached_property +from six.moves.urllib.request import url2pathname + +from compressor.cache import get_hexdigest, get_mtime +from compressor.conf import settings +from compressor.exceptions import (CompressorError, UncompressableFileError, + FilterDoesNotExist) +from compressor.filters import CachedCompilerFilter +from compressor.storage import compressor_file_storage +from compressor.signals import post_compress +from compressor.utils import get_class, get_mod_func, staticfiles + +# Some constants for nicer handling. +SOURCE_HUNK, SOURCE_FILE = 'inline', 'file' +METHOD_INPUT, METHOD_OUTPUT = 'input', 'output' + + +class Compressor(object): + """ + Base compressor object to be subclassed for content type + depending implementations details. + """ + + output_mimetypes = {} + + def __init__(self, resource_kind, content=None, output_prefix=None, + context=None, filters=None, *args, **kwargs): + if filters is None: + self.filters = settings.COMPRESS_FILTERS[resource_kind] + else: + self.filters = filters + if output_prefix is None: + self.output_prefix = resource_kind + else: + self.output_prefix = output_prefix + self.content = content or "" # rendered contents of {% compress %} tag + self.output_dir = settings.COMPRESS_OUTPUT_DIR.strip('/') + self.charset = settings.DEFAULT_CHARSET + self.split_content = [] + self.context = context or {} + self.resource_kind = resource_kind + self.extra_context = {} + self.precompiler_mimetypes = dict(settings.COMPRESS_PRECOMPILERS) + self.finders = staticfiles.finders + self._storage = None + + def copy(self, **kwargs): + keywords = dict( + content=self.content, + context=self.context, + output_prefix=self.output_prefix, + filters=self.filters) + keywords.update(kwargs) + return self.__class__(self.resource_kind, **keywords) + + @cached_property + def storage(self): + from compressor.storage import default_storage + return default_storage + + def split_contents(self): + """ + To be implemented in a subclass, should return an + iterable with four values: kind, value, basename, element + """ + raise NotImplementedError + + def get_template_name(self, mode): + """ + Returns the template path for the given mode. + """ + try: + template = getattr(self, "template_name_%s" % mode) + if template: + return template + except AttributeError: + pass + return "compressor/%s_%s.html" % (self.resource_kind, mode) + + def get_basename(self, url): + """ + Takes full path to a static file (eg. "/static/css/style.css") and + returns path with storage's base url removed (eg. "css/style.css"). + """ + try: + base_url = self.storage.base_url + except AttributeError: + base_url = settings.COMPRESS_URL + + # Cast ``base_url`` to a string to allow it to be + # a string-alike object to e.g. add ``SCRIPT_NAME`` + # WSGI param as a *path prefix* to the output URL. + # See https://code.djangoproject.com/ticket/25598. + base_url = six.text_type(base_url) + + if not url.startswith(base_url): + raise UncompressableFileError("'%s' isn't accessible via " + "COMPRESS_URL ('%s') and can't be " + "compressed" % (url, base_url)) + basename = url.replace(base_url, "", 1) + # drop the querystring, which is used for non-compressed cache-busting. + return basename.split("?", 1)[0] + + def get_filepath(self, content, basename=None): + """ + Returns file path for an output file based on contents. + + Returned path is relative to compressor storage's base url, for + example "CACHE/css/58a8c0714e59.css". + + When `basename` argument is provided then file name (without extension) + will be used as a part of returned file name, for example: + + get_filepath(content, "my_file.css") -> 'CACHE/css/my_file.58a8c0714e59.css' + """ + parts = [] + if basename: + filename = os.path.split(basename)[1] + parts.append(os.path.splitext(filename)[0]) + parts.extend([get_hexdigest(content, 12), self.resource_kind]) + return os.path.join(self.output_dir, self.output_prefix, '.'.join(parts)) + + def get_filename(self, basename): + """ + Returns full path to a file, for example: + + get_filename('css/one.css') -> '/full/path/to/static/css/one.css' + """ + filename = None + # First try finding the file using the storage class. + # This is skipped in DEBUG mode as files might be outdated in + # compressor's final destination (COMPRESS_ROOT) during development + if not settings.DEBUG: + try: + # call path first so remote storages don't make it to exists, + # which would cause network I/O + filename = self.storage.path(basename) + if not self.storage.exists(basename): + filename = None + except NotImplementedError: + # remote storages don't implement path, access the file locally + if compressor_file_storage.exists(basename): + filename = compressor_file_storage.path(basename) + # secondly try to find it with staticfiles + if not filename and self.finders: + filename = self.finders.find(url2pathname(basename)) + if filename: + return filename + # or just raise an exception as the last resort + raise UncompressableFileError( + "'%s' could not be found in the COMPRESS_ROOT '%s'%s" % + (basename, settings.COMPRESS_ROOT, + self.finders and " or with staticfiles." or ".")) + + def get_filecontent(self, filename, charset): + """ + Reads file contents using given `charset` and returns it as text. + """ + if charset == 'utf-8': + # Removes BOM + charset = 'utf-8-sig' + with codecs.open(filename, 'r', charset) as fd: + try: + return fd.read() + except IOError as e: + raise UncompressableFileError("IOError while processing " + "'%s': %s" % (filename, e)) + except UnicodeDecodeError as e: + raise UncompressableFileError("UnicodeDecodeError while " + "processing '%s' with " + "charset %s: %s" % + (filename, charset, e)) + + @cached_property + def parser(self): + return get_class(settings.COMPRESS_PARSER)(self.content) + + @cached_property + def cached_filters(self): + return [get_class(filter_cls) for filter_cls in self.filters] + + @cached_property + def mtimes(self): + return [str(get_mtime(value)) + for kind, value, basename, elem in self.split_contents() + if kind == SOURCE_FILE] + + @cached_property + def cachekey(self): + return get_hexdigest(''.join( + [self.content] + self.mtimes).encode(self.charset), 12) + + def hunks(self, forced=False): + """ + The heart of content parsing, iterates over the + list of split contents and looks at its kind + to decide what to do with it. Should yield a + bunch of precompiled and/or rendered hunks. + """ + enabled = settings.COMPRESS_ENABLED or forced + + for kind, value, basename, elem in self.split_contents(): + precompiled = False + attribs = self.parser.elem_attribs(elem) + charset = attribs.get("charset", self.charset) + options = { + 'method': METHOD_INPUT, + 'elem': elem, + 'kind': kind, + 'basename': basename, + 'charset': charset, + } + + if kind == SOURCE_FILE: + options = dict(options, filename=value) + value = self.get_filecontent(value, charset) + + if self.precompiler_mimetypes: + precompiled, value = self.precompile(value, **options) + + if enabled: + yield self.filter(value, self.cached_filters, **options) + elif precompiled: + for filter_cls in self.cached_filters: + if filter_cls.run_with_compression_disabled: + value = self.filter(value, [filter_cls], **options) + yield self.handle_output(kind, value, forced=True, + basename=basename) + else: + yield self.parser.elem_str(elem) + + def filter_output(self, content): + """ + Passes the concatenated content to the 'output' methods + of the compressor filters. + """ + return self.filter(content, self.cached_filters, method=METHOD_OUTPUT) + + def filter_input(self, forced=False): + """ + Passes each hunk (file or code) to the 'input' methods + of the compressor filters. + """ + content = [] + for hunk in self.hunks(forced): + content.append(hunk) + return content + + def precompile(self, content, kind=None, elem=None, filename=None, + charset=None, **kwargs): + """ + Processes file using a pre compiler. + + This is the place where files like coffee script are processed. + """ + if not kind: + return False, content + attrs = self.parser.elem_attribs(elem) + mimetype = attrs.get("type", None) + if mimetype is None: + return False, content + + filter_or_command = self.precompiler_mimetypes.get(mimetype) + if filter_or_command is None: + if mimetype in self.output_mimetypes: + return False, content + raise CompressorError("Couldn't find any precompiler in " + "COMPRESS_PRECOMPILERS setting for " + "mimetype '%s'." % mimetype) + + mod_name, cls_name = get_mod_func(filter_or_command) + try: + mod = import_module(mod_name) + except (ImportError, TypeError): + filter = CachedCompilerFilter( + content=content, filter_type=self.resource_kind, filename=filename, + charset=charset, command=filter_or_command, mimetype=mimetype) + return True, filter.input(**kwargs) + try: + precompiler_class = getattr(mod, cls_name) + except AttributeError: + raise FilterDoesNotExist('Could not find "%s".' % filter_or_command) + filter = precompiler_class( + content, attrs=attrs, filter_type=self.resource_kind, charset=charset, + filename=filename) + return True, filter.input(**kwargs) + + def filter(self, content, filters, method, **kwargs): + for filter_cls in filters: + filter_func = getattr( + filter_cls(content, filter_type=self.resource_kind), method) + try: + if callable(filter_func): + content = filter_func(**kwargs) + except NotImplementedError: + pass + return content + + def output(self, mode='file', forced=False, basename=None): + """ + The general output method, override in subclass if you need to do + any custom modification. Calls other mode specific methods or simply + returns the content directly. + """ + output = '\n'.join(self.filter_input(forced)) + + if not output: + return '' + + if settings.COMPRESS_ENABLED or forced: + filtered_output = self.filter_output(output) + return self.handle_output(mode, filtered_output, forced, basename) + + return output + + def handle_output(self, mode, content, forced, basename=None): + # Then check for the appropriate output method and call it + output_func = getattr(self, "output_%s" % mode, None) + if callable(output_func): + return output_func(mode, content, forced, basename) + # Total failure, raise a general exception + raise CompressorError( + "Couldn't find output method for mode '%s'" % mode) + + def output_file(self, mode, content, forced=False, basename=None): + """ + The output method that saves the content to a file and renders + the appropriate template with the file's URL. + """ + new_filepath = self.get_filepath(content, basename=basename) + if not self.storage.exists(new_filepath) or forced: + self.storage.save(new_filepath, ContentFile(content.encode(self.charset))) + url = mark_safe(self.storage.url(new_filepath)) + return self.render_output(mode, {"url": url}) + + def output_inline(self, mode, content, forced=False, basename=None): + """ + The output method that directly returns the content for inline + display. + """ + return self.render_output(mode, {"content": content}) + + def output_preload(self, mode, content, forced=False, basename=None): + """ + The output method that returns <link> with rel="preload" and + proper href attribute for given file. + """ + return self.output_file(mode, content, forced, basename) + + def render_output(self, mode, context=None): + """ + Renders the compressor output with the appropriate template for + the given mode and template context. + """ + # Just in case someone renders the compressor outside + # the usual template rendering cycle + if 'compressed' not in self.context: + self.context['compressed'] = {} + + self.context['compressed'].update(context or {}) + self.context['compressed'].update(self.extra_context) + + if hasattr(self.context, 'flatten'): + # Passing Contexts to Template.render is deprecated since Django 1.8. + final_context = self.context.flatten() + else: + final_context = self.context + + post_compress.send(sender=self.__class__, type=self.resource_kind, + mode=mode, context=final_context) + template_name = self.get_template_name(mode) + return render_to_string(template_name, context=final_context) diff --git a/compressor/base_BACKUP_20651.py b/compressor/base_BACKUP_20651.py new file mode 100644 index 0000000..c04fd22 --- /dev/null +++ b/compressor/base_BACKUP_20651.py @@ -0,0 +1,384 @@ +from __future__ import with_statement, unicode_literals +import os +import codecs +from importlib import import_module + +<<<<<<< HEAD +======= +import six +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b +from django.core.files.base import ContentFile +from django.utils.safestring import mark_safe +from django.template.loader import render_to_string +from django.utils.functional import cached_property +from six.moves.urllib.request import url2pathname + +from compressor.cache import get_hexdigest, get_mtime +from compressor.conf import settings +from compressor.exceptions import (CompressorError, UncompressableFileError, + FilterDoesNotExist) +from compressor.filters import CachedCompilerFilter +from compressor.storage import compressor_file_storage +from compressor.signals import post_compress +from compressor.utils import get_class, get_mod_func, staticfiles + +# Some constants for nicer handling. +SOURCE_HUNK, SOURCE_FILE = 'inline', 'file' +METHOD_INPUT, METHOD_OUTPUT = 'input', 'output' + + +class Compressor(object): + """ + Base compressor object to be subclassed for content type + depending implementations details. + """ + + output_mimetypes = {} + + def __init__(self, resource_kind, content=None, output_prefix=None, + context=None, filters=None, *args, **kwargs): + if filters is None: + self.filters = settings.COMPRESS_FILTERS[resource_kind] + else: + self.filters = filters + if output_prefix is None: + self.output_prefix = resource_kind + else: + self.output_prefix = output_prefix + self.content = content or "" # rendered contents of {% compress %} tag + self.output_dir = settings.COMPRESS_OUTPUT_DIR.strip('/') + self.charset = settings.DEFAULT_CHARSET + self.split_content = [] + self.context = context or {} + self.resource_kind = resource_kind + self.extra_context = {} + self.precompiler_mimetypes = dict(settings.COMPRESS_PRECOMPILERS) + self.finders = staticfiles.finders + self._storage = None + + def copy(self, **kwargs): + keywords = dict( + content=self.content, + context=self.context, + output_prefix=self.output_prefix, + filters=self.filters) + keywords.update(kwargs) + return self.__class__(self.resource_kind, **keywords) + + @cached_property + def storage(self): + from compressor.storage import default_storage + return default_storage + + def split_contents(self): + """ + To be implemented in a subclass, should return an + iterable with four values: kind, value, basename, element + """ + raise NotImplementedError + + def get_template_name(self, mode): + """ + Returns the template path for the given mode. + """ + try: + template = getattr(self, "template_name_%s" % mode) + if template: + return template + except AttributeError: + pass + return "compressor/%s_%s.html" % (self.resource_kind, mode) + + def get_basename(self, url): + """ + Takes full path to a static file (eg. "/static/css/style.css") and + returns path with storage's base url removed (eg. "css/style.css"). + """ + try: + base_url = self.storage.base_url + except AttributeError: + base_url = settings.COMPRESS_URL + + # Cast ``base_url`` to a string to allow it to be + # a string-alike object to e.g. add ``SCRIPT_NAME`` + # WSGI param as a *path prefix* to the output URL. + # See https://code.djangoproject.com/ticket/25598. + base_url = six.text_type(base_url) + + if not url.startswith(base_url): + raise UncompressableFileError("'%s' isn't accessible via " + "COMPRESS_URL ('%s') and can't be " + "compressed" % (url, base_url)) + basename = url.replace(base_url, "", 1) + # drop the querystring, which is used for non-compressed cache-busting. + return basename.split("?", 1)[0] + + def get_filepath(self, content, basename=None): + """ + Returns file path for an output file based on contents. + + Returned path is relative to compressor storage's base url, for + example "CACHE/css/58a8c0714e59.css". + + When `basename` argument is provided then file name (without extension) + will be used as a part of returned file name, for example: + + get_filepath(content, "my_file.css") -> 'CACHE/css/my_file.58a8c0714e59.css' + """ + parts = [] + if basename: + filename = os.path.split(basename)[1] + parts.append(os.path.splitext(filename)[0]) + parts.extend([get_hexdigest(content, 12), self.resource_kind]) + return os.path.join(self.output_dir, self.output_prefix, '.'.join(parts)) + + def get_filename(self, basename): + """ + Returns full path to a file, for example: + + get_filename('css/one.css') -> '/full/path/to/static/css/one.css' + """ + filename = None + # First try finding the file using the storage class. + # This is skipped in DEBUG mode as files might be outdated in + # compressor's final destination (COMPRESS_ROOT) during development + if not settings.DEBUG: + try: + # call path first so remote storages don't make it to exists, + # which would cause network I/O + filename = self.storage.path(basename) + if not self.storage.exists(basename): + filename = None + except NotImplementedError: + # remote storages don't implement path, access the file locally + if compressor_file_storage.exists(basename): + filename = compressor_file_storage.path(basename) + # secondly try to find it with staticfiles + if not filename and self.finders: + filename = self.finders.find(url2pathname(basename)) + if filename: + return filename + # or just raise an exception as the last resort + raise UncompressableFileError( + "'%s' could not be found in the COMPRESS_ROOT '%s'%s" % + (basename, settings.COMPRESS_ROOT, + self.finders and " or with staticfiles." or ".")) + + def get_filecontent(self, filename, charset): + """ + Reads file contents using given `charset` and returns it as text. + """ + if charset == 'utf-8': + # Removes BOM + charset = 'utf-8-sig' + with codecs.open(filename, 'r', charset) as fd: + try: + return fd.read() + except IOError as e: + raise UncompressableFileError("IOError while processing " + "'%s': %s" % (filename, e)) + except UnicodeDecodeError as e: + raise UncompressableFileError("UnicodeDecodeError while " + "processing '%s' with " + "charset %s: %s" % + (filename, charset, e)) + + @cached_property + def parser(self): + return get_class(settings.COMPRESS_PARSER)(self.content) + + @cached_property + def cached_filters(self): + return [get_class(filter_cls) for filter_cls in self.filters] + + @cached_property + def mtimes(self): + return [str(get_mtime(value)) + for kind, value, basename, elem in self.split_contents() + if kind == SOURCE_FILE] + + @cached_property + def cachekey(self): + return get_hexdigest(''.join( + [self.content] + self.mtimes).encode(self.charset), 12) + + def hunks(self, forced=False): + """ + The heart of content parsing, iterates over the + list of split contents and looks at its kind + to decide what to do with it. Should yield a + bunch of precompiled and/or rendered hunks. + """ + enabled = settings.COMPRESS_ENABLED or forced + + for kind, value, basename, elem in self.split_contents(): + precompiled = False + attribs = self.parser.elem_attribs(elem) + charset = attribs.get("charset", self.charset) + options = { + 'method': METHOD_INPUT, + 'elem': elem, + 'kind': kind, + 'basename': basename, + 'charset': charset, + } + + if kind == SOURCE_FILE: + options = dict(options, filename=value) + value = self.get_filecontent(value, charset) + + if self.precompiler_mimetypes: + precompiled, value = self.precompile(value, **options) + + if enabled: + yield self.filter(value, self.cached_filters, **options) + elif precompiled: + for filter_cls in self.cached_filters: + if filter_cls.run_with_compression_disabled: + value = self.filter(value, [filter_cls], **options) + yield self.handle_output(kind, value, forced=True, + basename=basename) + else: + yield self.parser.elem_str(elem) + + def filter_output(self, content): + """ + Passes the concatenated content to the 'output' methods + of the compressor filters. + """ + return self.filter(content, self.cached_filters, method=METHOD_OUTPUT) + + def filter_input(self, forced=False): + """ + Passes each hunk (file or code) to the 'input' methods + of the compressor filters. + """ + content = [] + for hunk in self.hunks(forced): + content.append(hunk) + return content + + def precompile(self, content, kind=None, elem=None, filename=None, + charset=None, **kwargs): + """ + Processes file using a pre compiler. + + This is the place where files like coffee script are processed. + """ + if not kind: + return False, content + attrs = self.parser.elem_attribs(elem) + mimetype = attrs.get("type", None) + if mimetype is None: + return False, content + + filter_or_command = self.precompiler_mimetypes.get(mimetype) + if filter_or_command is None: + if mimetype in self.output_mimetypes: + return False, content + raise CompressorError("Couldn't find any precompiler in " + "COMPRESS_PRECOMPILERS setting for " + "mimetype '%s'." % mimetype) + + mod_name, cls_name = get_mod_func(filter_or_command) + try: + mod = import_module(mod_name) + except (ImportError, TypeError): + filter = CachedCompilerFilter( + content=content, filter_type=self.resource_kind, filename=filename, + charset=charset, command=filter_or_command, mimetype=mimetype) + return True, filter.input(**kwargs) + try: + precompiler_class = getattr(mod, cls_name) + except AttributeError: + raise FilterDoesNotExist('Could not find "%s".' % filter_or_command) + filter = precompiler_class( + content, attrs=attrs, filter_type=self.resource_kind, charset=charset, + filename=filename) + return True, filter.input(**kwargs) + + def filter(self, content, filters, method, **kwargs): + for filter_cls in filters: + filter_func = getattr( + filter_cls(content, filter_type=self.resource_kind), method) + try: + if callable(filter_func): + content = filter_func(**kwargs) + except NotImplementedError: + pass + return content + + def output(self, mode='file', forced=False, basename=None): + """ + The general output method, override in subclass if you need to do + any custom modification. Calls other mode specific methods or simply + returns the content directly. + """ + output = '\n'.join(self.filter_input(forced)) + + if not output: + return '' + + if settings.COMPRESS_ENABLED or forced: + filtered_output = self.filter_output(output) + return self.handle_output(mode, filtered_output, forced, basename) + + return output + + def handle_output(self, mode, content, forced, basename=None): + # Then check for the appropriate output method and call it + output_func = getattr(self, "output_%s" % mode, None) + if callable(output_func): + return output_func(mode, content, forced, basename) + # Total failure, raise a general exception + raise CompressorError( + "Couldn't find output method for mode '%s'" % mode) + + def output_file(self, mode, content, forced=False, basename=None): + """ + The output method that saves the content to a file and renders + the appropriate template with the file's URL. + """ + new_filepath = self.get_filepath(content, basename=basename) + if not self.storage.exists(new_filepath) or forced: + self.storage.save(new_filepath, ContentFile(content.encode(self.charset))) + url = mark_safe(self.storage.url(new_filepath)) + return self.render_output(mode, {"url": url}) + + def output_inline(self, mode, content, forced=False, basename=None): + """ + The output method that directly returns the content for inline + display. + """ + return self.render_output(mode, {"content": content}) + + def output_preload(self, mode, content, forced=False, basename=None): + """ + The output method that returns <link> with rel="preload" and + proper href attribute for given file. + """ + return self.output_file(mode, content, forced, basename) + + def render_output(self, mode, context=None): + """ + Renders the compressor output with the appropriate template for + the given mode and template context. + """ + # Just in case someone renders the compressor outside + # the usual template rendering cycle + if 'compressed' not in self.context: + self.context['compressed'] = {} + + self.context['compressed'].update(context or {}) + self.context['compressed'].update(self.extra_context) + + if hasattr(self.context, 'flatten'): + # Passing Contexts to Template.render is deprecated since Django 1.8. + final_context = self.context.flatten() + else: + final_context = self.context + + post_compress.send(sender=self.__class__, type=self.resource_kind, + mode=mode, context=final_context) + template_name = self.get_template_name(mode) + return render_to_string(template_name, context=final_context) diff --git a/compressor/base_BASE_20651.py b/compressor/base_BASE_20651.py new file mode 100644 index 0000000..a2529e6 --- /dev/null +++ b/compressor/base_BASE_20651.py @@ -0,0 +1,365 @@ +from __future__ import with_statement, unicode_literals +import os +import codecs +from importlib import import_module + +from django import VERSION +from django.core.files.base import ContentFile +from django.utils import six +from django.utils.safestring import mark_safe +from django.utils.six.moves.urllib.request import url2pathname +from django.template.loader import render_to_string +from django.utils.functional import cached_property + +from compressor.cache import get_hexdigest, get_mtime +from compressor.conf import settings +from compressor.exceptions import (CompressorError, UncompressableFileError, + FilterDoesNotExist) +from compressor.filters import CachedCompilerFilter +from compressor.filters.css_default import CssAbsoluteFilter, CssRelativeFilter +from compressor.storage import compressor_file_storage +from compressor.signals import post_compress +from compressor.utils import get_class, get_mod_func, staticfiles + +# Some constants for nicer handling. +SOURCE_HUNK, SOURCE_FILE = 'inline', 'file' +METHOD_INPUT, METHOD_OUTPUT = 'input', 'output' + + +class Compressor(object): + """ + Base compressor object to be subclassed for content type + depending implementations details. + """ + + def __init__(self, content=None, output_prefix=None, + context=None, filters=None, *args, **kwargs): + self.content = content or "" # rendered contents of {% compress %} tag + self.output_prefix = output_prefix or "compressed" + self.output_dir = settings.COMPRESS_OUTPUT_DIR.strip('/') + self.charset = settings.DEFAULT_CHARSET + self.split_content = [] + self.context = context or {} + self.type = output_prefix or "" + self.filters = filters or [] + self.extra_context = {} + self.precompiler_mimetypes = dict(settings.COMPRESS_PRECOMPILERS) + self.finders = staticfiles.finders + self._storage = None + + @cached_property + def storage(self): + from compressor.storage import default_storage + return default_storage + + def split_contents(self): + """ + To be implemented in a subclass, should return an + iterable with four values: kind, value, basename, element + """ + raise NotImplementedError + + def get_template_name(self, mode): + """ + Returns the template path for the given mode. + """ + try: + template = getattr(self, "template_name_%s" % mode) + if template: + return template + except AttributeError: + pass + return "compressor/%s_%s.html" % (self.type, mode) + + def get_basename(self, url): + """ + Takes full path to a static file (eg. "/static/css/style.css") and + returns path with storage's base url removed (eg. "css/style.css"). + """ + try: + base_url = self.storage.base_url + except AttributeError: + base_url = settings.COMPRESS_URL + + # Cast ``base_url`` to a string to allow it to be + # a string-alike object to e.g. add ``SCRIPT_NAME`` + # WSGI param as a *path prefix* to the output URL. + # See https://code.djangoproject.com/ticket/25598. + base_url = six.text_type(base_url) + + if not url.startswith(base_url): + raise UncompressableFileError("'%s' isn't accessible via " + "COMPRESS_URL ('%s') and can't be " + "compressed" % (url, base_url)) + basename = url.replace(base_url, "", 1) + # drop the querystring, which is used for non-compressed cache-busting. + return basename.split("?", 1)[0] + + def get_filepath(self, content, basename=None): + """ + Returns file path for an output file based on contents. + + Returned path is relative to compressor storage's base url, for + example "CACHE/css/58a8c0714e59.css". + + When `basename` argument is provided then file name (without extension) + will be used as a part of returned file name, for example: + + get_filepath(content, "my_file.css") -> 'CACHE/css/my_file.58a8c0714e59.css' + """ + parts = [] + if basename: + filename = os.path.split(basename)[1] + parts.append(os.path.splitext(filename)[0]) + parts.extend([get_hexdigest(content, 12), self.type]) + return os.path.join(self.output_dir, self.output_prefix, '.'.join(parts)) + + def get_filename(self, basename): + """ + Returns full path to a file, for example: + + get_filename('css/one.css') -> '/full/path/to/static/css/one.css' + """ + filename = None + # First try finding the file using the storage class. + # This is skipped in DEBUG mode as files might be outdated in + # compressor's final destination (COMPRESS_ROOT) during development + if not settings.DEBUG: + try: + # call path first so remote storages don't make it to exists, + # which would cause network I/O + filename = self.storage.path(basename) + if not self.storage.exists(basename): + filename = None + except NotImplementedError: + # remote storages don't implement path, access the file locally + if compressor_file_storage.exists(basename): + filename = compressor_file_storage.path(basename) + # secondly try to find it with staticfiles + if not filename and self.finders: + filename = self.finders.find(url2pathname(basename)) + if filename: + return filename + # or just raise an exception as the last resort + raise UncompressableFileError( + "'%s' could not be found in the COMPRESS_ROOT '%s'%s" % + (basename, settings.COMPRESS_ROOT, + self.finders and " or with staticfiles." or ".")) + + def get_filecontent(self, filename, charset): + """ + Reads file contents using given `charset` and returns it as text. + """ + if charset == 'utf-8': + # Removes BOM + charset = 'utf-8-sig' + with codecs.open(filename, 'r', charset) as fd: + try: + return fd.read() + except IOError as e: + raise UncompressableFileError("IOError while processing " + "'%s': %s" % (filename, e)) + except UnicodeDecodeError as e: + raise UncompressableFileError("UnicodeDecodeError while " + "processing '%s' with " + "charset %s: %s" % + (filename, charset, e)) + + @cached_property + def parser(self): + return get_class(settings.COMPRESS_PARSER)(self.content) + + @cached_property + def cached_filters(self): + return [get_class(filter_cls) for filter_cls in self.filters] + + @cached_property + def mtimes(self): + return [str(get_mtime(value)) + for kind, value, basename, elem in self.split_contents() + if kind == SOURCE_FILE] + + @cached_property + def cachekey(self): + return get_hexdigest(''.join( + [self.content] + self.mtimes).encode(self.charset), 12) + + def hunks(self, forced=False): + """ + The heart of content parsing, iterates over the + list of split contents and looks at its kind + to decide what to do with it. Should yield a + bunch of precompiled and/or rendered hunks. + """ + enabled = settings.COMPRESS_ENABLED or forced + + for kind, value, basename, elem in self.split_contents(): + precompiled = False + attribs = self.parser.elem_attribs(elem) + charset = attribs.get("charset", self.charset) + options = { + 'method': METHOD_INPUT, + 'elem': elem, + 'kind': kind, + 'basename': basename, + 'charset': charset, + } + + if kind == SOURCE_FILE: + options = dict(options, filename=value) + value = self.get_filecontent(value, charset) + + if self.precompiler_mimetypes: + precompiled, value = self.precompile(value, **options) + + if enabled: + yield self.filter(value, self.cached_filters, **options) + elif precompiled: + # since precompiling moves files around, it breaks url() + # statements in css files. therefore we run the absolute filter + # on precompiled css files even if compression is disabled. + if CssAbsoluteFilter in self.cached_filters: + value = self.filter(value, [CssAbsoluteFilter], **options) + elif CssRelativeFilter in self.cached_filters: + value = self.filter(value, [CssRelativeFilter], **options) + yield self.handle_output(kind, value, forced=True, + basename=basename) + else: + yield self.parser.elem_str(elem) + + def filter_output(self, content): + """ + Passes the concatenated content to the 'output' methods + of the compressor filters. + """ + return self.filter(content, self.cached_filters, method=METHOD_OUTPUT) + + def filter_input(self, forced=False): + """ + Passes each hunk (file or code) to the 'input' methods + of the compressor filters. + """ + content = [] + for hunk in self.hunks(forced): + content.append(hunk) + return content + + def precompile(self, content, kind=None, elem=None, filename=None, + charset=None, **kwargs): + """ + Processes file using a pre compiler. + + This is the place where files like coffee script are processed. + """ + if not kind: + return False, content + attrs = self.parser.elem_attribs(elem) + mimetype = attrs.get("type", None) + if mimetype is None: + return False, content + + filter_or_command = self.precompiler_mimetypes.get(mimetype) + if filter_or_command is None: + if mimetype in ("text/css", "text/javascript"): + return False, content + raise CompressorError("Couldn't find any precompiler in " + "COMPRESS_PRECOMPILERS setting for " + "mimetype '%s'." % mimetype) + + mod_name, cls_name = get_mod_func(filter_or_command) + try: + mod = import_module(mod_name) + except (ImportError, TypeError): + filter = CachedCompilerFilter( + content=content, filter_type=self.type, filename=filename, + charset=charset, command=filter_or_command, mimetype=mimetype) + return True, filter.input(**kwargs) + try: + precompiler_class = getattr(mod, cls_name) + except AttributeError: + raise FilterDoesNotExist('Could not find "%s".' % filter_or_command) + filter = precompiler_class( + content, attrs=attrs, filter_type=self.type, charset=charset, + filename=filename) + return True, filter.input(**kwargs) + + def filter(self, content, filters, method, **kwargs): + for filter_cls in filters: + filter_func = getattr( + filter_cls(content, filter_type=self.type), method) + try: + if callable(filter_func): + content = filter_func(**kwargs) + except NotImplementedError: + pass + return content + + def output(self, mode='file', forced=False, basename=None): + """ + The general output method, override in subclass if you need to do + any custom modification. Calls other mode specific methods or simply + returns the content directly. + """ + output = '\n'.join(self.filter_input(forced)) + + if not output: + return '' + + if settings.COMPRESS_ENABLED or forced: + filtered_output = self.filter_output(output) + return self.handle_output(mode, filtered_output, forced, basename) + + return output + + def handle_output(self, mode, content, forced, basename=None): + # Then check for the appropriate output method and call it + output_func = getattr(self, "output_%s" % mode, None) + if callable(output_func): + return output_func(mode, content, forced, basename) + # Total failure, raise a general exception + raise CompressorError( + "Couldn't find output method for mode '%s'" % mode) + + def output_file(self, mode, content, forced=False, basename=None): + """ + The output method that saves the content to a file and renders + the appropriate template with the file's URL. + """ + new_filepath = self.get_filepath(content, basename=basename) + if not self.storage.exists(new_filepath) or forced: + self.storage.save(new_filepath, ContentFile(content.encode(self.charset))) + url = mark_safe(self.storage.url(new_filepath)) + return self.render_output(mode, {"url": url}) + + def output_inline(self, mode, content, forced=False, basename=None): + """ + The output method that directly returns the content for inline + display. + """ + return self.render_output(mode, {"content": content}) + + def render_output(self, mode, context=None): + """ + Renders the compressor output with the appropriate template for + the given mode and template context. + """ + # Just in case someone renders the compressor outside + # the usual template rendering cycle + if 'compressed' not in self.context: + self.context['compressed'] = {} + + self.context['compressed'].update(context or {}) + self.context['compressed'].update(self.extra_context) + + if hasattr(self.context, 'flatten') and VERSION >= (1, 9): + # Passing Contexts to Template.render is deprecated since Django 1.8. + # However, we use the fix below only for django 1.9 and above, since + # the flatten method is buggy in 1.8, see https://code.djangoproject.com/ticket/24765 + final_context = self.context.flatten() + else: + final_context = self.context + + post_compress.send(sender=self.__class__, type=self.type, + mode=mode, context=final_context) + template_name = self.get_template_name(mode) + return render_to_string(template_name, context=final_context) diff --git a/compressor/base_LOCAL_20651.py b/compressor/base_LOCAL_20651.py new file mode 100644 index 0000000..7200479 --- /dev/null +++ b/compressor/base_LOCAL_20651.py @@ -0,0 +1,374 @@ +from __future__ import with_statement, unicode_literals +import os +import codecs +from importlib import import_module + +from django.core.files.base import ContentFile +from django.utils import six +from django.utils.safestring import mark_safe +from django.utils.six.moves.urllib.request import url2pathname +from django.template.loader import render_to_string +from django.utils.functional import cached_property + +from compressor.cache import get_hexdigest, get_mtime +from compressor.conf import settings +from compressor.exceptions import (CompressorError, UncompressableFileError, + FilterDoesNotExist) +from compressor.filters import CachedCompilerFilter +from compressor.storage import compressor_file_storage +from compressor.signals import post_compress +from compressor.utils import get_class, get_mod_func, staticfiles + +# Some constants for nicer handling. +SOURCE_HUNK, SOURCE_FILE = 'inline', 'file' +METHOD_INPUT, METHOD_OUTPUT = 'input', 'output' + + +class Compressor(object): + """ + Base compressor object to be subclassed for content type + depending implementations details. + """ + + output_mimetypes = {} + + def __init__(self, resource_kind, content=None, output_prefix=None, + context=None, filters=None, *args, **kwargs): + if filters is None: + self.filters = settings.COMPRESS_FILTERS[resource_kind] + else: + self.filters = filters + if output_prefix is None: + self.output_prefix = resource_kind + else: + self.output_prefix = output_prefix + self.content = content or "" # rendered contents of {% compress %} tag + self.output_dir = settings.COMPRESS_OUTPUT_DIR.strip('/') + self.charset = settings.DEFAULT_CHARSET + self.split_content = [] + self.context = context or {} + self.resource_kind = resource_kind + self.extra_context = {} + self.precompiler_mimetypes = dict(settings.COMPRESS_PRECOMPILERS) + self.finders = staticfiles.finders + self._storage = None + + def copy(self, **kwargs): + keywords = dict( + content=self.content, + context=self.context, + output_prefix=self.output_prefix, + filters=self.filters) + keywords.update(kwargs) + return self.__class__(self.resource_kind, **keywords) + + @cached_property + def storage(self): + from compressor.storage import default_storage + return default_storage + + def split_contents(self): + """ + To be implemented in a subclass, should return an + iterable with four values: kind, value, basename, element + """ + raise NotImplementedError + + def get_template_name(self, mode): + """ + Returns the template path for the given mode. + """ + try: + template = getattr(self, "template_name_%s" % mode) + if template: + return template + except AttributeError: + pass + return "compressor/%s_%s.html" % (self.resource_kind, mode) + + def get_basename(self, url): + """ + Takes full path to a static file (eg. "/static/css/style.css") and + returns path with storage's base url removed (eg. "css/style.css"). + """ + try: + base_url = self.storage.base_url + except AttributeError: + base_url = settings.COMPRESS_URL + + # Cast ``base_url`` to a string to allow it to be + # a string-alike object to e.g. add ``SCRIPT_NAME`` + # WSGI param as a *path prefix* to the output URL. + # See https://code.djangoproject.com/ticket/25598. + base_url = six.text_type(base_url) + + if not url.startswith(base_url): + raise UncompressableFileError("'%s' isn't accessible via " + "COMPRESS_URL ('%s') and can't be " + "compressed" % (url, base_url)) + basename = url.replace(base_url, "", 1) + # drop the querystring, which is used for non-compressed cache-busting. + return basename.split("?", 1)[0] + + def get_filepath(self, content, basename=None): + """ + Returns file path for an output file based on contents. + + Returned path is relative to compressor storage's base url, for + example "CACHE/css/58a8c0714e59.css". + + When `basename` argument is provided then file name (without extension) + will be used as a part of returned file name, for example: + + get_filepath(content, "my_file.css") -> 'CACHE/css/my_file.58a8c0714e59.css' + """ + parts = [] + if basename: + filename = os.path.split(basename)[1] + parts.append(os.path.splitext(filename)[0]) + parts.extend([get_hexdigest(content, 12), self.resource_kind]) + return os.path.join(self.output_dir, self.output_prefix, '.'.join(parts)) + + def get_filename(self, basename): + """ + Returns full path to a file, for example: + + get_filename('css/one.css') -> '/full/path/to/static/css/one.css' + """ + filename = None + # First try finding the file using the storage class. + # This is skipped in DEBUG mode as files might be outdated in + # compressor's final destination (COMPRESS_ROOT) during development + if not settings.DEBUG: + try: + # call path first so remote storages don't make it to exists, + # which would cause network I/O + filename = self.storage.path(basename) + if not self.storage.exists(basename): + filename = None + except NotImplementedError: + # remote storages don't implement path, access the file locally + if compressor_file_storage.exists(basename): + filename = compressor_file_storage.path(basename) + # secondly try to find it with staticfiles + if not filename and self.finders: + filename = self.finders.find(url2pathname(basename)) + if filename: + return filename + # or just raise an exception as the last resort + raise UncompressableFileError( + "'%s' could not be found in the COMPRESS_ROOT '%s'%s" % + (basename, settings.COMPRESS_ROOT, + self.finders and " or with staticfiles." or ".")) + + def get_filecontent(self, filename, charset): + """ + Reads file contents using given `charset` and returns it as text. + """ + if charset == 'utf-8': + # Removes BOM + charset = 'utf-8-sig' + with codecs.open(filename, 'r', charset) as fd: + try: + return fd.read() + except IOError as e: + raise UncompressableFileError("IOError while processing " + "'%s': %s" % (filename, e)) + except UnicodeDecodeError as e: + raise UncompressableFileError("UnicodeDecodeError while " + "processing '%s' with " + "charset %s: %s" % + (filename, charset, e)) + + @cached_property + def parser(self): + return get_class(settings.COMPRESS_PARSER)(self.content) + + @cached_property + def cached_filters(self): + return [get_class(filter_cls) for filter_cls in self.filters] + + @cached_property + def mtimes(self): + return [str(get_mtime(value)) + for kind, value, basename, elem in self.split_contents() + if kind == SOURCE_FILE] + + @cached_property + def cachekey(self): + return get_hexdigest(''.join( + [self.content] + self.mtimes).encode(self.charset), 12) + + def hunks(self, forced=False): + """ + The heart of content parsing, iterates over the + list of split contents and looks at its kind + to decide what to do with it. Should yield a + bunch of precompiled and/or rendered hunks. + """ + enabled = settings.COMPRESS_ENABLED or forced + + for kind, value, basename, elem in self.split_contents(): + precompiled = False + attribs = self.parser.elem_attribs(elem) + charset = attribs.get("charset", self.charset) + options = { + 'method': METHOD_INPUT, + 'elem': elem, + 'kind': kind, + 'basename': basename, + 'charset': charset, + } + + if kind == SOURCE_FILE: + options = dict(options, filename=value) + value = self.get_filecontent(value, charset) + + if self.precompiler_mimetypes: + precompiled, value = self.precompile(value, **options) + + if enabled: + yield self.filter(value, self.cached_filters, **options) + elif precompiled: + for filter_cls in self.cached_filters: + if filter_cls.run_with_compression_disabled: + value = self.filter(value, [filter_cls], **options) + yield self.handle_output(kind, value, forced=True, + basename=basename) + else: + yield self.parser.elem_str(elem) + + def filter_output(self, content): + """ + Passes the concatenated content to the 'output' methods + of the compressor filters. + """ + return self.filter(content, self.cached_filters, method=METHOD_OUTPUT) + + def filter_input(self, forced=False): + """ + Passes each hunk (file or code) to the 'input' methods + of the compressor filters. + """ + content = [] + for hunk in self.hunks(forced): + content.append(hunk) + return content + + def precompile(self, content, kind=None, elem=None, filename=None, + charset=None, **kwargs): + """ + Processes file using a pre compiler. + + This is the place where files like coffee script are processed. + """ + if not kind: + return False, content + attrs = self.parser.elem_attribs(elem) + mimetype = attrs.get("type", None) + if mimetype is None: + return False, content + + filter_or_command = self.precompiler_mimetypes.get(mimetype) + if filter_or_command is None: + if mimetype in self.output_mimetypes: + return False, content + raise CompressorError("Couldn't find any precompiler in " + "COMPRESS_PRECOMPILERS setting for " + "mimetype '%s'." % mimetype) + + mod_name, cls_name = get_mod_func(filter_or_command) + try: + mod = import_module(mod_name) + except (ImportError, TypeError): + filter = CachedCompilerFilter( + content=content, filter_type=self.resource_kind, filename=filename, + charset=charset, command=filter_or_command, mimetype=mimetype) + return True, filter.input(**kwargs) + try: + precompiler_class = getattr(mod, cls_name) + except AttributeError: + raise FilterDoesNotExist('Could not find "%s".' % filter_or_command) + filter = precompiler_class( + content, attrs=attrs, filter_type=self.resource_kind, charset=charset, + filename=filename) + return True, filter.input(**kwargs) + + def filter(self, content, filters, method, **kwargs): + for filter_cls in filters: + filter_func = getattr( + filter_cls(content, filter_type=self.resource_kind), method) + try: + if callable(filter_func): + content = filter_func(**kwargs) + except NotImplementedError: + pass + return content + + def output(self, mode='file', forced=False, basename=None): + """ + The general output method, override in subclass if you need to do + any custom modification. Calls other mode specific methods or simply + returns the content directly. + """ + output = '\n'.join(self.filter_input(forced)) + + if not output: + return '' + + if settings.COMPRESS_ENABLED or forced: + filtered_output = self.filter_output(output) + return self.handle_output(mode, filtered_output, forced, basename) + + return output + + def handle_output(self, mode, content, forced, basename=None): + # Then check for the appropriate output method and call it + output_func = getattr(self, "output_%s" % mode, None) + if callable(output_func): + return output_func(mode, content, forced, basename) + # Total failure, raise a general exception + raise CompressorError( + "Couldn't find output method for mode '%s'" % mode) + + def output_file(self, mode, content, forced=False, basename=None): + """ + The output method that saves the content to a file and renders + the appropriate template with the file's URL. + """ + new_filepath = self.get_filepath(content, basename=basename) + if not self.storage.exists(new_filepath) or forced: + self.storage.save(new_filepath, ContentFile(content.encode(self.charset))) + url = mark_safe(self.storage.url(new_filepath)) + return self.render_output(mode, {"url": url}) + + def output_inline(self, mode, content, forced=False, basename=None): + """ + The output method that directly returns the content for inline + display. + """ + return self.render_output(mode, {"content": content}) + + def render_output(self, mode, context=None): + """ + Renders the compressor output with the appropriate template for + the given mode and template context. + """ + # Just in case someone renders the compressor outside + # the usual template rendering cycle + if 'compressed' not in self.context: + self.context['compressed'] = {} + + self.context['compressed'].update(context or {}) + self.context['compressed'].update(self.extra_context) + + if hasattr(self.context, 'flatten'): + # Passing Contexts to Template.render is deprecated since Django 1.8. + final_context = self.context.flatten() + else: + final_context = self.context + + post_compress.send(sender=self.__class__, type=self.resource_kind, + mode=mode, context=final_context) + template_name = self.get_template_name(mode) + return render_to_string(template_name, context=final_context) diff --git a/compressor/base_REMOTE_20651.py b/compressor/base_REMOTE_20651.py new file mode 100644 index 0000000..9d79dab --- /dev/null +++ b/compressor/base_REMOTE_20651.py @@ -0,0 +1,381 @@ +from __future__ import with_statement, unicode_literals +import os +import codecs +from importlib import import_module + +import six +from django.core.files.base import ContentFile +from django.utils.safestring import mark_safe +from django.template.loader import render_to_string +from django.utils.functional import cached_property +from six.moves.urllib.request import url2pathname + +from compressor.cache import get_hexdigest, get_mtime +from compressor.conf import settings +from compressor.exceptions import (CompressorError, UncompressableFileError, + FilterDoesNotExist) +from compressor.filters import CachedCompilerFilter +from compressor.storage import compressor_file_storage +from compressor.signals import post_compress +from compressor.utils import get_class, get_mod_func, staticfiles + +# Some constants for nicer handling. +SOURCE_HUNK, SOURCE_FILE = 'inline', 'file' +METHOD_INPUT, METHOD_OUTPUT = 'input', 'output' + + +class Compressor(object): + """ + Base compressor object to be subclassed for content type + depending implementations details. + """ + + output_mimetypes = {} + + def __init__(self, resource_kind, content=None, output_prefix=None, + context=None, filters=None, *args, **kwargs): + if filters is None: + self.filters = settings.COMPRESS_FILTERS[resource_kind] + else: + self.filters = filters + if output_prefix is None: + self.output_prefix = resource_kind + else: + self.output_prefix = output_prefix + self.content = content or "" # rendered contents of {% compress %} tag + self.output_dir = settings.COMPRESS_OUTPUT_DIR.strip('/') + self.charset = settings.DEFAULT_CHARSET + self.split_content = [] + self.context = context or {} + self.resource_kind = resource_kind + self.extra_context = {} + self.precompiler_mimetypes = dict(settings.COMPRESS_PRECOMPILERS) + self.finders = staticfiles.finders + self._storage = None + + def copy(self, **kwargs): + keywords = dict( + content=self.content, + context=self.context, + output_prefix=self.output_prefix, + filters=self.filters) + keywords.update(kwargs) + return self.__class__(self.resource_kind, **keywords) + + @cached_property + def storage(self): + from compressor.storage import default_storage + return default_storage + + def split_contents(self): + """ + To be implemented in a subclass, should return an + iterable with four values: kind, value, basename, element + """ + raise NotImplementedError + + def get_template_name(self, mode): + """ + Returns the template path for the given mode. + """ + try: + template = getattr(self, "template_name_%s" % mode) + if template: + return template + except AttributeError: + pass + return "compressor/%s_%s.html" % (self.resource_kind, mode) + + def get_basename(self, url): + """ + Takes full path to a static file (eg. "/static/css/style.css") and + returns path with storage's base url removed (eg. "css/style.css"). + """ + try: + base_url = self.storage.base_url + except AttributeError: + base_url = settings.COMPRESS_URL + + # Cast ``base_url`` to a string to allow it to be + # a string-alike object to e.g. add ``SCRIPT_NAME`` + # WSGI param as a *path prefix* to the output URL. + # See https://code.djangoproject.com/ticket/25598. + base_url = six.text_type(base_url) + + if not url.startswith(base_url): + raise UncompressableFileError("'%s' isn't accessible via " + "COMPRESS_URL ('%s') and can't be " + "compressed" % (url, base_url)) + basename = url.replace(base_url, "", 1) + # drop the querystring, which is used for non-compressed cache-busting. + return basename.split("?", 1)[0] + + def get_filepath(self, content, basename=None): + """ + Returns file path for an output file based on contents. + + Returned path is relative to compressor storage's base url, for + example "CACHE/css/58a8c0714e59.css". + + When `basename` argument is provided then file name (without extension) + will be used as a part of returned file name, for example: + + get_filepath(content, "my_file.css") -> 'CACHE/css/my_file.58a8c0714e59.css' + """ + parts = [] + if basename: + filename = os.path.split(basename)[1] + parts.append(os.path.splitext(filename)[0]) + parts.extend([get_hexdigest(content, 12), self.resource_kind]) + return os.path.join(self.output_dir, self.output_prefix, '.'.join(parts)) + + def get_filename(self, basename): + """ + Returns full path to a file, for example: + + get_filename('css/one.css') -> '/full/path/to/static/css/one.css' + """ + filename = None + # First try finding the file using the storage class. + # This is skipped in DEBUG mode as files might be outdated in + # compressor's final destination (COMPRESS_ROOT) during development + if not settings.DEBUG: + try: + # call path first so remote storages don't make it to exists, + # which would cause network I/O + filename = self.storage.path(basename) + if not self.storage.exists(basename): + filename = None + except NotImplementedError: + # remote storages don't implement path, access the file locally + if compressor_file_storage.exists(basename): + filename = compressor_file_storage.path(basename) + # secondly try to find it with staticfiles + if not filename and self.finders: + filename = self.finders.find(url2pathname(basename)) + if filename: + return filename + # or just raise an exception as the last resort + raise UncompressableFileError( + "'%s' could not be found in the COMPRESS_ROOT '%s'%s" % + (basename, settings.COMPRESS_ROOT, + self.finders and " or with staticfiles." or ".")) + + def get_filecontent(self, filename, charset): + """ + Reads file contents using given `charset` and returns it as text. + """ + if charset == 'utf-8': + # Removes BOM + charset = 'utf-8-sig' + with codecs.open(filename, 'r', charset) as fd: + try: + return fd.read() + except IOError as e: + raise UncompressableFileError("IOError while processing " + "'%s': %s" % (filename, e)) + except UnicodeDecodeError as e: + raise UncompressableFileError("UnicodeDecodeError while " + "processing '%s' with " + "charset %s: %s" % + (filename, charset, e)) + + @cached_property + def parser(self): + return get_class(settings.COMPRESS_PARSER)(self.content) + + @cached_property + def cached_filters(self): + return [get_class(filter_cls) for filter_cls in self.filters] + + @cached_property + def mtimes(self): + return [str(get_mtime(value)) + for kind, value, basename, elem in self.split_contents() + if kind == SOURCE_FILE] + + @cached_property + def cachekey(self): + return get_hexdigest(''.join( + [self.content] + self.mtimes).encode(self.charset), 12) + + def hunks(self, forced=False): + """ + The heart of content parsing, iterates over the + list of split contents and looks at its kind + to decide what to do with it. Should yield a + bunch of precompiled and/or rendered hunks. + """ + enabled = settings.COMPRESS_ENABLED or forced + + for kind, value, basename, elem in self.split_contents(): + precompiled = False + attribs = self.parser.elem_attribs(elem) + charset = attribs.get("charset", self.charset) + options = { + 'method': METHOD_INPUT, + 'elem': elem, + 'kind': kind, + 'basename': basename, + 'charset': charset, + } + + if kind == SOURCE_FILE: + options = dict(options, filename=value) + value = self.get_filecontent(value, charset) + + if self.precompiler_mimetypes: + precompiled, value = self.precompile(value, **options) + + if enabled: + yield self.filter(value, self.cached_filters, **options) + elif precompiled: + for filter_cls in self.cached_filters: + if filter_cls.run_with_compression_disabled: + value = self.filter(value, [filter_cls], **options) + yield self.handle_output(kind, value, forced=True, + basename=basename) + else: + yield self.parser.elem_str(elem) + + def filter_output(self, content): + """ + Passes the concatenated content to the 'output' methods + of the compressor filters. + """ + return self.filter(content, self.cached_filters, method=METHOD_OUTPUT) + + def filter_input(self, forced=False): + """ + Passes each hunk (file or code) to the 'input' methods + of the compressor filters. + """ + content = [] + for hunk in self.hunks(forced): + content.append(hunk) + return content + + def precompile(self, content, kind=None, elem=None, filename=None, + charset=None, **kwargs): + """ + Processes file using a pre compiler. + + This is the place where files like coffee script are processed. + """ + if not kind: + return False, content + attrs = self.parser.elem_attribs(elem) + mimetype = attrs.get("type", None) + if mimetype is None: + return False, content + + filter_or_command = self.precompiler_mimetypes.get(mimetype) + if filter_or_command is None: + if mimetype in self.output_mimetypes: + return False, content + raise CompressorError("Couldn't find any precompiler in " + "COMPRESS_PRECOMPILERS setting for " + "mimetype '%s'." % mimetype) + + mod_name, cls_name = get_mod_func(filter_or_command) + try: + mod = import_module(mod_name) + except (ImportError, TypeError): + filter = CachedCompilerFilter( + content=content, filter_type=self.resource_kind, filename=filename, + charset=charset, command=filter_or_command, mimetype=mimetype) + return True, filter.input(**kwargs) + try: + precompiler_class = getattr(mod, cls_name) + except AttributeError: + raise FilterDoesNotExist('Could not find "%s".' % filter_or_command) + filter = precompiler_class( + content, attrs=attrs, filter_type=self.resource_kind, charset=charset, + filename=filename) + return True, filter.input(**kwargs) + + def filter(self, content, filters, method, **kwargs): + for filter_cls in filters: + filter_func = getattr( + filter_cls(content, filter_type=self.resource_kind), method) + try: + if callable(filter_func): + content = filter_func(**kwargs) + except NotImplementedError: + pass + return content + + def output(self, mode='file', forced=False, basename=None): + """ + The general output method, override in subclass if you need to do + any custom modification. Calls other mode specific methods or simply + returns the content directly. + """ + output = '\n'.join(self.filter_input(forced)) + + if not output: + return '' + + if settings.COMPRESS_ENABLED or forced: + filtered_output = self.filter_output(output) + return self.handle_output(mode, filtered_output, forced, basename) + + return output + + def handle_output(self, mode, content, forced, basename=None): + # Then check for the appropriate output method and call it + output_func = getattr(self, "output_%s" % mode, None) + if callable(output_func): + return output_func(mode, content, forced, basename) + # Total failure, raise a general exception + raise CompressorError( + "Couldn't find output method for mode '%s'" % mode) + + def output_file(self, mode, content, forced=False, basename=None): + """ + The output method that saves the content to a file and renders + the appropriate template with the file's URL. + """ + new_filepath = self.get_filepath(content, basename=basename) + if not self.storage.exists(new_filepath) or forced: + self.storage.save(new_filepath, ContentFile(content.encode(self.charset))) + url = mark_safe(self.storage.url(new_filepath)) + return self.render_output(mode, {"url": url}) + + def output_inline(self, mode, content, forced=False, basename=None): + """ + The output method that directly returns the content for inline + display. + """ + return self.render_output(mode, {"content": content}) + + def output_preload(self, mode, content, forced=False, basename=None): + """ + The output method that returns <link> with rel="preload" and + proper href attribute for given file. + """ + return self.output_file(mode, content, forced, basename) + + def render_output(self, mode, context=None): + """ + Renders the compressor output with the appropriate template for + the given mode and template context. + """ + # Just in case someone renders the compressor outside + # the usual template rendering cycle + if 'compressed' not in self.context: + self.context['compressed'] = {} + + self.context['compressed'].update(context or {}) + self.context['compressed'].update(self.extra_context) + + if hasattr(self.context, 'flatten'): + # Passing Contexts to Template.render is deprecated since Django 1.8. + final_context = self.context.flatten() + else: + final_context = self.context + + post_compress.send(sender=self.__class__, type=self.resource_kind, + mode=mode, context=final_context) + template_name = self.get_template_name(mode) + return render_to_string(template_name, context=final_context) diff --git a/compressor/cache.py b/compressor/cache.py index 4a05702..c3ee2ba 100644 --- a/compressor/cache.py +++ b/compressor/cache.py @@ -5,9 +5,9 @@ import socket import time from importlib import import_module +import six from django.core.cache import caches from django.core.files.base import ContentFile -from django.utils import six from django.utils.encoding import force_text, smart_bytes from django.utils.functional import SimpleLazyObject diff --git a/compressor/contrib/jinja2ext.py b/compressor/contrib/jinja2ext.py index 63c2495..c519ceb 100644 --- a/compressor/contrib/jinja2ext.py +++ b/compressor/contrib/jinja2ext.py @@ -49,7 +49,7 @@ class CompressorExtension(compress.CompressorMixin, Extension): # The file mode optionally accepts a name if parser.stream.current.type != 'block_end': namearg = const(parser.parse_expression()) - elif modearg.value == compress.OUTPUT_INLINE: + elif modearg.value == compress.OUTPUT_INLINE or modearg.value == compress.OUTPUT_PRELOAD: pass else: raise TemplateSyntaxError( diff --git a/compressor/contrib/jinja2ext.py.orig b/compressor/contrib/jinja2ext.py.orig new file mode 100644 index 0000000..2d2717e --- /dev/null +++ b/compressor/contrib/jinja2ext.py.orig @@ -0,0 +1,90 @@ +from jinja2 import nodes +from jinja2.ext import Extension +from jinja2.exceptions import TemplateSyntaxError + +from compressor.templatetags import compress + + +# Allow django like definitions which assume constants instead of variables +def const(node): + if isinstance(node, nodes.Name): + return nodes.Const(node.name) + else: + return node + + +class CompressorExtension(compress.CompressorMixin, Extension): + + tags = set(['compress']) + + def parse(self, parser): + # Store the first lineno for the actual function call + lineno = parser.stream.current.lineno + next(parser.stream) + args = [] + + kindarg = const(parser.parse_expression()) + + if kindarg.value in self.compressors: + args.append(kindarg) + else: + raise TemplateSyntaxError( + 'Compress kind may be one of: %r, got: %r' % ( + self.compressors.keys(), kindarg.value), + parser.stream.current.lineno) + + # For legacy support, allow for a commma but simply ignore it + parser.stream.skip_if('comma') + + # Some sane defaults for file output + namearg = nodes.Const(None) + modearg = nodes.Const('file') + + # If we're not at the "%}" part yet we must have a output mode argument + if parser.stream.current.type != 'block_end': + modearg = const(parser.parse_expression()) + args.append(modearg) + + if modearg.value == compress.OUTPUT_FILE: + # The file mode optionally accepts a name + if parser.stream.current.type != 'block_end': + namearg = const(parser.parse_expression()) +<<<<<<< HEAD + elif modearg.value == compress.OUTPUT_INLINE: +======= + elif modearg.value == compress.OUTPUT_INLINE or modearg.value == compress.OUTPUT_PRELOAD: +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + pass + else: + raise TemplateSyntaxError( + 'Compress mode may be one of: %r, got %r' % ( + compress.OUTPUT_MODES, modearg.value), + parser.stream.current.lineno) + + # Parse everything between the compress and endcompress tags + body = parser.parse_statements(['name:endcompress'], drop_needle=True) + + # Skip the kind if used in the endblock, by using the kind in the + # endblock the templates are slightly more readable. + parser.stream.skip_if('name:' + kindarg.value) + + return nodes.CallBlock( + self.call_method('_compress_normal', [kindarg, modearg, namearg]), + [], [], body).set_lineno(lineno) + + def _compress_forced(self, kind, mode, name, caller): + return self._compress(kind, mode, name, caller, True) + + def _compress_normal(self, kind, mode, name, caller): + return self._compress(kind, mode, name, caller, False) + + def _compress(self, kind, mode, name, caller, forced): + mode = mode or compress.OUTPUT_FILE + original_content = caller() + context = { + 'original_content': original_content + } + return self.render_compressed(context, kind, mode, name, forced=forced) + + def get_original_content(self, context): + return context['original_content'] diff --git a/compressor/filters/base.py b/compressor/filters/base.py index e894776..67fcce8 100644 --- a/compressor/filters/base.py +++ b/compressor/filters/base.py @@ -20,8 +20,8 @@ else: from django.core.exceptions import ImproperlyConfigured from django.core.files.temp import NamedTemporaryFile +import six from django.utils.encoding import smart_text -from django.utils import six from compressor.cache import cache, get_precompiler_cachekey diff --git a/compressor/js.py b/compressor/js.py index 66b9ca1..7fc18f6 100644 --- a/compressor/js.py +++ b/compressor/js.py @@ -61,6 +61,6 @@ class JsCompressor(Compressor): # error like TypeError... # Forcing a semicolon in between fixes it. if settings.COMPRESS_ENABLED or forced: - hunk = ";" + hunk + hunk += ";" content.append(hunk) return content diff --git a/compressor/management/commands/compress.py b/compressor/management/commands/compress.py index 92d63d4..a6039c5 100644 --- a/compressor/management/commands/compress.py +++ b/compressor/management/commands/compress.py @@ -8,10 +8,10 @@ from fnmatch import fnmatch from importlib import import_module import django +import six from django.core.management.base import BaseCommand, CommandError import django.template from django.template import Context -from django.utils import six from django.utils.encoding import smart_text from django.template.loader import get_template # noqa Leave this in to preload template locations from django.template import engines diff --git a/compressor/parser/__init__.py b/compressor/parser/__init__.py index a53f2a4..a8d8697 100644 --- a/compressor/parser/__init__.py +++ b/compressor/parser/__init__.py @@ -1,6 +1,6 @@ from importlib import import_module -from django.utils import six +import six from django.utils.functional import LazyObject # support legacy parser module usage diff --git a/compressor/parser/beautifulsoup.py b/compressor/parser/beautifulsoup.py index 1068abe..02d7a3d 100644 --- a/compressor/parser/beautifulsoup.py +++ b/compressor/parser/beautifulsoup.py @@ -37,4 +37,8 @@ class BeautifulSoupParser(ParserBase): return elem.name def elem_str(self, elem): - return smart_text(elem) + elem_as_string = smart_text(elem) + if elem.name == 'link': + # This makes testcases happy + elem_as_string = elem_as_string.replace('/>', '>') + return elem_as_string diff --git a/compressor/parser/default_htmlparser.py b/compressor/parser/default_htmlparser.py index ac47343..baec0af 100644 --- a/compressor/parser/default_htmlparser.py +++ b/compressor/parser/default_htmlparser.py @@ -1,6 +1,6 @@ import sys -from django.utils import six +import six from django.utils.encoding import smart_text from compressor.exceptions import ParserError @@ -83,6 +83,6 @@ class DefaultHtmlParser(ParserBase, six.moves.html_parser.HTMLParser): if len(elem['attrs']): tag['attrs'] = ' %s' % ' '.join(['%s="%s"' % (name, value) for name, value in elem['attrs']]) if elem['tag'] == 'link': - return '<%(tag)s%(attrs)s />' % tag + return '<%(tag)s%(attrs)s>' % tag else: return '<%(tag)s%(attrs)s>%(text)s</%(tag)s>' % tag diff --git a/compressor/parser/html5lib.py b/compressor/parser/html5lib.py index 6d476f5..f479237 100644 --- a/compressor/parser/html5lib.py +++ b/compressor/parser/html5lib.py @@ -17,7 +17,7 @@ class Html5LibParser(ParserBase): def _serialize(self, elem): return self.html5lib.serialize( elem, tree="etree", quote_attr_values="always", - omit_optional_tags=False, use_trailing_solidus=True, + omit_optional_tags=False, ) def _find(self, *names): diff --git a/compressor/parser/html5lib.py.orig b/compressor/parser/html5lib.py.orig new file mode 100644 index 0000000..48c497e --- /dev/null +++ b/compressor/parser/html5lib.py.orig @@ -0,0 +1,63 @@ +from __future__ import absolute_import +from django.core.exceptions import ImproperlyConfigured +from django.utils.encoding import smart_text +from django.utils.functional import cached_property + +from compressor.exceptions import ParserError +from compressor.parser import ParserBase + + +class Html5LibParser(ParserBase): + + def __init__(self, content): + super(Html5LibParser, self).__init__(content) + import html5lib + self.html5lib = html5lib + + def _serialize(self, elem): + return self.html5lib.serialize( + elem, tree="etree", quote_attr_values="always", +<<<<<<< HEAD + omit_optional_tags=False, use_trailing_solidus=True, +======= + omit_optional_tags=False, +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + ) + + def _find(self, *names): + for elem in self.html: + if elem.tag in names: + yield elem + + @cached_property + def html(self): + try: + return self.html5lib.parseFragment(self.content, treebuilder="etree") + except ImportError as err: + raise ImproperlyConfigured("Error while importing html5lib: %s" % err) + except Exception as err: + raise ParserError("Error while initializing Parser: %s" % err) + + def css_elems(self): + return self._find('{http://www.w3.org/1999/xhtml}link', + '{http://www.w3.org/1999/xhtml}style') + + def js_elems(self): + return self._find('{http://www.w3.org/1999/xhtml}script') + + def elem_attribs(self, elem): + return elem.attrib + + def elem_content(self, elem): + return smart_text(elem.text) + + def elem_name(self, elem): + if '}' in elem.tag: + return elem.tag.split('}')[1] + return elem.tag + + def elem_str(self, elem): + # This method serializes HTML in a way that does not pass all tests. + # However, this method is only called in tests anyway, so it doesn't + # really matter. + return smart_text(self._serialize(elem)) diff --git a/compressor/parser/lxml.py b/compressor/parser/lxml.py index 5436de0..3328563 100644 --- a/compressor/parser/lxml.py +++ b/compressor/parser/lxml.py @@ -1,7 +1,7 @@ from __future__ import absolute_import, unicode_literals +import six from django.core.exceptions import ImproperlyConfigured -from django.utils import six from django.utils.encoding import smart_text from django.utils.functional import cached_property @@ -73,9 +73,4 @@ class LxmlParser(ParserBase): return elem.tag def elem_str(self, elem): - elem_as_string = smart_text( - self.tostring(elem, method='html', encoding=six.text_type)) - if elem.tag == 'link': - # This makes testcases happy - return elem_as_string.replace('>', ' />') - return elem_as_string + return smart_text(self.tostring(elem, method='html', encoding=six.text_type)) diff --git a/compressor/templates/compressor/css_file.html b/compressor/templates/compressor/css_file.html index 2b3a86f..8d629c1 100644 --- a/compressor/templates/compressor/css_file.html +++ b/compressor/templates/compressor/css_file.html @@ -1 +1 @@ -<link rel="stylesheet" href="{{ compressed.url }}" type="text/css"{% if compressed.media %} media="{{ compressed.media }}"{% endif %} />
\ No newline at end of file +<link rel="stylesheet" href="{{ compressed.url }}" type="text/css"{% if compressed.media %} media="{{ compressed.media }}"{% endif %}>
\ No newline at end of file diff --git a/compressor/templates/compressor/css_preload.html b/compressor/templates/compressor/css_preload.html new file mode 100644 index 0000000..7a6acf8 --- /dev/null +++ b/compressor/templates/compressor/css_preload.html @@ -0,0 +1 @@ +<link rel="preload" href="{{ compressed.url }}" as="style" />
\ No newline at end of file diff --git a/compressor/templates/compressor/js_file.html b/compressor/templates/compressor/js_file.html index e76d860..bda861e 100644 --- a/compressor/templates/compressor/js_file.html +++ b/compressor/templates/compressor/js_file.html @@ -1 +1 @@ -<script type="text/javascript" src="{{ compressed.url }}"{{ compressed.extra }}></script>
\ No newline at end of file +<script src="{{ compressed.url }}"{{ compressed.extra }}></script>
\ No newline at end of file diff --git a/compressor/templates/compressor/js_inline.html b/compressor/templates/compressor/js_inline.html index 403bec5..b7085a7 100644 --- a/compressor/templates/compressor/js_inline.html +++ b/compressor/templates/compressor/js_inline.html @@ -1 +1 @@ -<script type="text/javascript">{{ compressed.content|safe }}</script>
\ No newline at end of file +<script>{{ compressed.content|safe }}</script>
\ No newline at end of file diff --git a/compressor/templates/compressor/js_preload.html b/compressor/templates/compressor/js_preload.html new file mode 100644 index 0000000..6c78806 --- /dev/null +++ b/compressor/templates/compressor/js_preload.html @@ -0,0 +1 @@ +<link rel="preload" href="{{ compressed.url }}" as="script" />
\ No newline at end of file diff --git a/compressor/templatetags/compress.py b/compressor/templatetags/compress.py index 7472831..2f13fec 100644 --- a/compressor/templatetags/compress.py +++ b/compressor/templatetags/compress.py @@ -1,6 +1,6 @@ +import six from django import template from django.core.exceptions import ImproperlyConfigured -from django.utils import six from compressor.cache import (cache_get, cache_set, get_offline_hexdigest, get_offline_manifest, get_templatetag_cachekey) @@ -12,7 +12,8 @@ register = template.Library() OUTPUT_FILE = 'file' OUTPUT_INLINE = 'inline' -OUTPUT_MODES = (OUTPUT_FILE, OUTPUT_INLINE) +OUTPUT_PRELOAD = 'preload' +OUTPUT_MODES = (OUTPUT_FILE, OUTPUT_INLINE, OUTPUT_PRELOAD) class CompressorMixin(object): diff --git a/compressor/tests/test_base.py b/compressor/tests/test_base.py index 32bfd28..fcbcf13 100644 --- a/compressor/tests/test_base.py +++ b/compressor/tests/test_base.py @@ -1,6 +1,7 @@ from __future__ import with_statement, unicode_literals import os import re +import sys from tempfile import mkdtemp from shutil import rmtree, copytree @@ -25,8 +26,8 @@ def make_soup(markup): def css_tag(href, **kwargs): - rendered_attrs = ''.join(['%s="%s" ' % (k, v) for k, v in kwargs.items()]) - template = '<link rel="stylesheet" href="%s" type="text/css" %s/>' + rendered_attrs = ''.join([' %s="%s"' % (k, v) for k, v in kwargs.items()]) + template = '<link rel="stylesheet" href="%s" type="text/css"%s>' return template % (href, rendered_attrs) @@ -57,8 +58,9 @@ class PrecompilerAndAbsoluteFilterTestCase(SimpleTestCase): def setUp(self): self.html_orig = '<link rel="stylesheet" href="/static/css/relative_url.css" type="text/css" />' - self.html_link_to_precompiled_css = '<link rel="stylesheet" href="/static/CACHE/css/relative_url.e8602322bfa6.css" type="text/css" />' - self.html_link_to_absolutized_css = '<link rel="stylesheet" href="/static/CACHE/css/relative_url.376db5682982.css" type="text/css" />' + self.html_auto_close_removed = '<link rel="stylesheet" href="/static/css/relative_url.css" type="text/css">' + self.html_link_to_precompiled_css = '<link rel="stylesheet" href="/static/CACHE/css/relative_url.e8602322bfa6.css" type="text/css">' + self.html_link_to_absolutized_css = '<link rel="stylesheet" href="/static/CACHE/css/relative_url.376db5682982.css" type="text/css">' self.css_orig = "p { background: url('../img/python.png'); }" # content of relative_url.css self.css_absolutized = "p { background: url('/static/img/python.png?ccb38978f900'); }" @@ -81,8 +83,8 @@ class PrecompilerAndAbsoluteFilterTestCase(SimpleTestCase): in the filters setting. While at it, ensure that everything runs as expected when compression is enabled. """ - self.helper(enabled=False, use_precompiler=False, use_absolute_filter=False, expected_output=self.html_orig) - self.helper(enabled=False, use_precompiler=False, use_absolute_filter=True, expected_output=self.html_orig) + self.helper(enabled=False, use_precompiler=False, use_absolute_filter=False, expected_output=self.html_auto_close_removed) + self.helper(enabled=False, use_precompiler=False, use_absolute_filter=True, expected_output=self.html_auto_close_removed) self.helper(enabled=False, use_precompiler=True, use_absolute_filter=False, expected_output=self.html_link_to_precompiled_css) self.helper(enabled=False, use_precompiler=True, use_absolute_filter=True, expected_output=self.html_link_to_absolutized_css) self.helper(enabled=True, use_precompiler=False, use_absolute_filter=False, expected_output=self.css_orig) @@ -100,9 +102,9 @@ class CompressorTestCase(SimpleTestCase): def setUp(self): self.css = """\ -<link rel="stylesheet" href="/static/css/one.css" type="text/css" /> +<link rel="stylesheet" href="/static/css/one.css" type="text/css"> <style type="text/css">p { border:5px solid green;}</style> -<link rel="stylesheet" href="/static/css/two.css" type="text/css" />""" +<link rel="stylesheet" href="/static/css/two.css" type="text/css">""" self.css_node = CssCompressor('css', self.css) self.js = """\ @@ -133,7 +135,7 @@ class CompressorTestCase(SimpleTestCase): ( SOURCE_FILE, os.path.join(settings.COMPRESS_ROOT, 'css', 'one.css'), - 'css/one.css', '<link rel="stylesheet" href="/static/css/one.css" type="text/css" />', + 'css/one.css', '<link rel="stylesheet" href="/static/css/one.css" type="text/css">', ), ( SOURCE_HUNK, @@ -145,7 +147,7 @@ class CompressorTestCase(SimpleTestCase): SOURCE_FILE, os.path.join(settings.COMPRESS_ROOT, 'css', 'two.css'), 'css/two.css', - '<link rel="stylesheet" href="/static/css/two.css" type="text/css" />', + '<link rel="stylesheet" href="/static/css/two.css" type="text/css">', ), ] split = self.css_node.split_contents() @@ -188,6 +190,11 @@ class CompressorTestCase(SimpleTestCase): output = css_tag('/static/CACHE/css/58a8c0714e59.css') self.assertEqual(output, self.css_node.output().strip()) + def test_css_preload_output(self): + # this needs to have the same hash as in the test above + out = '<link rel="preload" href="/static/CACHE/css/58a8c0714e59.css" as="style" />' + self.assertEqual(out, self.css_node.output(mode="preload")) + def test_js_split(self): out = [ ( @@ -212,12 +219,17 @@ class CompressorTestCase(SimpleTestCase): self.assertEqual(out, list(self.js_node.hunks())) def test_js_output(self): - out = '<script type="text/javascript" src="/static/CACHE/js/74e158ccb432.js"></script>' + out = '<script src="/static/CACHE/js/8a0fed36c317.js"></script>' self.assertEqual(out, self.js_node.output()) + def test_js_preload_output(self): + # this needs to have the same hash as in the test above + out = '<link rel="preload" href="/static/CACHE/js/8a0fed36c317.js" as="script" />' + self.assertEqual(out, self.js_node.output(mode="preload")) + def test_js_override_url(self): self.js_node.context.update({'url': 'This is not a url, just a text'}) - out = '<script type="text/javascript" src="/static/CACHE/js/74e158ccb432.js"></script>' + out = '<script src="/static/CACHE/js/8a0fed36c317.js"></script>' self.assertEqual(out, self.js_node.output()) def test_css_override_url(self): @@ -230,22 +242,22 @@ class CompressorTestCase(SimpleTestCase): self.assertEqualCollapsed(self.js, self.js_node.output()) def test_js_return_if_on(self): - output = '<script type="text/javascript" src="/static/CACHE/js/74e158ccb432.js"></script>' + output = '<script src="/static/CACHE/js/8a0fed36c317.js"></script>' self.assertEqual(output, self.js_node.output()) @override_settings(COMPRESS_OUTPUT_DIR='custom') def test_custom_output_dir1(self): - output = '<script type="text/javascript" src="/static/custom/js/74e158ccb432.js"></script>' + output = '<script src="/static/custom/js/8a0fed36c317.js"></script>' self.assertEqual(output, JsCompressor('js', self.js).output()) @override_settings(COMPRESS_OUTPUT_DIR='') def test_custom_output_dir2(self): - output = '<script type="text/javascript" src="/static/js/74e158ccb432.js"></script>' + output = '<script src="/static/js/8a0fed36c317.js"></script>' self.assertEqual(output, JsCompressor('js', self.js).output()) @override_settings(COMPRESS_OUTPUT_DIR='/custom/nested/') def test_custom_output_dir3(self): - output = '<script type="text/javascript" src="/static/custom/nested/js/74e158ccb432.js"></script>' + output = '<script src="/static/custom/nested/js/8a0fed36c317.js"></script>' self.assertEqual(output, JsCompressor('js', self.js).output()) @override_settings(COMPRESS_PRECOMPILERS=( @@ -306,7 +318,7 @@ class CssMediaTestCase(SimpleTestCase): self.assertEqual(media, [l.get('media', None) for l in links]) @override_settings(COMPRESS_PRECOMPILERS=( - ('text/foobar', 'python %s {infile} {outfile}' % os.path.join(test_dir, 'precompiler.py')), + ('text/foobar', '%s %s {infile} {outfile}' % (sys.executable, os.path.join(test_dir, 'precompiler.py'))), ), COMPRESS_ENABLED=False) def test_passthough_when_compress_disabled(self): css = """\ @@ -368,8 +380,8 @@ class JSWithParensTestCase(SimpleTestCase): js_node = JsCompressor('js', self.js) content = js_node.filter_input() - self.assertEqual(content[0], ';obj = {};') - self.assertEqual(content[1], ';pollos = {}') + self.assertEqual(content[0], 'obj = {};;') + self.assertEqual(content[1], 'pollos = {};') class CacheTestCase(SimpleTestCase): @@ -434,7 +446,7 @@ class CompressorInDebugModeTestCase(SimpleTestCase): css.write(test_css_content) # We should generate a link with the hash of the original content, not # the modified one - expected = '<link rel="stylesheet" href="/static/CACHE/css/%s.css" type="text/css" />' % hashed + expected = '<link rel="stylesheet" href="/static/CACHE/css/%s.css" type="text/css">' % hashed compressor = CssCompressor('css', self.css) compressor.storage = DefaultStorage() output = compressor.output() diff --git a/compressor/tests/test_base.py.orig b/compressor/tests/test_base.py.orig new file mode 100644 index 0000000..c751bcb --- /dev/null +++ b/compressor/tests/test_base.py.orig @@ -0,0 +1,477 @@ +from __future__ import with_statement, unicode_literals +import os +import re +import sys +from tempfile import mkdtemp +from shutil import rmtree, copytree + +from bs4 import BeautifulSoup + +from django.core.cache.backends import locmem +from django.test import SimpleTestCase +from django.test.utils import override_settings + +from compressor import cache as cachemod +from compressor.base import SOURCE_FILE, SOURCE_HUNK +from compressor.cache import get_cachekey, get_precompiler_cachekey, get_hexdigest +from compressor.conf import settings +from compressor.css import CssCompressor +from compressor.exceptions import FilterDoesNotExist, FilterError +from compressor.js import JsCompressor +from compressor.storage import DefaultStorage + + +def make_soup(markup): + return BeautifulSoup(markup, "html.parser") + + +def css_tag(href, **kwargs): + rendered_attrs = ''.join([' %s="%s"' % (k, v) for k, v in kwargs.items()]) + template = '<link rel="stylesheet" href="%s" type="text/css"%s>' + return template % (href, rendered_attrs) + + +class TestPrecompiler(object): + """A filter whose output is always the string 'OUTPUT' """ + def __init__(self, content, attrs, filter_type=None, filename=None, + charset=None): + pass + + def input(self, **kwargs): + return 'OUTPUT' + + +class PassthroughPrecompiler(object): + """A filter whose outputs the input unmodified """ + def __init__(self, content, attrs, filter_type=None, filename=None, + charset=None): + self.content = content + + def input(self, **kwargs): + return self.content + + +test_dir = os.path.abspath(os.path.join(os.path.dirname(__file__))) + + +class PrecompilerAndAbsoluteFilterTestCase(SimpleTestCase): + + def setUp(self): + self.html_orig = '<link rel="stylesheet" href="/static/css/relative_url.css" type="text/css" />' + self.html_auto_close_removed = '<link rel="stylesheet" href="/static/css/relative_url.css" type="text/css">' + self.html_link_to_precompiled_css = '<link rel="stylesheet" href="/static/CACHE/css/relative_url.e8602322bfa6.css" type="text/css">' + self.html_link_to_absolutized_css = '<link rel="stylesheet" href="/static/CACHE/css/relative_url.376db5682982.css" type="text/css">' + self.css_orig = "p { background: url('../img/python.png'); }" # content of relative_url.css + self.css_absolutized = "p { background: url('/static/img/python.png?ccb38978f900'); }" + + def helper(self, enabled, use_precompiler, use_absolute_filter, expected_output): + precompiler = (('text/css', 'compressor.tests.test_base.PassthroughPrecompiler'),) if use_precompiler else () + filters = ('compressor.filters.css_default.CssAbsoluteFilter',) if use_absolute_filter else () + + with self.settings(COMPRESS_ENABLED=enabled, + COMPRESS_PRECOMPILERS=precompiler, + COMPRESS_FILTERS={'css': filters}): + css_node = CssCompressor('css', self.html_orig) + output = list(css_node.hunks())[0] + self.assertEqual(output, expected_output) + + @override_settings(COMPRESS_CSS_HASHING_METHOD="content") + def test_precompiler_enables_absolute(self): + """ + Tests whether specifying a precompiler also runs the CssAbsoluteFilter even if + compression is disabled, but only if the CssAbsoluteFilter is actually contained + in the filters setting. + While at it, ensure that everything runs as expected when compression is enabled. + """ + self.helper(enabled=False, use_precompiler=False, use_absolute_filter=False, expected_output=self.html_auto_close_removed) + self.helper(enabled=False, use_precompiler=False, use_absolute_filter=True, expected_output=self.html_auto_close_removed) + self.helper(enabled=False, use_precompiler=True, use_absolute_filter=False, expected_output=self.html_link_to_precompiled_css) + self.helper(enabled=False, use_precompiler=True, use_absolute_filter=True, expected_output=self.html_link_to_absolutized_css) + self.helper(enabled=True, use_precompiler=False, use_absolute_filter=False, expected_output=self.css_orig) + self.helper(enabled=True, use_precompiler=False, use_absolute_filter=True, expected_output=self.css_absolutized) + self.helper(enabled=True, use_precompiler=True, use_absolute_filter=False, expected_output=self.css_orig) + self.helper(enabled=True, use_precompiler=True, use_absolute_filter=True, expected_output=self.css_absolutized) + + +@override_settings( + COMPRESS_ENABLED=True, + COMPRESS_PRECOMPILERS=(), + COMPRESS_DEBUG_TOGGLE='nocompress', +) +class CompressorTestCase(SimpleTestCase): + + def setUp(self): + self.css = """\ +<link rel="stylesheet" href="/static/css/one.css" type="text/css"> +<style type="text/css">p { border:5px solid green;}</style> +<<<<<<< HEAD +<link rel="stylesheet" href="/static/css/two.css" type="text/css" />""" +======= +<link rel="stylesheet" href="/static/css/two.css" type="text/css">""" +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + self.css_node = CssCompressor('css', self.css) + + self.js = """\ +<script src="/static/js/one.js" type="text/javascript"></script> +<script type="text/javascript">obj.value = "value";</script>""" + self.js_node = JsCompressor('js', self.js) + + def assertEqualCollapsed(self, a, b): + """ + assertEqual with internal newlines collapsed to single, and + trailing whitespace removed. + """ + def collapse(s): + return re.sub(r'\n+', '\n', s).rstrip() + self.assertEqual(collapse(a), collapse(b)) + + def assertEqualSplits(self, a, b): + """ + assertEqual for splits, particularly ignoring the presence of + a trailing newline on the content. + """ + def mangle(split): + return [(x[0], x[1], x[2], x[3].rstrip()) for x in split] + self.assertEqual(mangle(a), mangle(b)) + + def test_css_split(self): + out = [ + ( + SOURCE_FILE, + os.path.join(settings.COMPRESS_ROOT, 'css', 'one.css'), + 'css/one.css', '<link rel="stylesheet" href="/static/css/one.css" type="text/css">', + ), + ( + SOURCE_HUNK, + 'p { border:5px solid green;}', + None, + '<style type="text/css">p { border:5px solid green;}</style>', + ), + ( + SOURCE_FILE, + os.path.join(settings.COMPRESS_ROOT, 'css', 'two.css'), + 'css/two.css', + '<link rel="stylesheet" href="/static/css/two.css" type="text/css">', + ), + ] + split = self.css_node.split_contents() + split = [(x[0], x[1], x[2], self.css_node.parser.elem_str(x[3])) for x in split] + self.assertEqualSplits(split, out) + + def test_css_hunks(self): + out = ['body { background:#990; }', 'p { border:5px solid green;}', 'body { color:#fff; }'] + self.assertEqual(out, list(self.css_node.hunks())) + + def test_css_output(self): + out = 'body { background:#990; }\np { border:5px solid green;}\nbody { color:#fff; }' + hunks = '\n'.join([h for h in self.css_node.hunks()]) + self.assertEqual(out, hunks) + + def test_css_output_with_bom_input(self): + out = 'body { background:#990; }\n.compress-test {color: red;}' + css = ("""<link rel="stylesheet" href="/static/css/one.css" type="text/css" /> + <link rel="stylesheet" href="/static/css/utf-8_with-BOM.css" type="text/css" />""") + css_node_with_bom = CssCompressor('css', css) + hunks = '\n'.join([h for h in css_node_with_bom.hunks()]) + self.assertEqual(out, hunks) + + def test_css_mtimes(self): + is_date = re.compile(r'^\d{10}[\.\d]+$') + for date in self.css_node.mtimes: + self.assertTrue(is_date.match(str(float(date))), + "mtimes is returning something that doesn't look like a date: %s" % date) + + @override_settings(COMPRESS_ENABLED=False) + def test_css_return_if_off(self): + self.assertEqualCollapsed(self.css, self.css_node.output()) + + def test_cachekey(self): + is_cachekey = re.compile(r'\w{12}') + self.assertTrue(is_cachekey.match(self.css_node.cachekey), + r"cachekey is returning something that doesn't look like r'\w{12}'") + + def test_css_return_if_on(self): + output = css_tag('/static/CACHE/css/58a8c0714e59.css') + self.assertEqual(output, self.css_node.output().strip()) + + def test_css_preload_output(self): + # this needs to have the same hash as in the test above + out = '<link rel="preload" href="/static/CACHE/css/58a8c0714e59.css" as="style" />' + self.assertEqual(out, self.css_node.output(mode="preload")) + + def test_js_split(self): + out = [ + ( + SOURCE_FILE, + os.path.join(settings.COMPRESS_ROOT, 'js', 'one.js'), + 'js/one.js', + '<script src="/static/js/one.js" type="text/javascript"></script>', + ), + ( + SOURCE_HUNK, + 'obj.value = "value";', + None, + '<script type="text/javascript">obj.value = "value";</script>', + ), + ] + split = self.js_node.split_contents() + split = [(x[0], x[1], x[2], self.js_node.parser.elem_str(x[3])) for x in split] + self.assertEqualSplits(split, out) + + def test_js_hunks(self): + out = ['obj = {};', 'obj.value = "value";'] + self.assertEqual(out, list(self.js_node.hunks())) + + def test_js_output(self): + out = '<script src="/static/CACHE/js/8a0fed36c317.js"></script>' + self.assertEqual(out, self.js_node.output()) + + def test_js_preload_output(self): + # this needs to have the same hash as in the test above + out = '<link rel="preload" href="/static/CACHE/js/8a0fed36c317.js" as="script" />' + self.assertEqual(out, self.js_node.output(mode="preload")) + + def test_js_override_url(self): + self.js_node.context.update({'url': 'This is not a url, just a text'}) + out = '<script src="/static/CACHE/js/8a0fed36c317.js"></script>' + self.assertEqual(out, self.js_node.output()) + + def test_css_override_url(self): + self.css_node.context.update({'url': 'This is not a url, just a text'}) + output = css_tag('/static/CACHE/css/58a8c0714e59.css') + self.assertEqual(output, self.css_node.output().strip()) + + @override_settings(COMPRESS_PRECOMPILERS=(), COMPRESS_ENABLED=False) + def test_js_return_if_off(self): + self.assertEqualCollapsed(self.js, self.js_node.output()) + + def test_js_return_if_on(self): + output = '<script src="/static/CACHE/js/8a0fed36c317.js"></script>' + self.assertEqual(output, self.js_node.output()) + + @override_settings(COMPRESS_OUTPUT_DIR='custom') + def test_custom_output_dir1(self): +<<<<<<< HEAD + output = '<script type="text/javascript" src="/static/custom/js/74e158ccb432.js"></script>' +======= + output = '<script src="/static/custom/js/8a0fed36c317.js"></script>' +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + self.assertEqual(output, JsCompressor('js', self.js).output()) + + @override_settings(COMPRESS_OUTPUT_DIR='') + def test_custom_output_dir2(self): +<<<<<<< HEAD + output = '<script type="text/javascript" src="/static/js/74e158ccb432.js"></script>' +======= + output = '<script src="/static/js/8a0fed36c317.js"></script>' +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + self.assertEqual(output, JsCompressor('js', self.js).output()) + + @override_settings(COMPRESS_OUTPUT_DIR='/custom/nested/') + def test_custom_output_dir3(self): +<<<<<<< HEAD + output = '<script type="text/javascript" src="/static/custom/nested/js/74e158ccb432.js"></script>' +======= + output = '<script src="/static/custom/nested/js/8a0fed36c317.js"></script>' +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + self.assertEqual(output, JsCompressor('js', self.js).output()) + + @override_settings(COMPRESS_PRECOMPILERS=( + ('text/foobar', 'compressor.tests.test_base.TestPrecompiler'), + ), COMPRESS_ENABLED=True) + def test_precompiler_class_used(self): + css = '<style type="text/foobar">p { border:10px solid red;}</style>' + css_node = CssCompressor('css', css) + output = make_soup(css_node.output('inline')) + self.assertEqual(output.text, 'OUTPUT') + + @override_settings(COMPRESS_PRECOMPILERS=( + ('text/foobar', 'compressor.tests.test_base.NonexistentFilter'), + ), COMPRESS_ENABLED=True) + def test_nonexistent_precompiler_class_error(self): + css = '<style type="text/foobar">p { border:10px solid red;}</style>' + css_node = CssCompressor('css', css) + self.assertRaises(FilterDoesNotExist, css_node.output, 'inline') + + @override_settings(COMPRESS_PRECOMPILERS=( + ('text/foobar', './foo -I ./bar/baz'), + ), COMPRESS_ENABLED=True) + def test_command_with_dot_precompiler(self): + css = '<style type="text/foobar">p { border:10px solid red;}</style>' + css_node = CssCompressor('css', css) + self.assertRaises(FilterError, css_node.output, 'inline') + + @override_settings(COMPRESS_PRECOMPILERS=( + ('text/django', 'compressor.filters.template.TemplateFilter'), + ), COMPRESS_ENABLED=True) + def test_template_precompiler(self): + css = '<style type="text/django">p { border:10px solid {% if 1 %}green{% else %}red{% endif %};}</style>' + css_node = CssCompressor('css', css) + output = make_soup(css_node.output('inline')) + self.assertEqual(output.text, 'p { border:10px solid green;}') + + +class CssMediaTestCase(SimpleTestCase): + def setUp(self): + self.css = """\ +<link rel="stylesheet" href="/static/css/one.css" type="text/css" media="screen"> +<style type="text/css" media="print">p { border:5px solid green;}</style> +<link rel="stylesheet" href="/static/css/two.css" type="text/css" media="all"> +<style type="text/css">h1 { border:5px solid green;}</style>""" + + def test_css_output(self): + css_node = CssCompressor('css', self.css) + links = make_soup(css_node.output()).find_all('link') + media = ['screen', 'print', 'all', None] + self.assertEqual(len(links), 4) + self.assertEqual(media, [l.get('media', None) for l in links]) + + def test_avoid_reordering_css(self): + css = self.css + '<style type="text/css" media="print">p { border:10px solid red;}</style>' + css_node = CssCompressor('css', css) + media = ['screen', 'print', 'all', None, 'print'] + links = make_soup(css_node.output()).find_all('link') + self.assertEqual(media, [l.get('media', None) for l in links]) + + @override_settings(COMPRESS_PRECOMPILERS=( + ('text/foobar', '%s %s {infile} {outfile}' % (sys.executable, os.path.join(test_dir, 'precompiler.py'))), + ), COMPRESS_ENABLED=False) + def test_passthough_when_compress_disabled(self): + css = """\ +<link rel="stylesheet" href="/static/css/one.css" type="text/css" media="screen"> +<link rel="stylesheet" href="/static/css/two.css" type="text/css" media="screen"> +<style type="text/foobar" media="screen">h1 { border:5px solid green;}</style>""" + css_node = CssCompressor('css', css) + output = make_soup(css_node.output()).find_all(['link', 'style']) + self.assertEqual(['/static/css/one.css', '/static/css/two.css', None], + [l.get('href', None) for l in output]) + self.assertEqual(['screen', 'screen', 'screen'], + [l.get('media', None) for l in output]) + + +@override_settings(COMPRESS_VERBOSE=True) +class VerboseTestCase(CompressorTestCase): + pass + + +class CacheBackendTestCase(CompressorTestCase): + + def test_correct_backend(self): + from compressor.cache import cache + self.assertEqual(cache.__class__, locmem.LocMemCache) + + +class JsAsyncDeferTestCase(SimpleTestCase): + def setUp(self): + self.js = """\ + <script src="/static/js/one.js" type="text/javascript"></script> + <script src="/static/js/two.js" type="text/javascript" async></script> + <script src="/static/js/three.js" type="text/javascript" defer></script> + <script type="text/javascript">obj.value = "value";</script> + <script src="/static/js/one.js" type="text/javascript" async></script> + <script src="/static/js/two.js" type="text/javascript" async></script> + <script src="/static/js/three.js" type="text/javascript"></script>""" + + def test_js_output(self): + def extract_attr(tag): + if tag.has_attr('async'): + return 'async' + if tag.has_attr('defer'): + return 'defer' + js_node = JsCompressor('js', self.js) + output = [None, 'async', 'defer', None, 'async', None] + scripts = make_soup(js_node.output()).find_all('script') + attrs = [extract_attr(s) for s in scripts] + self.assertEqual(output, attrs) + + +class JSWithParensTestCase(SimpleTestCase): + def setUp(self): + self.js = """ + <script src="/static/js/one.js"></script> + <script src="/static/js/two.js"></script> + """ + + def test_js_content(self): + js_node = JsCompressor('js', self.js) + + content = js_node.filter_input() + self.assertEqual(content[0], 'obj = {};;') + self.assertEqual(content[1], 'pollos = {};') + + +class CacheTestCase(SimpleTestCase): + + def setUp(self): + cachemod._cachekey_func = None + + def test_get_cachekey_basic(self): + self.assertEqual(get_cachekey("foo"), "django_compressor.foo") + + @override_settings(COMPRESS_CACHE_KEY_FUNCTION='.leading.dot') + def test_get_cachekey_leading_dot(self): + self.assertRaises(ImportError, lambda: get_cachekey("foo")) + + @override_settings(COMPRESS_CACHE_KEY_FUNCTION='invalid.module') + def test_get_cachekey_invalid_mod(self): + self.assertRaises(ImportError, lambda: get_cachekey("foo")) + + def test_get_precompiler_cachekey(self): + try: + get_precompiler_cachekey("asdf", "asdf") + except TypeError: + self.fail("get_precompiler_cachekey raised TypeError unexpectedly") + + +class CompressorInDebugModeTestCase(SimpleTestCase): + + def setUp(self): + self.css = '<link rel="stylesheet" href="/static/css/one.css" type="text/css" />' + self.tmpdir = mkdtemp() + new_static_root = os.path.join(self.tmpdir, "static") + copytree(settings.STATIC_ROOT, new_static_root) + + self.override_settings = self.settings( + COMPRESS_ENABLED=True, + COMPRESS_PRECOMPILERS=(), + COMPRESS_DEBUG_TOGGLE='nocompress', + DEBUG=True, + STATIC_ROOT=new_static_root, + COMPRESS_ROOT=new_static_root, + STATICFILES_DIRS=[settings.COMPRESS_ROOT] + ) + self.override_settings.__enter__() + + def tearDown(self): + rmtree(self.tmpdir) + self.override_settings.__exit__(None, None, None) + + def test_filename_in_debug_mode(self): + # In debug mode, compressor should look for files using staticfiles + # finders only, and not look into the global static directory, where + # files can be outdated + css_filename = os.path.join(settings.COMPRESS_ROOT, "css", "one.css") + # Store the hash of the original file's content + with open(css_filename) as f: + css_content = f.read() + hashed = get_hexdigest(css_content, 12) + # Now modify the file in the STATIC_ROOT + test_css_content = "p { font-family: 'test' }" + with open(css_filename, "a") as css: + css.write("\n") + css.write(test_css_content) + # We should generate a link with the hash of the original content, not + # the modified one +<<<<<<< HEAD + expected = '<link rel="stylesheet" href="/static/CACHE/css/%s.css" type="text/css" />' % hashed +======= + expected = '<link rel="stylesheet" href="/static/CACHE/css/%s.css" type="text/css">' % hashed +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + compressor = CssCompressor('css', self.css) + compressor.storage = DefaultStorage() + output = compressor.output() + self.assertEqual(expected, output) + with open(os.path.join(settings.COMPRESS_ROOT, "CACHE", "css", + "%s.css" % hashed), "r") as f: + result = f.read() + self.assertTrue(test_css_content not in result) diff --git a/compressor/tests/test_filters.py b/compressor/tests/test_filters.py index ec94b36..10aed42 100644 --- a/compressor/tests/test_filters.py +++ b/compressor/tests/test_filters.py @@ -5,7 +5,7 @@ import os import sys import mock -from django.utils import six +import six from django.utils.encoding import smart_text from django.test import TestCase from django.test.utils import override_settings diff --git a/compressor/tests/test_jinja2ext.py b/compressor/tests/test_jinja2ext.py index 514d713..42e0ab8 100644 --- a/compressor/tests/test_jinja2ext.py +++ b/compressor/tests/test_jinja2ext.py @@ -95,7 +95,7 @@ class TestJinja2CompressorExtension(TestCase): <script type="text/javascript" charset="utf-8">obj.value = "value";</script> {% endcompress %}""") context = {'STATIC_URL': settings.COMPRESS_URL} - out = '<script type="text/javascript" src="/static/CACHE/js/output.74e158ccb432.js"></script>' + out = '<script src="/static/CACHE/js/output.8a0fed36c317.js"></script>' self.assertEqual(out, template.render(context)) def test_nonascii_js_tag(self): @@ -104,7 +104,7 @@ class TestJinja2CompressorExtension(TestCase): <script type="text/javascript" charset="utf-8">var test_value = "\u2014";</script> {% endcompress %}""") context = {'STATIC_URL': settings.COMPRESS_URL} - out = '<script type="text/javascript" src="/static/CACHE/js/output.a18195c6ae48.js"></script>' + out = '<script src="/static/CACHE/js/output.8c00f1cf1e0a.js"></script>' self.assertEqual(out, template.render(context)) def test_nonascii_latin1_js_tag(self): @@ -113,7 +113,7 @@ class TestJinja2CompressorExtension(TestCase): <script type="text/javascript">var test_value = "\u2014";</script> {% endcompress %}""") context = {'STATIC_URL': settings.COMPRESS_URL} - out = '<script type="text/javascript" src="/static/CACHE/js/output.f64debbd8878.js"></script>' + out = '<script src="/static/CACHE/js/output.06a98ccfd380.js"></script>' self.assertEqual(out, template.render(context)) def test_css_inline(self): @@ -134,7 +134,7 @@ class TestJinja2CompressorExtension(TestCase): <script type="text/javascript" charset="utf-8">obj.value = "value";</script> {% endcompress %}""") context = {'STATIC_URL': settings.COMPRESS_URL} - out = '<script type="text/javascript">;obj={};;obj.value="value";</script>' + out = '<script>obj={};;obj.value="value";;</script>' self.assertEqual(out, template.render(context)) def test_nonascii_inline_css(self): @@ -143,6 +143,6 @@ class TestJinja2CompressorExtension(TestCase): '<style type="text/css">' '/* русский текст */' '</style>{% endcompress %}') - out = '<link rel="stylesheet" href="/static/CACHE/css/output.c836c9caed5c.css" type="text/css" />' + out = '<link rel="stylesheet" href="/static/CACHE/css/output.c836c9caed5c.css" type="text/css">' context = {'STATIC_URL': settings.COMPRESS_URL} self.assertEqual(out, template.render(context)) diff --git a/compressor/tests/test_mtime_cache.py b/compressor/tests/test_mtime_cache.py index 28b279e..bcc1265 100644 --- a/compressor/tests/test_mtime_cache.py +++ b/compressor/tests/test_mtime_cache.py @@ -8,7 +8,7 @@ class TestMtimeCacheCommand(TestCase): # FIXME: add actual tests, improve the existing ones. exclusion_patterns = [ - '*CACHE*', '*custom*', '*066cd253eada.js', '*d728fc7f9301.js', '*74e158ccb432.js', 'test.txt*' + '*CACHE*', '*custom*', '*066cd253eada.js', '*d728fc7f9301.js', '*8a0fed36c317.js', 'test.txt*' ] def default_ignore(self): diff --git a/compressor/tests/test_offline.py b/compressor/tests/test_offline.py index d18696c..2b3c801 100644 --- a/compressor/tests/test_offline.py +++ b/compressor/tests/test_offline.py @@ -9,12 +9,12 @@ from importlib import import_module from mock import patch from unittest import SkipTest +import six from django.core.management import call_command from django.core.management.base import CommandError from django.template import Template, Context from django.test import TestCase from django.test.utils import override_settings -from django.utils import six from compressor.cache import flush_offline_manifest, get_offline_manifest from compressor.conf import settings @@ -172,7 +172,7 @@ class OfflineTestCaseMixin(object): def _render_script(self, hash): return ( - '<script type="text/javascript" src="{}CACHE/js/{}.{}.js">' + '<script src="{}CACHE/js/{}.{}.js">' '</script>'.format( settings.COMPRESS_URL_PLACEHOLDER, self.expected_basename, hash ) @@ -181,7 +181,7 @@ class OfflineTestCaseMixin(object): def _render_link(self, hash): return ( '<link rel="stylesheet" href="{}CACHE/css/{}.{}.css" ' - 'type="text/css" />'.format( + 'type="text/css">'.format( settings.COMPRESS_URL_PLACEHOLDER, self.expected_basename, hash ) ) @@ -240,7 +240,7 @@ class OfflineTestCaseMixin(object): class OfflineCompressBasicTestCase(OfflineTestCaseMixin, TestCase): templates_dir = 'basic' - expected_hash = 'a432b6ddb2c4' + expected_hash = '822ac7501287' @patch.object(CompressCommand, 'compress') def test_handle_no_args(self, compress_mock): @@ -327,7 +327,7 @@ class OfflineCompressSkipDuplicatesTestCase(OfflineTestCaseMixin, TestCase): # Only one block compressed, the second identical one was skipped. self.assertEqual(1, count) # Only 1 <script> block in returned result as well. - self.assertEqual([self._render_script('a432b6ddb2c4')], result) + self.assertEqual([self._render_script('822ac7501287')], result) rendered_template = self._render_template(engine) # But rendering the template returns both (identical) scripts. self.assertEqual( @@ -342,19 +342,19 @@ class SuperMixin: class OfflineCompressBlockSuperTestCase( SuperMixin, OfflineTestCaseMixin, TestCase): templates_dir = 'test_block_super' - expected_hash = '68c645740177' + expected_hash = '817b5defb197' class OfflineCompressBlockSuperMultipleTestCase( SuperMixin, OfflineTestCaseMixin, TestCase): templates_dir = 'test_block_super_multiple' - expected_hash = 'f87403f4d8af' + expected_hash = 'd3f749e83c81' class OfflineCompressBlockSuperMultipleCachedLoaderTestCase( SuperMixin, OfflineTestCaseMixin, TestCase): templates_dir = 'test_block_super_multiple_cached' - expected_hash = 'ea860151aa21' + expected_hash = '055f88f4751f' additional_test_settings = { 'TEMPLATE_LOADERS': ( ('django.template.loaders.cached.Loader', ( @@ -373,8 +373,8 @@ class OfflineCompressBlockSuperTestCaseWithExtraContent( count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) self.assertEqual(2, count) self.assertEqual([ - self._render_script('9717f9c7e9ff'), - self._render_script('68c645740177') + self._render_script('bfcec76e0f28'), + self._render_script('817b5defb197') ], result) rendered_template = self._render_template(engine) self.assertEqual(rendered_template, self._render_result(result, '')) @@ -382,7 +382,7 @@ class OfflineCompressBlockSuperTestCaseWithExtraContent( class OfflineCompressConditionTestCase(OfflineTestCaseMixin, TestCase): templates_dir = 'test_condition' - expected_hash = '58517669cb7c' + expected_hash = 'a3275743dc69' additional_test_settings = { 'COMPRESS_OFFLINE_CONTEXT': { 'condition': 'red', @@ -392,23 +392,23 @@ class OfflineCompressConditionTestCase(OfflineTestCaseMixin, TestCase): class OfflineCompressTemplateTagTestCase(OfflineTestCaseMixin, TestCase): templates_dir = 'test_templatetag' - expected_hash = '16f8880b81ab' + expected_hash = '2bb88185b4f5' class OfflineCompressStaticTemplateTagTestCase(OfflineTestCaseMixin, TestCase): templates_dir = 'test_static_templatetag' - expected_hash = '2607a2085687' + expected_hash = 'be0b1eade28b' class OfflineCompressTemplateTagNamedTestCase(OfflineTestCaseMixin, TestCase): templates_dir = 'test_templatetag_named' expected_basename = 'output_name' - expected_hash = 'a432b6ddb2c4' + expected_hash = '822ac7501287' class OfflineCompressTestCaseWithContext(OfflineTestCaseMixin, TestCase): templates_dir = 'test_with_context' - expected_hash = '045b3ad664c8' + expected_hash = 'c6bf81bca7ad' additional_test_settings = { 'COMPRESS_OFFLINE_CONTEXT': { 'content': 'OK!', @@ -419,7 +419,7 @@ class OfflineCompressTestCaseWithContext(OfflineTestCaseMixin, TestCase): class OfflineCompressTestCaseWithContextSuper( SuperMixin, OfflineTestCaseMixin, TestCase): templates_dir = 'test_with_context_super' - expected_hash = '9a8b47adfe17' + expected_hash = 'dd79e1bd1527' additional_test_settings = { 'COMPRESS_OFFLINE_CONTEXT': { 'content': 'OK!', @@ -429,7 +429,7 @@ class OfflineCompressTestCaseWithContextSuper( class OfflineCompressTestCaseWithContextList(OfflineTestCaseMixin, TestCase): templates_dir = 'test_with_context' - expected_hash = ['3b6cd13d4bde', '5aef37564182', 'c6d6c723a18b'] + expected_hash = ['8b4a7452e1c5', '55b3123e884c', 'bfc63829cc58'] additional_test_settings = { 'COMPRESS_OFFLINE_CONTEXT': list(offline_context_generator()) } @@ -445,7 +445,7 @@ class OfflineCompressTestCaseWithContextList(OfflineTestCaseMixin, TestCase): class OfflineCompressTestCaseWithContextListSuper( SuperMixin, OfflineCompressTestCaseWithContextList): templates_dir = 'test_with_context_super' - expected_hash = ['dc68dd60aed4', 'c2e50f475853', '045b48455bee'] + expected_hash = ['b39975a8f6ea', 'ed565a1d262f', '6ac9e4b29feb'] additional_test_settings = { 'COMPRESS_OFFLINE_CONTEXT': list(offline_context_generator()) } @@ -454,7 +454,7 @@ class OfflineCompressTestCaseWithContextListSuper( class OfflineCompressTestCaseWithContextGenerator( OfflineTestCaseMixin, TestCase): templates_dir = 'test_with_context' - expected_hash = ['3b6cd13d4bde', '5aef37564182', 'c6d6c723a18b'] + expected_hash = ['8b4a7452e1c5', '55b3123e884c', 'bfc63829cc58'] additional_test_settings = { 'COMPRESS_OFFLINE_CONTEXT': 'compressor.tests.test_offline.' 'offline_context_generator' @@ -473,7 +473,7 @@ class OfflineCompressTestCaseWithContextGenerator( class OfflineCompressTestCaseWithContextGeneratorSuper( SuperMixin, OfflineCompressTestCaseWithContextGenerator): templates_dir = 'test_with_context_super' - expected_hash = ['dc68dd60aed4', 'c2e50f475853', '045b48455bee'] + expected_hash = ['b39975a8f6ea', 'ed565a1d262f', '6ac9e4b29feb'] additional_test_settings = { 'COMPRESS_OFFLINE_CONTEXT': 'compressor.tests.test_offline.' 'offline_context_generator' @@ -487,7 +487,7 @@ class OfflineCompressStaticUrlIndependenceTestCase( I.e. users can use the manifest with any other STATIC_URL in the future. """ templates_dir = 'test_static_url_independence' - expected_hash = '5014de5edcbe' + expected_hash = 'b0bfc3754fd4' additional_test_settings = { 'STATIC_URL': '/custom/static/url/', # We use ``COMPRESS_OFFLINE_CONTEXT`` generator to make sure that @@ -514,7 +514,7 @@ class OfflineCompressStaticUrlIndependenceTestCase( class OfflineCompressTestCaseWithContextVariableInheritance( OfflineTestCaseMixin, TestCase): templates_dir = 'test_with_context_variable_inheritance' - expected_hash = '0d88c897f64a' + expected_hash = 'b8376aad1357' additional_test_settings = { 'COMPRESS_OFFLINE_CONTEXT': { 'parent_template': 'base.html', @@ -537,7 +537,7 @@ class OfflineCompressTestCaseWithContextVariableInheritanceSuper( 'parent_template': 'base2.html', }] } - expected_hash = ['6a2f85c623c6', '04b482ba2855'] + expected_hash = ['cee48db7cedc', 'c877c436363a'] class OfflineCompressTestCaseWithContextGeneratorImportError( @@ -601,8 +601,8 @@ class OfflineCompressTestCaseErrors(OfflineTestCaseMixin, TestCase): self.assertIn(self._render_link('7ff52cb38987'), result) self.assertIn(self._render_link('2db2b4d36380'), result) - self.assertIn(self._render_script('3910ce35946a'), result) - self.assertIn(self._render_script('244f05154671'), result) + self.assertIn(self._render_script('eeabdac29232'), result) + self.assertIn(self._render_script('9a7f06880ce3'), result) class OfflineCompressTestCaseWithError(OfflineTestCaseMixin, TestCase): @@ -634,7 +634,7 @@ class OfflineCompressEmptyTag(OfflineTestCaseMixin, TestCase): compressor encounters such an emptystring in the manifest. """ templates_dir = 'basic' - expected_hash = 'a432b6ddb2c4' + expected_hash = '822ac7501287' def _test_offline(self, engine): CompressCommand().handle_inner(engines=[engine], verbosity=0) @@ -647,8 +647,8 @@ class OfflineCompressBlockSuperBaseCompressed(OfflineTestCaseMixin, TestCase): template_names = ['base.html', 'base2.html', 'test_compressor_offline.html'] templates_dir = 'test_block_super_base_compressed' - expected_hash_offline = ['5a2fda9ac8e4', '5b7c5e6473f8', 'f87403f4d8af'] - expected_hash = ['028c3fc42232', '2e9d3f5545a6', 'f87403f4d8af'] + expected_hash_offline = ['e4e9263fa4c0', '9cecd41a505f', 'd3f749e83c81'] + expected_hash = ['028c3fc42232', '2e9d3f5545a6', 'd3f749e83c81'] # Block.super not supported for Jinja2 yet. engines = ('django',) @@ -715,9 +715,9 @@ class OfflineCompressComplexTestCase(OfflineTestCaseMixin, TestCase): count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) self.assertEqual(3, count) self.assertEqual([ - self._render_script('2c1f0f85a90d'), - self._render_script('8b594c4f7264'), - self._render_script('e0e424964c8c') + self._render_script('76a82cfab9ab'), + self._render_script('7219642b8ab4'), + self._render_script('567bb77b13db') ], result) rendered_template = self._render_template(engine) self.assertEqual( @@ -765,20 +765,20 @@ class TestCompressCommand(OfflineTestCaseMixin, TestCase): call_command('compress', engines=["django"], **opts) manifest_django = get_offline_manifest() manifest_django_expected = self._build_expected_manifest( - {'0fed9c02607acba22316a328075a81a74e0983ae79470daa9d3707a337623dc3': '023629c58235'}) + {'0fed9c02607acba22316a328075a81a74e0983ae79470daa9d3707a337623dc3': '0241107e9a9a'}) self.assertEqual(manifest_django, manifest_django_expected) call_command('compress', engines=["jinja2"], **opts) manifest_jinja2 = get_offline_manifest() manifest_jinja2_expected = self._build_expected_manifest( - {'077408d23d4a829b8f88db2eadcf902b29d71b14f94018d900f38a3f8ed24c94': 'b6695d1aa847'}) + {'077408d23d4a829b8f88db2eadcf902b29d71b14f94018d900f38a3f8ed24c94': '5694ca83dd14'}) self.assertEqual(manifest_jinja2, manifest_jinja2_expected) call_command('compress', engines=["django", "jinja2"], **opts) manifest_both = get_offline_manifest() manifest_both_expected = self._build_expected_manifest( - {'0fed9c02607acba22316a328075a81a74e0983ae79470daa9d3707a337623dc3': '023629c58235', - '077408d23d4a829b8f88db2eadcf902b29d71b14f94018d900f38a3f8ed24c94': 'b6695d1aa847'}) + {'0fed9c02607acba22316a328075a81a74e0983ae79470daa9d3707a337623dc3': '0241107e9a9a', + '077408d23d4a829b8f88db2eadcf902b29d71b14f94018d900f38a3f8ed24c94': '5694ca83dd14'}) self.assertEqual(manifest_both, manifest_both_expected) @@ -817,7 +817,7 @@ class OfflineCompressTestCaseWithLazyStringAlikeUrls(OfflineCompressTestCaseWith 'compressor.tests.test_offline.static_url_context_generator' ) } - expected_hash = '2607a2085687' + expected_hash = 'be0b1eade28b' def _test_offline(self, engine): count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) diff --git a/compressor/tests/test_offline.py.orig b/compressor/tests/test_offline.py.orig new file mode 100644 index 0000000..b941fa0 --- /dev/null +++ b/compressor/tests/test_offline.py.orig @@ -0,0 +1,868 @@ +from __future__ import with_statement, unicode_literals +import copy +from contextlib import contextmanager + +import io +import os +from importlib import import_module + +from mock import patch +from unittest import SkipTest + +import six +from django.core.management import call_command +from django.core.management.base import CommandError +from django.template import Template, Context +from django.test import TestCase +from django.test.utils import override_settings + +from compressor.cache import flush_offline_manifest, get_offline_manifest +from compressor.conf import settings +from compressor.exceptions import OfflineGenerationError +from compressor.management.commands.compress import Command as CompressCommand +from compressor.storage import default_storage +from compressor.utils import get_mod_func + +from django.urls import get_script_prefix, set_script_prefix + + +def offline_context_generator(): + for i in range(1, 4): + yield {'content': 'OK %d!' % i} + + +def static_url_context_generator(): + yield {'STATIC_URL': settings.STATIC_URL} + + +class LazyScriptNamePrefixedUrl(six.text_type): + """ + Lazy URL with ``SCRIPT_NAME`` WSGI param as path prefix. + + .. code-block :: python + + settings.STATIC_URL = LazyScriptNamePrefixedUrl('/static/') + + # HTTP request to '/some/page/' without SCRIPT_NAME + str(settings.STATIC_URL) == '/static/' + + # HTTP request to '/app/prefix/some/page/` with SCRIPT_NAME = '/app/prefix/' + str(settings.STATIC_URL) == '/app/prefix/static/' + + # HTTP request to '/another/prefix/some/page/` with SCRIPT_NAME = '/another/prefix/' + str(settings.STATIC_URL) == '/another/prefix/static/' + + The implementation is incomplete, all ``str`` methods must be overridden + in order to work correctly with the rest of Django core. + """ + def __str__(self): + return get_script_prefix() + self[1:] if self.startswith('/') else self + + def __unicode__(self): + return str(self) + + def split(self, *args, **kwargs): + """ + Override ``.split()`` method to make it work with ``{% static %}``. + """ + return six.text_type(self).split(*args, **kwargs) + + +@contextmanager +def script_prefix(new_prefix): + """ + Override ``SCRIPT_NAME`` WSGI param, yield, then restore its original value. + + :param new_prefix: New ``SCRIPT_NAME`` value. + """ + old_prefix = get_script_prefix() + set_script_prefix(new_prefix) + yield + set_script_prefix(old_prefix) + + +class OfflineTestCaseMixin(object): + CHARSET = 'utf-8' + template_name = 'test_compressor_offline.html' + # Change this for each test class + templates_dir = '' + expected_basename = 'output' + expected_hash = '' + # Engines to test + engines = ('django', 'jinja2') + additional_test_settings = None + + def setUp(self): + # Reset template dirs, because it enables us to force compress to + # consider only a specific directory (helps us make true, + # independent unit tests). + # Specify both Jinja2 and Django template locations. When the wrong + # engine is used to parse a template, the TemplateSyntaxError will + # cause the template to be skipped over. + # We've hardcoded TEMPLATES[0] to be Django templates backend and + # TEMPLATES[1] to be Jinja2 templates backend in test_settings. + TEMPLATES = copy.deepcopy(settings.TEMPLATES) + + django_template_dir = os.path.join( + TEMPLATES[0]['DIRS'][0], self.templates_dir) + jinja2_template_dir = os.path.join( + TEMPLATES[1]['DIRS'][0], self.templates_dir) + + TEMPLATES[0]['DIRS'] = [django_template_dir] + TEMPLATES[1]['DIRS'] = [jinja2_template_dir] + + override_settings = { + 'TEMPLATES': TEMPLATES, + 'COMPRESS_ENABLED': True, + 'COMPRESS_OFFLINE': True + } + + if 'jinja2' in self.engines: + override_settings['COMPRESS_JINJA2_GET_ENVIRONMENT'] = ( + lambda: self._get_jinja2_env()) + + if self.additional_test_settings is not None: + override_settings.update(self.additional_test_settings) + + self.override_settings = self.settings(**override_settings) + self.override_settings.__enter__() + + if 'django' in self.engines: + self.template_path = os.path.join( + django_template_dir, self.template_name) + + with io.open(self.template_path, + encoding=self.CHARSET) as file_: + self.template = Template(file_.read()) + + if 'jinja2' in self.engines: + self.template_path_jinja2 = os.path.join( + jinja2_template_dir, self.template_name) + jinja2_env = override_settings['COMPRESS_JINJA2_GET_ENVIRONMENT']() + + with io.open(self.template_path_jinja2, + encoding=self.CHARSET) as file_: + self.template_jinja2 = jinja2_env.from_string(file_.read()) + + def tearDown(self): + self.override_settings.__exit__(None, None, None) + + manifest_path = os.path.join('CACHE', 'manifest.json') + if default_storage.exists(manifest_path): + default_storage.delete(manifest_path) + + def _prepare_contexts(self, engine): + contexts = settings.COMPRESS_OFFLINE_CONTEXT + if not isinstance(contexts, (list, tuple)): + contexts = [contexts] + if engine == 'django': + return [Context(c) for c in contexts] + if engine == 'jinja2': + return contexts + return None + + def _render_template(self, engine): + contexts = self._prepare_contexts(engine) + if engine == 'django': + return ''.join(self.template.render(c) for c in contexts) + if engine == 'jinja2': + return '\n'.join( + self.template_jinja2.render(c) for c in contexts) + '\n' + return None + + def _render_script(self, hash): + return ( + '<script src="{}CACHE/js/{}.{}.js">' + '</script>'.format( + settings.COMPRESS_URL_PLACEHOLDER, self.expected_basename, hash + ) + ) + + def _render_link(self, hash): + return ( + '<link rel="stylesheet" href="{}CACHE/css/{}.{}.css" ' + 'type="text/css">'.format( + settings.COMPRESS_URL_PLACEHOLDER, self.expected_basename, hash + ) + ) + + def _render_result(self, result, separator='\n'): + return (separator.join(result) + '\n').replace( + settings.COMPRESS_URL_PLACEHOLDER, six.text_type(settings.COMPRESS_URL) + ) + + def _test_offline(self, engine): + hashes = self.expected_hash + if not isinstance(hashes, (list, tuple)): + hashes = [hashes] + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + self.assertEqual(len(hashes), count) + self.assertEqual([self._render_script(h) for h in hashes], result) + rendered_template = self._render_template(engine) + self.assertEqual(rendered_template, self._render_result(result)) + + def test_offline_django(self): + if 'django' not in self.engines: + raise SkipTest('This test class does not support django engine.') + self._test_offline(engine='django') + + def test_offline_jinja2(self): + if 'jinja2' not in self.engines: + raise SkipTest('This test class does not support jinja2 engine.') + self._test_offline(engine='jinja2') + + def _get_jinja2_env(self): + import jinja2 + import jinja2.ext + from compressor.offline.jinja2 import url_for, SpacelessExtension + from compressor.contrib.jinja2ext import CompressorExtension + + # Extensions needed for the test cases only. + extensions = [ + CompressorExtension, + SpacelessExtension, + jinja2.ext.with_, + jinja2.ext.do, + ] + loader = self._get_jinja2_loader() + env = jinja2.Environment(extensions=extensions, loader=loader) + env.globals['url_for'] = url_for + + return env + + def _get_jinja2_loader(self): + import jinja2 + + loader = jinja2.FileSystemLoader( + settings.TEMPLATES[1]['DIRS'], encoding=self.CHARSET) + return loader + + +class OfflineCompressBasicTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'basic' + expected_hash = '822ac7501287' + + @patch.object(CompressCommand, 'compress') + def test_handle_no_args(self, compress_mock): + compress_mock.return_value = {}, 1, [] + CompressCommand().handle() + self.assertEqual(compress_mock.call_count, 1) + + @patch.object(CompressCommand, 'compress') + def test_handle_compress_disabled(self, compress_mock): + with self.settings(COMPRESS_ENABLED=False): + with self.assertRaises(CommandError): + CompressCommand().handle() + self.assertEqual(compress_mock.call_count, 0) + + @patch.object(CompressCommand, 'compress') + def test_handle_compress_offline_disabled(self, compress_mock): + with self.settings(COMPRESS_OFFLINE=False): + with self.assertRaises(CommandError): + CompressCommand().handle() + self.assertEqual(compress_mock.call_count, 0) + + @patch.object(CompressCommand, 'compress') + def test_handle_compress_offline_disabled_force(self, compress_mock): + compress_mock.return_value = {}, 1, [] + with self.settings(COMPRESS_OFFLINE=False): + CompressCommand().handle(force=True) + self.assertEqual(compress_mock.call_count, 1) + + def test_rendering_without_manifest_raises_exception(self): + # flush cached manifest + flush_offline_manifest() + self.assertRaises(OfflineGenerationError, + self.template.render, Context({})) + + def test_rendering_without_manifest_raises_exception_jinja2(self): + # flush cached manifest + flush_offline_manifest() + self.assertRaises(OfflineGenerationError, + self.template_jinja2.render, {}) + + def _test_deleting_manifest_does_not_affect_rendering(self, engine): + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + get_offline_manifest() + manifest_path = os.path.join('CACHE', 'manifest.json') + if default_storage.exists(manifest_path): + default_storage.delete(manifest_path) + self.assertEqual(1, count) + self.assertEqual([self._render_script(self.expected_hash)], result) + rendered_template = self._render_template(engine) + self.assertEqual(rendered_template, self._render_result(result)) + + def test_deleting_manifest_does_not_affect_rendering(self): + for engine in self.engines: + self._test_deleting_manifest_does_not_affect_rendering(engine) + + def test_get_loaders(self): + TEMPLATE_LOADERS = ( + ('django.template.loaders.cached.Loader', ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + )), + ) + with self.settings(TEMPLATE_LOADERS=TEMPLATE_LOADERS): + from django.template.loaders.filesystem import ( + Loader as FileSystemLoader) + from django.template.loaders.app_directories import ( + Loader as AppDirectoriesLoader) + loaders = CompressCommand().get_loaders() + self.assertTrue(isinstance(loaders[0], FileSystemLoader)) + self.assertTrue(isinstance(loaders[1], AppDirectoriesLoader)) + + @patch("compressor.offline.django.DjangoParser.render_node", + side_effect=Exception(b"non-ascii character here:\xc3\xa4")) + def test_non_ascii_exception_messages(self, mock): + with self.assertRaises(CommandError): + CompressCommand().handle(verbosity=0) + + +class OfflineCompressSkipDuplicatesTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_duplicate' + + def _test_offline(self, engine): + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + # Only one block compressed, the second identical one was skipped. + self.assertEqual(1, count) + # Only 1 <script> block in returned result as well. + self.assertEqual([self._render_script('822ac7501287')], result) + rendered_template = self._render_template(engine) + # But rendering the template returns both (identical) scripts. + self.assertEqual( + rendered_template, self._render_result(result * 2, '')) + + +class SuperMixin: + # Block.super not supported for Jinja2 yet. + engines = ('django',) + + +class OfflineCompressBlockSuperTestCase( + SuperMixin, OfflineTestCaseMixin, TestCase): + templates_dir = 'test_block_super' +<<<<<<< HEAD + expected_hash = '68c645740177' +======= + expected_hash = '817b5defb197' +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + + +class OfflineCompressBlockSuperMultipleTestCase( + SuperMixin, OfflineTestCaseMixin, TestCase): + templates_dir = 'test_block_super_multiple' +<<<<<<< HEAD + expected_hash = 'f87403f4d8af' +======= + expected_hash = 'd3f749e83c81' +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + + +class OfflineCompressBlockSuperMultipleCachedLoaderTestCase( + SuperMixin, OfflineTestCaseMixin, TestCase): + templates_dir = 'test_block_super_multiple_cached' +<<<<<<< HEAD + expected_hash = 'ea860151aa21' +======= + expected_hash = '055f88f4751f' +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + additional_test_settings = { + 'TEMPLATE_LOADERS': ( + ('django.template.loaders.cached.Loader', ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + )), + ) + } + + +class OfflineCompressBlockSuperTestCaseWithExtraContent( + SuperMixin, OfflineTestCaseMixin, TestCase): + templates_dir = 'test_block_super_extra' + + def _test_offline(self, engine): + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + self.assertEqual(2, count) + self.assertEqual([ + self._render_script('bfcec76e0f28'), + self._render_script('817b5defb197') + ], result) + rendered_template = self._render_template(engine) + self.assertEqual(rendered_template, self._render_result(result, '')) + + +class OfflineCompressConditionTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_condition' + expected_hash = 'a3275743dc69' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { + 'condition': 'red', + } + } + + +class OfflineCompressTemplateTagTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_templatetag' + expected_hash = '2bb88185b4f5' + + +class OfflineCompressStaticTemplateTagTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_static_templatetag' + expected_hash = 'be0b1eade28b' + + +class OfflineCompressTemplateTagNamedTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_templatetag_named' + expected_basename = 'output_name' +<<<<<<< HEAD + expected_hash = 'a432b6ddb2c4' +======= + expected_hash = '822ac7501287' +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + + +class OfflineCompressTestCaseWithContext(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context' + expected_hash = 'c6bf81bca7ad' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { + 'content': 'OK!', + } + } + + +class OfflineCompressTestCaseWithContextSuper( + SuperMixin, OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context_super' + expected_hash = 'dd79e1bd1527' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { + 'content': 'OK!', + } + } + + +class OfflineCompressTestCaseWithContextList(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context' + expected_hash = ['8b4a7452e1c5', '55b3123e884c', 'bfc63829cc58'] + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': list(offline_context_generator()) + } + + def _prepare_contexts(self, engine): + if engine == 'django': + return [Context(c) for c in settings.COMPRESS_OFFLINE_CONTEXT] + if engine == 'jinja2': + return settings.COMPRESS_OFFLINE_CONTEXT + return None + + +class OfflineCompressTestCaseWithContextListSuper( + SuperMixin, OfflineCompressTestCaseWithContextList): + templates_dir = 'test_with_context_super' + expected_hash = ['b39975a8f6ea', 'ed565a1d262f', '6ac9e4b29feb'] + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': list(offline_context_generator()) + } + + +class OfflineCompressTestCaseWithContextGenerator( + OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context' + expected_hash = ['8b4a7452e1c5', '55b3123e884c', 'bfc63829cc58'] + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': 'compressor.tests.test_offline.' + 'offline_context_generator' + } + + def _prepare_contexts(self, engine): + module, function = get_mod_func(settings.COMPRESS_OFFLINE_CONTEXT) + contexts = getattr(import_module(module), function)() + if engine == 'django': + return (Context(c) for c in contexts) + if engine == 'jinja2': + return contexts + return None + + +class OfflineCompressTestCaseWithContextGeneratorSuper( + SuperMixin, OfflineCompressTestCaseWithContextGenerator): + templates_dir = 'test_with_context_super' + expected_hash = ['b39975a8f6ea', 'ed565a1d262f', '6ac9e4b29feb'] + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': 'compressor.tests.test_offline.' + 'offline_context_generator' + } + + +class OfflineCompressStaticUrlIndependenceTestCase( + OfflineCompressTestCaseWithContextGenerator): + """ + Test that the offline manifest is independent of STATIC_URL. + I.e. users can use the manifest with any other STATIC_URL in the future. + """ + templates_dir = 'test_static_url_independence' + expected_hash = 'b0bfc3754fd4' + additional_test_settings = { + 'STATIC_URL': '/custom/static/url/', + # We use ``COMPRESS_OFFLINE_CONTEXT`` generator to make sure that + # ``STATIC_URL`` is not cached when rendering the template. + 'COMPRESS_OFFLINE_CONTEXT': ( + 'compressor.tests.test_offline.static_url_context_generator' + ) + } + + def _test_offline(self, engine): + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + self.assertEqual(1, count) + self.assertEqual([self._render_script(self.expected_hash)], result) + self.assertEqual( + self._render_template(engine), self._render_result(result)) + + # Changing STATIC_URL setting doesn't break things despite that + # offline compression was made with different STATIC_URL. + with self.settings(STATIC_URL='/another/static/url/'): + self.assertEqual( + self._render_template(engine), self._render_result(result)) + + +class OfflineCompressTestCaseWithContextVariableInheritance( + OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context_variable_inheritance' + expected_hash = 'b8376aad1357' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { + 'parent_template': 'base.html', + } + } + + def _render_result(self, result, separator='\n'): + return '\n' + super( + OfflineCompressTestCaseWithContextVariableInheritance, self + )._render_result(result, separator) + + +class OfflineCompressTestCaseWithContextVariableInheritanceSuper( + SuperMixin, OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context_variable_inheritance_super' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': [{ + 'parent_template': 'base1.html', + }, { + 'parent_template': 'base2.html', + }] + } +<<<<<<< HEAD + expected_hash = ['6a2f85c623c6', '04b482ba2855'] +======= + expected_hash = ['cee48db7cedc', 'c877c436363a'] +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + + +class OfflineCompressTestCaseWithContextGeneratorImportError( + OfflineTestCaseMixin, TestCase): + templates_dir = 'test_with_context' + + def _test_offline(self, engine): + # Test that we are properly generating ImportError when + # COMPRESS_OFFLINE_CONTEXT looks like a function but can't be imported + # for whatever reason. + + with self.settings( + COMPRESS_OFFLINE_CONTEXT='invalid_mod.invalid_func'): + # Path with invalid module name -- ImportError: + self.assertRaises( + ImportError, CompressCommand().handle_inner, engines=[engine]) + + with self.settings(COMPRESS_OFFLINE_CONTEXT='compressor'): + # Valid module name only without function -- AttributeError: + self.assertRaises( + ImportError, CompressCommand().handle_inner, engines=[engine]) + + with self.settings( + COMPRESS_OFFLINE_CONTEXT='compressor.tests.invalid_function'): + # Path with invalid function name -- AttributeError: + self.assertRaises( + ImportError, CompressCommand().handle_inner, engines=[engine]) + + with self.settings( + COMPRESS_OFFLINE_CONTEXT='compressor.tests.test_offline'): + # Path without function attempts call on module -- TypeError: + self.assertRaises( + ImportError, CompressCommand().handle_inner, engines=[engine]) + + valid_path = 'compressor.tests.test_offline.offline_context_generator' + with self.settings(COMPRESS_OFFLINE_CONTEXT=valid_path): + # Valid path to generator function -- no ImportError: + + try: + CompressCommand().handle_inner(engines=[engine], verbosity=0) + except ImportError: + self.fail('Valid path to offline context generator must' + ' not raise ImportError.') + + +class OfflineCompressTestCaseErrors(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_error_handling' + + def _test_offline(self, engine): + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + + if engine == 'django': + self.assertEqual(2, count) + else: + # Because we use env.parse in Jinja2Parser, the engine does not + # actually load the 'extends' and 'includes' templates, and so + # it is unable to detect that they are missing. So all the + # 'compress' nodes are processed correctly. + self.assertEqual(4, count) + self.assertEqual(engine, 'jinja2') + self.assertIn(self._render_link('7ff52cb38987'), result) + self.assertIn(self._render_link('2db2b4d36380'), result) + + self.assertIn(self._render_script('eeabdac29232'), result) + self.assertIn(self._render_script('9a7f06880ce3'), result) + + +class OfflineCompressTestCaseWithError(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_error_handling' + additional_test_settings = { + 'COMPRESS_PRECOMPILERS': (('text/coffeescript', 'nonexisting-binary'),) + } + + def _test_offline(self, engine): + """ + Test that a CommandError is raised with DEBUG being False as well as + True, as otherwise errors in configuration will never show in + production. + """ + with self.settings(DEBUG=True): + self.assertRaises( + CommandError, CompressCommand().handle_inner, engines=[engine], verbosity=0) + + with self.settings(DEBUG=False): + self.assertRaises( + CommandError, CompressCommand().handle_inner, engines=[engine], verbosity=0) + + +class OfflineCompressEmptyTag(OfflineTestCaseMixin, TestCase): + """ + In case of a compress template tag with no content, an entry + will be added to the manifest with an empty string as value. + This test makes sure there is no recompression happening when + compressor encounters such an emptystring in the manifest. + """ + templates_dir = 'basic' +<<<<<<< HEAD + expected_hash = 'a432b6ddb2c4' +======= + expected_hash = '822ac7501287' +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + + def _test_offline(self, engine): + CompressCommand().handle_inner(engines=[engine], verbosity=0) + manifest = get_offline_manifest() + manifest[list(manifest)[0]] = '' + self.assertEqual(self._render_template(engine), '\n') + + +class OfflineCompressBlockSuperBaseCompressed(OfflineTestCaseMixin, TestCase): + template_names = ['base.html', 'base2.html', + 'test_compressor_offline.html'] + templates_dir = 'test_block_super_base_compressed' + expected_hash_offline = ['e4e9263fa4c0', '9cecd41a505f', 'd3f749e83c81'] + expected_hash = ['028c3fc42232', '2e9d3f5545a6', 'd3f749e83c81'] + # Block.super not supported for Jinja2 yet. + engines = ('django',) + + def setUp(self): + super(OfflineCompressBlockSuperBaseCompressed, self).setUp() + + self.template_paths = [] + self.templates = [] + for template_name in self.template_names: + template_path = os.path.join( + settings.TEMPLATES[0]['DIRS'][0], template_name) + self.template_paths.append(template_path) + with io.open(template_path, + encoding=self.CHARSET) as file_: + template = Template(file_.read()) + self.templates.append(template) + + def _render_template(self, template, engine): + if engine == 'django': + return template.render(Context(settings.COMPRESS_OFFLINE_CONTEXT)) + elif engine == 'jinja2': + return template.render(settings.COMPRESS_OFFLINE_CONTEXT) + '\n' + else: + return None + + def _test_offline(self, engine): + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + self.assertEqual(len(self.expected_hash), count) + for expected_hash, template in zip(self.expected_hash_offline, self.templates): + expected = self._render_script(expected_hash) + self.assertIn(expected, result) + rendered_template = self._render_template(template, engine) + self.assertEqual( + rendered_template, self._render_result([expected])) + + +class OfflineCompressInlineNonAsciiTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_inline_non_ascii' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { + 'test_non_ascii_value': '\u2014', + } + } + + def _test_offline(self, engine): + _, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + rendered_template = self._render_template(engine) + self.assertEqual(rendered_template, ''.join(result) + '\n') + + +class OfflineCompressComplexTestCase(OfflineTestCaseMixin, TestCase): + templates_dir = 'test_complex' + additional_test_settings = { + 'COMPRESS_OFFLINE_CONTEXT': { + 'condition': 'OK!', + # Django templating does not allow definition of tuples in the + # templates. + # Make sure this is same as test_templates_jinja2/test_complex. + 'my_names': ('js/one.js', 'js/nonasc.js'), + } + } + + def _test_offline(self, engine): + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + self.assertEqual(3, count) + self.assertEqual([ + self._render_script('76a82cfab9ab'), + self._render_script('7219642b8ab4'), + self._render_script('567bb77b13db') + ], result) + rendered_template = self._render_template(engine) + self.assertEqual( + rendered_template, self._render_result([result[0], result[2]], '')) + + +class OfflineCompressExtendsRecursionTestCase(OfflineTestCaseMixin, TestCase): + """ + Test that templates extending templates with the same name + (e.g. admin/index.html) don't cause an infinite test_extends_recursion + """ + templates_dir = 'test_extends_recursion' + + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.staticfiles', + 'compressor', + ] + + @override_settings(INSTALLED_APPS=INSTALLED_APPS) + def _test_offline(self, engine): + count, _ = CompressCommand().handle_inner(engines=[engine], verbosity=0) + self.assertEqual(count, 1) + + +class TestCompressCommand(OfflineTestCaseMixin, TestCase): + templates_dir = "test_compress_command" + + def _test_offline(self, engine): + raise SkipTest("Not utilized for this test case") + + def _build_expected_manifest(self, expected): + return { + k: self._render_script(v) for k, v in expected.items() + } + + def test_multiple_engines(self): + opts = { + "force": True, + "verbosity": 0, + } + + call_command('compress', engines=["django"], **opts) + manifest_django = get_offline_manifest() + manifest_django_expected = self._build_expected_manifest( + {'0fed9c02607acba22316a328075a81a74e0983ae79470daa9d3707a337623dc3': '0241107e9a9a'}) + self.assertEqual(manifest_django, manifest_django_expected) + + call_command('compress', engines=["jinja2"], **opts) + manifest_jinja2 = get_offline_manifest() + manifest_jinja2_expected = self._build_expected_manifest( + {'077408d23d4a829b8f88db2eadcf902b29d71b14f94018d900f38a3f8ed24c94': '5694ca83dd14'}) + self.assertEqual(manifest_jinja2, manifest_jinja2_expected) + + call_command('compress', engines=["django", "jinja2"], **opts) + manifest_both = get_offline_manifest() + manifest_both_expected = self._build_expected_manifest( + {'0fed9c02607acba22316a328075a81a74e0983ae79470daa9d3707a337623dc3': '0241107e9a9a', + '077408d23d4a829b8f88db2eadcf902b29d71b14f94018d900f38a3f8ed24c94': '5694ca83dd14'}) + self.assertEqual(manifest_both, manifest_both_expected) + + +class OfflineCompressTestCaseWithLazyStringAlikeUrls(OfflineCompressTestCaseWithContextGenerator): + """ + Test offline compressing with ``STATIC_URL`` and ``COMPRESS_URL`` as instances of + *lazy string-alike objects* instead of strings. + + In particular, lazy string-alike objects that add ``SCRIPT_NAME`` WSGI param + as URL path prefix. + + For example: + + - We've generated offline assets and deployed them with our Django project. + - We've configured HTTP server (e.g. Nginx) to serve our app at two different URLs: + ``http://example.com/my/app/`` and ``http://app.example.com/``. + - Both URLs are leading to the same app, but in the first case we pass + ``SCRIPT_NAME = /my/app/`` to WSGI app server (e.g. to uWSGI, which is *behind* Nginx). + - Django (1.11.7, as of today) *ignores* ``SCRIPT_NAME`` when generating + static URLs, while it uses ``SCRIPT_NAME`` when generating Django views URLs - + see https://code.djangoproject.com/ticket/25598. + - As a solution - we can use a lazy string-alike object instead of ``str`` for ``STATIC_URL`` + so it will know about ``SCRIPT_NAME`` and add it as a prefix every time we do any + string operation with ``STATIC_URL``. + - However, there are some cases when we cannot force CPython to render our lazy string + correctly - e.g. ``some_string.replace(STATIC_URL, '...')``. So we need to do explicit + ``str`` type cast: ``some_string.replace(str(STATIC_URL), '...')``. + """ + templates_dir = 'test_static_templatetag' + additional_test_settings = { + 'STATIC_URL': LazyScriptNamePrefixedUrl('/static/'), + 'COMPRESS_URL': LazyScriptNamePrefixedUrl('/static/'), + # We use ``COMPRESS_OFFLINE_CONTEXT`` generator to make sure that + # ``STATIC_URL`` is not cached when rendering the template. + 'COMPRESS_OFFLINE_CONTEXT': ( + 'compressor.tests.test_offline.static_url_context_generator' + ) + } + expected_hash = 'be0b1eade28b' + + def _test_offline(self, engine): + count, result = CompressCommand().handle_inner(engines=[engine], verbosity=0) + self.assertEqual(1, count) + + # Change ``SCRIPT_NAME`` WSGI param - it can be changed on every HTTP request, + # e.g. passed via HTTP header. + for script_name in ['', '/app/prefix/', '/another/prefix/']: + with script_prefix(script_name): + self.assertEqual( + six.text_type(settings.STATIC_URL), + script_name.rstrip('/') + '/static/' + ) + + self.assertEqual( + six.text_type(settings.COMPRESS_URL), + script_name.rstrip('/') + '/static/' + ) + + expected_result = self._render_result(result) + actual_result = self._render_template(engine) + + self.assertEqual(actual_result, expected_result) + self.assertIn(six.text_type(settings.COMPRESS_URL), actual_result) diff --git a/compressor/tests/test_parsers.py b/compressor/tests/test_parsers.py index 6b37503..a4975d2 100644 --- a/compressor/tests/test_parsers.py +++ b/compressor/tests/test_parsers.py @@ -147,10 +147,7 @@ class BeautifulSoupParserTests(ParserTestCase, CompressorTestCase): @override_settings(COMPRESS_ENABLED=False) def test_css_return_if_off(self): - # in addition to unspecified attribute order, - # bs4 output doesn't have the extra space, so we add that here - fixed_output = self.css_node.output().replace('"/>', '" />') - self.assertEqual(len(self.css), len(fixed_output)) + self.assertEqual(len(self.css), len(self.css_node.output())) class HtmlParserTests(ParserTestCase, CompressorTestCase): diff --git a/compressor/tests/test_sekizai.py b/compressor/tests/test_sekizai.py index c8c5fd7..d1809dd 100644 --- a/compressor/tests/test_sekizai.py +++ b/compressor/tests/test_sekizai.py @@ -24,7 +24,7 @@ class TestSekizaiCompressorExtension(TestCase): html = template.render(context).strip() self.assertEqual(html, '''<script src="https://code.jquery.com/jquery-3.3.1.min.js" type="text/javascript"></script> -<script type="text/javascript" src="/static/CACHE/js/output.cb5ca6a608a4.js"></script> +<script src="/static/CACHE/js/output.e682d84f6b17.js"></script> <script async="async" defer="defer" src="https://maps.googleapis.com/maps/api/js?key=XYZ"></script>''') def test_postprocess_css(self): @@ -38,5 +38,5 @@ class TestSekizaiCompressorExtension(TestCase): context = SekizaiContext() html = template.render(context).strip() self.assertEqual(html, -'''<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/css/select2.min.css" rel="stylesheet" type="text/css" /> -<link rel="stylesheet" href="/static/CACHE/css/output.20f9b535162f.css" type="text/css" />''') +'''<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/css/select2.min.css" rel="stylesheet" type="text/css"> +<link rel="stylesheet" href="/static/CACHE/css/output.20f9b535162f.css" type="text/css">''') diff --git a/compressor/tests/test_sekizai.py.orig b/compressor/tests/test_sekizai.py.orig new file mode 100644 index 0000000..5eab344 --- /dev/null +++ b/compressor/tests/test_sekizai.py.orig @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from __future__ import with_statement, unicode_literals + +from django.template import Template +from django.test import TestCase +from sekizai.context import SekizaiContext + + +class TestSekizaiCompressorExtension(TestCase): + """ + Test case for Sekizai extension. + """ + def test_postprocess_js(self): + template_string = ''' +{% load static compress sekizai_tags %} +{% addtoblock "js" %}<script src="{% static 'js/one.js' %}" type="text/javascript"></script>{% endaddtoblock %} +{% addtoblock "js" %}<script async="async" defer="defer" src="https://maps.googleapis.com/maps/api/js?key={{ apiKey }}"></script>{% endaddtoblock %} +{% addtoblock "js" %}<script src="{% static 'js/two.js' %}" type="text/javascript"></script>{% endaddtoblock %} +{% addtoblock "js" %}<script src="https://code.jquery.com/jquery-3.3.1.min.js" type="text/javascript"></script>{% endaddtoblock %} +{% addtoblock "js" %}<script src="{% static 'js/three.js' %}" type="text/javascript"></script>{% endaddtoblock %} +{% render_block "js" postprocessor "compressor.contrib.sekizai.compress" %}''' + template = Template(template_string) + context = SekizaiContext({'apiKey': 'XYZ'}) + html = template.render(context).strip() + self.assertEqual(html, +'''<script src="https://code.jquery.com/jquery-3.3.1.min.js" type="text/javascript"></script> +<<<<<<< HEAD +<script type="text/javascript" src="/static/CACHE/js/output.cb5ca6a608a4.js"></script> +======= +<script src="/static/CACHE/js/output.e682d84f6b17.js"></script> +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b +<script async="async" defer="defer" src="https://maps.googleapis.com/maps/api/js?key=XYZ"></script>''') + + def test_postprocess_css(self): + template_string = ''' +{% load static compress sekizai_tags %} +{% addtoblock "css" %}<link href="{% static 'css/one.css' %}" rel="stylesheet" type="text/css" />{% endaddtoblock %} +{% addtoblock "css" %}<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/css/select2.min.css" rel="stylesheet" type="text/css" />{% endaddtoblock %} +{% addtoblock "css" %}<link href="{% static 'css/two.css' %}" rel="stylesheet" type="text/css" />{% endaddtoblock %} +{% render_block "css" postprocessor "compressor.contrib.sekizai.compress" %}''' + template = Template(template_string) + context = SekizaiContext() + html = template.render(context).strip() + self.assertEqual(html, +<<<<<<< HEAD +'''<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/css/select2.min.css" rel="stylesheet" type="text/css" /> +<link rel="stylesheet" href="/static/CACHE/css/output.20f9b535162f.css" type="text/css" />''') +======= +'''<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/css/select2.min.css" rel="stylesheet" type="text/css"> +<link rel="stylesheet" href="/static/CACHE/css/output.20f9b535162f.css" type="text/css">''') +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b diff --git a/compressor/tests/test_signals.py b/compressor/tests/test_signals.py index 6ba855e..447014b 100644 --- a/compressor/tests/test_signals.py +++ b/compressor/tests/test_signals.py @@ -16,9 +16,9 @@ from compressor.signals import post_compress class PostCompressSignalTestCase(TestCase): def setUp(self): self.css = """\ -<link rel="stylesheet" href="/static/css/one.css" type="text/css" /> +<link rel="stylesheet" href="/static/css/one.css" type="text/css"> <style type="text/css">p { border:5px solid green;}</style> -<link rel="stylesheet" href="/static/css/two.css" type="text/css" />""" +<link rel="stylesheet" href="/static/css/two.css" type="text/css">""" self.css_node = CssCompressor('css', self.css) self.js = """\ @@ -59,7 +59,7 @@ class PostCompressSignalTestCase(TestCase): css = """\ <link rel="stylesheet" href="/static/css/one.css" media="handheld" type="text/css" /> <style type="text/css" media="print">p { border:5px solid green;}</style> -<link rel="stylesheet" href="/static/css/two.css" type="text/css" />""" +<link rel="stylesheet" href="/static/css/two.css" type="text/css">""" css_node = CssCompressor('css', css) def listener(sender, **kwargs): diff --git a/compressor/tests/test_signals.py.orig b/compressor/tests/test_signals.py.orig new file mode 100644 index 0000000..6df72ee --- /dev/null +++ b/compressor/tests/test_signals.py.orig @@ -0,0 +1,78 @@ +from django.test import TestCase +from django.test.utils import override_settings + +from mock import Mock + +from compressor.css import CssCompressor +from compressor.js import JsCompressor +from compressor.signals import post_compress + + +@override_settings( + COMPRESS_ENABLED=True, + COMPRESS_PRECOMPILERS=(), + COMPRESS_DEBUG_TOGGLE='nocompress' +) +class PostCompressSignalTestCase(TestCase): + def setUp(self): + self.css = """\ +<link rel="stylesheet" href="/static/css/one.css" type="text/css"> +<style type="text/css">p { border:5px solid green;}</style> +<<<<<<< HEAD +<link rel="stylesheet" href="/static/css/two.css" type="text/css" />""" +======= +<link rel="stylesheet" href="/static/css/two.css" type="text/css">""" +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + self.css_node = CssCompressor('css', self.css) + + self.js = """\ +<script src="/static/js/one.js" type="text/javascript"></script> +<script type="text/javascript">obj.value = "value";</script>""" + self.js_node = JsCompressor('js', self.js) + + def tearDown(self): + post_compress.disconnect() + + def test_js_signal_sent(self): + def listener(sender, **kwargs): + pass + callback = Mock(wraps=listener) + post_compress.connect(callback) + self.js_node.output() + args, kwargs = callback.call_args + self.assertEqual(JsCompressor, kwargs['sender']) + self.assertEqual('js', kwargs['type']) + self.assertEqual('file', kwargs['mode']) + context = kwargs['context'] + assert 'url' in context['compressed'] + + def test_css_signal_sent(self): + def listener(sender, **kwargs): + pass + callback = Mock(wraps=listener) + post_compress.connect(callback) + self.css_node.output() + args, kwargs = callback.call_args + self.assertEqual(CssCompressor, kwargs['sender']) + self.assertEqual('css', kwargs['type']) + self.assertEqual('file', kwargs['mode']) + context = kwargs['context'] + assert 'url' in context['compressed'] + + def test_css_signal_multiple_media_attributes(self): + css = """\ +<link rel="stylesheet" href="/static/css/one.css" media="handheld" type="text/css" /> +<style type="text/css" media="print">p { border:5px solid green;}</style> +<<<<<<< HEAD +<link rel="stylesheet" href="/static/css/two.css" type="text/css" />""" +======= +<link rel="stylesheet" href="/static/css/two.css" type="text/css">""" +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + css_node = CssCompressor('css', css) + + def listener(sender, **kwargs): + pass + callback = Mock(wraps=listener) + post_compress.connect(callback) + css_node.output() + self.assertEqual(3, callback.call_count) diff --git a/compressor/tests/test_templatetags.py b/compressor/tests/test_templatetags.py index 8cb199a..37c1dcc 100644 --- a/compressor/tests/test_templatetags.py +++ b/compressor/tests/test_templatetags.py @@ -98,7 +98,7 @@ class TemplatetagTestCase(TestCase): <script type="text/javascript">obj.value = "value";</script> {% endcompress %} """ - out = '<script type="text/javascript" src="/static/CACHE/js/output.74e158ccb432.js"></script>' + out = '<script src="/static/CACHE/js/output.8a0fed36c317.js"></script>' self.assertEqual(out, render(template, self.context)) def test_nonascii_js_tag(self): @@ -107,7 +107,7 @@ class TemplatetagTestCase(TestCase): <script type="text/javascript">var test_value = "\u2014";</script> {% endcompress %} """ - out = '<script type="text/javascript" src="/static/CACHE/js/output.a18195c6ae48.js"></script>' + out = '<script src="/static/CACHE/js/output.8c00f1cf1e0a.js"></script>' self.assertEqual(out, render(template, self.context)) def test_nonascii_latin1_js_tag(self): @@ -116,7 +116,7 @@ class TemplatetagTestCase(TestCase): <script type="text/javascript">var test_value = "\u2014";</script> {% endcompress %} """ - out = '<script type="text/javascript" src="/static/CACHE/js/output.f64debbd8878.js"></script>' + out = '<script src="/static/CACHE/js/output.06a98ccfd380.js"></script>' self.assertEqual(out, render(template, self.context)) def test_compress_tag_with_illegal_arguments(self): @@ -151,7 +151,7 @@ class TemplatetagTestCase(TestCase): <link rel="stylesheet" href="{{ STATIC_URL }}css/two.css" type="text/css"> {% endcompress %}""" - out_js = '<script type="text/javascript">;obj={};;obj.value="value";</script>' + out_js = '<script>obj={};;obj.value="value";;</script>' out_css = '\n'.join(('<style type="text/css">body { background:#990; }', 'p { border:5px solid green;}', 'body { color:#fff; }</style>')) @@ -177,7 +177,7 @@ class TemplatetagTestCase(TestCase): <script type="text/javascript">var tmpl="{% templatetag openblock %} if x == 3 %}x IS 3{% templatetag openblock %} endif %}"</script> {% endaddtoblock %}{% render_block "js" postprocessor "compressor.contrib.sekizai.compress" %} """ - out = '<script type="text/javascript" src="/static/CACHE/js/output.4d88842b99b3.js"></script>' + out = '<script src="/static/CACHE/js/output.ffc39dec05fd.js"></script>' self.assertEqual(out, render(template, self.context, SekizaiContext)) @@ -206,7 +206,7 @@ class PrecompilerTemplatetagTestCase(TestCase): template = """{% load compress %}{% compress js %} <script type="text/coffeescript"># this is a comment.</script> {% endcompress %}""" - out = script(src="/static/CACHE/js/output.fb128b610c3e.js") + out = script(src="/static/CACHE/js/output.ec862f0ff42c.js") self.assertEqual(out, render(template, self.context)) def test_compress_coffeescript_tag_and_javascript_tag(self): @@ -214,7 +214,7 @@ class PrecompilerTemplatetagTestCase(TestCase): <script type="text/coffeescript"># this is a comment.</script> <script type="text/javascript"># this too is a comment.</script> {% endcompress %}""" - out = script(src="/static/CACHE/js/output.cf3495aaff6e.js") + out = script(src="/static/CACHE/js/output.fb4a0d84e914.js") self.assertEqual(out, render(template, self.context)) @override_settings(COMPRESS_ENABLED=False) @@ -224,7 +224,7 @@ class PrecompilerTemplatetagTestCase(TestCase): <script type="text/javascript"># this too is a comment.</script> {% endcompress %}""" out = (script('# this is a comment.\n') + '\n' + - script('# this too is a comment.')) + script('# this too is a comment.', scripttype="text/javascript")) self.assertEqual(out, render(template, self.context)) @override_settings(COMPRESS_ENABLED=False) @@ -272,8 +272,8 @@ class PrecompilerTemplatetagTestCase(TestCase): <link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}css/two.css"></link> {% endcompress %}""" - out = ''.join(['<link rel="stylesheet" type="text/css" href="/static/css/one.css" />', - '<link rel="stylesheet" type="text/css" href="/static/css/two.css" />']) + out = ''.join(['<link rel="stylesheet" type="text/css" href="/static/css/one.css">', + '<link rel="stylesheet" type="text/css" href="/static/css/two.css">']) self.assertEqual(out, render(template, self.context)) @@ -287,13 +287,13 @@ class PrecompilerTemplatetagTestCase(TestCase): <link rel="stylesheet" type="text/less" href="{{ STATIC_URL }}css/url/test.css"/> {% endcompress %}""" - out = ''.join(['<link rel="stylesheet" type="text/css" href="/static/css/one.css" />', - '<link rel="stylesheet" type="text/css" href="/static/css/two.css" />', - '<link rel="stylesheet" href="/static/CACHE/css/test.222f958fb191.css" type="text/css" />']) + out = ''.join(['<link rel="stylesheet" type="text/css" href="/static/css/one.css">', + '<link rel="stylesheet" type="text/css" href="/static/css/two.css">', + '<link rel="stylesheet" href="/static/CACHE/css/test.222f958fb191.css" type="text/css">']) self.assertEqual(out, render(template, self.context)) -def script(content="", src="", scripttype="text/javascript"): +def script(content="", src="", scripttype=""): """ returns a unicode text html script element. diff --git a/docs/changelog.txt b/docs/changelog.txt index a61ddb6..afe8ea9 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -1,7 +1,7 @@ Changelog ========= -v2.3 (2019-06-31) +v2.3 (2019-05-31) ----------------- `Full Changelog <https://github.com/django-compressor/django-compressor/compare/2.2...2.3>`_ diff --git a/docs/changelog.txt.orig b/docs/changelog.txt.orig new file mode 100644 index 0000000..d33708f --- /dev/null +++ b/docs/changelog.txt.orig @@ -0,0 +1,542 @@ +Changelog +========= + +<<<<<<< HEAD +v2.3 (2019-06-31) +======= +v2.3 (2019-05-31) +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b +----------------- + +`Full Changelog <https://github.com/django-compressor/django-compressor/compare/2.2...2.3>`_ + +- Drop support for Django 1.8, 1.9 and 1.10 +- Add support for Django 2.1 and 2.2, as well as Python 3.7 +- Update all dependencies. This required minor code changes, you might need to update some optional dependencies if you use any +- Allow the mixed use of JS/CSS in Sekizai's templatetags `{% addtoblock "js" %}` and `{% addtoblock "css" %}` (#891) +- Allow the implementation of new types other than css and js. (#900) +- Update jinja2 extension to behave similar to the django tag (#899) +- Fix crash in offline compression when child nodelist is None, again (#605) +- Support STATIC_URL and COMPRESS_URL being string-like objects +- Improve compress command memory usage (#870) +- Ensure generated file always contains a base name (#775) +- Add BrotliCompressorFileStorage (#867) + +v2.2 (2017-08-16) +----------------- + +`Full Changelog <https://github.com/django-compressor/django-compressor/compare/2.1.1...2.2>`_ + +- Switch from MD5 to SHA256 for hashes generation. + +- Add Django 1.11 compatibility + +- Various compatibility fixes for Python 3.6 and Django 1.8 + +- Made OfflineGenerationError easier to debug + +- Drop support for Python 3.2 + +- Add new CssRelativeFilter which works like CssAbsoluteFilter but outputs relative URLs. + +- Fix URL CssAbsoluteFilter URL detection + +v2.1.1 (2017-02-02) +------------------- + +- Fix to file permissions issue with packaging. + + +v2.1 (2016-08-09) +----------------- + +`Full Changelog <https://github.com/django-compressor/django-compressor/compare/2.0...2.1>`_ + +- Add Django 1.10 compatibility + +- Add support for inheritance using a variable in offline compression + +- Fix recursion error with offline compression when extending templates with the same name + +- Fix UnicodeDecodeError when using CompilerFilter and caching + +- Fix CssAbsoluteFilter changing double quotes to single quotes, breaking SVG + + +v2.0 (2016-01-07) +----------------- + +`Full Changelog <https://github.com/django-compressor/django-compressor/compare/1.6...2.0>`_ + +- Add Django 1.9 compatibility + +- Remove official support for Django 1.4 and 1.7 + +- Add official support for Python 3.5 + +- Remove official support for Python 2.6 + +- Remove support for coffin and jingo + +- Fix Jinja2 compatibility for Django 1.8+ + +- Stop bundling vendored versions of rcssmin and rjsmin, make them proper dependencies + +- Remove support for CSSTidy + +- Remove support for beautifulsoup 3. + +- Replace cssmin by csscompressor (cssmin is still available for backwards-compatibility but points to rcssmin) + + +v1.6 (2015-11-19) +----------------- + +`Full Changelog <https://github.com/django-compressor/django-compressor/compare/1.5...1.6>`_ + +- Upgrade rcssmin and rjsmin + +- Apply CssAbsoluteFilter to precompiled css even when compression is disabled + +- Add optional caching to CompilerFilter to avoid re-compiling unchanged files + +- Fix various deprecation warnings on Django 1.7 / 1.8 + +- Fix TemplateFilter + +- Fix double-rendering bug with sekizai extension + +- Fix debug mode using destination directory instead of staticfiles finders first + +- Removed some silent exception catching in compress command + + +v1.5 (2015-03-27) +----------------- + +`Full Changelog <https://github.com/django-compressor/django-compressor/compare/1.4...1.5>`_ + +- Fix compress command and run automated tests for Django 1.8 + +- Fix Django 1.8 warnings + +- Handle TypeError from import_module + +- Fix reading UTF-8 files which have BOM + +- Fix incompatibility with Windows (shell_quote is not supported) + +- Run automated tests on Django 1.7 + +- Ignore non-existent {{ block.super }} in offline compression instead of raising AttributeError + +- Support for clean-css + +- Fix link markup + +- Add support for COMPRESS_CSS_HASHING_METHOD = None + +- Remove compatibility with old 'staticfiles' app + +- In compress command, use get_template() instead of opening template files manually, fixing compatibility issues with custom template loaders + +- Fix FilterBase so that does not override self.type for subclasses if filter_type is not specified at init + +- Remove unnecessary filename and existence checks in CssAbsoluteFilter + + +v1.4 (2014-06-20) +----------------- + +- Added Python 3 compatibility. + +- Added compatibility with Django 1.6.x and dropped support for Django 1.3.X. + +- Fixed compatibility with html5lib 1.0. + +- Added offline compression for Jinja2 with Jingo and Coffin integration. + +- Improved support for template inheritance in offline compression. + +- Made offline compression avoid compressing the same block multiple times. + +- Added a ``testenv`` target in the Makefile to make it easier to set up the + test environment. + +- Allowed data-uri filter to handle external/protocol-relative references. + +- Made ``CssCompressor`` class easier to extend. + +- Added support for explicitly stating the block being ended. + +- Added rcssmin and updated rjsmin. + +- Removed implicit requirement on BeautifulSoup. + +- Made GzipCompressorFileStorage set access and modified times to the same time + as the corresponding base file. + +- Defaulted to using django's simplejson, if present. + +- Fixed CompilerFilter to always output Unicode strings. + +- Fixed windows line endings in offline compression. + +v1.3 (2013-03-18) +----------------- + +- *Backward incompatible changes* + + - Dropped support for Python 2.5. Removed ``any`` and ``walk`` compatibility + functions in ``compressor.utils``. + + - Removed compatibility with some old django setttings: + + - :attr:`~COMPRESS_ROOT` no longer uses ``MEDIA_ROOT`` if ``STATIC_ROOT`` is + not defined. It expects ``STATIC_ROOT`` to be defined instead. + + - :attr:`~COMPRESS_URL` no longer uses ``MEDIA_URL`` if ``STATIC_URL`` is + not defined. It expects ``STATIC_URL`` to be defined instead. + + - :attr:`~COMPRESS_CACHE_BACKEND` no longer uses ``CACHE_BACKEND`` and simply + defaults to ``default``. + +- Added precompiler class support. This enables you to write custom precompilers + with Python logic in them instead of just relying on executables. + +- Made CssAbsoluteFilter smarter: it now handles URLs with hash fragments or + querystring correctly. In addition, it now leaves alone fragment-only URLs. + +- Removed a ``fsync()`` call in ``CompilerFilter`` to improve performance. + We already called ``self.infile.flush()`` so that call was not necessary. + +- Added an extension to provide django-sekizai support. + See :ref:`django-sekizai Support <django-sekizai_support>` for more + information. + +- Fixed a ``DeprecationWarning`` regarding the use of ``django.utils.hashcompat`` + +- Updated bundled ``rjsmin.py`` to fix some JavaScript compression errors. + +v1.2 +---- + +- Added compatibility with Django 1.4 and dropped support for Django 1.2.X. + +- Added contributing docs. Be sure to check them out and start contributing! + +- Moved CI to Travis: http://travis-ci.org/django-compressor/django-compressor + +- Introduced a new ``compressed`` context dictionary that is passed to + the templates that are responsible for rendering the compressed snippets. + + This is a **backwards-incompatible change** if you've overridden any of + the included templates: + + - ``compressor/css_file.html`` + - ``compressor/css_inline.html`` + - ``compressor/js_file.html`` + - ``compressor/js_inline.html`` + + The variables passed to those templates have been namespaced in a + dictionary, so it's easy to fix your own templates. + + For example, the old ``compressor/js_file.html``:: + + <script type="text/javascript" src="{{ url }}"></script> + + The new ``compressor/js_file.html``:: + + <script type="text/javascript" src="{{ compressed.url }}"></script> + +- Removed old templates named ``compressor/css.html`` and + ``compressor/js.html`` that were originally left for backwards + compatibility. If you've overridden them, just rename them to + ``compressor/css_file.html`` or ``compressor/js_file.html`` and + make sure you've accounted for the backwards incompatible change + of the template context mentioned above. + +- Reverted an unfortunate change to the YUI filter that prepended + ``'java -jar'`` to the binary name, which doesn't alway work, e.g. + if the YUI compressor is shipped as a script like + ``/usr/bin/yui-compressor``. + +- Changed the sender parameter of the :func:`~compressor.signals.post_compress` + signal to be either :class:`compressor.css.CssCompressor` or + :class:`compressor.js.JsCompressor` for easier customization. + +- Correctly handle offline compressing files that are found in ``{% if %}`` + template blocks. + +- Renamed the second option for the ``COMPRESS_CSS_HASHING_METHOD`` setting + from ``'hash'`` to ``'content'`` to better describe what it does. The old + name is also supported, as well as the default being ``'mtime'``. + +- Fixed CssAbsoluteFilter, ``src`` attributes in includes now get transformed. + +- Added a new hook to allow developers to completely bypass offline + compression in CompressorNode subclasses: ``is_offline_compression_enabled``. + +- Dropped versiontools from required dependencies again. + +v1.1.2 +------ + +- Fixed an installation issue related to versiontools. + +v1.1.1 +------ + +- Fixed a stupid ImportError bug introduced in 1.1. + +- Fixed Jinja2 docs of since ``JINJA2_EXTENSIONS`` expects + a class, not a module. + +- Fixed a Windows bug with regard to file resolving with + staticfiles finders. + +- Stopped a potential memory leak when memoizing the rendered + output. + +- Fixed the integration between staticfiles (e.g. in Django <= 1.3.1) + and compressor which prevents the collectstatic management command + to work. + + .. warning:: + + Make sure to **remove** the ``path`` method of your custom + :ref:`remote storage <remote_storages>` class! + +v1.1 +---- + +- Made offline compression completely independent from cache (by writing a + manifest.json file). + + You can now easily run the :ref:`compress <pre-compression>` management + command locally and transfer the :attr:`~django.conf.settings.COMPRESS_ROOT` + dir to your server. + +- Updated installation instructions to properly mention all dependencies, + even those internally used. + +- Fixed a bug introduced in 1.0 which would prevent the proper deactivation + of the compression in production. + +- Added a Jinja2_ :doc:`contrib extension </jinja2>`. + +- Made sure the rel attribute of link tags can be mixed case. + +- Avoid overwriting context variables needed for compressor to work. + +- Stopped the compress management command to require model validation. + +- Added missing imports and fixed a few :pep:`8` issues. + +.. _Jinja2: http://jinja.pocoo.org/2/ + +v1.0.1 +------ + +- Fixed regression in ``compressor.utils.staticfiles`` compatibility + module. + +v1.0 +---- + +- **BACKWARDS-INCOMPATIBLE** Stopped swallowing exceptions raised by + rendering the template tag in production (``DEBUG = False``). This + has the potential to breaking lots of apps but on the other hand + will help find bugs. + +- **BACKWARDS-INCOMPATIBLE** The default function to create the cache + key stopped containing the server hostname. Instead the cache key + now only has the form ``'django_compressor.<KEY>'``. + + To revert to the previous way simply set the ``COMPRESS_CACHE_KEY_FUNCTION`` + to ``'compressor.cache.socket_cachekey'``. + +- **BACKWARDS-INCOMPATIBLE** Renamed ambigously named + ``COMPRESS_DATA_URI_MAX_SIZE`` setting to ``COMPRESS_DATA_URI_MAX_SIZE``. + It's the maximum size the ``compressor.filters.datauri.DataUriFilter`` + filter will embed files as data: URIs. + +- Added ``COMPRESS_CSS_HASHING_METHOD`` setting with the options ``'mtime'`` + (default) and ``'hash'`` for the ``CssAbsoluteFilter`` filter. The latter + uses the content of the file to calculate the cache-busting hash. + +- Added support for ``{{ block.super }}`` to ``compress`` management command. + +- Dropped Django 1.1.X support. + +- Fixed compiler filters on Windows. + +- Handle new-style cached template loaders in the compress management command. + +- Documented included filters. + +- Added `Slim It`_ filter. + +- Added new CallbackOutputFilter to ease the implementation of Python-based + callback filters that only need to pass the content to a callable. + +- Make use of `django-appconf`_ for settings handling and `versiontools`_ + for versions. + +- Uses the current context when rendering the render templates. + +- Added :func:`post_compress<compressor.signals.post_compress>` signal. + +.. _`Slim It`: http://slimit.org/ +.. _`django-appconf`: https://django-appconf.readthedocs.io/ +.. _`versiontools`: http://pypi.python.org/pypi/versiontools + +v0.9.2 +------ + +- Fixed stdin handling of precompiler filter. + +v0.9.1 +------ + +- Fixed encoding related issue. + +- Minor cleanups. + +v0.9 +---- + +- Fixed the precompiler support to also use the full file path instead of a + temporarily created file. + +- Enabled test coverage. + +- Refactored caching and other utility code. + +- Switched from SHA1 to MD5 for hash generation to lower the computational impact. + +v0.8 +---- + +- Replace naive jsmin.py with rJSmin (http://opensource.perlig.de/rjsmin/) + and fixed a few problems with JavaScript comments. + +- Fixed converting relative URLs in CSS files when running in debug mode. + +.. note:: + + If you relied on the ``split_contents`` method of ``Compressor`` classes, + please make sure a fourth item is returned in the iterable that denotes + the base name of the file that is compressed. + +v0.7.1 +------ + +- Fixed import error when using the standalone django-staticfiles app. + +v0.7 +---- + +- Created new parser, HtmlParser, based on the stdlib HTMLParser module. + +- Added a new default AutoSelectParser, which picks the LxmlParser if lxml + is available and falls back to HtmlParser. + +- Use unittest2 for testing goodness. + +- Fixed YUI JavaScript filter argument handling. + +- Updated bundled jsmin to use version by Dave St.Germain that was refactored for speed. + +v0.6.4 +------ + +- Fixed Closure filter argument handling. + +v0.6.3 +------ + +- Fixed options mangling in CompilerFilter initialization. + +- Fixed tox configuration. + +- Extended documentation and README. + +- In the compress command ignore hidden files when looking for templates. + +- Restructured utilities and added staticfiles compat layer. + +- Restructered parsers and added a html5lib based parser. + +v0.6.2 +------ + +- Minor bugfixes that caused the compression not working reliably in + development mode (e.g. updated files didn't trigger a new compression). + +v0.6.1 +------ + +- Fixed staticfiles support to also use its finder API to find files during + development -- when the static files haven't been collected in + ``STATIC_ROOT``. + +- Fixed regression with the ``COMPRESS`` setting, pre-compilation and + staticfiles. + +v0.6 +---- + +Major improvements and a lot of bugfixes, some of which are: + +- New precompilation support, which allows compilation of files and + hunks with easily configurable compilers before calling the actual + output filters. See the + :attr:`~django.conf.settings.COMPRESS_PRECOMPILERS` for more details. + +- New staticfiles support. With the introduction of the staticfiles app + to Django 1.3, compressor officially supports finding the files to + compress using the app's finder API. Have a look at the documentation + about :ref:`remote storages <remote_storages>` in case you want to use + those together with compressor. + +- New ``compress`` management command which allows pre-running of what the + compress template tag does. See the + :ref:`pre-compression <pre-compression>` docs for more information. + +- Various performance improvements by better caching and mtime cheking. + +- Deprecated ``COMPRESS_LESSC_BINARY`` setting because it's now + superseded by the :attr:`~django.conf.settings.COMPRESS_PRECOMPILERS` + setting. Just make sure to use the correct mimetype when linking to less + files or adding inline code and add the following to your settings:: + + COMPRESS_PRECOMPILERS = ( + ('text/less', 'lessc {infile} {outfile}'), + ) + +- Added cssmin_ filter (``compressor.filters.CSSMinFilter``) based on + Zachary Voase's Python port of the YUI CSS compression algorithm. + +- Reimplemented the dog-piling prevention. + +- Make sure the CssAbsoluteFilter works for relative paths. + +- Added inline render mode. See :ref:`usage <usage>` docs. + +- Added ``mtime_cache`` management command to add and/or remove all mtimes + from the cache. + +- Moved docs to Read The Docs: https://django-compressor.readthedocs.io/en/latest/ + +- Added optional ``compressor.storage.GzipCompressorFileStorage`` storage + backend that gzips of the saved files automatically for easier deployment. + +- Reimplemented a few filters on top of the new + ``compressor.filters.base.CompilerFilter`` to be a bit more DRY. + +- Added tox based test configuration, testing on Django 1.1-1.3 and Python + 2.5-2.7. + +.. _cssmin: http://pypi.python.org/pypi/cssmin/ + diff --git a/docs/usage.txt b/docs/usage.txt index 52b7b6f..ac572c7 100644 --- a/docs/usage.txt +++ b/docs/usage.txt @@ -6,7 +6,7 @@ Usage .. code-block:: django {% load compress %} - {% compress <js/css> [<file/inline> [block_name]] %} + {% compress <js/css> [<file/inline/preload> [block_name]] %} <html of inline or linked JS/CSS> {% endcompress %} @@ -48,6 +48,21 @@ Result: obj.value = "value"; </script> +Adding the ``preload`` parameter will generate the preload tag for the compressed resource in the template: + + .. code-block:: django + + {% compress js preload %} + <script src="/static/js/one.js" type="text/javascript" charset="utf-8"></script> + {% endcompress %} + +Result: + + .. code-block:: django + + <link rel="preload" href="/static/CACHE/js/d01466eb4fc6.js" as="script" /> + + Specifying a ``block_name`` will change the output filename. It can also be accessed in the :ref:`post_compress signal <signals>` in the ``context`` parameter. diff --git a/requirements/tests.txt b/requirements/tests.txt index a595d22..428c09a 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -2,7 +2,7 @@ flake8==3.5.0 coverage==4.5.1 html5lib==1.0.1 mock==2.0.0 -Jinja2==2.10.0 +Jinja2==2.10.1 lxml==4.2.5 beautifulsoup4==4.6.3 django-sekizai==0.10.0 diff --git a/requirements/tests.txt.orig b/requirements/tests.txt.orig new file mode 100644 index 0000000..b4755ae --- /dev/null +++ b/requirements/tests.txt.orig @@ -0,0 +1,16 @@ +flake8==3.5.0 +coverage==4.5.1 +html5lib==1.0.1 +mock==2.0.0 +<<<<<<< HEAD +Jinja2==2.10.0 +======= +Jinja2==2.10.1 +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b +lxml==4.2.5 +beautifulsoup4==4.6.3 +django-sekizai==0.10.0 +csscompressor==0.9.5 +rcssmin==1.0.6 +rjsmin==1.1.0 +brotli==1.0.6 @@ -144,5 +144,6 @@ setup( 'django-appconf >= 1.0', 'rcssmin == 1.0.6', 'rjsmin == 1.1.0', + 'six == 1.12.0', ], ) diff --git a/setup.py.orig b/setup.py.orig new file mode 100644 index 0000000..0efaad9 --- /dev/null +++ b/setup.py.orig @@ -0,0 +1,152 @@ +from __future__ import print_function +import ast +import os +import sys +import codecs +from fnmatch import fnmatchcase +from distutils.util import convert_path +from setuptools import setup, find_packages + +class VersionFinder(ast.NodeVisitor): + def __init__(self): + self.version = None + + def visit_Assign(self, node): + if node.targets[0].id == '__version__': + self.version = node.value.s + + +def read(*parts): + filename = os.path.join(os.path.dirname(__file__), *parts) + with codecs.open(filename, encoding='utf-8') as fp: + return fp.read() + + +def find_version(*parts): + finder = VersionFinder() + finder.visit(ast.parse(read(*parts))) + return finder.version + + +# Provided as an attribute, so you can append to these instead +# of replicating them: +standard_exclude = ('*.py', '*.pyc', '*$py.class', '*~', '.*', '*.bak') +standard_exclude_directories = ('.*', 'CVS', '_darcs', './build', + './dist', 'EGG-INFO', '*.egg-info') + + +# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) +# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php +# Note: you may want to copy this into your setup.py file verbatim, as +# you can't import this from another package, when you don't know if +# that package is installed yet. +def find_package_data(where='.', package='', + exclude=standard_exclude, + exclude_directories=standard_exclude_directories, + only_in_packages=True, + show_ignored=False): + """ + Return a dictionary suitable for use in ``package_data`` + in a distutils ``setup.py`` file. + + The dictionary looks like:: + + {'package': [files]} + + Where ``files`` is a list of all the files in that package that + don't match anything in ``exclude``. + + If ``only_in_packages`` is true, then top-level directories that + are not packages won't be included (but directories under packages + will). + + Directories matching any pattern in ``exclude_directories`` will + be ignored; by default directories with leading ``.``, ``CVS``, + and ``_darcs`` will be ignored. + + If ``show_ignored`` is true, then all the files that aren't + included in package data are shown on stderr (for debugging + purposes). + + Note patterns use wildcards, or can be exact paths (including + leading ``./``), and all searching is case-insensitive. + """ + + out = {} + stack = [(convert_path(where), '', package, only_in_packages)] + while stack: + where, prefix, package, only_in_packages = stack.pop(0) + for name in os.listdir(where): + fn = os.path.join(where, name) + if os.path.isdir(fn): + bad_name = False + for pattern in exclude_directories: + if (fnmatchcase(name, pattern) or fn.lower() == pattern.lower()): + bad_name = True + if show_ignored: + print("Directory %s ignored by pattern %s" % + (fn, pattern), file=sys.stderr) + break + if bad_name: + continue + if (os.path.isfile(os.path.join(fn, '__init__.py')) and not prefix): + if not package: + new_package = name + else: + new_package = package + '.' + name + stack.append((fn, '', new_package, False)) + else: + stack.append((fn, prefix + name + '/', package, only_in_packages)) + elif package or not only_in_packages: + # is a file + bad_name = False + for pattern in exclude: + if (fnmatchcase(name, pattern) or fn.lower() == pattern.lower()): + bad_name = True + if show_ignored: + print("File %s ignored by pattern %s" % + (fn, pattern), file=sys.stderr) + break + if bad_name: + continue + out.setdefault(package, []).append(prefix + name) + return out + +setup( + name="django_compressor", + version=find_version("compressor", "__init__.py"), + url='https://django-compressor.readthedocs.io/en/latest/', + license='MIT', + description="Compresses linked and inline JavaScript or CSS into single cached files.", + long_description=read('README.rst'), + author='Jannis Leidel', + author_email='jannis@leidel.info', + packages=find_packages(), + package_data=find_package_data(), + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Topic :: Internet :: WWW/HTTP', + ], + zip_safe=False, + install_requires=[ + 'django-appconf >= 1.0', + 'rcssmin == 1.0.6', + 'rjsmin == 1.1.0', +<<<<<<< HEAD +======= + 'six == 1.12.0', +>>>>>>> 1b26e63e3ebce49570e57f6a5233af15278a518b + ], +) |