From fc2147152637e21bc73f991b50fa06254af02739 Mon Sep 17 00:00:00 2001 From: Preston Timmons Date: Tue, 3 Mar 2015 15:48:26 -0600 Subject: [PATCH] Fixed #15053 -- Enabled recursive template loading. --- django/template/__init__.py | 4 +- django/template/base.py | 38 ++-- django/template/engine.py | 34 ++-- django/template/loader.py | 28 ++- django/template/loader_tags.py | 36 +++- django/template/loaders/base.py | 82 ++++++-- django/template/loaders/cached.py | 96 +++++++++- django/template/loaders/eggs.py | 52 ++++- django/template/loaders/filesystem.py | 44 +++-- django/template/loaders/locmem.py | 23 ++- docs/internals/deprecation.txt | 20 ++ tests/template_loader/tests.py | 32 +++- .../fs/extend-missing.html | 1 + .../recursive_templates/fs/one.html | 3 + .../fs/other-recursive.html | 1 + .../recursive_templates/fs/recursive.html | 3 + .../recursive_templates/fs/self.html | 1 + .../recursive_templates/fs/three.html | 1 + .../recursive_templates/fs/two.html | 3 + .../recursive_templates/fs2/recursive.html | 3 + .../recursive_templates/fs3/recursive.html | 1 + tests/template_tests/test_engine.py | 2 +- tests/template_tests/test_extends.py | 178 ++++++++++++++++++ tests/template_tests/test_loaders.py | 178 ++++++++++++++---- tests/template_tests/tests.py | 5 +- 25 files changed, 740 insertions(+), 129 deletions(-) create mode 100644 tests/template_tests/recursive_templates/fs/extend-missing.html create mode 100644 tests/template_tests/recursive_templates/fs/one.html create mode 100644 tests/template_tests/recursive_templates/fs/other-recursive.html create mode 100644 tests/template_tests/recursive_templates/fs/recursive.html create mode 100644 tests/template_tests/recursive_templates/fs/self.html create mode 100644 tests/template_tests/recursive_templates/fs/three.html create mode 100644 tests/template_tests/recursive_templates/fs/two.html create mode 100644 tests/template_tests/recursive_templates/fs2/recursive.html create mode 100644 tests/template_tests/recursive_templates/fs3/recursive.html create mode 100644 tests/template_tests/test_extends.py diff --git a/django/template/__init__.py b/django/template/__init__.py index b2566decad..780dbc29fc 100644 --- a/django/template/__init__.py +++ b/django/template/__init__.py @@ -59,8 +59,8 @@ from .base import (TemplateDoesNotExist, TemplateSyntaxError, # NOQA from .context import ContextPopException # NOQA # Template parts -from .base import (Context, Node, NodeList, RequestContext, # NOQA - StringOrigin, Template, Variable) +from .base import (Context, Node, NodeList, Origin, RequestContext, # NOQA + Template, Variable) # Deprecated in Django 1.8, will be removed in Django 2.0. from .base import resolve_variable # NOQA diff --git a/django/template/base.py b/django/template/base.py index 19b6b76d45..e1849999f2 100644 --- a/django/template/base.py +++ b/django/template/base.py @@ -134,7 +134,15 @@ class TemplateSyntaxError(Exception): class TemplateDoesNotExist(Exception): - pass + """ + This exception is used when template loaders are unable to find a + template. The tried argument is an optional list of tuples containing + (origin, status), where origin is an Origin object and status is a string + with the reason the template wasn't found. + """ + def __init__(self, msg, tried=None): + self.tried = tried or [] + super(TemplateDoesNotExist, self).__init__(msg) class TemplateEncodingError(Exception): @@ -157,23 +165,29 @@ class InvalidTemplateLibrary(Exception): class Origin(object): - def __init__(self, name): + def __init__(self, name, template_name=None, loader=None): self.name = name - - def reload(self): - raise NotImplementedError('subclasses of Origin must provide a reload() method') + self.template_name = template_name + self.loader = loader def __str__(self): return self.name + def __eq__(self, other): + if not isinstance(other, Origin): + return False -class StringOrigin(Origin): - def __init__(self, source): - super(StringOrigin, self).__init__(UNKNOWN_SOURCE) - self.source = source + return ( + self.name == other.name and + self.loader == other.loader + ) - def reload(self): - return self.source + @property + def loader_name(self): + if self.loader: + return '%s.%s' % ( + self.loader.__module__, self.loader.__class__.__name__, + ) class Template(object): @@ -191,7 +205,7 @@ class Template(object): from .engine import Engine engine = Engine.get_default() if origin is None: - origin = StringOrigin(template_string) + origin = Origin(UNKNOWN_SOURCE) self.name = name self.origin = origin self.engine = engine diff --git a/django/template/engine.py b/django/template/engine.py index ed398b217b..90015dfacc 100644 --- a/django/template/engine.py +++ b/django/template/engine.py @@ -124,15 +124,25 @@ class Engine(object): raise ImproperlyConfigured( "Invalid value in template loaders configuration: %r" % loader) - def find_template(self, name, dirs=None): + def find_template(self, name, dirs=None, skip=None): + tried = [] for loader in self.template_loaders: - try: - source, display_name = loader(name, dirs) - origin = self.make_origin(display_name, loader, name, dirs) - return source, origin - except TemplateDoesNotExist: - pass - raise TemplateDoesNotExist(name) + if loader.supports_recursion: + try: + template = loader.get_template( + name, template_dirs=dirs, skip=skip, + ) + return template, template.origin + except TemplateDoesNotExist as e: + tried.extend(e.tried) + else: + # RemovedInDjango21Warning: Use old api for non-recursive + # loaders. + try: + return loader(name, dirs) + except TemplateDoesNotExist: + pass + raise TemplateDoesNotExist(name, tried=tried) def from_string(self, template_code): """ @@ -234,11 +244,3 @@ class Engine(object): continue # If we get here, none of the templates could be loaded raise TemplateDoesNotExist(', '.join(not_found)) - - def make_origin(self, display_name, loader, name, dirs): - if self.debug and display_name: - # Inner import to avoid circular dependency - from .loader import LoaderOrigin - return LoaderOrigin(display_name, loader, name, dirs) - else: - return None diff --git a/django/template/loader.py b/django/template/loader.py index 182d9aacf6..2f9afd3071 100644 --- a/django/template/loader.py +++ b/django/template/loader.py @@ -4,25 +4,20 @@ from django.utils.deprecation import RemovedInDjango20Warning from . import engines from .backends.django import DjangoTemplates -from .base import Origin, TemplateDoesNotExist +from .base import TemplateDoesNotExist from .engine import ( _context_instance_undefined, _dictionary_undefined, _dirs_undefined, ) from .loaders import base -class LoaderOrigin(Origin): - def __init__(self, display_name, loader, name, dirs): - super(LoaderOrigin, self).__init__(display_name) - self.loader, self.loadname, self.dirs = loader, name, dirs - - def get_template(template_name, dirs=_dirs_undefined, using=None): """ Loads and returns a template for the given name. Raises TemplateDoesNotExist if no such template exists. """ + tried = [] engines = _engine_list(using) for engine in engines: try: @@ -37,10 +32,10 @@ def get_template(template_name, dirs=_dirs_undefined, using=None): stacklevel=2) else: return engine.get_template(template_name) - except TemplateDoesNotExist: - pass + except TemplateDoesNotExist as e: + tried.extend(e.tried) - raise TemplateDoesNotExist(template_name) + raise TemplateDoesNotExist(template_name, tried=tried) def select_template(template_name_list, dirs=_dirs_undefined, using=None): @@ -51,6 +46,7 @@ def select_template(template_name_list, dirs=_dirs_undefined, using=None): Raises TemplateDoesNotExist if no such template exists. """ + tried = [] engines = _engine_list(using) for template_name in template_name_list: for engine in engines: @@ -66,11 +62,11 @@ def select_template(template_name_list, dirs=_dirs_undefined, using=None): stacklevel=2) else: return engine.get_template(template_name) - except TemplateDoesNotExist: - pass + except TemplateDoesNotExist as e: + tried.extend(e.tried) if template_name_list: - raise TemplateDoesNotExist(', '.join(template_name_list)) + raise TemplateDoesNotExist(', '.join(template_name_list), tried=tried) else: raise TemplateDoesNotExist("No template names provided") @@ -96,6 +92,7 @@ def render_to_string(template_name, context=None, return template.render(context, request) else: + tried = [] # Some deprecated arguments were passed - use the legacy code path for engine in _engine_list(using): try: @@ -126,13 +123,14 @@ def render_to_string(template_name, context=None, "Skipping template backend %s because its render_to_string " "method doesn't support the dictionary argument." % engine.name, stacklevel=2) - except TemplateDoesNotExist: + except TemplateDoesNotExist as e: + tried.extend(e.tried) continue if template_name: if isinstance(template_name, (list, tuple)): template_name = ', '.join(template_name) - raise TemplateDoesNotExist(template_name) + raise TemplateDoesNotExist(template_name, tried=tried) else: raise TemplateDoesNotExist("No template names provided") diff --git a/django/template/loader_tags.py b/django/template/loader_tags.py index 398914b6b7..3b846a4099 100644 --- a/django/template/loader_tags.py +++ b/django/template/loader_tags.py @@ -82,6 +82,7 @@ class BlockNode(Node): class ExtendsNode(Node): must_be_first = True + context_key = 'extends_context' def __init__(self, nodelist, parent_name, template_dirs=None): self.nodelist = nodelist @@ -92,6 +93,39 @@ class ExtendsNode(Node): def __repr__(self): return '' % self.parent_name.token + def find_template(self, template_name, context): + """ + This is a wrapper around engine.find_template(). A history is kept in + the render_context attribute between successive extends calls and + passed as the skip argument. This enables extends to work recursively + without extending the same template twice. + """ + # RemovedInDjango21Warning: If any non-recursive loaders are installed + # do a direct template lookup. If the same template name appears twice, + # raise an exception to avoid system recursion. + for loader in context.template.engine.template_loaders: + if not loader.supports_recursion: + history = context.render_context.setdefault( + self.context_key, [context.template.origin.template_name], + ) + if template_name in history: + raise ExtendsError( + "Cannot extend templates recursively when using " + "non-recursive template loaders", + ) + template = context.template.engine.get_template(template_name) + history.append(template_name) + return template + + history = context.render_context.setdefault( + self.context_key, [context.template.origin], + ) + template, origin = context.template.engine.find_template( + template_name, skip=history, + ) + history.append(origin) + return template + def get_parent(self, context): parent = self.parent_name.resolve(context) if not parent: @@ -107,7 +141,7 @@ class ExtendsNode(Node): if isinstance(getattr(parent, 'template', None), Template): # parent is a django.template.backends.django.Template return parent.template - return context.template.engine.get_template(parent) + return self.find_template(parent, context) def render(self, context): compiled_parent = self.get_parent(context) diff --git a/django/template/loaders/base.py b/django/template/loaders/base.py index 66c2986343..b848bc05ce 100644 --- a/django/template/loaders/base.py +++ b/django/template/loaders/base.py @@ -1,4 +1,8 @@ -from django.template.base import Template, TemplateDoesNotExist +import warnings +from inspect import getargspec + +from django.template.base import Origin, Template, TemplateDoesNotExist +from django.utils.deprecation import RemovedInDjango21Warning class Loader(object): @@ -9,15 +13,54 @@ class Loader(object): self.engine = engine def __call__(self, template_name, template_dirs=None): + # RemovedInDjango21Warning: Allow loaders to be called like functions. return self.load_template(template_name, template_dirs) - def load_template(self, template_name, template_dirs=None): - source, display_name = self.load_template_source( - template_name, template_dirs) - origin = self.engine.make_origin( - display_name, self.load_template_source, - template_name, template_dirs) + def get_template(self, template_name, template_dirs=None, skip=None): + """ + Calls self.get_template_sources() and returns a Template object for + the first template matching template_name. If skip is provided, + template origins in skip are ignored. This is used to avoid recursion + during template extending. + """ + tried = [] + args = [template_name] + # RemovedInDjango21Warning: Add template_dirs for compatibility with + # old loaders + if 'template_dirs' in getargspec(self.get_template_sources)[0]: + args.append(template_dirs) + + for origin in self.get_template_sources(*args): + if skip is not None and origin in skip: + tried.append((origin, 'Skipped')) + continue + + try: + contents = self.get_contents(origin) + except TemplateDoesNotExist: + tried.append((origin, 'Source does not exist')) + continue + else: + return Template( + contents, origin, origin.template_name, self.engine, + ) + + raise TemplateDoesNotExist(template_name, tried=tried) + + def load_template(self, template_name, template_dirs=None): + warnings.warn( + 'The load_template() method is deprecated. Use get_template() ' + 'instead.', RemovedInDjango21Warning, + ) + source, display_name = self.load_template_source( + template_name, template_dirs, + ) + origin = Origin( + name=display_name, + template_name=template_name, + loader=self, + ) try: template = Template(source, origin, template_name, self.engine) except TemplateDoesNotExist: @@ -29,14 +72,23 @@ class Loader(object): else: return template, None - def load_template_source(self, template_name, template_dirs=None): + def get_template_sources(self, template_name): """ - Returns a tuple containing the source and origin for the given + An iterator that yields possible matching template paths for a template name. """ raise NotImplementedError( - "subclasses of Loader must provide " - "a load_template_source() method") + 'subclasses of Loader must provide a get_template_sources() method' + ) + + def load_template_source(self, template_name, template_dirs=None): + """ + RemovedInDjango21Warning: Returns a tuple containing the source and + origin for the given template name. + """ + raise NotImplementedError( + 'subclasses of Loader must provide a load_template_source() method' + ) def reset(self): """ @@ -44,3 +96,11 @@ class Loader(object): templates or cached loader modules). """ pass + + @property + def supports_recursion(self): + """ + RemovedInDjango21Warning: This is an internal property used by the + ExtendsNode during the deprecation of non-recursive loaders. + """ + return hasattr(self, 'get_contents') diff --git a/django/template/loaders/cached.py b/django/template/loaders/cached.py index 188268e92e..b543a83026 100644 --- a/django/template/loaders/cached.py +++ b/django/template/loaders/cached.py @@ -4,8 +4,11 @@ to load templates from them in order, caching the result. """ import hashlib +import warnings +from inspect import getargspec -from django.template.base import Template, TemplateDoesNotExist +from django.template.base import Origin, Template, TemplateDoesNotExist +from django.utils.deprecation import RemovedInDjango21Warning from django.utils.encoding import force_bytes from .base import Loader as BaseLoader @@ -15,20 +18,84 @@ class Loader(BaseLoader): def __init__(self, engine, loaders): self.template_cache = {} - self.find_template_cache = {} + self.find_template_cache = {} # RemovedInDjango21Warning + self.get_template_cache = {} self.loaders = engine.get_template_loaders(loaders) super(Loader, self).__init__(engine) - def cache_key(self, template_name, template_dirs): - if template_dirs: - # If template directories were specified, use a hash to differentiate - return '-'.join([template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) + def get_contents(self, origin): + return origin.loader.get_contents(origin) + + def get_template(self, template_name, template_dirs=None, skip=None): + key = self.cache_key(template_name, template_dirs, skip) + cached = self.get_template_cache.get(key) + if cached: + if isinstance(cached, TemplateDoesNotExist): + raise cached + return cached + + try: + template = super(Loader, self).get_template( + template_name, template_dirs, skip, + ) + except TemplateDoesNotExist as e: + self.get_template_cache[key] = e + raise else: - return template_name + self.get_template_cache[key] = template + + return template + + def get_template_sources(self, template_name, template_dirs=None): + for loader in self.loaders: + args = [template_name] + # RemovedInDjango21Warning: Add template_dirs for compatibility + # with old loaders + if 'template_dirs' in getargspec(loader.get_template_sources)[0]: + args.append(template_dirs) + for origin in loader.get_template_sources(*args): + yield origin + + def cache_key(self, template_name, template_dirs, skip=None): + """ + Generate a cache key for the template name, dirs, and skip. + + If skip is provided, only origins that match template_name are included + in the cache key. This ensures each template is only parsed and cached + once if contained in different extend chains like: + + x -> a -> a + y -> a -> a + z -> a -> a + """ + dirs_prefix = '' + skip_prefix = '' + + if skip: + matching = [origin.name for origin in skip if origin.template_name == template_name] + if matching: + skip_prefix = self.generate_hash(matching) + + if template_dirs: + dirs_prefix = self.generate_hash(template_dirs) + + return ("%s-%s-%s" % (template_name, skip_prefix, dirs_prefix)).strip('-') + + def generate_hash(self, values): + return hashlib.sha1(force_bytes('|'.join(values))).hexdigest() + + @property + def supports_recursion(self): + """ + RemovedInDjango21Warning: This is an internal property used by the + ExtendsNode during the deprecation of non-recursive loaders. + """ + return all(hasattr(loader, 'get_contents') for loader in self.loaders) def find_template(self, name, dirs=None): """ - Helper method. Lookup the template :param name: in all the configured loaders + RemovedInDjango21Warning: An internal method to lookup the template + name in all the configured loaders. """ key = self.cache_key(name, dirs) try: @@ -41,7 +108,11 @@ class Loader(BaseLoader): except TemplateDoesNotExist: pass else: - origin = self.engine.make_origin(display_name, loader, name, dirs) + origin = Origin( + name=display_name, + template_name=name, + loader=loader, + ) result = template, origin break self.find_template_cache[key] = result @@ -52,6 +123,10 @@ class Loader(BaseLoader): raise TemplateDoesNotExist(name) def load_template(self, template_name, template_dirs=None): + warnings.warn( + 'The load_template() method is deprecated. Use get_template() ' + 'instead.', RemovedInDjango21Warning, + ) key = self.cache_key(template_name, template_dirs) template_tuple = self.template_cache.get(key) # A cached previous failure: @@ -74,4 +149,5 @@ class Loader(BaseLoader): def reset(self): "Empty the template cache." self.template_cache.clear() - self.find_template_cache.clear() + self.find_template_cache.clear() # RemovedInDjango21Warning + self.get_template_cache.clear() diff --git a/django/template/loaders/eggs.py b/django/template/loaders/eggs.py index 14a586877d..9dc291e485 100644 --- a/django/template/loaders/eggs.py +++ b/django/template/loaders/eggs.py @@ -1,9 +1,12 @@ # Wrapper for loading templates from eggs via pkg_resources.resource_string. from __future__ import unicode_literals +import warnings + from django.apps import apps -from django.template.base import TemplateDoesNotExist +from django.template.base import Origin, TemplateDoesNotExist from django.utils import six +from django.utils.deprecation import RemovedInDjango21Warning from .base import Loader as BaseLoader @@ -13,6 +16,14 @@ except ImportError: resource_string = None +class EggOrigin(Origin): + + def __init__(self, app_name, pkg_name, *args, **kwargs): + self.app_name = app_name + self.pkg_name = pkg_name + return super(EggOrigin, self).__init__(*args, **kwargs) + + class Loader(BaseLoader): def __init__(self, engine): @@ -20,19 +31,42 @@ class Loader(BaseLoader): raise RuntimeError("Setuptools must be installed to use the egg loader") super(Loader, self).__init__(engine) + def get_contents(self, origin): + try: + source = resource_string(origin.app_name, origin.pkg_name) + except: + raise TemplateDoesNotExist(origin) + + if six.PY2: + source = source.decode(self.engine.file_charset) + + return source + + def get_template_sources(self, template_name): + pkg_name = 'templates/' + template_name + for app_config in apps.get_app_configs(): + yield EggOrigin( + app_name=app_config.name, + pkg_name=pkg_name, + name="egg:%s:%s" % (app_config.name, pkg_name), + template_name=template_name, + loader=self, + ) + def load_template_source(self, template_name, template_dirs=None): """ Loads templates from Python eggs via pkg_resource.resource_string. For every installed app, it tries to get the resource (app, template_name). """ - pkg_name = 'templates/' + template_name - for app_config in apps.get_app_configs(): + warnings.warn( + 'The load_template_sources() method is deprecated. Use ' + 'get_template() or get_contents() instead.', + RemovedInDjango21Warning, + ) + for origin in self.get_template_sources(template_name): try: - resource = resource_string(app_config.name, pkg_name) - except Exception: - continue - if six.PY2: - resource = resource.decode(self.engine.file_charset) - return (resource, 'egg:%s:%s' % (app_config.name, pkg_name)) + return self.get_contents(origin), origin.name + except TemplateDoesNotExist: + pass raise TemplateDoesNotExist(template_name) diff --git a/django/template/loaders/filesystem.py b/django/template/loaders/filesystem.py index 74801efe56..e1ed0ba5e0 100644 --- a/django/template/loaders/filesystem.py +++ b/django/template/loaders/filesystem.py @@ -4,10 +4,12 @@ Wrapper for loading templates from the filesystem. import errno import io +import warnings from django.core.exceptions import SuspiciousFileOperation -from django.template.base import TemplateDoesNotExist +from django.template.base import Origin, TemplateDoesNotExist from django.utils._os import safe_join +from django.utils.deprecation import RemovedInDjango21Warning from .base import Loader as BaseLoader @@ -17,28 +19,46 @@ class Loader(BaseLoader): def get_dirs(self): return self.engine.dirs + def get_contents(self, origin): + try: + with io.open(origin.name, encoding=self.engine.file_charset) as fp: + return fp.read() + except IOError as e: + if e.errno == errno.ENOENT: + raise TemplateDoesNotExist(origin) + raise + def get_template_sources(self, template_name, template_dirs=None): """ - Returns the absolute paths to "template_name", when appended to each - directory in "template_dirs". Any paths that don't lie inside one of the - template dirs are excluded from the result set, for security reasons. + Return an Origin object pointing to an absolute path in each directory + in template_dirs. For security reasons, if a path doesn't lie inside + one of the template_dirs it is excluded from the result set. """ if not template_dirs: template_dirs = self.get_dirs() for template_dir in template_dirs: try: - yield safe_join(template_dir, template_name) + name = safe_join(template_dir, template_name) except SuspiciousFileOperation: # The joined path was located outside of this template_dir # (it might be inside another one, so this isn't fatal). - pass + continue + + yield Origin( + name=name, + template_name=template_name, + loader=self, + ) def load_template_source(self, template_name, template_dirs=None): - for filepath in self.get_template_sources(template_name, template_dirs): + warnings.warn( + 'The load_template_sources() method is deprecated. Use ' + 'get_template() or get_contents() instead.', + RemovedInDjango21Warning, + ) + for origin in self.get_template_sources(template_name, template_dirs): try: - with io.open(filepath, encoding=self.engine.file_charset) as fp: - return fp.read(), filepath - except IOError as e: - if e.errno != errno.ENOENT: - raise + return self.get_contents(origin), origin.name + except TemplateDoesNotExist: + pass raise TemplateDoesNotExist(template_name) diff --git a/django/template/loaders/locmem.py b/django/template/loaders/locmem.py index 598907bb71..f0ef792b35 100644 --- a/django/template/loaders/locmem.py +++ b/django/template/loaders/locmem.py @@ -2,7 +2,10 @@ Wrapper for loading templates from a plain Python dict. """ -from django.template.base import TemplateDoesNotExist +import warnings + +from django.template.base import Origin, TemplateDoesNotExist +from django.utils.deprecation import RemovedInDjango21Warning from .base import Loader as BaseLoader @@ -13,7 +16,25 @@ class Loader(BaseLoader): self.templates_dict = templates_dict super(Loader, self).__init__(engine) + def get_contents(self, origin): + try: + return self.templates_dict[origin.name] + except KeyError: + raise TemplateDoesNotExist(origin) + + def get_template_sources(self, template_name): + yield Origin( + name=template_name, + template_name=template_name, + loader=self, + ) + def load_template_source(self, template_name, template_dirs=None): + warnings.warn( + 'The load_template_sources() method is deprecated. Use ' + 'get_template() or get_contents() instead.', + RemovedInDjango21Warning, + ) try: return self.templates_dict[template_name], template_name except KeyError: diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 98432a7e38..ce9b330b69 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -37,6 +37,26 @@ details on these changes. * The ``GeoManager`` and ``GeoQuerySet`` classes will be removed. +* The ``supports_recursion`` check for template loaders will be removed from: + + * ``django.template.engine.Engine.find_template()`` + * ``django.template.loader_tags.ExtendsNode.find_template()`` + * ``django.template.loaders.base.Loader.supports_recursion()`` + * ``django.template.loaders.cached.Loader.supports_recursion()`` + +* The ``load_template and ``load_template_sources`` template loader methods + will be removed. + +* The ``template_dirs`` argument for template loaders will be removed: + + * ``django.template.loaders.base.Loader.get_template()`` + * ``django.template.loaders.cached.Loader.cache_key()`` + * ``django.template.loaders.cached.Loader.get_template()`` + * ``django.template.loaders.cached.Loader.get_template_sources()`` + * ``django.template.loaders.filesystem.Loader.get_template_sources()`` + +* The ``django.template.loaders.base.Loader.__call__`` method will be removed. + .. _deprecation-removed-in-2.0: 2.0 diff --git a/tests/template_loader/tests.py b/tests/template_loader/tests.py index a4a4542f32..c698c0a398 100644 --- a/tests/template_loader/tests.py +++ b/tests/template_loader/tests.py @@ -33,8 +33,12 @@ class TemplateLoaderTests(SimpleTestCase): self.assertEqual(template.render(), "Hello! (Django templates)\n") def test_get_template_not_found(self): - with self.assertRaises(TemplateDoesNotExist): + with self.assertRaises(TemplateDoesNotExist) as e: get_template("template_loader/unknown.html") + self.assertEqual( + e.exception.tried[-1][0].template_name, + 'template_loader/unknown.html', + ) def test_select_template_first_engine(self): template = select_template(["template_loader/unknown.html", @@ -56,9 +60,17 @@ class TemplateLoaderTests(SimpleTestCase): select_template([]) def test_select_template_not_found(self): - with self.assertRaises(TemplateDoesNotExist): + with self.assertRaises(TemplateDoesNotExist) as e: select_template(["template_loader/unknown.html", "template_loader/missing.html"]) + self.assertEqual( + e.exception.tried[0][0].template_name, + 'template_loader/unknown.html', + ) + self.assertEqual( + e.exception.tried[-1][0].template_name, + 'template_loader/missing.html', + ) def test_select_template_tries_all_engines_before_names(self): template = select_template(["template_loader/goodbye.html", @@ -83,8 +95,12 @@ class TemplateLoaderTests(SimpleTestCase): self.assertEqual(content, "Hello! (Django templates)\n") def test_render_to_string_not_found(self): - with self.assertRaises(TemplateDoesNotExist): + with self.assertRaises(TemplateDoesNotExist) as e: render_to_string("template_loader/unknown.html") + self.assertEqual( + e.exception.tried[-1][0].template_name, + 'template_loader/unknown.html', + ) def test_render_to_string_with_list_first_engine(self): content = render_to_string(["template_loader/unknown.html", @@ -106,9 +122,17 @@ class TemplateLoaderTests(SimpleTestCase): render_to_string([]) def test_render_to_string_with_list_not_found(self): - with self.assertRaises(TemplateDoesNotExist): + with self.assertRaises(TemplateDoesNotExist) as e: render_to_string(["template_loader/unknown.html", "template_loader/missing.html"]) + self.assertEqual( + e.exception.tried[0][0].template_name, + 'template_loader/unknown.html', + ) + self.assertEqual( + e.exception.tried[-1][0].template_name, + 'template_loader/missing.html', + ) def test_render_to_string_with_list_tries_all_engines_before_names(self): content = render_to_string(["template_loader/goodbye.html", diff --git a/tests/template_tests/recursive_templates/fs/extend-missing.html b/tests/template_tests/recursive_templates/fs/extend-missing.html new file mode 100644 index 0000000000..e3c106e2c1 --- /dev/null +++ b/tests/template_tests/recursive_templates/fs/extend-missing.html @@ -0,0 +1 @@ +{% extends "missing.html" %} diff --git a/tests/template_tests/recursive_templates/fs/one.html b/tests/template_tests/recursive_templates/fs/one.html new file mode 100644 index 0000000000..f72e72e1a6 --- /dev/null +++ b/tests/template_tests/recursive_templates/fs/one.html @@ -0,0 +1,3 @@ +{% extends "two.html" %} + +{% block content %}{{ block.super }} one{% endblock %} diff --git a/tests/template_tests/recursive_templates/fs/other-recursive.html b/tests/template_tests/recursive_templates/fs/other-recursive.html new file mode 100644 index 0000000000..84e5ac9eae --- /dev/null +++ b/tests/template_tests/recursive_templates/fs/other-recursive.html @@ -0,0 +1 @@ +{% extends "recursive.html" %} diff --git a/tests/template_tests/recursive_templates/fs/recursive.html b/tests/template_tests/recursive_templates/fs/recursive.html new file mode 100644 index 0000000000..cbf242d8d5 --- /dev/null +++ b/tests/template_tests/recursive_templates/fs/recursive.html @@ -0,0 +1,3 @@ +{% extends "recursive.html" %} + +{% block content %}{{ block.super }} fs/recursive{% endblock %} diff --git a/tests/template_tests/recursive_templates/fs/self.html b/tests/template_tests/recursive_templates/fs/self.html new file mode 100644 index 0000000000..f3e5bbf301 --- /dev/null +++ b/tests/template_tests/recursive_templates/fs/self.html @@ -0,0 +1 @@ +{% extends "self.html" %} diff --git a/tests/template_tests/recursive_templates/fs/three.html b/tests/template_tests/recursive_templates/fs/three.html new file mode 100644 index 0000000000..360aeeea5e --- /dev/null +++ b/tests/template_tests/recursive_templates/fs/three.html @@ -0,0 +1 @@ +{% block content %}three{% endblock %} diff --git a/tests/template_tests/recursive_templates/fs/two.html b/tests/template_tests/recursive_templates/fs/two.html new file mode 100644 index 0000000000..b9b80ec7a0 --- /dev/null +++ b/tests/template_tests/recursive_templates/fs/two.html @@ -0,0 +1,3 @@ +{% extends "three.html" %} + +{% block content %}{{ block.super }} two{% endblock %} diff --git a/tests/template_tests/recursive_templates/fs2/recursive.html b/tests/template_tests/recursive_templates/fs2/recursive.html new file mode 100644 index 0000000000..52a338ca9f --- /dev/null +++ b/tests/template_tests/recursive_templates/fs2/recursive.html @@ -0,0 +1,3 @@ +{% extends "recursive.html" %} + +{% block content %}{{ block.super }} fs2/recursive{% endblock %} diff --git a/tests/template_tests/recursive_templates/fs3/recursive.html b/tests/template_tests/recursive_templates/fs3/recursive.html new file mode 100644 index 0000000000..aefbad4582 --- /dev/null +++ b/tests/template_tests/recursive_templates/fs3/recursive.html @@ -0,0 +1 @@ +{% block content %}fs3/recursive{% endblock %} diff --git a/tests/template_tests/test_engine.py b/tests/template_tests/test_engine.py index 6b43fd9dd2..d9929660cc 100644 --- a/tests/template_tests/test_engine.py +++ b/tests/template_tests/test_engine.py @@ -55,7 +55,7 @@ class LoaderTests(SimpleTestCase): def test_origin(self): engine = Engine(dirs=[TEMPLATE_DIR], debug=True) template = engine.get_template('index.html') - self.assertEqual(template.origin.loadname, 'index.html') + self.assertEqual(template.origin.template_name, 'index.html') def test_loader_priority(self): """ diff --git a/tests/template_tests/test_extends.py b/tests/template_tests/test_extends.py new file mode 100644 index 0000000000..48290547e7 --- /dev/null +++ b/tests/template_tests/test_extends.py @@ -0,0 +1,178 @@ +import os + +from django.template import Context, Engine, TemplateDoesNotExist +from django.template.loader_tags import ExtendsError +from django.template.loaders.base import Loader +from django.test import SimpleTestCase, ignore_warnings +from django.utils.deprecation import RemovedInDjango21Warning + +from .utils import ROOT + +RECURSIVE = os.path.join(ROOT, 'recursive_templates') + + +class ExtendsBehaviorTests(SimpleTestCase): + + def test_normal_extend(self): + engine = Engine(dirs=[os.path.join(RECURSIVE, 'fs')]) + template = engine.get_template('one.html') + output = template.render(Context({})) + self.assertEqual(output.strip(), 'three two one') + + def test_extend_recursive(self): + engine = Engine(dirs=[ + os.path.join(RECURSIVE, 'fs'), + os.path.join(RECURSIVE, 'fs2'), + os.path.join(RECURSIVE, 'fs3'), + ]) + template = engine.get_template('recursive.html') + output = template.render(Context({})) + self.assertEqual(output.strip(), 'fs3/recursive fs2/recursive fs/recursive') + + def test_extend_missing(self): + engine = Engine(dirs=[os.path.join(RECURSIVE, 'fs')]) + template = engine.get_template('extend-missing.html') + with self.assertRaises(TemplateDoesNotExist) as e: + template.render(Context({})) + + tried = e.exception.tried + self.assertEqual(len(tried), 1) + self.assertEqual(tried[0][0].template_name, 'missing.html') + + def test_recursive_multiple_loaders(self): + engine = Engine( + dirs=[os.path.join(RECURSIVE, 'fs')], + loaders=[ + ('django.template.loaders.locmem.Loader', { + 'one.html': '{% extends "one.html" %}{% block content %}{{ block.super }} locmem-one{% endblock %}', + 'two.html': '{% extends "two.html" %}{% block content %}{{ block.super }} locmem-two{% endblock %}', + 'three.html': ( + '{% extends "three.html" %}{% block content %}{{ block.super }} locmem-three{% endblock %}' + ), + }), + 'django.template.loaders.filesystem.Loader', + ], + ) + template = engine.get_template('one.html') + output = template.render(Context({})) + self.assertEqual(output.strip(), 'three locmem-three two locmem-two one locmem-one') + + def test_extend_self_error(self): + """ + Catch if a template extends itself and no other matching + templates are found. + """ + engine = Engine(dirs=[os.path.join(RECURSIVE, 'fs')]) + template = engine.get_template('self.html') + with self.assertRaises(TemplateDoesNotExist): + template.render(Context({})) + + def test_extend_cached(self): + engine = Engine( + dirs=[ + os.path.join(RECURSIVE, 'fs'), + os.path.join(RECURSIVE, 'fs2'), + os.path.join(RECURSIVE, 'fs3'), + ], + loaders=[ + ('django.template.loaders.cached.Loader', [ + 'django.template.loaders.filesystem.Loader', + ]), + ], + ) + template = engine.get_template('recursive.html') + output = template.render(Context({})) + self.assertEqual(output.strip(), 'fs3/recursive fs2/recursive fs/recursive') + + cache = engine.template_loaders[0].get_template_cache + self.assertEqual(len(cache), 3) + self.assertTrue(cache['recursive.html'].origin.name.endswith('fs/recursive.html')) + + # Render another path that uses the same templates from the cache + template = engine.get_template('other-recursive.html') + output = template.render(Context({})) + self.assertEqual(output.strip(), 'fs3/recursive fs2/recursive fs/recursive') + + # Template objects should not be duplicated. + self.assertEqual(len(cache), 4) + self.assertTrue(cache['other-recursive.html'].origin.name.endswith('fs/other-recursive.html')) + + def test_unique_history_per_loader(self): + """ + Extending should continue even if two loaders return the same + name for a template. + """ + engine = Engine( + loaders=[ + ['django.template.loaders.locmem.Loader', { + 'base.html': '{% extends "base.html" %}{% block content %}{{ block.super }} loader1{% endblock %}', + }], + ['django.template.loaders.locmem.Loader', { + 'base.html': '{% block content %}loader2{% endblock %}', + }], + ] + ) + template = engine.get_template('base.html') + output = template.render(Context({})) + self.assertEqual(output.strip(), 'loader2 loader1') + + +class NonRecursiveLoader(Loader): + + def __init__(self, engine, templates_dict): + self.templates_dict = templates_dict + super(NonRecursiveLoader, self).__init__(engine) + + def load_template_source(self, template_name, template_dirs=None): + try: + return self.templates_dict[template_name], template_name + except KeyError: + raise TemplateDoesNotExist(template_name) + + +@ignore_warnings(category=RemovedInDjango21Warning) +class NonRecursiveLoaderExtendsTests(SimpleTestCase): + + loaders = [ + ('template_tests.test_extends.NonRecursiveLoader', { + 'base.html': 'base', + 'index.html': '{% extends "base.html" %}', + 'recursive.html': '{% extends "recursive.html" %}', + 'other-recursive.html': '{% extends "recursive.html" %}', + 'a.html': '{% extends "b.html" %}', + 'b.html': '{% extends "a.html" %}', + }), + ] + + def test_extend(self): + engine = Engine(loaders=self.loaders) + output = engine.render_to_string('index.html') + self.assertEqual(output, 'base') + + def test_extend_cached(self): + engine = Engine(loaders=[ + ('django.template.loaders.cached.Loader', self.loaders), + ]) + output = engine.render_to_string('index.html') + self.assertEqual(output, 'base') + + cache = engine.template_loaders[0].template_cache + self.assertTrue('base.html' in cache) + self.assertTrue('index.html' in cache) + + # Render a second time from cache + output = engine.render_to_string('index.html') + self.assertEqual(output, 'base') + + def test_extend_error(self): + engine = Engine(loaders=self.loaders) + msg = 'Cannot extend templates recursively when using non-recursive template loaders' + + with self.assertRaisesMessage(ExtendsError, msg): + engine.render_to_string('recursive.html') + + with self.assertRaisesMessage(ExtendsError, msg): + engine.render_to_string('other-recursive.html') + + with self.assertRaisesMessage(ExtendsError, msg): + engine.render_to_string('a.html') diff --git a/tests/template_tests/test_loaders.py b/tests/template_tests/test_loaders.py index caa0b85db3..41cac5cb9d 100644 --- a/tests/template_tests/test_loaders.py +++ b/tests/template_tests/test_loaders.py @@ -10,8 +10,9 @@ from contextlib import contextmanager from django.template import Context, TemplateDoesNotExist from django.template.engine import Engine -from django.test import SimpleTestCase, override_settings +from django.test import SimpleTestCase, ignore_warnings, override_settings from django.utils import six +from django.utils.deprecation import RemovedInDjango21Warning from .utils import TEMPLATE_DIR @@ -23,8 +24,9 @@ except ImportError: class CachedLoaderTests(SimpleTestCase): - def create_engine(self, **kwargs): - return Engine( + def setUp(self): + self.engine = Engine( + dirs=[TEMPLATE_DIR], loaders=[ ('django.template.loaders.cached.Loader', [ 'django.template.loaders.filesystem.Loader', @@ -32,26 +34,47 @@ class CachedLoaderTests(SimpleTestCase): ], ) - def test_templatedir_caching(self): - """ - #13573 -- Template directories should be part of the cache key. - """ - engine = self.create_engine() + def test_get_template(self): + template = self.engine.get_template('index.html') + self.assertEqual(template.origin.name, os.path.join(TEMPLATE_DIR, 'index.html')) + self.assertEqual(template.origin.template_name, 'index.html') + self.assertEqual(template.origin.loader, self.engine.template_loaders[0].loaders[0]) - # Retrieve a template specifying a template directory to check - t1, name = engine.find_template('test.html', (os.path.join(TEMPLATE_DIR, 'first'),)) - # Now retrieve the same template name, but from a different directory - t2, name = engine.find_template('test.html', (os.path.join(TEMPLATE_DIR, 'second'),)) + cache = self.engine.template_loaders[0].get_template_cache + self.assertEqual(cache['index.html'], template) - # The two templates should not have the same content - self.assertNotEqual(t1.render(Context({})), t2.render(Context({}))) + # Run a second time from cache + template = self.engine.get_template('index.html') + self.assertEqual(template.origin.name, os.path.join(TEMPLATE_DIR, 'index.html')) + self.assertEqual(template.origin.template_name, 'index.html') + self.assertEqual(template.origin.loader, self.engine.template_loaders[0].loaders[0]) - def test_missing_template_is_cached(self): + def test_get_template_missing(self): + with self.assertRaises(TemplateDoesNotExist): + self.engine.get_template('doesnotexist.html') + e = self.engine.template_loaders[0].get_template_cache['doesnotexist.html'] + self.assertEqual(e.args[0], 'doesnotexist.html') + + @ignore_warnings(category=RemovedInDjango21Warning) + def test_load_template(self): + loader = self.engine.template_loaders[0] + template, origin = loader.load_template('index.html') + self.assertEqual(template.origin.template_name, 'index.html') + + cache = self.engine.template_loaders[0].template_cache + self.assertEqual(cache['index.html'][0], template) + + # Run a second time from cache + loader = self.engine.template_loaders[0] + source, name = loader.load_template('index.html') + self.assertEqual(template.origin.template_name, 'index.html') + + @ignore_warnings(category=RemovedInDjango21Warning) + def test_load_template_missing(self): """ #19949 -- TemplateDoesNotExist exceptions should be cached. """ - engine = self.create_engine() - loader = engine.template_loaders[0] + loader = self.engine.template_loaders[0] self.assertFalse('missing.html' in loader.template_cache) @@ -64,6 +87,18 @@ class CachedLoaderTests(SimpleTestCase): "Cached loader failed to cache the TemplateDoesNotExist exception", ) + def test_templatedir_caching(self): + """ + #13573 -- Template directories should be part of the cache key. + """ + # Retrieve a template specifying a template directory to check + t1, name = self.engine.find_template('test.html', (os.path.join(TEMPLATE_DIR, 'first'),)) + # Now retrieve the same template name, but from a different directory + t2, name = self.engine.find_template('test.html', (os.path.join(TEMPLATE_DIR, 'second'),)) + + # The two templates should not have the same content + self.assertNotEqual(t1.render(Context({})), t2.render(Context({}))) + @unittest.skipUnless(pkg_resources, 'setuptools is not installed') class EggLoaderTests(SimpleTestCase): @@ -117,22 +152,43 @@ class EggLoaderTests(SimpleTestCase): del sys.modules[name] del pkg_resources._provider_factories[MockLoader] - def setUp(self): - engine = Engine(loaders=[ + @classmethod + def setUpClass(cls): + cls.engine = Engine(loaders=[ 'django.template.loaders.eggs.Loader', ]) - self.loader = engine.template_loaders[0] + cls.loader = cls.engine.template_loaders[0] + super(EggLoaderTests, cls).setUpClass() - def test_existing(self): + def test_get_template(self): templates = { os.path.normcase('templates/y.html'): six.StringIO("y"), } with self.create_egg('egg', templates): with override_settings(INSTALLED_APPS=['egg']): - contents, template_name = self.loader.load_template_source("y.html") - self.assertEqual(contents, "y") - self.assertEqual(template_name, "egg:egg:templates/y.html") + template = self.engine.get_template("y.html") + + self.assertEqual(template.origin.name, 'egg:egg:templates/y.html') + self.assertEqual(template.origin.template_name, 'y.html') + self.assertEqual(template.origin.loader, self.engine.template_loaders[0]) + + output = template.render(Context({})) + self.assertEqual(output, "y") + + @ignore_warnings(category=RemovedInDjango21Warning) + def test_load_template_source(self): + loader = self.engine.template_loaders[0] + templates = { + os.path.normcase('templates/y.html'): six.StringIO("y"), + } + + with self.create_egg('egg', templates): + with override_settings(INSTALLED_APPS=['egg']): + source, name = loader.load_template_source('y.html') + + self.assertEqual(source.strip(), 'y') + self.assertEqual(name, 'egg:egg:templates/y.html') def test_non_existing(self): """ @@ -141,7 +197,7 @@ class EggLoaderTests(SimpleTestCase): with self.create_egg('egg', {}): with override_settings(INSTALLED_APPS=['egg']): with self.assertRaises(TemplateDoesNotExist): - self.loader.load_template_source("not-existing.html") + self.engine.get_template('not-existing.html') def test_not_installed(self): """ @@ -153,13 +209,15 @@ class EggLoaderTests(SimpleTestCase): with self.create_egg('egg', templates): with self.assertRaises(TemplateDoesNotExist): - self.loader.load_template_source("y.html") + self.engine.get_template('y.html') class FileSystemLoaderTests(SimpleTestCase): - def setUp(self): - self.engine = Engine(dirs=[TEMPLATE_DIR]) + @classmethod + def setUpClass(cls): + cls.engine = Engine(dirs=[TEMPLATE_DIR]) + super(FileSystemLoaderTests, cls).setUpClass() @contextmanager def set_dirs(self, dirs): @@ -177,13 +235,27 @@ class FileSystemLoaderTests(SimpleTestCase): def check_sources(path, expected_sources): expected_sources = [os.path.abspath(s) for s in expected_sources] self.assertEqual( - list(loader.get_template_sources(path)), + [origin.name for origin in loader.get_template_sources(path)], expected_sources, ) with self.set_dirs(dirs): yield check_sources + def test_get_template(self): + template = self.engine.get_template('index.html') + self.assertEqual(template.origin.name, os.path.join(TEMPLATE_DIR, 'index.html')) + self.assertEqual(template.origin.template_name, 'index.html') + self.assertEqual(template.origin.loader, self.engine.template_loaders[0]) + self.assertEqual(template.origin.loader_name, 'django.template.loaders.filesystem.Loader') + + @ignore_warnings(category=RemovedInDjango21Warning) + def test_load_template_source(self): + loader = self.engine.template_loaders[0] + source, name = loader.load_template_source('index.html') + self.assertEqual(source.strip(), 'index') + self.assertEqual(name, os.path.join(TEMPLATE_DIR, 'index.html')) + def test_directory_security(self): with self.source_checker(['/dir1', '/dir2']) as check_sources: check_sources('index.html', ['/dir1/index.html', '/dir2/index.html']) @@ -248,18 +320,56 @@ class FileSystemLoaderTests(SimpleTestCase): self.engine.get_template('first') -class AppDirectoriesLoaderTest(SimpleTestCase): +class AppDirectoriesLoaderTests(SimpleTestCase): - def setUp(self): - self.engine = Engine( + @classmethod + def setUpClass(cls): + cls.engine = Engine( loaders=['django.template.loaders.app_directories.Loader'], ) + super(AppDirectoriesLoaderTests, cls).setUpClass() @override_settings(INSTALLED_APPS=['template_tests']) - def test_load_template(self): - self.engine.get_template('index.html') + def test_get_template(self): + template = self.engine.get_template('index.html') + self.assertEqual(template.origin.name, os.path.join(TEMPLATE_DIR, 'index.html')) + self.assertEqual(template.origin.template_name, 'index.html') + self.assertEqual(template.origin.loader, self.engine.template_loaders[0]) + + @ignore_warnings(category=RemovedInDjango21Warning) + @override_settings(INSTALLED_APPS=['template_tests']) + def test_load_template_source(self): + loader = self.engine.template_loaders[0] + source, name = loader.load_template_source('index.html') + self.assertEqual(source.strip(), 'index') + self.assertEqual(name, os.path.join(TEMPLATE_DIR, 'index.html')) @override_settings(INSTALLED_APPS=[]) def test_not_installed(self): with self.assertRaises(TemplateDoesNotExist): self.engine.get_template('index.html') + + +class LocmemLoaderTests(SimpleTestCase): + + @classmethod + def setUpClass(cls): + cls.engine = Engine( + loaders=[('django.template.loaders.locmem.Loader', { + 'index.html': 'index', + })], + ) + super(LocmemLoaderTests, cls).setUpClass() + + def test_get_template(self): + template = self.engine.get_template('index.html') + self.assertEqual(template.origin.name, 'index.html') + self.assertEqual(template.origin.template_name, 'index.html') + self.assertEqual(template.origin.loader, self.engine.template_loaders[0]) + + @ignore_warnings(category=RemovedInDjango21Warning) + def test_load_template_source(self): + loader = self.engine.template_loaders[0] + source, name = loader.load_template_source('index.html') + self.assertEqual(source.strip(), 'index') + self.assertEqual(name, 'index.html') diff --git a/tests/template_tests/tests.py b/tests/template_tests/tests.py index dec21559ae..0a71489f3e 100644 --- a/tests/template_tests/tests.py +++ b/tests/template_tests/tests.py @@ -6,6 +6,7 @@ import sys from django.contrib.auth.models import Group from django.core import urlresolvers from django.template import Context, Engine, TemplateSyntaxError +from django.template.base import UNKNOWN_SOURCE from django.test import SimpleTestCase, override_settings @@ -13,7 +14,9 @@ class TemplateTests(SimpleTestCase): def test_string_origin(self): template = Engine().from_string('string template') - self.assertEqual(template.origin.source, 'string template') + self.assertEqual(template.origin.name, UNKNOWN_SOURCE) + self.assertEqual(template.origin.loader_name, None) + self.assertEqual(template.source, 'string template') @override_settings(SETTINGS_MODULE=None) def test_url_reverse_no_settings_module(self):