From 1d32bdd3c9586ff10d0799264105850fa7e3f512 Mon Sep 17 00:00:00 2001 From: Jannis Leidel <jannis@leidel.info> Date: Thu, 11 Aug 2011 14:07:39 +0000 Subject: [PATCH] Fixed #15252 -- Added static template tag and CachedStaticFilesStorage to staticfiles contrib app. Many thanks to Florian Apolloner and Jacob Kaplan-Moss for reviewing and eagle eyeing. git-svn-id: http://code.djangoproject.com/svn/django/trunk@16594 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/admin/helpers.py | 3 +- django/contrib/admin/options.py | 13 +- .../admin/auth/user/change_password.html | 2 +- .../contrib/admin/templates/admin/base.html | 2 +- .../admin/templates/admin/change_form.html | 2 +- .../admin/templates/admin/change_list.html | 2 +- .../templates/admin/change_list_results.html | 2 +- .../templates/admin/edit_inline/stacked.html | 2 +- .../templates/admin/edit_inline/tabular.html | 2 +- .../contrib/admin/templates/admin/index.html | 2 +- .../contrib/admin/templates/admin/login.html | 2 +- .../admin/templates/admin/search_form.html | 2 +- .../contrib/admin/templatetags/admin_list.py | 2 +- .../admin/templatetags/admin_static.py | 11 + django/contrib/admin/widgets.py | 34 +-- django/contrib/staticfiles/finders.py | 2 +- .../management/commands/collectstatic.py | 84 +++++--- django/contrib/staticfiles/storage.py | 171 +++++++++++++++- .../staticfiles/templatetags/__init__.py | 0 .../staticfiles/templatetags/staticfiles.py | 13 ++ django/contrib/staticfiles/utils.py | 14 +- docs/howto/static-files.txt | 44 ++-- docs/ref/contrib/staticfiles.txt | 143 ++++++++++++- docs/ref/templates/builtins.txt | 17 +- docs/releases/1.4.txt | 23 +++ .../project/documents/cached/absolute.css | 1 + .../project/documents/cached/denorm.css | 1 + .../project/documents/cached/other.css | 0 .../project/documents/cached/relative.css | 2 + .../project/documents/cached/styles.css | 1 + .../project/documents/cached/url.css | 1 + .../project/site_media/static/testfile.txt | 1 + .../staticfiles_tests/tests.py | 193 +++++++++++++----- 33 files changed, 646 insertions(+), 148 deletions(-) create mode 100644 django/contrib/admin/templatetags/admin_static.py create mode 100644 django/contrib/staticfiles/templatetags/__init__.py create mode 100644 django/contrib/staticfiles/templatetags/staticfiles.py create mode 100644 tests/regressiontests/staticfiles_tests/project/documents/cached/absolute.css create mode 100644 tests/regressiontests/staticfiles_tests/project/documents/cached/denorm.css create mode 100644 tests/regressiontests/staticfiles_tests/project/documents/cached/other.css create mode 100644 tests/regressiontests/staticfiles_tests/project/documents/cached/relative.css create mode 100644 tests/regressiontests/staticfiles_tests/project/documents/cached/styles.css create mode 100644 tests/regressiontests/staticfiles_tests/project/documents/cached/url.css create mode 100644 tests/regressiontests/staticfiles_tests/project/site_media/static/testfile.txt diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py index d15716b909..04a3492e1c 100644 --- a/django/contrib/admin/helpers.py +++ b/django/contrib/admin/helpers.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.admin.util import (flatten_fieldsets, lookup_field, display_for_field, label_for_field, help_text_for_field) +from django.contrib.admin.templatetags.admin_static import static from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.models.fields.related import ManyToManyRel @@ -75,7 +76,7 @@ class Fieldset(object): def _media(self): if 'collapse' in self.classes: js = ['jquery.min.js', 'jquery.init.js', 'collapse.min.js'] - return forms.Media(js=['admin/js/%s' % url for url in js]) + return forms.Media(js=[static('admin/js/%s' % url) for url in js]) return forms.Media() media = property(_media) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 35c3cde0fc..81e8ae5614 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -6,6 +6,7 @@ from django.forms.models import (modelform_factory, modelformset_factory, from django.contrib.contenttypes.models import ContentType from django.contrib.admin import widgets, helpers from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_format_dict +from django.contrib.admin.templatetags.admin_static import static from django.contrib import messages from django.views.decorators.csrf import csrf_protect from django.core.exceptions import PermissionDenied, ValidationError @@ -350,7 +351,8 @@ class ModelAdmin(BaseModelAdmin): return self.get_urls() urls = property(urls) - def _media(self): + @property + def media(self): js = [ 'core.js', 'admin/RelatedObjectLookups.js', @@ -363,8 +365,7 @@ class ModelAdmin(BaseModelAdmin): js.extend(['urlify.js', 'prepopulate.min.js']) if self.opts.get_ordered_objects(): js.extend(['getElementsBySelector.js', 'dom-drag.js' , 'admin/ordering.js']) - return forms.Media(js=['admin/js/%s' % url for url in js]) - media = property(_media) + return forms.Media(js=[static('admin/js/%s' % url) for url in js]) def has_add_permission(self, request): """ @@ -1322,14 +1323,14 @@ class InlineModelAdmin(BaseModelAdmin): if self.verbose_name_plural is None: self.verbose_name_plural = self.model._meta.verbose_name_plural - def _media(self): + @property + def media(self): js = ['jquery.min.js', 'jquery.init.js', 'inlines.min.js'] if self.prepopulated_fields: js.extend(['urlify.js', 'prepopulate.min.js']) if self.filter_vertical or self.filter_horizontal: js.extend(['SelectBox.js', 'SelectFilter2.js']) - return forms.Media(js=['admin/js/%s' % url for url in js]) - media = property(_media) + return forms.Media(js=[static('admin/js/%s' % url) for url in js]) def get_formset(self, request, obj=None, **kwargs): """Returns a BaseInlineFormSet class for use in admin add/change views.""" diff --git a/django/contrib/admin/templates/admin/auth/user/change_password.html b/django/contrib/admin/templates/admin/auth/user/change_password.html index c280f50d60..b18b0aabf3 100644 --- a/django/contrib/admin/templates/admin/auth/user/change_password.html +++ b/django/contrib/admin/templates/admin/auth/user/change_password.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n static admin_modify %} +{% load i18n admin_static admin_modify %} {% load url from future %} {% block extrahead %}{{ block.super }} {% url 'admin:jsi18n' as jsi18nurl %} diff --git a/django/contrib/admin/templates/admin/base.html b/django/contrib/admin/templates/admin/base.html index 928244053f..4b3c42934f 100644 --- a/django/contrib/admin/templates/admin/base.html +++ b/django/contrib/admin/templates/admin/base.html @@ -1,4 +1,4 @@ -{% load static %}{% load url from future %}<!DOCTYPE html> +{% load admin_static %}{% load url from future %}<!DOCTYPE html> <html lang="{{ LANGUAGE_CODE|default:"en-us" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}> <head> <title>{% block title %}{% endblock %}</title> diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index c5e428d174..56661e9a8e 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n static admin_modify %} +{% load i18n admin_static admin_modify %} {% load url from future %} {% block extrahead %}{{ block.super }} diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html index 29af47ff79..24c6d8cced 100644 --- a/django/contrib/admin/templates/admin/change_list.html +++ b/django/contrib/admin/templates/admin/change_list.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n static admin_list %} +{% load i18n admin_static admin_list %} {% load url from future %} {% block extrastyle %} {{ block.super }} diff --git a/django/contrib/admin/templates/admin/change_list_results.html b/django/contrib/admin/templates/admin/change_list_results.html index 233d4e12d0..b1db6470c9 100644 --- a/django/contrib/admin/templates/admin/change_list_results.html +++ b/django/contrib/admin/templates/admin/change_list_results.html @@ -1,4 +1,4 @@ -{% load i18n static %} +{% load i18n admin_static %} {% if result_hidden_fields %} <div class="hiddenfields">{# DIV for HTML validation #} {% for item in result_hidden_fields %}{{ item }}{% endfor %} diff --git a/django/contrib/admin/templates/admin/edit_inline/stacked.html b/django/contrib/admin/templates/admin/edit_inline/stacked.html index 7e69450866..476e2613c5 100644 --- a/django/contrib/admin/templates/admin/edit_inline/stacked.html +++ b/django/contrib/admin/templates/admin/edit_inline/stacked.html @@ -1,4 +1,4 @@ -{% load i18n static %} +{% load i18n admin_static %} <div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group"> <h2>{{ inline_admin_formset.opts.verbose_name_plural|title }}</h2> {{ inline_admin_formset.formset.management_form }} diff --git a/django/contrib/admin/templates/admin/edit_inline/tabular.html b/django/contrib/admin/templates/admin/edit_inline/tabular.html index 829422711d..29db95a171 100644 --- a/django/contrib/admin/templates/admin/edit_inline/tabular.html +++ b/django/contrib/admin/templates/admin/edit_inline/tabular.html @@ -1,4 +1,4 @@ -{% load i18n static admin_modify %} +{% load i18n admin_static admin_modify %} <div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group"> <div class="tabular inline-related {% if forloop.last %}last-related{% endif %}"> {{ inline_admin_formset.formset.management_form }} diff --git a/django/contrib/admin/templates/admin/index.html b/django/contrib/admin/templates/admin/index.html index 0f81a1a4fe..7164220ce8 100644 --- a/django/contrib/admin/templates/admin/index.html +++ b/django/contrib/admin/templates/admin/index.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n static %} +{% load i18n admin_static %} {% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static "admin/css/dashboard.css" %}" />{% endblock %} diff --git a/django/contrib/admin/templates/admin/login.html b/django/contrib/admin/templates/admin/login.html index a95cfd8e6b..dbaa119224 100644 --- a/django/contrib/admin/templates/admin/login.html +++ b/django/contrib/admin/templates/admin/login.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n static %} +{% load i18n admin_static %} {% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static "admin/css/login.css" %}" />{% endblock %} diff --git a/django/contrib/admin/templates/admin/search_form.html b/django/contrib/admin/templates/admin/search_form.html index 2b23a215fb..162b54addf 100644 --- a/django/contrib/admin/templates/admin/search_form.html +++ b/django/contrib/admin/templates/admin/search_form.html @@ -1,4 +1,4 @@ -{% load i18n static %} +{% load i18n admin_static %} {% if cl.search_fields %} <div id="toolbar"><form id="changelist-search" action="" method="get"> <div><!-- DIV needed for valid HTML --> diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py index 7a3a9b826a..0f5eafc24d 100644 --- a/django/contrib/admin/templatetags/admin_list.py +++ b/django/contrib/admin/templatetags/admin_list.py @@ -3,9 +3,9 @@ import datetime from django.contrib.admin.util import lookup_field, display_for_field, label_for_field from django.contrib.admin.views.main import (ALL_VAR, EMPTY_CHANGELIST_VALUE, ORDER_VAR, PAGE_VAR, SEARCH_VAR) +from django.contrib.admin.templatetags.admin_static import static from django.core.exceptions import ObjectDoesNotExist from django.db import models -from django.templatetags.static import static from django.utils import formats from django.utils.html import escape, conditional_escape from django.utils.safestring import mark_safe diff --git a/django/contrib/admin/templatetags/admin_static.py b/django/contrib/admin/templatetags/admin_static.py new file mode 100644 index 0000000000..5ea3ba5838 --- /dev/null +++ b/django/contrib/admin/templatetags/admin_static.py @@ -0,0 +1,11 @@ +from django.conf import settings +from django.template import Library + +register = Library() + +if 'django.contrib.staticfiles' in settings.INSTALLED_APPS: + from django.contrib.staticfiles.templatetags.staticfiles import static +else: + from django.templatetags.static import static + +static = register.simple_tag(static) diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py index 038351e6da..0d1f2a9ff7 100644 --- a/django/contrib/admin/widgets.py +++ b/django/contrib/admin/widgets.py @@ -4,16 +4,17 @@ Form Widget classes specific to the Django admin site. import copy from django import forms +from django.contrib.admin.templatetags.admin_static import static from django.core.urlresolvers import reverse from django.forms.widgets import RadioFieldRenderer from django.forms.util import flatatt -from django.templatetags.static import static from django.utils.html import escape from django.utils.text import Truncator from django.utils.translation import ugettext as _ from django.utils.safestring import mark_safe from django.utils.encoding import force_unicode + class FilteredSelectMultiple(forms.SelectMultiple): """ A SelectMultiple with a JavaScript filter interface. @@ -21,9 +22,10 @@ class FilteredSelectMultiple(forms.SelectMultiple): Note that the resulting JavaScript assumes that the jsi18n catalog has been loaded in the page """ - class Media: - js = ["admin/js/%s" % path - for path in ["core.js", "SelectBox.js", "SelectFilter2.js"]] + @property + def media(self): + js = ["core.js", "SelectBox.js", "SelectFilter2.js"] + return forms.Media(js=[static("admin/js/%s" % path) for path in js]) def __init__(self, verbose_name, is_stacked, attrs=None, choices=()): self.verbose_name = verbose_name @@ -31,9 +33,11 @@ class FilteredSelectMultiple(forms.SelectMultiple): super(FilteredSelectMultiple, self).__init__(attrs, choices) def render(self, name, value, attrs=None, choices=()): - if attrs is None: attrs = {} + if attrs is None: + attrs = {} attrs['class'] = 'selectfilter' - if self.is_stacked: attrs['class'] += 'stacked' + if self.is_stacked: + attrs['class'] += 'stacked' output = [super(FilteredSelectMultiple, self).render(name, value, attrs, choices)] output.append(u'<script type="text/javascript">addEvent(window, "load", function(e) {') # TODO: "id_" is hard-coded here. This should instead use the correct @@ -43,15 +47,21 @@ class FilteredSelectMultiple(forms.SelectMultiple): return mark_safe(u''.join(output)) class AdminDateWidget(forms.DateInput): - class Media: - js = ["admin/js/calendar.js", "admin/js/admin/DateTimeShortcuts.js"] + + @property + def media(self): + js = ["calendar.js", "admin/DateTimeShortcuts.js"] + return forms.Media(js=[static("admin/js/%s" % path) for path in js]) def __init__(self, attrs={}, format=None): super(AdminDateWidget, self).__init__(attrs={'class': 'vDateField', 'size': '10'}, format=format) class AdminTimeWidget(forms.TimeInput): - class Media: - js = ["admin/js/calendar.js", "admin/js/admin/DateTimeShortcuts.js"] + + @property + def media(self): + js = ["calendar.js", "admin/DateTimeShortcuts.js"] + return forms.Media(js=[static("admin/js/%s" % path) for path in js]) def __init__(self, attrs={}, format=None): super(AdminTimeWidget, self).__init__(attrs={'class': 'vTimeField', 'size': '8'}, format=format) @@ -232,9 +242,9 @@ class RelatedFieldWidgetWrapper(forms.Widget): memo[id(self)] = obj return obj - def _media(self): + @property + def media(self): return self.widget.media - media = property(_media) def render(self, name, value, *args, **kwargs): rel_to = self.rel.to diff --git a/django/contrib/staticfiles/finders.py b/django/contrib/staticfiles/finders.py index ffb96e0af2..45bf4a1023 100644 --- a/django/contrib/staticfiles/finders.py +++ b/django/contrib/staticfiles/finders.py @@ -28,7 +28,7 @@ class BaseFinder(object): """ raise NotImplementedError() - def list(self, ignore_patterns=[]): + def list(self, ignore_patterns): """ Given an optional list of paths to ignore, this should return a two item iterable consisting of the relative path and storage diff --git a/django/contrib/staticfiles/management/commands/collectstatic.py b/django/contrib/staticfiles/management/commands/collectstatic.py index dd597ada07..18f889374b 100644 --- a/django/contrib/staticfiles/management/commands/collectstatic.py +++ b/django/contrib/staticfiles/management/commands/collectstatic.py @@ -4,12 +4,11 @@ import os import sys from optparse import make_option -from django.conf import settings -from django.core.files.storage import FileSystemStorage, get_storage_class +from django.core.files.storage import FileSystemStorage from django.core.management.base import CommandError, NoArgsCommand from django.utils.encoding import smart_str, smart_unicode -from django.contrib.staticfiles import finders +from django.contrib.staticfiles import finders, storage class Command(NoArgsCommand): @@ -18,32 +17,39 @@ class Command(NoArgsCommand): locations to the settings.STATIC_ROOT. """ option_list = NoArgsCommand.option_list + ( - make_option('--noinput', action='store_false', dest='interactive', - default=True, help="Do NOT prompt the user for input of any kind."), + make_option('--noinput', + action='store_false', dest='interactive', default=True, + help="Do NOT prompt the user for input of any kind."), + make_option('--no-post-process', + action='store_false', dest='post_process', default=True, + help="Do NOT post process collected files."), make_option('-i', '--ignore', action='append', default=[], dest='ignore_patterns', metavar='PATTERN', help="Ignore files or directories matching this glob-style " "pattern. Use multiple times to ignore more."), - make_option('-n', '--dry-run', action='store_true', dest='dry_run', - default=False, help="Do everything except modify the filesystem."), - make_option('-c', '--clear', action='store_true', dest='clear', - default=False, help="Clear the existing files using the storage " - "before trying to copy or link the original file."), - make_option('-l', '--link', action='store_true', dest='link', - default=False, help="Create a symbolic link to each file instead of copying."), + make_option('-n', '--dry-run', + action='store_true', dest='dry_run', default=False, + help="Do everything except modify the filesystem."), + make_option('-c', '--clear', + action='store_true', dest='clear', default=False, + help="Clear the existing files using the storage " + "before trying to copy or link the original file."), + make_option('-l', '--link', + action='store_true', dest='link', default=False, + help="Create a symbolic link to each file instead of copying."), make_option('--no-default-ignore', action='store_false', dest='use_default_ignore_patterns', default=True, help="Don't ignore the common private glob-style patterns 'CVS', " "'.*' and '*~'."), ) - help = "Collect static files from apps and other locations in a single location." + help = "Collect static files in a single location." def __init__(self, *args, **kwargs): super(NoArgsCommand, self).__init__(*args, **kwargs) self.copied_files = [] self.symlinked_files = [] self.unmodified_files = [] - self.storage = get_storage_class(settings.STATICFILES_STORAGE)() + self.storage = storage.staticfiles_storage try: self.storage.path('') except NotImplementedError: @@ -64,6 +70,7 @@ class Command(NoArgsCommand): self.interactive = options['interactive'] self.symlink = options['link'] self.verbosity = int(options.get('verbosity', 1)) + self.post_process = options['post_process'] if self.symlink: if sys.platform == 'win32': @@ -104,9 +111,10 @@ Type 'yes' to continue, or 'no' to cancel: """ handler = { True: self.link_file, - False: self.copy_file + False: self.copy_file, }[self.symlink] + found_files = [] for finder in finders.get_finders(): for path, storage in finder.list(self.ignore_patterns): # Prefix the relative path if the source storage contains it @@ -114,19 +122,35 @@ Type 'yes' to continue, or 'no' to cancel: """ prefixed_path = os.path.join(storage.prefix, path) else: prefixed_path = path + found_files.append(prefixed_path) handler(path, prefixed_path, storage) - actual_count = len(self.copied_files) + len(self.symlinked_files) + # Here we check if the storage backend has a post_process + # method and pass it the list of modified files. + if self.post_process and hasattr(self.storage, 'post_process'): + post_processed = self.storage.post_process(found_files, **options) + for path in post_processed: + self.log(u"Post-processed '%s'" % path, level=1) + else: + post_processed = [] + + modified_files = self.copied_files + self.symlinked_files + actual_count = len(modified_files) unmodified_count = len(self.unmodified_files) + if self.verbosity >= 1: - self.stdout.write(smart_str(u"\n%s static file%s %s %s%s.\n" - % (actual_count, - actual_count != 1 and 's' or '', - self.symlink and 'symlinked' or 'copied', - destination_path and "to '%s'" - % destination_path or '', - unmodified_count and ' (%s unmodified)' - % unmodified_count or ''))) + template = ("\n%(actual_count)s %(identifier)s %(action)s" + "%(destination)s%(unmodified)s.\n") + summary = template % { + 'actual_count': actual_count, + 'identifier': 'static file' + (actual_count > 1 and 's' or ''), + 'action': self.symlink and 'symlinked' or 'copied', + 'destination': (destination_path and " to '%s'" + % destination_path or ''), + 'unmodified': (self.unmodified_files and ', %s unmodified' + % unmodified_count or ''), + } + self.stdout.write(smart_str(summary)) def log(self, msg, level=2): """ @@ -146,7 +170,8 @@ Type 'yes' to continue, or 'no' to cancel: """ for f in files: fpath = os.path.join(path, f) if self.dry_run: - self.log(u"Pretending to delete '%s'" % smart_unicode(fpath), level=1) + self.log(u"Pretending to delete '%s'" % + smart_unicode(fpath), level=1) else: self.log(u"Deleting '%s'" % smart_unicode(fpath), level=1) self.storage.delete(fpath) @@ -159,7 +184,8 @@ Type 'yes' to continue, or 'no' to cancel: """ if self.storage.exists(prefixed_path): try: # When was the target file modified last time? - target_last_modified = self.storage.modified_time(prefixed_path) + target_last_modified = \ + self.storage.modified_time(prefixed_path) except (OSError, NotImplementedError): # The storage doesn't support ``modified_time`` or failed pass @@ -177,8 +203,10 @@ Type 'yes' to continue, or 'no' to cancel: """ full_path = None # Skip the file if the source file is younger if target_last_modified >= source_last_modified: - if not ((self.symlink and full_path and not os.path.islink(full_path)) or - (not self.symlink and full_path and os.path.islink(full_path))): + if not ((self.symlink and full_path + and not os.path.islink(full_path)) or + (not self.symlink and full_path + and os.path.islink(full_path))): if prefixed_path not in self.unmodified_files: self.unmodified_files.append(prefixed_path) self.log(u"Skipping '%s' (not modified)" % path) diff --git a/django/contrib/staticfiles/storage.py b/django/contrib/staticfiles/storage.py index a69ae239df..d2f5cdd9d5 100644 --- a/django/contrib/staticfiles/storage.py +++ b/django/contrib/staticfiles/storage.py @@ -1,10 +1,20 @@ +import hashlib import os -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from django.core.files.storage import FileSystemStorage -from django.utils.importlib import import_module +import posixpath +import re -from django.contrib.staticfiles import utils +from django.conf import settings +from django.core.cache import (get_cache, InvalidCacheBackendError, + cache as default_cache) +from django.core.exceptions import ImproperlyConfigured +from django.core.files.base import ContentFile +from django.core.files.storage import FileSystemStorage, get_storage_class +from django.utils.encoding import force_unicode +from django.utils.functional import LazyObject +from django.utils.importlib import import_module +from django.utils.datastructures import SortedDict + +from django.contrib.staticfiles.utils import check_settings, matches_patterns class StaticFilesStorage(FileSystemStorage): @@ -26,8 +36,148 @@ class StaticFilesStorage(FileSystemStorage): if base_url is None: raise ImproperlyConfigured("You're using the staticfiles app " "without having set the STATIC_URL setting.") - utils.check_settings() - super(StaticFilesStorage, self).__init__(location, base_url, *args, **kwargs) + check_settings() + super(StaticFilesStorage, self).__init__(location, base_url, + *args, **kwargs) + + +class CachedFilesMixin(object): + patterns = ( + ("*.css", ( + r"""(url\(['"]{0,1}\s*(.*?)["']{0,1}\))""", + r"""(@import\s*["']\s*(.*?)["'])""", + )), + ) + + def __init__(self, *args, **kwargs): + super(CachedFilesMixin, self).__init__(*args, **kwargs) + try: + self.cache = get_cache('staticfiles') + except InvalidCacheBackendError: + # Use the default backend + self.cache = default_cache + self._patterns = SortedDict() + for extension, patterns in self.patterns: + for pattern in patterns: + compiled = re.compile(pattern) + self._patterns.setdefault(extension, []).append(compiled) + + def hashed_name(self, name, content=None): + if content is None: + if not self.exists(name): + raise ValueError("The file '%s' could not be found with %r." % + (name, self)) + try: + content = self.open(name) + except IOError: + # Handle directory paths + return name + path, filename = os.path.split(name) + root, ext = os.path.splitext(filename) + # Get the MD5 hash of the file + md5 = hashlib.md5() + for chunk in content.chunks(): + md5.update(chunk) + md5sum = md5.hexdigest()[:12] + return os.path.join(path, u"%s.%s%s" % (root, md5sum, ext)) + + def cache_key(self, name): + return u'staticfiles:cache:%s' % name + + def url(self, name, force=False): + """ + Returns the real URL in DEBUG mode. + """ + if settings.DEBUG and not force: + return super(CachedFilesMixin, self).url(name) + cache_key = self.cache_key(name) + hashed_name = self.cache.get(cache_key) + if hashed_name is None: + hashed_name = self.hashed_name(name) + return super(CachedFilesMixin, self).url(hashed_name) + + def url_converter(self, name): + """ + Returns the custom URL converter for the given file name. + """ + def converter(matchobj): + """ + Converts the matched URL depending on the parent level (`..`) + and returns the normalized and hashed URL using the url method + of the storage. + """ + matched, url = matchobj.groups() + # Completely ignore http(s) prefixed URLs + if url.startswith(('http', 'https')): + return matched + name_parts = name.split('/') + # Using posix normpath here to remove duplicates + result = url_parts = posixpath.normpath(url).split('/') + level = url.count('..') + if level: + result = name_parts[:-level - 1] + url_parts[level:] + elif name_parts[:-1]: + result = name_parts[:-1] + url_parts[-1:] + joined_result = '/'.join(result) + hashed_url = self.url(joined_result, force=True) + # Return the hashed and normalized version to the file + return 'url("%s")' % hashed_url + return converter + + def post_process(self, paths, dry_run=False, **options): + """ + Post process the given list of files (called from collectstatic). + """ + processed_files = [] + # don't even dare to process the files if we're in dry run mode + if dry_run: + return processed_files + + # delete cache of all handled paths + self.cache.delete_many([self.cache_key(path) for path in paths]) + + # only try processing the files we have patterns for + matches = lambda path: matches_patterns(path, self._patterns.keys()) + processing_paths = [path for path in paths if matches(path)] + + # then sort the files by the directory level + path_level = lambda name: len(name.split(os.sep)) + for name in sorted(paths, key=path_level, reverse=True): + + # first get a hashed name for the given file + hashed_name = self.hashed_name(name) + + with self.open(name) as original_file: + # then get the original's file content + content = original_file.read() + + # to apply each replacement pattern on the content + if name in processing_paths: + converter = self.url_converter(name) + for patterns in self._patterns.values(): + for pattern in patterns: + content = pattern.sub(converter, content) + + # then save the processed result + if self.exists(hashed_name): + self.delete(hashed_name) + + saved_name = self._save(hashed_name, ContentFile(content)) + hashed_name = force_unicode(saved_name.replace('\\', '/')) + processed_files.append(hashed_name) + + # and then set the cache accordingly + self.cache.set(self.cache_key(name), hashed_name) + + return processed_files + + +class CachedStaticFilesStorage(CachedFilesMixin, StaticFilesStorage): + """ + A static file system storage backend which also saves + hashed copies of the files it saves. + """ + pass class AppStaticStorage(FileSystemStorage): @@ -47,3 +197,10 @@ class AppStaticStorage(FileSystemStorage): mod_path = os.path.dirname(mod.__file__) location = os.path.join(mod_path, self.source_dir) super(AppStaticStorage, self).__init__(location, *args, **kwargs) + + +class ConfiguredStorage(LazyObject): + def _setup(self): + self._wrapped = get_storage_class(settings.STATICFILES_STORAGE)() + +staticfiles_storage = ConfiguredStorage() diff --git a/django/contrib/staticfiles/templatetags/__init__.py b/django/contrib/staticfiles/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django/contrib/staticfiles/templatetags/staticfiles.py b/django/contrib/staticfiles/templatetags/staticfiles.py new file mode 100644 index 0000000000..788f06ec16 --- /dev/null +++ b/django/contrib/staticfiles/templatetags/staticfiles.py @@ -0,0 +1,13 @@ +from django import template +from django.contrib.staticfiles.storage import staticfiles_storage + +register = template.Library() + + +@register.simple_tag +def static(path): + """ + A template tag that returns the URL to a file + using staticfiles' storage backend + """ + return staticfiles_storage.url(path) diff --git a/django/contrib/staticfiles/utils.py b/django/contrib/staticfiles/utils.py index 9ff4bc4321..9aee928063 100644 --- a/django/contrib/staticfiles/utils.py +++ b/django/contrib/staticfiles/utils.py @@ -3,30 +3,34 @@ import fnmatch from django.conf import settings from django.core.exceptions import ImproperlyConfigured -def is_ignored(path, ignore_patterns=[]): +def matches_patterns(path, patterns=None): """ Return True or False depending on whether the ``path`` should be ignored (if it matches any pattern in ``ignore_patterns``). """ - for pattern in ignore_patterns: + if patterns is None: + patterns = [] + for pattern in patterns: if fnmatch.fnmatchcase(path, pattern): return True return False -def get_files(storage, ignore_patterns=[], location=''): +def get_files(storage, ignore_patterns=None, location=''): """ Recursively walk the storage directories yielding the paths of all files that should be copied. """ + if ignore_patterns is None: + ignore_patterns = [] directories, files = storage.listdir(location) for fn in files: - if is_ignored(fn, ignore_patterns): + if matches_patterns(fn, ignore_patterns): continue if location: fn = os.path.join(location, fn) yield fn for dir in directories: - if is_ignored(dir, ignore_patterns): + if matches_patterns(dir, ignore_patterns): continue if location: dir = os.path.join(location, dir) diff --git a/docs/howto/static-files.txt b/docs/howto/static-files.txt index 16f8ac4c11..465b5baeae 100644 --- a/docs/howto/static-files.txt +++ b/docs/howto/static-files.txt @@ -70,7 +70,7 @@ Basic usage <img src="{{ STATIC_URL }}images/hi.jpg" /> - See :ref:`staticfiles-in-templates` for more details, including an + See :ref:`staticfiles-in-templates` for more details, **including** an alternate method using a template tag. Deploying static files in a nutshell @@ -143,7 +143,7 @@ A far better way is to use the value of the :setting:`STATIC_URL` setting directly in your templates. This means that a switch of static files servers only requires changing that single value. Much better! -``staticfiles`` includes two built-in ways of getting at this setting in your +Django includes multiple built-in ways of using this setting in your templates: a context processor and a template tag. With a context processor @@ -180,14 +180,19 @@ but in views written by hand you'll need to explicitly use ``RequestContext`` To see how that works, and to read more details, check out :ref:`subclassing-context-requestcontext`. +Another option is the :ttag:`get_static_prefix` template tag that is part of +Django's core. + With a template tag ------------------- -To easily link to static files Django ships with a :ttag:`static` template tag. +The more powerful tool is the :ttag:`static<staticfiles-static>` template +tag. It builds the URL for the given relative path by using the configured +:setting:`STATICFILES_STORAGE` storage. .. code-block:: html+django - {% load static %} + {% load staticfiles %} <img src="{% static "images/hi.jpg" %}" /> It is also able to consume standard context variables, e.g. assuming a @@ -195,30 +200,21 @@ It is also able to consume standard context variables, e.g. assuming a .. code-block:: html+django - {% load static %} + {% load staticfiles %} <link rel="stylesheet" href="{% static user_stylesheet %}" type="text/css" media="screen" /> -Another option is the :ttag:`get_static_prefix` template tag. You can use -this if you're not using :class:`~django.template.RequestContext` (and -therefore not relying on the ``django.core.context_processors.static`` -context processor), or if you need more control over exactly where and how -:setting:`STATIC_URL` is injected into the template. Here's an example: +.. note:: -.. code-block:: html+django + There is also a template tag named :ttag:`static` in Django's core set + of :ref:`built in template tags<ref-templates-builtins-tags>` which has + the same argument signature but only uses `urlparse.urljoin()`_ with the + :setting:`STATIC_URL` setting and the given path. This has the + disadvantage of not being able to easily switch the storage backend + without changing the templates, so in doubt use the ``staticfiles`` + :ttag:`static<staticfiles-static>` + template tag. - {% load static %} - <img src="{% get_static_prefix %}images/hi.jpg" /> - -There's also a second form you can use to avoid extra processing if you need -the value multiple times: - -.. code-block:: html+django - - {% load static %} - {% get_static_prefix as STATIC_PREFIX %} - - <img src="{{ STATIC_PREFIX }}images/hi.jpg" /> - <img src="{{ STATIC_PREFIX }}images/hi2.jpg" /> +.. _`urlparse.urljoin()`: http://docs.python.org/library/urlparse.html#urlparse.urljoin .. _staticfiles-development: diff --git a/docs/ref/contrib/staticfiles.txt b/docs/ref/contrib/staticfiles.txt index 5ab3c1e02f..ae9521051e 100644 --- a/docs/ref/contrib/staticfiles.txt +++ b/docs/ref/contrib/staticfiles.txt @@ -68,7 +68,9 @@ in a ``'downloads'`` subdirectory of :setting:`STATIC_ROOT`. This would allow you to refer to the local file ``'/opt/webfiles/stats/polls_20101022.tar.gz'`` with -``'/static/downloads/polls_20101022.tar.gz'`` in your templates, e.g.:: +``'/static/downloads/polls_20101022.tar.gz'`` in your templates, e.g.: + +.. code-block:: html+django <a href="{{ STATIC_URL }}downloads/polls_20101022.tar.gz"> @@ -82,6 +84,11 @@ Default: ``'django.contrib.staticfiles.storage.StaticFilesStorage'`` The file storage engine to use when collecting static files with the :djadmin:`collectstatic` management command. +.. versionadded:: 1.4 + +A ready-to-use instance of the storage backend defined in this setting +can be found at ``django.contrib.staticfiles.storage.staticfiles_storage``. + For an example, see :ref:`staticfiles-from-cdn`. .. setting:: STATICFILES_FINDERS @@ -141,6 +148,16 @@ Files are searched by using the :setting:`enabled finders :setting:`STATICFILES_DIRS` and in the ``'static'`` directory of apps specified by the :setting:`INSTALLED_APPS` setting. +.. versionadded:: 1.4 + +The :djadmin:`collectstatic` management command calls the +:meth:`~django.contrib.staticfiles.storage.StaticFilesStorage.post_process` +method of the :setting:`STATICFILES_STORAGE` after each run and passes +a list of paths that have been found by the management command. It also +receives all command line options of :djadmin:`collectstatic`. This is used +by the :class:`~django.contrib.staticfiles.storage.CachedStaticFilesStorage` +by default. + Some commonly used options are: .. django-admin-option:: --noinput @@ -169,6 +186,13 @@ Some commonly used options are: Create a symbolic link to each file instead of copying. +.. django-admin-option:: --no-post-process +.. versionadded:: 1.4 + + Don't call the + :meth:`~django.contrib.staticfiles.storage.StaticFilesStorage.post_process` + method of the configured :setting:`STATICFILES_STORAGE` storage backend. + .. django-admin-option:: --no-default-ignore Don't ignore the common private glob-style patterns ``'CVS'``, ``'.*'`` @@ -237,7 +261,120 @@ Example usage:: django-admin.py runserver --insecure -.. currentmodule:: None +Storages +======== + +StaticFilesStorage +------------------ + +.. class:: storage.StaticFilesStorage + + A subclass of the :class:`~django.core.files.storage.FileSystemStorage` + storage backend that uses the :setting:`STATIC_ROOT` setting as the base + file system location and the :setting:`STATIC_URL` setting respectively + as the base URL. + + .. method:: post_process(paths, **options) + + .. versionadded:: 1.4 + + This method is called by the :djadmin:`collectstatic` management command + after each run and gets passed the paths of found files, as well as the + command line options. + + The :class:`~django.contrib.staticfiles.storage.CachedStaticFilesStorage` + uses this behind the scenes to replace the paths with their hashed + counterparts and update the cache appropriately. + +CachedStaticFilesStorage +------------------------ + +.. class:: storage.CachedStaticFilesStorage + + .. versionadded:: 1.4 + + A subclass of the :class:`~django.contrib.staticfiles.storage.StaticFilesStorage` + storage backend which caches the files it saves by appending the MD5 hash + of the file's content to the filename. For example, the file + ``css/styles.css`` would also be saved as ``css/styles.55e7cbb9ba48.css``. + + The purpose of this storage is to keep serving the old files in case some + pages still refer to those files, e.g. because they are cached by you or + a 3rd party proxy server. Additionally, it's very helpful if you want to + apply `far future Expires headers`_ to the deployed files to speed up the + load time for subsequent page visits. + + The storage backend automatically replaces the paths found in the saved + files matching other saved files with the path of the cached copy (using + the :meth:`~django.contrib.staticfiles.storage.StaticFilesStorage.post_process` + method). The regular expressions used to find those paths + (``django.contrib.staticfiles.storage.CachedStaticFilesStorage.cached_patterns``) + by default cover the `@import`_ rule and `url()`_ statement of `Cascading + Style Sheets`_. For example, the ``'css/styles.css'`` file with the + content + + .. code-block:: css+django + + @import url("../admin/css/base.css"); + + would be replaced by calling the + :meth:`~django.core.files.storage.Storage.url` + method of the ``CachedStaticFilesStorage`` storage backend, ultimatively + saving a ``'css/styles.55e7cbb9ba48.css'`` file with the following + content: + + .. code-block:: css+django + + @import url("/static/admin/css/base.27e20196a850.css"); + + To enable the ``CachedStaticFilesStorage`` you have to make sure the + following requirements are met: + + * the :setting:`STATICFILES_STORAGE` setting is set to + ``'django.contrib.staticfiles.storage.CachedStaticFilesStorage'`` + * the :setting:`DEBUG` setting is set to ``False`` + * you use the ``staticfiles`` :ttag:`static<staticfiles-static>` template + tag to refer to your static files in your templates + * you've collected all your static files by using the + :djadmin:`collectstatic` management command + + Since creating the MD5 hash can be a performance burden to your website + during runtime, ``staticfiles`` will automatically try to cache the + hashed name for each file path using Django's :doc:`caching + framework</topics/cache>`. If you want to override certain options of the + cache backend the storage uses, simply specify a custom entry in the + :setting:`CACHES` setting named ``'staticfiles'``. It falls back to using + the ``'default'`` cache backend. + +.. _`far future Expires headers`: http://developer.yahoo.com/performance/rules.html#expires +.. _`@import`: http://www.w3.org/TR/CSS2/cascade.html#at-import +.. _`url()`: http://www.w3.org/TR/CSS2/syndata.html#uri +.. _`Cascading Style Sheets`: http://www.w3.org/Style/CSS/ + +.. currentmodule:: django.contrib.staticfiles.templatetags.staticfiles + +Template tags +============= + +static +------ + +.. templatetag:: staticfiles-static + +.. versionadded:: 1.4 + +Uses the configued :setting:`STATICFILES_STORAGE` storage to create the +full URL for the given relative path, e.g.: + +.. code-block:: html+django + + {% load static from staticfiles %} + <img src="{% static "css/base.css" %}" /> + +The previous example is equal to calling the ``url`` method of an instance of +:setting:`STATICFILES_STORAGE` with ``"css/base.css"``. This is especially +useful when using a non-local storage backend to deploy files as documented +in :ref:`staticfiles-from-cdn`. Other Helpers ============= @@ -251,7 +388,7 @@ files: with :class:`~django.template.RequestContext` contexts. - The builtin template tag :ttag:`static` which takes a path and - joins it with the the static prefix :setting:`STATIC_URL`. + urljoins it with the static prefix :setting:`STATIC_URL`. - The builtin template tag :ttag:`get_static_prefix` which populates a template variable with the static prefix :setting:`STATIC_URL` to be diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index 5c08c66c21..a7d548a01c 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -2353,9 +2353,9 @@ static .. highlight:: html+django -To link to static files Django ships with a :ttag:`static` template tag. You -can use this regardless if you're using :class:`~django.template.RequestContext` -or not. +To link to static files that are saved in :setting:`STATIC_ROOT` Django ships +with a :ttag:`static` template tag. You can use this regardless if you're +using :class:`~django.template.RequestContext` or not. .. code-block:: html+django @@ -2370,6 +2370,17 @@ It is also able to consume standard context variables, e.g. assuming a {% load static %} <link rel="stylesheet" href="{% static user_stylesheet %}" type="text/css" media="screen" /> +.. note:: + + The :mod:`staticfiles<django.contrib.staticfiles>` contrib app also ships + with a :ttag:`static template tag<staticfiles-static>` which uses + ``staticfiles'`` :setting:`STATICFILES_STORAGE` to build the URL of the + given path. Use that instead if you have an advanced use case such as + :ref:`using a cloud service to serve static files<staticfiles-from-cdn>`:: + + {% load static from staticfiles %} + <img src="{% static "images/hi.jpg" %}" /> + .. templatetag:: get_static_prefix get_static_prefix diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index ec4eae09af..02c4e82693 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -212,6 +212,29 @@ Additionally, it's now possible to define translatable URL patterns using :ref:`url-internationalization` for more information about the language prefix and how to internationalize URL patterns. +``static`` template tag +~~~~~~~~~~~~~~~~~~~~~~~ + +The :mod:`staticfiles<django.contrib.staticfiles>` contrib app has now a new +:ttag:`static template tag<staticfiles-static>` to refer to files saved with +the :setting:`STATICFILES_STORAGE` storage backend. It'll use the storage +``url`` method and therefore supports advanced features such as +:ref:`serving files from a cloud service<staticfiles-from-cdn>`. + +``CachedStaticFilesStorage`` storage backend +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Additional to the `static template tag`_ the +:mod:`staticfiles<django.contrib.staticfiles>` contrib app now has a +:class:`~django.contrib.staticfiles.storage.CachedStaticFilesStorage` which +caches the files it saves (when running the :djadmin:`collectstatic` +management command) by appending the MD5 hash of the file's content to the +filename. For example, the file ``css/styles.css`` would also be saved as +``css/styles.55e7cbb9ba48.css`` + +See the :class:`~django.contrib.staticfiles.storage.CachedStaticFilesStorage` +docs for more information. + Minor features ~~~~~~~~~~~~~~ diff --git a/tests/regressiontests/staticfiles_tests/project/documents/cached/absolute.css b/tests/regressiontests/staticfiles_tests/project/documents/cached/absolute.css new file mode 100644 index 0000000000..e64e7ccca7 --- /dev/null +++ b/tests/regressiontests/staticfiles_tests/project/documents/cached/absolute.css @@ -0,0 +1 @@ +@import url("/static/cached/styles.css"); diff --git a/tests/regressiontests/staticfiles_tests/project/documents/cached/denorm.css b/tests/regressiontests/staticfiles_tests/project/documents/cached/denorm.css new file mode 100644 index 0000000000..27b9a349b0 --- /dev/null +++ b/tests/regressiontests/staticfiles_tests/project/documents/cached/denorm.css @@ -0,0 +1 @@ +@import url("..//cached///styles.css"); diff --git a/tests/regressiontests/staticfiles_tests/project/documents/cached/other.css b/tests/regressiontests/staticfiles_tests/project/documents/cached/other.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/regressiontests/staticfiles_tests/project/documents/cached/relative.css b/tests/regressiontests/staticfiles_tests/project/documents/cached/relative.css new file mode 100644 index 0000000000..40c4a256aa --- /dev/null +++ b/tests/regressiontests/staticfiles_tests/project/documents/cached/relative.css @@ -0,0 +1,2 @@ +@import url("../cached/styles.css"); +@import url("absolute.css"); \ No newline at end of file diff --git a/tests/regressiontests/staticfiles_tests/project/documents/cached/styles.css b/tests/regressiontests/staticfiles_tests/project/documents/cached/styles.css new file mode 100644 index 0000000000..84936d1dcb --- /dev/null +++ b/tests/regressiontests/staticfiles_tests/project/documents/cached/styles.css @@ -0,0 +1 @@ +@import url("cached/other.css"); \ No newline at end of file diff --git a/tests/regressiontests/staticfiles_tests/project/documents/cached/url.css b/tests/regressiontests/staticfiles_tests/project/documents/cached/url.css new file mode 100644 index 0000000000..184e254004 --- /dev/null +++ b/tests/regressiontests/staticfiles_tests/project/documents/cached/url.css @@ -0,0 +1 @@ +@import url("https://www.djangoproject.com/m/css/base.css"); \ No newline at end of file diff --git a/tests/regressiontests/staticfiles_tests/project/site_media/static/testfile.txt b/tests/regressiontests/staticfiles_tests/project/site_media/static/testfile.txt new file mode 100644 index 0000000000..4d92dbe1ad --- /dev/null +++ b/tests/regressiontests/staticfiles_tests/project/site_media/static/testfile.txt @@ -0,0 +1 @@ +Test! \ No newline at end of file diff --git a/tests/regressiontests/staticfiles_tests/tests.py b/tests/regressiontests/staticfiles_tests/tests.py index 77b771d23d..40569484df 100644 --- a/tests/regressiontests/staticfiles_tests/tests.py +++ b/tests/regressiontests/staticfiles_tests/tests.py @@ -8,6 +8,7 @@ import sys import tempfile from StringIO import StringIO +from django.template import loader, Context from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.files.storage import default_storage @@ -21,9 +22,25 @@ from django.utils._os import rmtree_errorhandler from django.contrib.staticfiles import finders, storage TEST_ROOT = os.path.dirname(__file__) +TEST_SETTINGS = { + 'DEBUG': True, + 'MEDIA_URL': '/media/', + 'STATIC_URL': '/static/', + 'MEDIA_ROOT': os.path.join(TEST_ROOT, 'project', 'site_media', 'media'), + 'STATIC_ROOT': os.path.join(TEST_ROOT, 'project', 'site_media', 'static'), + 'STATICFILES_DIRS': ( + os.path.join(TEST_ROOT, 'project', 'documents'), + ('prefix', os.path.join(TEST_ROOT, 'project', 'prefixed')), + ), + 'STATICFILES_FINDERS': ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'django.contrib.staticfiles.finders.DefaultStorageFinder', + ), +} -class StaticFilesTestCase(TestCase): +class BaseStaticFilesTestCase(object): """ Test case with a couple utility assertions. """ @@ -32,6 +49,7 @@ class StaticFilesTestCase(TestCase): # gets accessed (by some other test), it evaluates settings.MEDIA_ROOT, # since we're planning on changing that we need to clear out the cache. default_storage._wrapped = empty + storage.staticfiles_storage._wrapped = empty # To make sure SVN doesn't hangs itself with the non-ASCII characters # during checkout, we actually create one file dynamically. @@ -48,27 +66,26 @@ class StaticFilesTestCase(TestCase): def assertFileNotFound(self, filepath): self.assertRaises(IOError, self._get_file, filepath) -StaticFilesTestCase = override_settings( - DEBUG = True, - MEDIA_URL = '/media/', - STATIC_URL = '/static/', - MEDIA_ROOT = os.path.join(TEST_ROOT, 'project', 'site_media', 'media'), - STATIC_ROOT = os.path.join(TEST_ROOT, 'project', 'site_media', 'static'), - STATICFILES_DIRS = ( - os.path.join(TEST_ROOT, 'project', 'documents'), - ('prefix', os.path.join(TEST_ROOT, 'project', 'prefixed')), - ), - STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', - 'django.contrib.staticfiles.finders.DefaultStorageFinder', - ), -)(StaticFilesTestCase) + def render_template(self, template, **kwargs): + if isinstance(template, basestring): + template = loader.get_template_from_string(template) + return template.render(Context(kwargs)).strip() + + def assertTemplateRenders(self, template, result, **kwargs): + self.assertEqual(self.render_template(template, **kwargs), result) + + def assertTemplateRaises(self, exc, template, result, **kwargs): + self.assertRaises(exc, self.assertTemplateRenders, template, result, **kwargs) -class BuildStaticTestCase(StaticFilesTestCase): +class StaticFilesTestCase(BaseStaticFilesTestCase, TestCase): + pass +StaticFilesTestCase = override_settings(**TEST_SETTINGS)(StaticFilesTestCase) + + +class BaseCollectionTestCase(BaseStaticFilesTestCase): """ - Tests shared by all file-resolving features (collectstatic, + Tests shared by all file finding features (collectstatic, findstatic, and static serve view). This relies on the asserts defined in UtilityAssertsTestCase, but @@ -76,7 +93,7 @@ class BuildStaticTestCase(StaticFilesTestCase): all these tests. """ def setUp(self): - super(BuildStaticTestCase, self).setUp() + super(BaseCollectionTestCase, self).setUp() self.old_root = settings.STATIC_ROOT settings.STATIC_ROOT = tempfile.mkdtemp() self.run_collectstatic() @@ -86,7 +103,7 @@ class BuildStaticTestCase(StaticFilesTestCase): def tearDown(self): settings.STATIC_ROOT = self.old_root - super(BuildStaticTestCase, self).tearDown() + super(BaseCollectionTestCase, self).tearDown() def run_collectstatic(self, **kwargs): call_command('collectstatic', interactive=False, verbosity='0', @@ -99,6 +116,10 @@ class BuildStaticTestCase(StaticFilesTestCase): return f.read() +class CollectionTestCase(BaseCollectionTestCase, StaticFilesTestCase): + pass + + class TestDefaults(object): """ A few standard test cases. @@ -142,7 +163,7 @@ class TestDefaults(object): self.assertFileContains(u'test/camelCase.txt', u'camelCase') -class TestFindStatic(BuildStaticTestCase, TestDefaults): +class TestFindStatic(CollectionTestCase, TestDefaults): """ Test ``findstatic`` management command. """ @@ -171,12 +192,12 @@ class TestFindStatic(BuildStaticTestCase, TestDefaults): lines = [l.strip() for l in sys.stdout.readlines()] finally: sys.stdout = _stdout - self.assertEqual(len(lines), 3) # three because there is also the "Found <file> here" line + self.assertEqual(len(lines), 3) # three because there is also the "Found <file> here" line self.assertTrue('project' in lines[1]) self.assertTrue('apps' in lines[2]) -class TestBuildStatic(BuildStaticTestCase, TestDefaults): +class TestCollection(CollectionTestCase, TestDefaults): """ Test ``collectstatic`` management command. """ @@ -195,7 +216,7 @@ class TestBuildStatic(BuildStaticTestCase, TestDefaults): self.assertFileNotFound('test/CVS') -class TestBuildStaticClear(BuildStaticTestCase): +class TestCollectionClear(CollectionTestCase): """ Test the ``--clear`` option of the ``collectstatic`` managemenet command. """ @@ -203,19 +224,19 @@ class TestBuildStaticClear(BuildStaticTestCase): clear_filepath = os.path.join(settings.STATIC_ROOT, 'cleared.txt') with open(clear_filepath, 'w') as f: f.write('should be cleared') - super(TestBuildStaticClear, self).run_collectstatic(clear=True) + super(TestCollectionClear, self).run_collectstatic(clear=True) def test_cleared_not_found(self): self.assertFileNotFound('cleared.txt') -class TestBuildStaticExcludeNoDefaultIgnore(BuildStaticTestCase, TestDefaults): +class TestCollectionExcludeNoDefaultIgnore(CollectionTestCase, TestDefaults): """ Test ``--exclude-dirs`` and ``--no-default-ignore`` options of the ``collectstatic`` management command. """ def run_collectstatic(self): - super(TestBuildStaticExcludeNoDefaultIgnore, self).run_collectstatic( + super(TestCollectionExcludeNoDefaultIgnore, self).run_collectstatic( use_default_ignore_patterns=False) def test_no_common_ignore_patterns(self): @@ -238,27 +259,98 @@ class TestNoFilesCreated(object): self.assertEqual(os.listdir(settings.STATIC_ROOT), []) -class TestBuildStaticDryRun(BuildStaticTestCase, TestNoFilesCreated): +class TestCollectionDryRun(CollectionTestCase, TestNoFilesCreated): """ Test ``--dry-run`` option for ``collectstatic`` management command. """ def run_collectstatic(self): - super(TestBuildStaticDryRun, self).run_collectstatic(dry_run=True) + super(TestCollectionDryRun, self).run_collectstatic(dry_run=True) -class TestBuildStaticNonLocalStorage(BuildStaticTestCase, TestNoFilesCreated): +class TestCollectionNonLocalStorage(CollectionTestCase, TestNoFilesCreated): """ Tests for #15035 """ pass -TestBuildStaticNonLocalStorage = override_settings( +TestCollectionNonLocalStorage = override_settings( STATICFILES_STORAGE='regressiontests.staticfiles_tests.storage.DummyStorage', -)(TestBuildStaticNonLocalStorage) +)(TestCollectionNonLocalStorage) + + +class TestCollectionCachedStorage(BaseCollectionTestCase, + BaseStaticFilesTestCase, TestCase): + """ + Tests for the Cache busting storage + """ + def cached_file_path(self, relpath): + template = "{%% load static from staticfiles %%}{%% static '%s' %%}" + fullpath = self.render_template(template % relpath) + return fullpath.replace(settings.STATIC_URL, '') + + def test_template_tag_return(self): + """ + Test the CachedStaticFilesStorage backend. + """ + self.assertTemplateRaises(ValueError, """ + {% load static from staticfiles %}{% static "does/not/exist.png" %} + """, "/static/does/not/exist.png") + self.assertTemplateRenders(""" + {% load static from staticfiles %}{% static "test/file.txt" %} + """, "/static/test/file.dad0999e4f8f.txt") + self.assertTemplateRenders(""" + {% load static from staticfiles %}{% static "cached/styles.css" %} + """, "/static/cached/styles.5653c259030b.css") + + def test_template_tag_simple_content(self): + relpath = self.cached_file_path("cached/styles.css") + self.assertEqual(relpath, "cached/styles.5653c259030b.css") + with storage.staticfiles_storage.open(relpath) as relfile: + content = relfile.read() + self.assertFalse("cached/other.css" in content, content) + self.assertTrue("/static/cached/other.d41d8cd98f00.css" in content) + + def test_template_tag_absolute(self): + relpath = self.cached_file_path("cached/absolute.css") + self.assertEqual(relpath, "cached/absolute.cc80cb5e2eb1.css") + with storage.staticfiles_storage.open(relpath) as relfile: + content = relfile.read() + self.assertFalse("/static/cached/styles.css" in content) + self.assertTrue("/static/cached/styles.5653c259030b.css" in content) + + def test_template_tag_denorm(self): + relpath = self.cached_file_path("cached/denorm.css") + self.assertEqual(relpath, "cached/denorm.363de96e9b4b.css") + with storage.staticfiles_storage.open(relpath) as relfile: + content = relfile.read() + self.assertFalse("..//cached///styles.css" in content) + self.assertTrue("/static/cached/styles.5653c259030b.css" in content) + + def test_template_tag_relative(self): + relpath = self.cached_file_path("cached/relative.css") + self.assertEqual(relpath, "cached/relative.298ff891a8d4.css") + with storage.staticfiles_storage.open(relpath) as relfile: + content = relfile.read() + self.assertFalse("../cached/styles.css" in content) + self.assertFalse('@import "styles.css"' in content) + self.assertTrue("/static/cached/styles.5653c259030b.css" in content) + + def test_template_tag_url(self): + relpath = self.cached_file_path("cached/url.css") + self.assertEqual(relpath, "cached/url.615e21601e4b.css") + with storage.staticfiles_storage.open(relpath) as relfile: + self.assertTrue("https://" in relfile.read()) + +# we set DEBUG to False here since the template tag wouldn't work otherwise +TestCollectionCachedStorage = override_settings(**dict(TEST_SETTINGS, + STATICFILES_STORAGE='django.contrib.staticfiles.storage.CachedStaticFilesStorage', + DEBUG=False, +))(TestCollectionCachedStorage) if sys.platform != 'win32': - class TestBuildStaticLinks(BuildStaticTestCase, TestDefaults): + + class TestCollectionLinks(CollectionTestCase, TestDefaults): """ Test ``--link`` option for ``collectstatic`` management command. @@ -267,7 +359,7 @@ if sys.platform != 'win32': ``--link`` does not change the file-selection semantics. """ def run_collectstatic(self): - super(TestBuildStaticLinks, self).run_collectstatic(link=True) + super(TestCollectionLinks, self).run_collectstatic(link=True) def test_links_created(self): """ @@ -312,6 +404,7 @@ class TestServeStaticWithDefaultURL(TestServeStatic, TestDefaults): """ pass + class TestServeStaticWithURLHelper(TestServeStatic, TestDefaults): """ Test static asset serving view with staticfiles_urlpatterns helper. @@ -399,22 +492,28 @@ class TestMiscFinder(TestCase): finders.FileSystemFinder)) def test_get_finder_bad_classname(self): - self.assertRaises(ImproperlyConfigured, - finders.get_finder, 'django.contrib.staticfiles.finders.FooBarFinder') + self.assertRaises(ImproperlyConfigured, finders.get_finder, + 'django.contrib.staticfiles.finders.FooBarFinder') def test_get_finder_bad_module(self): self.assertRaises(ImproperlyConfigured, finders.get_finder, 'foo.bar.FooBarFinder') - -class TestStaticfilesDirsType(TestCase): - """ - We can't determine if STATICFILES_DIRS is set correctly just by looking at - the type, but we can determine if it's definitely wrong. - """ def test_non_tuple_raises_exception(self): - self.assertRaises(ImproperlyConfigured, finders.FileSystemFinder) + """ + We can't determine if STATICFILES_DIRS is set correctly just by + looking at the type, but we can determine if it's definitely wrong. + """ + with self.settings(STATICFILES_DIRS='a string'): + self.assertRaises(ImproperlyConfigured, finders.FileSystemFinder) -TestStaticfilesDirsType = override_settings( - STATICFILES_DIRS = 'a string', -)(TestStaticfilesDirsType) + +class TestTemplateTag(StaticFilesTestCase): + + def test_template_tag(self): + self.assertTemplateRenders(""" + {% load static from staticfiles %}{% static "does/not/exist.png" %} + """, "/static/does/not/exist.png") + self.assertTemplateRenders(""" + {% load static from staticfiles %}{% static "testfile.txt" %} + """, "/static/testfile.txt")