diff options
Diffstat (limited to 'django')
21 files changed, 992 insertions, 184 deletions
diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 2dabf3b1e3..e9ab5c7c98 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -194,7 +194,7 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.contrib.auth.context_processors.auth', 'django.core.context_processors.debug', 'django.core.context_processors.i18n', - 'django.core.context_processors.media', + 'django.contrib.staticfiles.context_processors.staticfiles', # 'django.core.context_processors.request', 'django.contrib.messages.context_processors.messages', ) @@ -202,11 +202,6 @@ TEMPLATE_CONTEXT_PROCESSORS = ( # Output to use in template system for invalid (e.g. misspelled) variables. TEMPLATE_STRING_IF_INVALID = '' -# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a -# trailing slash. -# Examples: "http://foo.com/media/", "/media/". -ADMIN_MEDIA_PREFIX = '/media/' - # Default e-mail address to use for various automated correspondence from # the site managers. DEFAULT_FROM_EMAIL = 'webmaster@localhost' @@ -551,3 +546,34 @@ TEST_DATABASE_COLLATION = None # The list of directories to search for fixtures FIXTURE_DIRS = () + +############### +# STATICFILES # +############### + +# Absolute path to the directory that holds media. +# Example: "/home/media/media.lawrence.com/static/" +STATICFILES_ROOT = '' + +# URL that handles the static files served from STATICFILES_ROOT. +# Example: "http://media.lawrence.com/static/" +STATICFILES_URL = '/static/' + +# A list of locations of additional static files +STATICFILES_DIRS = () + +# The default file storage backend used during the build process +STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage' + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +# 'django.contrib.staticfiles.finders.DefaultStorageFinder', +) + +# URL prefix for admin media -- CSS, JavaScript and images. +# Make sure to use a trailing slash. +# Examples: "http://foo.com/static/admin/", "/static/admin/". +ADMIN_MEDIA_PREFIX = '/static/admin/' diff --git a/django/conf/project_template/settings.py b/django/conf/project_template/settings.py index 3c783d4565..7791b47cab 100644 --- a/django/conf/project_template/settings.py +++ b/django/conf/project_template/settings.py @@ -44,7 +44,7 @@ USE_I18N = True USE_L10N = True # Absolute path to the directory that holds media. -# Example: "/home/media/media.lawrence.com/" +# Example: "/home/media/media.lawrence.com/media/" MEDIA_ROOT = '' # URL that handles the media served from MEDIA_ROOT. Make sure to use a @@ -52,10 +52,29 @@ MEDIA_ROOT = '' # Examples: "http://media.lawrence.com", "http://example.com/media/" MEDIA_URL = '' -# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a -# trailing slash. -# Examples: "http://foo.com/media/", "/media/". -ADMIN_MEDIA_PREFIX = '/media/' +# Absolute path to the directory that holds media. +# Example: "/home/media/media.lawrence.com/static/" +STATICFILES_ROOT = '' + +# URL that handles the static files served from STATICFILES_ROOT. +# Example: "http://static.lawrence.com/", "http://example.com/static/" +STATICFILES_URL = '/static/' + +# URL prefix for admin media -- CSS, JavaScript and images. +# Make sure to use a trailing slash. +# Examples: "http://foo.com/static/admin/", "/static/admin/". +ADMIN_MEDIA_PREFIX = '/static/admin/' + +# A list of locations of additional static files +STATICFILES_DIRS = () + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +# 'django.contrib.staticfiles.finders.DefaultStorageFinder', +) # Make this unique, and don't share it with anybody. SECRET_KEY = '' @@ -89,6 +108,7 @@ INSTALLED_APPS = ( 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', + 'django.contrib.staticfiles', # Uncomment the next line to enable the admin: # 'django.contrib.admin', # Uncomment the next line to enable admin documentation: diff --git a/django/contrib/staticfiles/__init__.py b/django/contrib/staticfiles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/django/contrib/staticfiles/__init__.py diff --git a/django/contrib/staticfiles/context_processors.py b/django/contrib/staticfiles/context_processors.py new file mode 100644 index 0000000000..e1c8d8c8ed --- /dev/null +++ b/django/contrib/staticfiles/context_processors.py @@ -0,0 +1,7 @@ +from django.conf import settings + +def staticfiles(request): + return { + 'STATICFILES_URL': settings.STATICFILES_URL, + 'MEDIA_URL': settings.MEDIA_URL, + } diff --git a/django/contrib/staticfiles/finders.py b/django/contrib/staticfiles/finders.py new file mode 100644 index 0000000000..7f0a497174 --- /dev/null +++ b/django/contrib/staticfiles/finders.py @@ -0,0 +1,254 @@ +import os +from django.conf import settings +from django.db import models +from django.core.exceptions import ImproperlyConfigured +from django.core.files.storage import default_storage, Storage, FileSystemStorage +from django.utils.datastructures import SortedDict +from django.utils.functional import memoize, LazyObject +from django.utils.importlib import import_module + +from django.contrib.staticfiles import utils +from django.contrib.staticfiles.storage import AppStaticStorage + +_finders = {} + + +class BaseFinder(object): + """ + A base file finder to be used for custom staticfiles finder classes. + + """ + def find(self, path, all=False): + """ + Given a relative file path this ought to find an + absolute file path. + + If the ``all`` parameter is ``False`` (default) only + the first found file path will be returned; if set + to ``True`` a list of all found files paths is returned. + """ + raise NotImplementedError() + + def list(self, ignore_patterns=[]): + """ + Given an optional list of paths to ignore, this should return + a three item iterable with path, prefix and a storage instance. + """ + raise NotImplementedError() + + +class FileSystemFinder(BaseFinder): + """ + A static files finder that uses the ``STATICFILES_DIRS`` setting + to locate files. + """ + storages = SortedDict() + locations = set() + + def __init__(self, apps=None, *args, **kwargs): + for root in settings.STATICFILES_DIRS: + if isinstance(root, (list, tuple)): + prefix, root = root + else: + prefix = '' + self.locations.add((prefix, root)) + # Don't initialize multiple storages for the same location + for prefix, root in self.locations: + self.storages[root] = FileSystemStorage(location=root) + super(FileSystemFinder, self).__init__(*args, **kwargs) + + def find(self, path, all=False): + """ + Looks for files in the extra media locations + as defined in ``STATICFILES_DIRS``. + """ + matches = [] + for prefix, root in self.locations: + matched_path = self.find_location(root, path, prefix) + if matched_path: + if not all: + return matched_path + matches.append(matched_path) + return matches + + def find_location(self, root, path, prefix=None): + """ + Find a requested static file in a location, returning the found + absolute path (or ``None`` if no match). + """ + if prefix: + prefix = '%s/' % prefix + if not path.startswith(prefix): + return None + path = path[len(prefix):] + path = os.path.join(root, path) + if os.path.exists(path): + return path + + def list(self, ignore_patterns): + """ + List all files in all locations. + """ + for prefix, root in self.locations: + storage = self.storages[root] + for path in utils.get_files(storage, ignore_patterns): + yield path, prefix, storage + + +class AppDirectoriesFinder(BaseFinder): + """ + A static files finder that looks in the ``media`` directory of each app. + """ + storages = {} + storage_class = AppStaticStorage + + def __init__(self, apps=None, *args, **kwargs): + if apps is not None: + self.apps = apps + else: + self.apps = models.get_apps() + for app in self.apps: + self.storages[app] = self.storage_class(app) + super(AppDirectoriesFinder, self).__init__(*args, **kwargs) + + def list(self, ignore_patterns): + """ + List all files in all app storages. + """ + for storage in self.storages.itervalues(): + if storage.exists(''): # check if storage location exists + prefix = storage.get_prefix() + for path in utils.get_files(storage, ignore_patterns): + yield path, prefix, storage + + def find(self, path, all=False): + """ + Looks for files in the app directories. + """ + matches = [] + for app in self.apps: + app_matches = self.find_in_app(app, path) + if app_matches: + if not all: + return app_matches + matches.append(app_matches) + return matches + + def find_in_app(self, app, path): + """ + Find a requested static file in an app's media locations. + """ + storage = self.storages[app] + prefix = storage.get_prefix() + if prefix: + prefix = '%s/' % prefix + if not path.startswith(prefix): + return None + path = path[len(prefix):] + # only try to find a file if the source dir actually exists + if storage.exists(path): + matched_path = storage.path(path) + if matched_path: + return matched_path + + +class BaseStorageFinder(BaseFinder): + """ + A base static files finder to be used to extended + with an own storage class. + """ + storage = None + + def __init__(self, storage=None, *args, **kwargs): + if storage is not None: + self.storage = storage + if self.storage is None: + raise ImproperlyConfigured("The staticfiles storage finder %r " + "doesn't have a storage class " + "assigned." % self.__class__) + # Make sure we have an storage instance here. + if not isinstance(self.storage, (Storage, LazyObject)): + self.storage = self.storage() + super(BaseStorageFinder, self).__init__(*args, **kwargs) + + def find(self, path, all=False): + """ + Looks for files in the default file storage, if it's local. + """ + try: + self.storage.path('') + except NotImplementedError: + pass + else: + if self.storage.exists(path): + match = self.storage.path(path) + if all: + match = [match] + return match + return [] + + def list(self, ignore_patterns): + """ + List all files of the storage. + """ + for path in utils.get_files(self.storage, ignore_patterns): + yield path, '', self.storage + +class DefaultStorageFinder(BaseStorageFinder): + """ + A static files finder that uses the default storage backend. + """ + storage = default_storage + + +def find(path, all=False): + """ + Find a requested static file, first looking in any defined extra media + locations and next in any (non-excluded) installed apps. + + If no matches are found and the static location is local, look for a match + there too. + + If ``all`` is ``False`` (default), return the first matching + absolute path (or ``None`` if no match). Otherwise return a list of + found absolute paths. + + """ + matches = [] + for finder in get_finders(): + result = finder.find(path, all=all) + if not all and result: + return result + if not isinstance(result, (list, tuple)): + result = [result] + matches.extend(result) + if matches: + return matches + # No match. + return all and [] or None + +def get_finders(): + for finder_path in settings.STATICFILES_FINDERS: + yield get_finder(finder_path) + +def _get_finder(import_path): + """ + Imports the message storage class described by import_path, where + import_path is the full Python path to the class. + """ + module, attr = import_path.rsplit('.', 1) + try: + mod = import_module(module) + except ImportError, e: + raise ImproperlyConfigured('Error importing module %s: "%s"' % + (module, e)) + try: + Finder = getattr(mod, attr) + except AttributeError: + raise ImproperlyConfigured('Module "%s" does not define a "%s" ' + 'class.' % (module, attr)) + if not issubclass(Finder, BaseFinder): + raise ImproperlyConfigured('Finder "%s" is not a subclass of "%s"' % + (Finder, BaseFinder)) + return Finder() +get_finder = memoize(_get_finder, _finders, 1) diff --git a/django/contrib/staticfiles/handlers.py b/django/contrib/staticfiles/handlers.py new file mode 100644 index 0000000000..8681c55c16 --- /dev/null +++ b/django/contrib/staticfiles/handlers.py @@ -0,0 +1,72 @@ +import os +import urllib +from urlparse import urlparse + +from django.core.handlers.wsgi import WSGIHandler, STATUS_CODE_TEXT +from django.http import Http404 + +from django.contrib.staticfiles.views import serve + +class StaticFilesHandler(WSGIHandler): + """ + WSGI middleware that intercepts calls to the static files directory, as + defined by the STATICFILES_URL setting, and serves those files. + """ + def __init__(self, application, media_dir=None): + self.application = application + if media_dir: + self.media_dir = media_dir + else: + self.media_dir = self.get_media_dir() + self.media_url = self.get_media_url() + + def get_media_dir(self): + from django.conf import settings + return settings.STATICFILES_ROOT + + def get_media_url(self): + from django.conf import settings + return settings.STATICFILES_URL + + def file_path(self, url): + """ + Returns the relative path to the media file on disk for the given URL. + + The passed URL is assumed to begin with ``media_url``. If the + resultant file path is outside the media directory, then a ValueError + is raised. + """ + # Remove ``media_url``. + relative_url = url[len(self.media_url):] + return urllib.url2pathname(relative_url) + + def serve(self, request, path): + from django.contrib.staticfiles import finders + absolute_path = finders.find(path) + if not absolute_path: + raise Http404('%r could not be matched to a static file.' % path) + absolute_path, filename = os.path.split(absolute_path) + return serve(request, path=filename, document_root=absolute_path) + + def __call__(self, environ, start_response): + media_url_bits = urlparse(self.media_url) + # Ignore all requests if the host is provided as part of the media_url. + # Also ignore requests that aren't under the media path. + if (media_url_bits[1] or + not environ['PATH_INFO'].startswith(media_url_bits[2])): + return self.application(environ, start_response) + request = self.application.request_class(environ) + try: + response = self.serve(request, self.file_path(environ['PATH_INFO'])) + except Http404: + status = '404 NOT FOUND' + start_response(status, {'Content-type': 'text/plain'}.items()) + return [str('Page not found: %s' % environ['PATH_INFO'])] + status_text = STATUS_CODE_TEXT[response.status_code] + status = '%s %s' % (response.status_code, status_text) + response_headers = [(str(k), str(v)) for k, v in response.items()] + for c in response.cookies.values(): + response_headers.append(('Set-Cookie', str(c.output(header='')))) + start_response(status, response_headers) + return response + diff --git a/django/contrib/staticfiles/management/__init__.py b/django/contrib/staticfiles/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/django/contrib/staticfiles/management/__init__.py diff --git a/django/contrib/staticfiles/management/commands/__init__.py b/django/contrib/staticfiles/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/django/contrib/staticfiles/management/commands/__init__.py diff --git a/django/contrib/staticfiles/management/commands/collectstatic.py b/django/contrib/staticfiles/management/commands/collectstatic.py new file mode 100644 index 0000000000..aae6bee10e --- /dev/null +++ b/django/contrib/staticfiles/management/commands/collectstatic.py @@ -0,0 +1,184 @@ +import os +import sys +import shutil +from optparse import make_option + +from django.conf import settings +from django.core.files.storage import get_storage_class +from django.core.management.base import CommandError, NoArgsCommand + +from django.contrib.staticfiles import finders + +class Command(NoArgsCommand): + """ + Command that allows to copy or symlink media files from different + locations to the settings.STATICFILES_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('-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('-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." + + def handle_noargs(self, **options): + symlink = options['link'] + ignore_patterns = options['ignore_patterns'] + if options['use_default_ignore_patterns']: + ignore_patterns += ['CVS', '.*', '*~'] + ignore_patterns = list(set(ignore_patterns)) + self.copied_files = [] + self.symlinked_files = [] + self.unmodified_files = [] + self.destination_storage = get_storage_class(settings.STATICFILES_STORAGE)() + + try: + self.destination_storage.path('') + except NotImplementedError: + self.destination_local = False + else: + self.destination_local = True + + if symlink: + if sys.platform == 'win32': + raise CommandError("Symlinking is not supported by this " + "platform (%s)." % sys.platform) + if not self.destination_local: + raise CommandError("Can't symlink to a remote destination.") + + # Warn before doing anything more. + if options.get('interactive'): + confirm = raw_input(""" +You have requested to collate static files and collect them at the destination +location as specified in your settings file. + +This will overwrite existing files. +Are you sure you want to do this? + +Type 'yes' to continue, or 'no' to cancel: """) + if confirm != 'yes': + raise CommandError("Static files build cancelled.") + + for finder in finders.get_finders(): + for source, prefix, storage in finder.list(ignore_patterns): + self.copy_file(source, prefix, storage, **options) + + verbosity = int(options.get('verbosity', 1)) + actual_count = len(self.copied_files) + len(self.symlinked_files) + unmodified_count = len(self.unmodified_files) + if verbosity >= 1: + self.stdout.write("\n%s static file%s %s to '%s'%s.\n" + % (actual_count, actual_count != 1 and 's' or '', + symlink and 'symlinked' or 'copied', + settings.STATICFILES_ROOT, + unmodified_count and ' (%s unmodified)' + % unmodified_count or '')) + + def copy_file(self, source, prefix, source_storage, **options): + """ + Attempt to copy (or symlink) ``source`` to ``destination``, + returning True if successful. + """ + source_path = source_storage.path(source) + try: + source_last_modified = source_storage.modified_time(source) + except (OSError, NotImplementedError): + source_last_modified = None + if prefix: + destination = '/'.join([prefix, source]) + else: + destination = source + symlink = options['link'] + dry_run = options['dry_run'] + verbosity = int(options.get('verbosity', 1)) + + if destination in self.copied_files: + if verbosity >= 2: + self.stdout.write("Skipping '%s' (already copied earlier)\n" + % destination) + return False + + if destination in self.symlinked_files: + if verbosity >= 2: + self.stdout.write("Skipping '%s' (already linked earlier)\n" + % destination) + return False + + if self.destination_storage.exists(destination): + try: + destination_last_modified = \ + self.destination_storage.modified_time(destination) + except (OSError, NotImplementedError): + # storage doesn't support ``modified_time`` or failed. + pass + else: + destination_is_link= os.path.islink( + self.destination_storage.path(destination)) + if destination_last_modified == source_last_modified: + if (not symlink and not destination_is_link): + if verbosity >= 2: + self.stdout.write("Skipping '%s' (not modified)\n" + % destination) + self.unmodified_files.append(destination) + return False + if dry_run: + if verbosity >= 2: + self.stdout.write("Pretending to delete '%s'\n" + % destination) + else: + if verbosity >= 2: + self.stdout.write("Deleting '%s'\n" % destination) + self.destination_storage.delete(destination) + + if symlink: + destination_path = self.destination_storage.path(destination) + if dry_run: + if verbosity >= 1: + self.stdout.write("Pretending to symlink '%s' to '%s'\n" + % (source_path, destination_path)) + else: + if verbosity >= 1: + self.stdout.write("Symlinking '%s' to '%s'\n" + % (source_path, destination_path)) + try: + os.makedirs(os.path.dirname(destination_path)) + except OSError: + pass + os.symlink(source_path, destination_path) + self.symlinked_files.append(destination) + else: + if dry_run: + if verbosity >= 1: + self.stdout.write("Pretending to copy '%s' to '%s'\n" + % (source_path, destination)) + else: + if self.destination_local: + destination_path = self.destination_storage.path(destination) + try: + os.makedirs(os.path.dirname(destination_path)) + except OSError: + pass + shutil.copy2(source_path, destination_path) + if verbosity >= 1: + self.stdout.write("Copying '%s' to '%s'\n" + % (source_path, destination_path)) + else: + source_file = source_storage.open(source) + self.destination_storage.save(destination, source_file) + if verbosity >= 1: + self.stdout.write("Copying %s to %s\n" + % (source_path, destination)) + self.copied_files.append(destination) + return True diff --git a/django/contrib/staticfiles/management/commands/findstatic.py b/django/contrib/staticfiles/management/commands/findstatic.py new file mode 100644 index 0000000000..0f13277c37 --- /dev/null +++ b/django/contrib/staticfiles/management/commands/findstatic.py @@ -0,0 +1,24 @@ +import os +from optparse import make_option +from django.core.management.base import LabelCommand + +from django.contrib.staticfiles import finders + +class Command(LabelCommand): + help = "Finds the absolute paths for the given static file(s)." + args = "[file ...]" + label = 'static file' + option_list = LabelCommand.option_list + ( + make_option('--first', action='store_false', dest='all', default=True, + help="Only return the first match for each static file."), + ) + + def handle_label(self, path, **options): + verbosity = int(options.get('verbosity', 1)) + result = finders.find(path, all=options['all']) + if result: + output = '\n '.join((os.path.realpath(path) for path in result)) + self.stdout.write("Found %r here:\n %s\n" % (path, output)) + else: + if verbosity >= 1: + self.stdout.write("No matching file found for %r.\n" % path) diff --git a/django/contrib/staticfiles/models.py b/django/contrib/staticfiles/models.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/django/contrib/staticfiles/models.py diff --git a/django/contrib/staticfiles/storage.py b/django/contrib/staticfiles/storage.py new file mode 100644 index 0000000000..87b569af76 --- /dev/null +++ b/django/contrib/staticfiles/storage.py @@ -0,0 +1,84 @@ +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 + +from django.contrib.staticfiles import utils + + +class StaticFilesStorage(FileSystemStorage): + """ + Standard file system storage for site media files. + + The defaults for ``location`` and ``base_url`` are + ``STATICFILES_ROOT`` and ``STATICFILES_URL``. + """ + def __init__(self, location=None, base_url=None, *args, **kwargs): + if location is None: + location = settings.STATICFILES_ROOT + if base_url is None: + base_url = settings.STATICFILES_URL + if not location: + raise ImproperlyConfigured("You're using the staticfiles app " + "without having set the STATICFILES_ROOT setting. Set it to " + "the absolute path of the directory that holds static media.") + if not base_url: + raise ImproperlyConfigured("You're using the staticfiles app " + "without having set the STATICFILES_URL setting. Set it to " + "URL that handles the files served from STATICFILES_ROOT.") + super(StaticFilesStorage, self).__init__(location, base_url, *args, **kwargs) + + +class AppStaticStorage(FileSystemStorage): + """ + A file system storage backend that takes an app module and works + for the ``static`` directory of it. + """ + source_dir = 'static' + + def __init__(self, app, *args, **kwargs): + """ + Returns a static file storage if available in the given app. + """ + # app is actually the models module of the app. Remove the '.models'. + bits = app.__name__.split('.')[:-1] + self.app_name = bits[-1] + self.app_module = '.'.join(bits) + # The models module (app) may be a package in which case + # dirname(app.__file__) would be wrong. Import the actual app + # as opposed to the models module. + app = import_module(self.app_module) + location = self.get_location(os.path.dirname(app.__file__)) + super(AppStaticStorage, self).__init__(location, *args, **kwargs) + + def get_location(self, app_root): + """ + Given the app root, return the location of the static files of an app, + by default 'static'. We special case the admin app here since it has + its static files in 'media'. + """ + if self.app_module == 'django.contrib.admin': + return os.path.join(app_root, 'media') + return os.path.join(app_root, self.source_dir) + + def get_prefix(self): + """ + Return the path name that should be prepended to files for this app. + """ + if self.app_module == 'django.contrib.admin': + return self.app_name + return None + + def get_files(self, ignore_patterns=[]): + """ + Return a list containing the relative source paths for all files that + should be copied for an app. + """ + files = [] + prefix = self.get_prefix() + for path in utils.get_files(self, ignore_patterns): + if prefix: + path = '/'.join([prefix, path]) + files.append(path) + return files diff --git a/django/contrib/staticfiles/templatetags/__init__.py b/django/contrib/staticfiles/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/django/contrib/staticfiles/templatetags/__init__.py diff --git a/django/contrib/staticfiles/templatetags/staticfiles.py b/django/contrib/staticfiles/templatetags/staticfiles.py new file mode 100644 index 0000000000..6153f5a385 --- /dev/null +++ b/django/contrib/staticfiles/templatetags/staticfiles.py @@ -0,0 +1,43 @@ +from django import template +from django.utils.encoding import iri_to_uri + +register = template.Library() + +class StaticFilesPrefixNode(template.Node): + + def __init__(self, varname=None): + self.varname = varname + + def render(self, context): + try: + from django.conf import settings + except ImportError: + prefix = '' + else: + prefix = iri_to_uri(settings.STATICFILES_URL) + if self.varname is None: + return prefix + context[self.varname] = prefix + return '' + +@register.tag +def get_staticfiles_prefix(parser, token): + """ + Populates a template variable with the prefix (settings.STATICFILES_URL). + + Usage:: + + {% get_staticfiles_prefix [as varname] %} + + Examples:: + + {% get_staticfiles_prefix %} + {% get_staticfiles_prefix as staticfiles_prefix %} + + """ + tokens = token.contents.split() + if len(tokens) > 1 and tokens[1] != 'as': + raise template.TemplateSyntaxError( + "First argument in '%s' must be 'as'" % tokens[0]) + return StaticFilesPrefixNode(varname=(len(tokens) > 1 and tokens[2] or None)) + diff --git a/django/contrib/staticfiles/urls.py b/django/contrib/staticfiles/urls.py new file mode 100644 index 0000000000..131b10266b --- /dev/null +++ b/django/contrib/staticfiles/urls.py @@ -0,0 +1,29 @@ +import re +from django.conf import settings +from django.conf.urls.defaults import patterns, url, include +from django.core.exceptions import ImproperlyConfigured + +urlpatterns = [] + +# only serve non-fqdn URLs +if not settings.DEBUG: + urlpatterns += patterns('', + url(r'^(?P<path>.*)$', 'django.contrib.staticfiles.views.serve'), + ) + +def staticfiles_urlpatterns(prefix=None): + """ + Helper function to return a URL pattern for serving static files. + """ + if settings.DEBUG: + return [] + if prefix is None: + prefix = settings.STATICFILES_URL + if '://' in prefix: + raise ImproperlyConfigured( + "The STATICFILES_URL setting is a full URL, not a path and " + "can't be used with the urls.staticfiles_urlpatterns() helper.") + if prefix.startswith("/"): + prefix = prefix[1:] + return patterns('', + url(r'^%s' % re.escape(prefix), include(urlpatterns)),) diff --git a/django/contrib/staticfiles/utils.py b/django/contrib/staticfiles/utils.py new file mode 100644 index 0000000000..f5a30befe9 --- /dev/null +++ b/django/contrib/staticfiles/utils.py @@ -0,0 +1,30 @@ +import fnmatch + +def get_files(storage, ignore_patterns=[], location=''): + """ + Recursively walk the storage directories gathering a complete list of files + that should be copied, returning this list. + + """ + def is_ignored(path): + """ + 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 fnmatch.fnmatchcase(path, pattern): + return True + return False + + directories, files = storage.listdir(location) + static_files = [location and '/'.join([location, fn]) or fn + for fn in files + if not is_ignored(fn)] + for dir in directories: + if is_ignored(dir): + continue + if location: + dir = '/'.join([location, dir]) + static_files.extend(get_files(storage, ignore_patterns, dir)) + return static_files diff --git a/django/contrib/staticfiles/views.py b/django/contrib/staticfiles/views.py new file mode 100644 index 0000000000..27d499df87 --- /dev/null +++ b/django/contrib/staticfiles/views.py @@ -0,0 +1,159 @@ +""" +Views and functions for serving static files. These are only to be used during +development, and SHOULD NOT be used in a production setting. + +""" +import mimetypes +import os +import posixpath +import re +import stat +import urllib +from email.Utils import parsedate_tz, mktime_tz + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404, HttpResponse, HttpResponseRedirect, HttpResponseNotModified +from django.template import loader, Template, Context, TemplateDoesNotExist +from django.utils.http import http_date + +from django.contrib.staticfiles import finders + + +def serve(request, path, document_root=None, show_indexes=False): + """ + Serve static files below a given point in the directory structure or + from locations inferred from the static files finders. + + To use, put a URL pattern such as:: + + (r'^(?P<path>.*)$', 'django.contrib.staticfiles.views.serve') + + in your URLconf. + + If you provide the ``document_root`` parameter, the file won't be looked + up with the staticfiles finders, but in the given filesystem path, e.g.:: + + (r'^(?P<path>.*)$', 'django.contrib.staticfiles.views.serve', {'document_root' : '/path/to/my/files/'}) + + You may also set ``show_indexes`` to ``True`` if you'd like to serve a + basic index of the directory. This index view will use the + template hardcoded below, but if you'd like to override it, you can create + a template called ``static/directory_index.html``. + """ + if settings.DEBUG: + raise ImproperlyConfigured("The view to serve static files can only " + "be used if the DEBUG setting is True") + if not document_root: + absolute_path = finders.find(path) + if not absolute_path: + raise Http404("%r could not be matched to a static file." % path) + document_root, path = os.path.split(absolute_path) + # Clean up given path to only allow serving files below document_root. + path = posixpath.normpath(urllib.unquote(path)) + path = path.lstrip('/') + newpath = '' + for part in path.split('/'): + if not part: + # Strip empty path components. + continue + drive, part = os.path.splitdrive(part) + head, part = os.path.split(part) + if part in (os.curdir, os.pardir): + # Strip '.' and '..' in path. + continue + newpath = os.path.join(newpath, part).replace('\\', '/') + if newpath and path != newpath: + return HttpResponseRedirect(newpath) + fullpath = os.path.join(document_root, newpath) + if os.path.isdir(fullpath): + if show_indexes: + return directory_index(newpath, fullpath) + raise Http404("Directory indexes are not allowed here.") + if not os.path.exists(fullpath): + raise Http404('"%s" does not exist' % fullpath) + # Respect the If-Modified-Since header. + statobj = os.stat(fullpath) + mimetype, encoding = mimetypes.guess_type(fullpath) + mimetype = mimetype or 'application/octet-stream' + if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'), + statobj[stat.ST_MTIME], statobj[stat.ST_SIZE]): + return HttpResponseNotModified(mimetype=mimetype) + contents = open(fullpath, 'rb').read() + response = HttpResponse(contents, mimetype=mimetype) + response["Last-Modified"] = http_date(statobj[stat.ST_MTIME]) + response["Content-Length"] = len(contents) + if encoding: + response["Content-Encoding"] = encoding + return response + + +DEFAULT_DIRECTORY_INDEX_TEMPLATE = """ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <head> + <meta http-equiv="Content-type" content="text/html; charset=utf-8" /> + <meta http-equiv="Content-Language" content="en-us" /> + <meta name="robots" content="NONE,NOARCHIVE" /> + <title>Index of {{ directory }}</title> + </head> + <body> + <h1>Index of {{ directory }}</h1> + <ul> + {% ifnotequal directory "/" %} + <li><a href="../">../</a></li> + {% endifnotequal %} + {% for f in file_list %} + <li><a href="{{ f|urlencode }}">{{ f }}</a></li> + {% endfor %} + </ul> + </body> +</html> +""" + +def directory_index(path, fullpath): + try: + t = loader.select_template(['static/directory_index.html', + 'static/directory_index']) + except TemplateDoesNotExist: + t = Template(DEFAULT_DIRECTORY_INDEX_TEMPLATE, name='Default directory index template') + files = [] + for f in os.listdir(fullpath): + if not f.startswith('.'): + if os.path.isdir(os.path.join(fullpath, f)): + f += '/' + files.append(f) + c = Context({ + 'directory' : path + '/', + 'file_list' : files, + }) + return HttpResponse(t.render(c)) + +def was_modified_since(header=None, mtime=0, size=0): + """ + Was something modified since the user last downloaded it? + + header + This is the value of the If-Modified-Since header. If this is None, + I'll just return True. + + mtime + This is the modification time of the item we're talking about. + + size + This is the size of the item we're talking about. + """ + try: + if header is None: + raise ValueError + matches = re.match(r"^([^;]+)(; length=([0-9]+))?$", header, + re.IGNORECASE) + header_mtime = mktime_tz(parsedate_tz(matches.group(1))) + header_len = matches.group(3) + if header_len and int(header_len) != size: + raise ValueError + if mtime > header_mtime: + raise ValueError + except (AttributeError, ValueError, OverflowError): + return True + return False diff --git a/django/core/context_processors.py b/django/core/context_processors.py index 7a59728bfc..a529c612aa 100644 --- a/django/core/context_processors.py +++ b/django/core/context_processors.py @@ -71,7 +71,15 @@ def media(request): Adds media-related context variables to the context. """ - return {'MEDIA_URL': settings.MEDIA_URL} + import warnings + warnings.warn( + "The context processor at `django.core.context_processors.media` is " \ + "deprecated; use the path `django.contrib.staticfiles.context_processors.staticfiles` " \ + "instead.", + PendingDeprecationWarning + ) + from django.contrib.staticfiles.context_processors import staticfiles as staticfiles_context_processor + return staticfiles_context_processor(request) def request(request): return {'request': request} diff --git a/django/core/management/commands/runserver.py b/django/core/management/commands/runserver.py index fc2c694ab6..21391e82ad 100644 --- a/django/core/management/commands/runserver.py +++ b/django/core/management/commands/runserver.py @@ -1,7 +1,9 @@ -from django.core.management.base import BaseCommand, CommandError from optparse import make_option import os import sys +import warnings + +from django.core.management.base import BaseCommand, CommandError class Command(BaseCommand): option_list = BaseCommand.option_list + ( @@ -20,6 +22,7 @@ class Command(BaseCommand): import django from django.core.servers.basehttp import run, AdminMediaHandler, WSGIServerException from django.core.handlers.wsgi import WSGIHandler + from django.contrib.staticfiles.handlers import StaticFilesHandler if args: raise CommandError('Usage is runserver %s' % self.args) if not addrport: @@ -56,7 +59,10 @@ class Command(BaseCommand): translation.activate(settings.LANGUAGE_CODE) try: - handler = AdminMediaHandler(WSGIHandler(), admin_media_path) + handler = WSGIHandler() + handler = StaticFilesHandler(handler) + # serve admin media like old-school (deprecation pending) + handler = AdminMediaHandler(handler, admin_media_path) run(addr, int(port), handler) except WSGIServerException, e: # Use helpful error messages instead of ugly tracebacks. diff --git a/django/core/servers/basehttp.py b/django/core/servers/basehttp.py index dae4297379..ab38e98f13 100644 --- a/django/core/servers/basehttp.py +++ b/django/core/servers/basehttp.py @@ -8,16 +8,17 @@ been reviewed for security issues. Don't use it for production use. """ from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -import mimetypes import os import re -import stat import sys import urllib +import warnings from django.core.management.color import color_style from django.utils.http import http_date from django.utils._os import safe_join +from django.contrib.staticfiles.handlers import StaticFilesHandler +from django.views import static __version__ = "0.1" __all__ = ['WSGIServer','WSGIRequestHandler'] @@ -633,86 +634,46 @@ class WSGIRequestHandler(BaseHTTPRequestHandler): sys.stderr.write(msg) -class AdminMediaHandler(object): + +class AdminMediaHandler(StaticFilesHandler): """ WSGI middleware that intercepts calls to the admin media directory, as defined by the ADMIN_MEDIA_PREFIX setting, and serves those images. Use this ONLY LOCALLY, for development! This hasn't been tested for security and is not super efficient. """ - def __init__(self, application, media_dir=None): + + def get_media_dir(self): + import django + return os.path.join(django.__path__[0], 'contrib', 'admin', 'media') + + def get_media_url(self): from django.conf import settings - self.application = application - if not media_dir: - import django - self.media_dir = \ - os.path.join(django.__path__[0], 'contrib', 'admin', 'media') - else: - self.media_dir = media_dir - self.media_url = settings.ADMIN_MEDIA_PREFIX + return settings.ADMIN_MEDIA_PREFIX + + def __init__(self, application, media_dir=None): + warnings.warn('The AdminMediaHandler handler is deprecated; use the ' + '`django.contrib.staticfiles.handlers.StaticFilesHandler` instead.', + PendingDeprecationWarning) + super(AdminMediaHandler, self).__init__(application, media_dir) def file_path(self, url): """ Returns the path to the media file on disk for the given URL. - The passed URL is assumed to begin with ADMIN_MEDIA_PREFIX. If the + The passed URL is assumed to begin with ``media_url``. If the resultant file path is outside the media directory, then a ValueError is raised. """ - # Remove ADMIN_MEDIA_PREFIX. + # Remove ``media_url``. relative_url = url[len(self.media_url):] relative_path = urllib.url2pathname(relative_url) return safe_join(self.media_dir, relative_path) - def __call__(self, environ, start_response): - import os.path - - # Ignore requests that aren't under ADMIN_MEDIA_PREFIX. Also ignore - # all requests if ADMIN_MEDIA_PREFIX isn't a relative URL. - if self.media_url.startswith('http://') or self.media_url.startswith('https://') \ - or not environ['PATH_INFO'].startswith(self.media_url): - return self.application(environ, start_response) + def serve(self, request, path): + document_root, path = os.path.split(path) + return static.serve(request, path, document_root=document_root) - # Find the admin file and serve it up, if it exists and is readable. - try: - file_path = self.file_path(environ['PATH_INFO']) - except ValueError: # Resulting file path was not valid. - status = '404 NOT FOUND' - headers = {'Content-type': 'text/plain'} - output = ['Page not found: %s' % environ['PATH_INFO']] - start_response(status, headers.items()) - return output - if not os.path.exists(file_path): - status = '404 NOT FOUND' - headers = {'Content-type': 'text/plain'} - output = ['Page not found: %s' % environ['PATH_INFO']] - else: - try: - fp = open(file_path, 'rb') - except IOError: - status = '401 UNAUTHORIZED' - headers = {'Content-type': 'text/plain'} - output = ['Permission denied: %s' % environ['PATH_INFO']] - else: - # This is a very simple implementation of conditional GET with - # the Last-Modified header. It makes media files a bit speedier - # because the files are only read off disk for the first - # request (assuming the browser/client supports conditional - # GET). - mtime = http_date(os.stat(file_path)[stat.ST_MTIME]) - headers = {'Last-Modified': mtime} - if environ.get('HTTP_IF_MODIFIED_SINCE', None) == mtime: - status = '304 NOT MODIFIED' - output = [] - else: - status = '200 OK' - mime_type = mimetypes.guess_type(file_path)[0] - if mime_type: - headers['Content-Type'] = mime_type - output = [fp.read()] - fp.close() - start_response(status, headers.items()) - return output def run(addr, port, wsgi_handler): server_address = (addr, port) diff --git a/django/views/static.py b/django/views/static.py index cc88211876..eee9885c02 100644 --- a/django/views/static.py +++ b/django/views/static.py @@ -9,6 +9,7 @@ import posixpath import re import stat import urllib +import warnings from email.Utils import parsedate_tz, mktime_tz from django.template import loader @@ -16,6 +17,10 @@ from django.http import Http404, HttpResponse, HttpResponseRedirect, HttpRespons from django.template import Template, Context, TemplateDoesNotExist from django.utils.http import http_date +from django.contrib.staticfiles.views import \ + directory_index, was_modified_since, serve as staticfiles_serve + + def serve(request, path, document_root=None, show_indexes=False): """ Serve static files below a given point in the directory structure. @@ -30,111 +35,7 @@ def serve(request, path, document_root=None, show_indexes=False): but if you'd like to override it, you can create a template called ``static/directory_index.html``. """ - - # Clean up given path to only allow serving files below document_root. - path = posixpath.normpath(urllib.unquote(path)) - path = path.lstrip('/') - newpath = '' - for part in path.split('/'): - if not part: - # Strip empty path components. - continue - drive, part = os.path.splitdrive(part) - head, part = os.path.split(part) - if part in (os.curdir, os.pardir): - # Strip '.' and '..' in path. - continue - newpath = os.path.join(newpath, part).replace('\\', '/') - if newpath and path != newpath: - return HttpResponseRedirect(newpath) - fullpath = os.path.join(document_root, newpath) - if os.path.isdir(fullpath): - if show_indexes: - return directory_index(newpath, fullpath) - raise Http404("Directory indexes are not allowed here.") - if not os.path.exists(fullpath): - raise Http404('"%s" does not exist' % fullpath) - # Respect the If-Modified-Since header. - statobj = os.stat(fullpath) - mimetype, encoding = mimetypes.guess_type(fullpath) - mimetype = mimetype or 'application/octet-stream' - if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'), - statobj[stat.ST_MTIME], statobj[stat.ST_SIZE]): - return HttpResponseNotModified(mimetype=mimetype) - contents = open(fullpath, 'rb').read() - response = HttpResponse(contents, mimetype=mimetype) - response["Last-Modified"] = http_date(statobj[stat.ST_MTIME]) - response["Content-Length"] = len(contents) - if encoding: - response["Content-Encoding"] = encoding - return response - -DEFAULT_DIRECTORY_INDEX_TEMPLATE = """ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> -<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> - <head> - <meta http-equiv="Content-type" content="text/html; charset=utf-8" /> - <meta http-equiv="Content-Language" content="en-us" /> - <meta name="robots" content="NONE,NOARCHIVE" /> - <title>Index of {{ directory }}</title> - </head> - <body> - <h1>Index of {{ directory }}</h1> - <ul> - {% ifnotequal directory "/" %} - <li><a href="../">../</a></li> - {% endifnotequal %} - {% for f in file_list %} - <li><a href="{{ f|urlencode }}">{{ f }}</a></li> - {% endfor %} - </ul> - </body> -</html> -""" - -def directory_index(path, fullpath): - try: - t = loader.select_template(['static/directory_index.html', - 'static/directory_index']) - except TemplateDoesNotExist: - t = Template(DEFAULT_DIRECTORY_INDEX_TEMPLATE, name='Default directory index template') - files = [] - for f in os.listdir(fullpath): - if not f.startswith('.'): - if os.path.isdir(os.path.join(fullpath, f)): - f += '/' - files.append(f) - c = Context({ - 'directory' : path + '/', - 'file_list' : files, - }) - return HttpResponse(t.render(c)) - -def was_modified_since(header=None, mtime=0, size=0): - """ - Was something modified since the user last downloaded it? - - header - This is the value of the If-Modified-Since header. If this is None, - I'll just return True. - - mtime - This is the modification time of the item we're talking about. - - size - This is the size of the item we're talking about. - """ - try: - if header is None: - raise ValueError - matches = re.match(r"^([^;]+)(; length=([0-9]+))?$", header, - re.IGNORECASE) - header_mtime = mktime_tz(parsedate_tz(matches.group(1))) - header_len = matches.group(3) - if header_len and int(header_len) != size: - raise ValueError - if mtime > header_mtime: - raise ValueError - except (AttributeError, ValueError, OverflowError): - return True - return False + warnings.warn("The view at `django.views.static.serve` is deprecated; " + "use the path `django.contrib.staticfiles.views.serve` " + "instead.", PendingDeprecationWarning) + return staticfiles_serve(request, path, document_root, show_indexes) |
