diff --git a/django/contrib/admindocs/views.py b/django/contrib/admindocs/views.py index dc7f384393..35aad4c897 100644 --- a/django/contrib/admindocs/views.py +++ b/django/contrib/admindocs/views.py @@ -12,12 +12,7 @@ from django.core import urlresolvers from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist from django.db import models from django.http import Http404 -from django.template.base import ( - InvalidTemplateLibrary, builtins, get_library, get_templatetags_modules, - libraries, -) from django.template.engine import Engine -from django.utils._os import upath from django.utils.decorators import method_decorator from django.utils.translation import ugettext as _ from django.views.generic import TemplateView @@ -60,31 +55,32 @@ class TemplateTagIndexView(BaseAdminDocsView): template_name = 'admin_doc/template_tag_index.html' def get_context_data(self, **kwargs): - load_all_installed_template_libraries() - tags = [] - app_libs = list(libraries.items()) - builtin_libs = [(None, lib) for lib in builtins] - for module_name, library in builtin_libs + app_libs: - for tag_name, tag_func in library.tags.items(): - title, body, metadata = utils.parse_docstring(tag_func.__doc__) - if title: - title = utils.parse_rst(title, 'tag', _('tag:') + tag_name) - if body: - body = utils.parse_rst(body, 'tag', _('tag:') + tag_name) - for key in metadata: - metadata[key] = utils.parse_rst(metadata[key], 'tag', _('tag:') + tag_name) - if library in builtins: - tag_library = '' - else: + try: + engine = Engine.get_default() + except ImproperlyConfigured: + # Non-trivial TEMPLATES settings aren't supported (#24125). + pass + else: + app_libs = sorted(engine.template_libraries.items()) + builtin_libs = [('', lib) for lib in engine.template_builtins] + for module_name, library in builtin_libs + app_libs: + for tag_name, tag_func in library.tags.items(): + title, body, metadata = utils.parse_docstring(tag_func.__doc__) + if title: + title = utils.parse_rst(title, 'tag', _('tag:') + tag_name) + if body: + body = utils.parse_rst(body, 'tag', _('tag:') + tag_name) + for key in metadata: + metadata[key] = utils.parse_rst(metadata[key], 'tag', _('tag:') + tag_name) tag_library = module_name.split('.')[-1] - tags.append({ - 'name': tag_name, - 'title': title, - 'body': body, - 'meta': metadata, - 'library': tag_library, - }) + tags.append({ + 'name': tag_name, + 'title': title, + 'body': body, + 'meta': metadata, + 'library': tag_library, + }) kwargs.update({'tags': tags}) return super(TemplateTagIndexView, self).get_context_data(**kwargs) @@ -93,31 +89,32 @@ class TemplateFilterIndexView(BaseAdminDocsView): template_name = 'admin_doc/template_filter_index.html' def get_context_data(self, **kwargs): - load_all_installed_template_libraries() - filters = [] - app_libs = list(libraries.items()) - builtin_libs = [(None, lib) for lib in builtins] - for module_name, library in builtin_libs + app_libs: - for filter_name, filter_func in library.filters.items(): - title, body, metadata = utils.parse_docstring(filter_func.__doc__) - if title: - title = utils.parse_rst(title, 'filter', _('filter:') + filter_name) - if body: - body = utils.parse_rst(body, 'filter', _('filter:') + filter_name) - for key in metadata: - metadata[key] = utils.parse_rst(metadata[key], 'filter', _('filter:') + filter_name) - if library in builtins: - tag_library = '' - else: + try: + engine = Engine.get_default() + except ImproperlyConfigured: + # Non-trivial TEMPLATES settings aren't supported (#24125). + pass + else: + app_libs = sorted(engine.template_libraries.items()) + builtin_libs = [('', lib) for lib in engine.template_builtins] + for module_name, library in builtin_libs + app_libs: + for filter_name, filter_func in library.filters.items(): + title, body, metadata = utils.parse_docstring(filter_func.__doc__) + if title: + title = utils.parse_rst(title, 'filter', _('filter:') + filter_name) + if body: + body = utils.parse_rst(body, 'filter', _('filter:') + filter_name) + for key in metadata: + metadata[key] = utils.parse_rst(metadata[key], 'filter', _('filter:') + filter_name) tag_library = module_name.split('.')[-1] - filters.append({ - 'name': filter_name, - 'title': title, - 'body': body, - 'meta': metadata, - 'library': tag_library, - }) + filters.append({ + 'name': filter_name, + 'title': title, + 'body': body, + 'meta': metadata, + 'library': tag_library, + }) kwargs.update({'filters': filters}) return super(TemplateFilterIndexView, self).get_context_data(**kwargs) @@ -320,29 +317,6 @@ class TemplateDetailView(BaseAdminDocsView): # Helper functions # #################### -def load_all_installed_template_libraries(): - # Load/register all template tag libraries from installed apps. - for module_name in get_templatetags_modules(): - mod = import_module(module_name) - if not hasattr(mod, '__file__'): - # e.g. packages installed as eggs - continue - - try: - libraries = [ - os.path.splitext(p)[0] - for p in os.listdir(os.path.dirname(upath(mod.__file__))) - if p.endswith('.py') and p[0].isalpha() - ] - except OSError: - continue - else: - for library_name in libraries: - try: - get_library(library_name) - except InvalidTemplateLibrary: - pass - def get_return_data_type(func_name): """Return a somewhat-helpful data type given a function name""" diff --git a/django/template/__init__.py b/django/template/__init__.py index cd1bf167c8..20f4986837 100644 --- a/django/template/__init__.py +++ b/django/template/__init__.py @@ -66,7 +66,7 @@ from .base import (Context, Node, NodeList, Origin, RequestContext, # NOQA from .base import resolve_variable # NOQA # Library management -from .base import Library # NOQA +from .library import Library # NOQA __all__ += ('Template', 'Context', 'RequestContext') diff --git a/django/template/backends/django.py b/django/template/backends/django.py index 9c2d24af13..0f5481d941 100644 --- a/django/template/backends/django.py +++ b/django/template/backends/django.py @@ -3,11 +3,15 @@ from __future__ import absolute_import import sys import warnings +from importlib import import_module +from pkgutil import walk_packages +from django.apps import apps from django.conf import settings from django.template import TemplateDoesNotExist from django.template.context import Context, RequestContext, make_context from django.template.engine import Engine, _dirs_undefined +from django.template.library import InvalidTemplateLibrary from django.utils import six from django.utils.deprecation import RemovedInDjango20Warning @@ -23,6 +27,8 @@ class DjangoTemplates(BaseEngine): options = params.pop('OPTIONS').copy() options.setdefault('debug', settings.DEBUG) options.setdefault('file_charset', settings.FILE_CHARSET) + libraries = options.get('libraries', {}) + options['libraries'] = self.get_templatetag_libraries(libraries) super(DjangoTemplates, self).__init__(params) self.engine = Engine(self.dirs, self.app_dirs, **options) @@ -35,6 +41,15 @@ class DjangoTemplates(BaseEngine): except TemplateDoesNotExist as exc: reraise(exc, self) + def get_templatetag_libraries(self, custom_libraries): + """ + Return a collation of template tag libraries from installed + applications and the supplied custom_libraries argument. + """ + libraries = get_installed_libraries() + libraries.update(custom_libraries) + return libraries + class Template(object): @@ -90,3 +105,48 @@ def reraise(exc, backend): if hasattr(exc, 'template_debug'): new.template_debug = exc.template_debug six.reraise(exc.__class__, new, sys.exc_info()[2]) + + +def get_installed_libraries(): + """ + Return the built-in template tag libraries and those from installed + applications. Libraries are stored in a dictionary where keys are the + individual module names, not the full module paths. Example: + django.templatetags.i18n is stored as i18n. + """ + libraries = {} + candidates = ['django.templatetags'] + candidates.extend( + '%s.templatetags' % app_config.name + for app_config in apps.get_app_configs()) + + for candidate in candidates: + try: + pkg = import_module(candidate) + except ImportError: + # No templatetags package defined. This is safe to ignore. + continue + + if hasattr(pkg, '__path__'): + for name in get_package_libraries(pkg): + libraries[name[len(candidate) + 1:]] = name + + return libraries + + +def get_package_libraries(pkg): + """ + Recursively yield template tag libraries defined in submodules of a + package. + """ + for entry in walk_packages(pkg.__path__, pkg.__name__ + '.'): + try: + module = import_module(entry[1]) + except ImportError as e: + raise InvalidTemplateLibrary( + "Invalid template library specified. ImportError raised when " + "trying to load '%s': %s" % (entry[1], e) + ) + + if hasattr(module, 'register'): + yield entry[1] diff --git a/django/template/base.py b/django/template/base.py index 1b05fe57b8..deebde7a7c 100644 --- a/django/template/base.py +++ b/django/template/base.py @@ -54,25 +54,18 @@ from __future__ import unicode_literals import logging import re import warnings -from functools import partial -from importlib import import_module from inspect import getargspec, getcallargs -from django.apps import apps from django.template.context import ( # NOQA: imported for backwards compatibility BaseContext, Context, ContextPopException, RequestContext, ) -from django.utils import lru_cache, six -from django.utils.deprecation import ( - RemovedInDjango20Warning, RemovedInDjango21Warning, -) +from django.utils import six +from django.utils.deprecation import RemovedInDjango20Warning from django.utils.encoding import ( force_str, force_text, python_2_unicode_compatible, ) from django.utils.formats import localize from django.utils.html import conditional_escape, escape -from django.utils.itercompat import is_iterable -from django.utils.module_loading import module_has_submodule from django.utils.safestring import ( EscapeData, SafeData, mark_for_escaping, mark_safe, ) @@ -123,11 +116,6 @@ tag_re = (re.compile('(%s.*?%s|%s.*?%s|%s.*?%s)' % re.escape(VARIABLE_TAG_START), re.escape(VARIABLE_TAG_END), re.escape(COMMENT_TAG_START), re.escape(COMMENT_TAG_END)))) -# global dictionary of libraries that have been loaded using get_library -libraries = {} -# global list of libraries to load by default for a new parser -builtins = [] - logger = logging.getLogger('django.template') @@ -146,10 +134,6 @@ class VariableDoesNotExist(Exception): return self.msg % tuple(force_text(p, errors='replace') for p in self.params) -class InvalidTemplateLibrary(Exception): - pass - - class Origin(object): def __init__(self, name, template_name=None, loader=None): self.name = name @@ -232,7 +216,9 @@ class Template(object): lexer = Lexer(self.source) tokens = lexer.tokenize() - parser = Parser(tokens) + parser = Parser( + tokens, self.engine.template_libraries, self.engine.template_builtins, + ) try: return parser.parse() @@ -452,13 +438,20 @@ class DebugLexer(Lexer): class Parser(object): - def __init__(self, tokens): + def __init__(self, tokens, libraries=None, builtins=None): self.tokens = tokens self.tags = {} self.filters = {} self.command_stack = [] - for lib in builtins: - self.add_library(lib) + + if libraries is None: + libraries = {} + if builtins is None: + builtins = [] + + self.libraries = libraries + for builtin in builtins: + self.add_library(builtin) def parse(self, parse_until=None): """ @@ -1073,377 +1066,3 @@ def token_kwargs(bits, parser, support_legacy=False): return kwargs del bits[:1] return kwargs - - -def parse_bits(parser, bits, params, varargs, varkw, defaults, - takes_context, name): - """ - Parses bits for template tag helpers simple_tag and inclusion_tag, in - particular by detecting syntax errors and by extracting positional and - keyword arguments. - """ - if takes_context: - if params[0] == 'context': - params = params[1:] - else: - raise TemplateSyntaxError( - "'%s' is decorated with takes_context=True so it must " - "have a first argument of 'context'" % name) - args = [] - kwargs = {} - unhandled_params = list(params) - for bit in bits: - # First we try to extract a potential kwarg from the bit - kwarg = token_kwargs([bit], parser) - if kwarg: - # The kwarg was successfully extracted - param, value = kwarg.popitem() - if param not in params and varkw is None: - # An unexpected keyword argument was supplied - raise TemplateSyntaxError( - "'%s' received unexpected keyword argument '%s'" % - (name, param)) - elif param in kwargs: - # The keyword argument has already been supplied once - raise TemplateSyntaxError( - "'%s' received multiple values for keyword argument '%s'" % - (name, param)) - else: - # All good, record the keyword argument - kwargs[str(param)] = value - if param in unhandled_params: - # If using the keyword syntax for a positional arg, then - # consume it. - unhandled_params.remove(param) - else: - if kwargs: - raise TemplateSyntaxError( - "'%s' received some positional argument(s) after some " - "keyword argument(s)" % name) - else: - # Record the positional argument - args.append(parser.compile_filter(bit)) - try: - # Consume from the list of expected positional arguments - unhandled_params.pop(0) - except IndexError: - if varargs is None: - raise TemplateSyntaxError( - "'%s' received too many positional arguments" % - name) - if defaults is not None: - # Consider the last n params handled, where n is the - # number of defaults. - unhandled_params = unhandled_params[:-len(defaults)] - if unhandled_params: - # Some positional arguments were not supplied - raise TemplateSyntaxError( - "'%s' did not receive value(s) for the argument(s): %s" % - (name, ", ".join("'%s'" % p for p in unhandled_params))) - return args, kwargs - - -def generic_tag_compiler(parser, token, params, varargs, varkw, defaults, - name, takes_context, node_class): - """ - Returns a template.Node subclass. - """ - bits = token.split_contents()[1:] - args, kwargs = parse_bits(parser, bits, params, varargs, varkw, - defaults, takes_context, name) - return node_class(takes_context, args, kwargs) - - -class TagHelperNode(Node): - """ - Base class for tag helper nodes such as SimpleNode and InclusionNode. - Manages the positional and keyword arguments to be passed to the decorated - function. - """ - - def __init__(self, takes_context, args, kwargs): - self.takes_context = takes_context - self.args = args - self.kwargs = kwargs - - def get_resolved_arguments(self, context): - resolved_args = [var.resolve(context) for var in self.args] - if self.takes_context: - resolved_args = [context] + resolved_args - resolved_kwargs = {k: v.resolve(context) for k, v in self.kwargs.items()} - return resolved_args, resolved_kwargs - - -class Library(object): - def __init__(self): - self.filters = {} - self.tags = {} - - def tag(self, name=None, compile_function=None): - if name is None and compile_function is None: - # @register.tag() - return self.tag_function - elif name is not None and compile_function is None: - if callable(name): - # @register.tag - return self.tag_function(name) - else: - # @register.tag('somename') or @register.tag(name='somename') - def dec(func): - return self.tag(name, func) - return dec - elif name is not None and compile_function is not None: - # register.tag('somename', somefunc) - self.tags[name] = compile_function - return compile_function - else: - raise InvalidTemplateLibrary("Unsupported arguments to " - "Library.tag: (%r, %r)", (name, compile_function)) - - def tag_function(self, func): - self.tags[getattr(func, "_decorated_function", func).__name__] = func - return func - - def filter(self, name=None, filter_func=None, **flags): - if name is None and filter_func is None: - # @register.filter() - def dec(func): - return self.filter_function(func, **flags) - return dec - - elif name is not None and filter_func is None: - if callable(name): - # @register.filter - return self.filter_function(name, **flags) - else: - # @register.filter('somename') or @register.filter(name='somename') - def dec(func): - return self.filter(name, func, **flags) - return dec - - elif name is not None and filter_func is not None: - # register.filter('somename', somefunc) - self.filters[name] = filter_func - for attr in ('expects_localtime', 'is_safe', 'needs_autoescape'): - if attr in flags: - value = flags[attr] - # set the flag on the filter for FilterExpression.resolve - setattr(filter_func, attr, value) - # set the flag on the innermost decorated function - # for decorators that need it e.g. stringfilter - if hasattr(filter_func, "_decorated_function"): - setattr(filter_func._decorated_function, attr, value) - filter_func._filter_name = name - return filter_func - else: - raise InvalidTemplateLibrary("Unsupported arguments to " - "Library.filter: (%r, %r)", (name, filter_func)) - - def filter_function(self, func, **flags): - name = getattr(func, "_decorated_function", func).__name__ - return self.filter(name, func, **flags) - - def simple_tag(self, func=None, takes_context=None, name=None): - def dec(func): - params, varargs, varkw, defaults = getargspec(func) - - class SimpleNode(TagHelperNode): - def __init__(self, takes_context, args, kwargs, target_var): - super(SimpleNode, self).__init__(takes_context, args, kwargs) - self.target_var = target_var - - def render(self, context): - resolved_args, resolved_kwargs = self.get_resolved_arguments(context) - output = func(*resolved_args, **resolved_kwargs) - if self.target_var is not None: - context[self.target_var] = output - return '' - return output - - function_name = (name or - getattr(func, '_decorated_function', func).__name__) - - def compile_func(parser, token): - bits = token.split_contents()[1:] - target_var = None - if len(bits) >= 2 and bits[-2] == 'as': - target_var = bits[-1] - bits = bits[:-2] - args, kwargs = parse_bits(parser, bits, params, - varargs, varkw, defaults, takes_context, function_name) - return SimpleNode(takes_context, args, kwargs, target_var) - - compile_func.__doc__ = func.__doc__ - self.tag(function_name, compile_func) - return func - - if func is None: - # @register.simple_tag(...) - return dec - elif callable(func): - # @register.simple_tag - return dec(func) - else: - raise TemplateSyntaxError("Invalid arguments provided to simple_tag") - - def assignment_tag(self, func=None, takes_context=None, name=None): - warnings.warn( - "assignment_tag() is deprecated. Use simple_tag() instead", - RemovedInDjango21Warning, - stacklevel=2, - ) - return self.simple_tag(func, takes_context, name) - - def inclusion_tag(self, file_name, takes_context=False, name=None): - def dec(func): - params, varargs, varkw, defaults = getargspec(func) - - class InclusionNode(TagHelperNode): - - def render(self, context): - """ - Renders the specified template and context. Caches the - template object in render_context to avoid reparsing and - loading when used in a for loop. - """ - resolved_args, resolved_kwargs = self.get_resolved_arguments(context) - _dict = func(*resolved_args, **resolved_kwargs) - - t = context.render_context.get(self) - if t is None: - if isinstance(file_name, Template): - t = file_name - elif isinstance(getattr(file_name, 'template', None), Template): - t = file_name.template - elif not isinstance(file_name, six.string_types) and is_iterable(file_name): - t = context.template.engine.select_template(file_name) - else: - t = context.template.engine.get_template(file_name) - context.render_context[self] = t - new_context = context.new(_dict) - # Copy across the CSRF token, if present, because - # inclusion tags are often used for forms, and we need - # instructions for using CSRF protection to be as simple - # as possible. - csrf_token = context.get('csrf_token') - if csrf_token is not None: - new_context['csrf_token'] = csrf_token - return t.render(new_context) - - function_name = (name or - getattr(func, '_decorated_function', func).__name__) - compile_func = partial(generic_tag_compiler, - params=params, varargs=varargs, varkw=varkw, - defaults=defaults, name=function_name, - takes_context=takes_context, node_class=InclusionNode) - compile_func.__doc__ = func.__doc__ - self.tag(function_name, compile_func) - return func - return dec - - -def is_library_missing(name): - """Check if library that failed to load cannot be found under any - templatetags directory or does exist but fails to import. - - Non-existing condition is checked recursively for each subpackage in cases - like /templatetags/subpackage/package/module.py. - """ - # Don't bother to check if '.' is in name since any name will be prefixed - # with some template root. - path, module = name.rsplit('.', 1) - try: - package = import_module(path) - return not module_has_submodule(package, module) - except ImportError: - return is_library_missing(path) - - -def import_library(taglib_module): - """ - Load a template tag library module. - - Verifies that the library contains a 'register' attribute, and - returns that attribute as the representation of the library - """ - try: - mod = import_module(taglib_module) - except ImportError as e: - # If the ImportError is because the taglib submodule does not exist, - # that's not an error that should be raised. If the submodule exists - # and raised an ImportError on the attempt to load it, that we want - # to raise. - if is_library_missing(taglib_module): - return None - else: - raise InvalidTemplateLibrary("ImportError raised loading %s: %s" % - (taglib_module, e)) - try: - return mod.register - except AttributeError: - raise InvalidTemplateLibrary("Template library %s does not have " - "a variable named 'register'" % - taglib_module) - - -@lru_cache.lru_cache() -def get_templatetags_modules(): - """ - Return the list of all available template tag modules. - - Caches the result for faster access. - """ - templatetags_modules_candidates = ['django.templatetags'] - templatetags_modules_candidates.extend( - '%s.templatetags' % app_config.name - for app_config in apps.get_app_configs()) - - templatetags_modules = [] - for templatetag_module in templatetags_modules_candidates: - try: - import_module(templatetag_module) - except ImportError: - continue - else: - templatetags_modules.append(templatetag_module) - return templatetags_modules - - -def get_library(library_name): - """ - Load the template library module with the given name. - - If library is not already loaded loop over all templatetags modules - to locate it. - - {% load somelib %} and {% load someotherlib %} loops twice. - - Subsequent loads eg. {% load somelib %} in the same process will grab - the cached module from libraries. - """ - lib = libraries.get(library_name) - if not lib: - templatetags_modules = get_templatetags_modules() - tried_modules = [] - for module in templatetags_modules: - taglib_module = '%s.%s' % (module, library_name) - tried_modules.append(taglib_module) - lib = import_library(taglib_module) - if lib: - libraries[library_name] = lib - break - if not lib: - raise InvalidTemplateLibrary("Template library %s not found, " - "tried %s" % - (library_name, - ','.join(tried_modules))) - return lib - - -def add_to_builtins(module): - builtins.append(import_library(module)) - - -add_to_builtins('django.template.defaulttags') -add_to_builtins('django.template.defaultfilters') -add_to_builtins('django.template.loader_tags') diff --git a/django/template/defaultfilters.py b/django/template/defaultfilters.py index 7073bfaf4f..0ab957f056 100644 --- a/django/template/defaultfilters.py +++ b/django/template/defaultfilters.py @@ -25,7 +25,8 @@ from django.utils.text import ( from django.utils.timesince import timesince, timeuntil from django.utils.translation import ugettext, ungettext -from .base import Library, Variable, VariableDoesNotExist +from .base import Variable, VariableDoesNotExist +from .library import Library register = Library() diff --git a/django/template/defaulttags.py b/django/template/defaulttags.py index 54d0d5d449..fd64b49d13 100644 --- a/django/template/defaulttags.py +++ b/django/template/defaulttags.py @@ -19,12 +19,12 @@ from django.utils.safestring import mark_safe from .base import ( BLOCK_TAG_END, BLOCK_TAG_START, COMMENT_TAG_END, COMMENT_TAG_START, SINGLE_BRACE_END, SINGLE_BRACE_START, VARIABLE_ATTRIBUTE_SEPARATOR, - VARIABLE_TAG_END, VARIABLE_TAG_START, Context, InvalidTemplateLibrary, - Library, Node, NodeList, Template, TemplateSyntaxError, - VariableDoesNotExist, get_library, kwarg_re, render_value_in_context, - token_kwargs, + VARIABLE_TAG_END, VARIABLE_TAG_START, Context, Node, NodeList, Template, + TemplateSyntaxError, VariableDoesNotExist, kwarg_re, + render_value_in_context, token_kwargs, ) from .defaultfilters import date +from .library import Library from .smartif import IfParser, Literal register = Library() @@ -1121,10 +1121,43 @@ def ssi(parser, token): return SsiNode(filepath, parsed) +def find_library(parser, name): + try: + return parser.libraries[name] + except KeyError: + raise TemplateSyntaxError( + "'%s' is not a registered tag library. Must be one of:\n%s" % ( + name, "\n".join(sorted(parser.libraries.keys())), + ), + ) + + +def load_from_library(library, label, names): + """ + Return a subset of tags and filters from a library. + """ + subset = Library() + for name in names: + found = False + if name in library.tags: + found = True + subset.tags[name] = library.tags[name] + if name in library.filters: + found = True + subset.filters[name] = library.filters[name] + if found is False: + raise TemplateSyntaxError( + "'%s' is not a valid tag or filter in tag library '%s'" % ( + name, label, + ), + ) + return subset + + @register.tag def load(parser, token): """ - Loads a custom template tag set. + Loads a custom template tag library into the parser. For example, to load the template tags in ``django/templatetags/news/photos.py``:: @@ -1140,35 +1173,16 @@ def load(parser, token): # token.split_contents() isn't useful here because this tag doesn't accept variable as arguments bits = token.contents.split() if len(bits) >= 4 and bits[-2] == "from": - try: - taglib = bits[-1] - lib = get_library(taglib) - except InvalidTemplateLibrary as e: - raise TemplateSyntaxError("'%s' is not a valid tag library: %s" % - (taglib, e)) - else: - temp_lib = Library() - for name in bits[1:-2]: - if name in lib.tags: - temp_lib.tags[name] = lib.tags[name] - # a name could be a tag *and* a filter, so check for both - if name in lib.filters: - temp_lib.filters[name] = lib.filters[name] - elif name in lib.filters: - temp_lib.filters[name] = lib.filters[name] - else: - raise TemplateSyntaxError("'%s' is not a valid tag or filter in tag library '%s'" % - (name, taglib)) - parser.add_library(temp_lib) + # from syntax is used; load individual tags from the library + name = bits[-1] + lib = find_library(parser, name) + subset = load_from_library(lib, name, bits[1:-2]) + parser.add_library(subset) else: - for taglib in bits[1:]: - # add the library to the parser - try: - lib = get_library(taglib) - parser.add_library(lib) - except InvalidTemplateLibrary as e: - raise TemplateSyntaxError("'%s' is not a valid tag library: %s" % - (taglib, e)) + # one or more libraries are specified; load and add them to the parser + for name in bits[1:]: + lib = find_library(parser, name) + parser.add_library(lib) return LoadNode() diff --git a/django/template/engine.py b/django/template/engine.py index 241759229b..e7cd522527 100644 --- a/django/template/engine.py +++ b/django/template/engine.py @@ -9,6 +9,7 @@ from django.utils.module_loading import import_string from .base import Context, Template from .context import _builtin_context_processors from .exceptions import TemplateDoesNotExist +from .library import import_library _context_instance_undefined = object() _dictionary_undefined = object() @@ -16,11 +17,16 @@ _dirs_undefined = object() class Engine(object): + default_builtins = [ + 'django.template.defaulttags', + 'django.template.defaultfilters', + 'django.template.loader_tags', + ] def __init__(self, dirs=None, app_dirs=False, allowed_include_roots=None, context_processors=None, debug=False, loaders=None, string_if_invalid='', - file_charset='utf-8'): + file_charset='utf-8', libraries=None, builtins=None): if dirs is None: dirs = [] if allowed_include_roots is None: @@ -35,6 +41,10 @@ class Engine(object): if app_dirs: raise ImproperlyConfigured( "app_dirs must not be set when loaders is defined.") + if libraries is None: + libraries = {} + if builtins is None: + builtins = [] if isinstance(allowed_include_roots, six.string_types): raise ImproperlyConfigured( @@ -48,6 +58,10 @@ class Engine(object): self.loaders = loaders self.string_if_invalid = string_if_invalid self.file_charset = file_charset + self.libraries = libraries + self.template_libraries = self.get_template_libraries(libraries) + self.builtins = self.default_builtins + builtins + self.template_builtins = self.get_template_builtins(self.builtins) @staticmethod @lru_cache.lru_cache() @@ -90,6 +104,15 @@ class Engine(object): context_processors += tuple(self.context_processors) return tuple(import_string(path) for path in context_processors) + def get_template_builtins(self, builtins): + return [import_library(x) for x in builtins] + + def get_template_libraries(self, libraries): + loaded = {} + for name, path in libraries.items(): + loaded[name] = import_library(path) + return loaded + @cached_property def template_loaders(self): return self.get_template_loaders(self.loaders) diff --git a/django/template/library.py b/django/template/library.py new file mode 100644 index 0000000000..182dacf964 --- /dev/null +++ b/django/template/library.py @@ -0,0 +1,327 @@ +import functools +import warnings +from importlib import import_module +from inspect import getargspec + +from django.utils import six +from django.utils.deprecation import RemovedInDjango21Warning +from django.utils.itercompat import is_iterable + +from .base import Node, Template, token_kwargs +from .exceptions import TemplateSyntaxError + + +class InvalidTemplateLibrary(Exception): + pass + + +class Library(object): + """ + A class for registering template tags and filters. Compiled filter and + template tag functions are stored in the filters and tags attributes. + The filter, simple_tag, and inclusion_tag methods provide a convenient + way to register callables as tags. + """ + def __init__(self): + self.filters = {} + self.tags = {} + + def tag(self, name=None, compile_function=None): + if name is None and compile_function is None: + # @register.tag() + return self.tag_function + elif name is not None and compile_function is None: + if callable(name): + # @register.tag + return self.tag_function(name) + else: + # @register.tag('somename') or @register.tag(name='somename') + def dec(func): + return self.tag(name, func) + return dec + elif name is not None and compile_function is not None: + # register.tag('somename', somefunc) + self.tags[name] = compile_function + return compile_function + else: + raise ValueError( + "Unsupported arguments to Library.tag: (%r, %r)" % + (name, compile_function), + ) + + def tag_function(self, func): + self.tags[getattr(func, "_decorated_function", func).__name__] = func + return func + + def filter(self, name=None, filter_func=None, **flags): + """ + Register a callable as a template filter. Example: + + @register.filter + def lower(value): + return value.lower() + """ + if name is None and filter_func is None: + # @register.filter() + def dec(func): + return self.filter_function(func, **flags) + return dec + elif name is not None and filter_func is None: + if callable(name): + # @register.filter + return self.filter_function(name, **flags) + else: + # @register.filter('somename') or @register.filter(name='somename') + def dec(func): + return self.filter(name, func, **flags) + return dec + elif name is not None and filter_func is not None: + # register.filter('somename', somefunc) + self.filters[name] = filter_func + for attr in ('expects_localtime', 'is_safe', 'needs_autoescape'): + if attr in flags: + value = flags[attr] + # set the flag on the filter for FilterExpression.resolve + setattr(filter_func, attr, value) + # set the flag on the innermost decorated function + # for decorators that need it, e.g. stringfilter + if hasattr(filter_func, "_decorated_function"): + setattr(filter_func._decorated_function, attr, value) + filter_func._filter_name = name + return filter_func + else: + raise ValueError( + "Unsupported arguments to Library.filter: (%r, %r)" % + (name, filter_func), + ) + + def filter_function(self, func, **flags): + name = getattr(func, "_decorated_function", func).__name__ + return self.filter(name, func, **flags) + + def simple_tag(self, func=None, takes_context=None, name=None): + """ + Register a callable as a compiled template tag. Example: + + @register.simple_tag + def hello(*args, **kwargs): + return 'world' + """ + def dec(func): + params, varargs, varkw, defaults = getargspec(func) + function_name = (name or getattr(func, '_decorated_function', func).__name__) + + @functools.wraps(func) + def compile_func(parser, token): + bits = token.split_contents()[1:] + target_var = None + if len(bits) >= 2 and bits[-2] == 'as': + target_var = bits[-1] + bits = bits[:-2] + args, kwargs = parse_bits(parser, bits, params, + varargs, varkw, defaults, takes_context, function_name) + return SimpleNode(func, takes_context, args, kwargs, target_var) + self.tag(function_name, compile_func) + return func + + if func is None: + # @register.simple_tag(...) + return dec + elif callable(func): + # @register.simple_tag + return dec(func) + else: + raise ValueError("Invalid arguments provided to simple_tag") + + def assignment_tag(self, func=None, takes_context=None, name=None): + warnings.warn( + "assignment_tag() is deprecated. Use simple_tag() instead", + RemovedInDjango21Warning, + stacklevel=2, + ) + return self.simple_tag(func, takes_context, name) + + def inclusion_tag(self, filename, func=None, takes_context=None, name=None): + """ + Register a callable as an inclusion tag: + + @register.inclusion_tag('results.html') + def show_results(poll): + choices = poll.choice_set.all() + return {'choices': choices} + """ + def dec(func): + params, varargs, varkw, defaults = getargspec(func) + function_name = (name or getattr(func, '_decorated_function', func).__name__) + + @functools.wraps(func) + def compile_func(parser, token): + bits = token.split_contents()[1:] + args, kwargs = parse_bits( + parser, bits, params, varargs, varkw, defaults, + takes_context, function_name, + ) + return InclusionNode( + func, takes_context, args, kwargs, filename, + ) + self.tag(function_name, compile_func) + return func + return dec + + +class TagHelperNode(Node): + """ + Base class for tag helper nodes such as SimpleNode and InclusionNode. + Manages the positional and keyword arguments to be passed to the decorated + function. + """ + def __init__(self, func, takes_context, args, kwargs): + self.func = func + self.takes_context = takes_context + self.args = args + self.kwargs = kwargs + + def get_resolved_arguments(self, context): + resolved_args = [var.resolve(context) for var in self.args] + if self.takes_context: + resolved_args = [context] + resolved_args + resolved_kwargs = {k: v.resolve(context) for k, v in self.kwargs.items()} + return resolved_args, resolved_kwargs + + +class SimpleNode(TagHelperNode): + + def __init__(self, func, takes_context, args, kwargs, target_var): + super(SimpleNode, self).__init__(func, takes_context, args, kwargs) + self.target_var = target_var + + def render(self, context): + resolved_args, resolved_kwargs = self.get_resolved_arguments(context) + output = self.func(*resolved_args, **resolved_kwargs) + if self.target_var is not None: + context[self.target_var] = output + return '' + return output + + +class InclusionNode(TagHelperNode): + + def __init__(self, func, takes_context, args, kwargs, filename): + super(InclusionNode, self).__init__(func, takes_context, args, kwargs) + self.filename = filename + + def render(self, context): + """ + Render the specified template and context. Cache the template object + in render_context to avoid reparsing and loading when used in a for + loop. + """ + resolved_args, resolved_kwargs = self.get_resolved_arguments(context) + _dict = self.func(*resolved_args, **resolved_kwargs) + + t = context.render_context.get(self) + if t is None: + if isinstance(self.filename, Template): + t = self.filename + elif isinstance(getattr(self.filename, 'template', None), Template): + t = self.filename.template + elif not isinstance(self.filename, six.string_types) and is_iterable(self.filename): + t = context.template.engine.select_template(self.filename) + else: + t = context.template.engine.get_template(self.filename) + context.render_context[self] = t + new_context = context.new(_dict) + # Copy across the CSRF token, if present, because inclusion tags are + # often used for forms, and we need instructions for using CSRF + # protection to be as simple as possible. + csrf_token = context.get('csrf_token') + if csrf_token is not None: + new_context['csrf_token'] = csrf_token + return t.render(new_context) + + +def parse_bits(parser, bits, params, varargs, varkw, defaults, + takes_context, name): + """ + Parse bits for template tag helpers simple_tag and inclusion_tag, in + particular by detecting syntax errors and by extracting positional and + keyword arguments. + """ + if takes_context: + if params[0] == 'context': + params = params[1:] + else: + raise TemplateSyntaxError( + "'%s' is decorated with takes_context=True so it must " + "have a first argument of 'context'" % name) + args = [] + kwargs = {} + unhandled_params = list(params) + for bit in bits: + # First we try to extract a potential kwarg from the bit + kwarg = token_kwargs([bit], parser) + if kwarg: + # The kwarg was successfully extracted + param, value = kwarg.popitem() + if param not in params and varkw is None: + # An unexpected keyword argument was supplied + raise TemplateSyntaxError( + "'%s' received unexpected keyword argument '%s'" % + (name, param)) + elif param in kwargs: + # The keyword argument has already been supplied once + raise TemplateSyntaxError( + "'%s' received multiple values for keyword argument '%s'" % + (name, param)) + else: + # All good, record the keyword argument + kwargs[str(param)] = value + if param in unhandled_params: + # If using the keyword syntax for a positional arg, then + # consume it. + unhandled_params.remove(param) + else: + if kwargs: + raise TemplateSyntaxError( + "'%s' received some positional argument(s) after some " + "keyword argument(s)" % name) + else: + # Record the positional argument + args.append(parser.compile_filter(bit)) + try: + # Consume from the list of expected positional arguments + unhandled_params.pop(0) + except IndexError: + if varargs is None: + raise TemplateSyntaxError( + "'%s' received too many positional arguments" % + name) + if defaults is not None: + # Consider the last n params handled, where n is the + # number of defaults. + unhandled_params = unhandled_params[:-len(defaults)] + if unhandled_params: + # Some positional arguments were not supplied + raise TemplateSyntaxError( + "'%s' did not receive value(s) for the argument(s): %s" % + (name, ", ".join("'%s'" % p for p in unhandled_params))) + return args, kwargs + + +def import_library(name): + """ + Load a Library object from a template tag module. + """ + try: + module = import_module(name) + except ImportError as e: + raise InvalidTemplateLibrary( + "Invalid template library specified. ImportError raised when " + "trying to load '%s': %s" % (name, e) + ) + try: + return module.register + except AttributeError: + raise InvalidTemplateLibrary( + "Module %s does not have a variable named 'register'" % name, + ) diff --git a/django/template/loader_tags.py b/django/template/loader_tags.py index 4c662168e9..de77ecbb7a 100644 --- a/django/template/loader_tags.py +++ b/django/template/loader_tags.py @@ -4,9 +4,9 @@ from django.utils import six from django.utils.safestring import mark_safe from .base import ( - Library, Node, Template, TemplateSyntaxError, TextNode, Variable, - token_kwargs, + Node, Template, TemplateSyntaxError, TextNode, Variable, token_kwargs, ) +from .library import Library register = Library() diff --git a/django/test/signals.py b/django/test/signals.py index a7774adb08..6d8a914ad7 100644 --- a/django/test/signals.py +++ b/django/test/signals.py @@ -35,9 +35,6 @@ def update_installed_apps(**kwargs): # Rebuild management commands cache from django.core.management import get_commands get_commands.cache_clear() - # Rebuild templatetags module cache. - from django.template.base import get_templatetags_modules - get_templatetags_modules.cache_clear() # Rebuild get_app_template_dirs cache. from django.template.utils import get_app_template_dirs get_app_template_dirs.cache_clear() diff --git a/docs/howto/custom-template-tags.txt b/docs/howto/custom-template-tags.txt index ec675aada2..c4e414cdb5 100644 --- a/docs/howto/custom-template-tags.txt +++ b/docs/howto/custom-template-tags.txt @@ -13,9 +13,11 @@ available to your templates using the :ttag:`{% load %}` tag. Code layout ----------- -Custom template tags and filters must live inside a Django app. If they relate -to an existing app it makes sense to bundle them there; otherwise, you should -create a new app to hold them. +The most common place to specify custom template tags and filters is inside +a Django app. If they relate to an existing app, it makes sense to bundle them +there; otherwise, they can be added to a new app. When a Django app is added +to :setting:`INSTALLED_APPS`, any tags it defines in the conventional location +described below are automatically made available to load within templates. The app should contain a ``templatetags`` directory, at the same level as ``models.py``, ``views.py``, etc. If this doesn't already exist, create it - @@ -63,6 +65,15 @@ following:: register = template.Library() +.. versionadded:: 1.9 + +Alternatively, template tag modules can be registered through the +``'libraries'`` argument to +:class:`~django.template.backends.django.DjangoTemplates`. This is useful if +you want to use a different label from the template tag module name when +loading template tags. It also enables you to register tags without installing +an application. + .. admonition:: Behind the scenes For a ton of examples, read the source code for Django's default filters diff --git a/docs/ref/templates/api.txt b/docs/ref/templates/api.txt index 4a9a73f758..6ff2dd6c7b 100644 --- a/docs/ref/templates/api.txt +++ b/docs/ref/templates/api.txt @@ -41,7 +41,7 @@ lower level APIs: Configuring an engine ===================== -.. class:: Engine([dirs][, app_dirs][, allowed_include_roots][, context_processors][, debug][, loaders][, string_if_invalid][, file_charset]) +.. class:: Engine([dirs][, app_dirs][, allowed_include_roots][, context_processors][, debug][, loaders][, string_if_invalid][, file_charset][, libraries][, builtins]) .. versionadded:: 1.8 @@ -114,6 +114,34 @@ Configuring an engine It defaults to ``'utf-8'``. + * ``'libraries'``: A dictionary of labels and dotted Python paths of template + tag modules to register with the template engine. This is used to add new + libraries or provide alternate labels for existing ones. For example:: + + Engine( + libraries={ + 'myapp_tags': 'path.to.myapp.tags', + 'admin.urls': 'django.contrib.admin.templatetags.admin_urls', + }, + ) + + Libraries can be loaded by passing the corresponding dictionary key to + the :ttag:`{% load %}` tag. + + * ``'builtins'``: A list of dotted Python paths of template tag modules to + add to :doc:`built-ins `. For example:: + + Engine( + builtins=['myapp.builtins'], + ) + + Tags and filters from built-in libraries can be used without first calling + the :ttag:`{% load %}` tag. + +.. versionadded:: 1.9 + + The ``libraries`` and ``builtins`` arguments were added. + .. staticmethod:: Engine.get_default() When a Django project configures one and only one diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index 76e8085fbd..1aff56aef3 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -263,6 +263,10 @@ Templates * :ref:`Debug page integration ` for custom template engines was added. +* The :class:`~django.template.backends.django.DjangoTemplates` backend gained + the ability to register libraries and builtins explicitly through the + template :setting:`OPTIONS `. + Requests and Responses ^^^^^^^^^^^^^^^^^^^^^^ @@ -467,6 +471,28 @@ You don't need any of this if you're querying the database through the ORM, even if you're using :meth:`raw() ` queries. The ORM takes care of managing time zone information. +Template tag modules are imported when templates are configured +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :class:`~django.template.backends.django.DjangoTemplates` backend now +performs discovery on installed template tag modules when instantiated. This +update enables libraries to be provided explicitly via the ``'libraries'`` +key of :setting:`OPTIONS ` when defining a +:class:`~django.template.backends.django.DjangoTemplates` backend. Import +or syntax errors in template tag modules now fail early at instantiation time +rather than when a template with a :ttag:`{% load %}` tag is first +compiled. + +``django.template.base.add_to_builtins()`` is removed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Although it was a private API, projects commonly used ``add_to_builtins()`` to +make template tags and filters available without using the +:ttag:`{% load %}` tag. This API has been formalized. Projects should now +define built-in libraries via the ``'builtins'`` key of :setting:`OPTIONS +` when defining a +:class:`~django.template.backends.django.DjangoTemplates` backend. + Miscellaneous ~~~~~~~~~~~~~ diff --git a/docs/topics/templates.txt b/docs/topics/templates.txt index 3810ea7483..66bb8314d6 100644 --- a/docs/topics/templates.txt +++ b/docs/topics/templates.txt @@ -401,6 +401,34 @@ applications. This generic name was kept for backwards-compatibility. It defaults to the value of :setting:`FILE_CHARSET`. +* ``'libraries'``: A dictionary of labels and dotted Python paths of template + tag modules to register with the template engine. This can be used to add + new libraries or provide alternate labels for existing ones. For example:: + + OPTIONS={ + 'libraries': { + 'myapp_tags': 'path.to.myapp.tags', + 'admin.urls': 'django.contrib.admin.templatetags.admin_urls', + }, + } + + Libraries can be loaded by passing the corresponding dictionary key to + the :ttag:`{% load %}` tag. + +* ``'builtins'``: A list of dotted Python paths of template tag modules to + add to :doc:`built-ins `. For example:: + + OPTIONS={ + 'builtins': ['myapp.builtins'], + } + + Tags and filters from built-in libraries can be used without first calling + the :ttag:`{% load %} ` tag. + +.. versionadded:: 1.9 + + The ``libraries`` and ``builtins`` arguments were added. + .. module:: django.template.backends.jinja2 .. class:: Jinja2 diff --git a/tests/template_backends/apps/__init__.py b/tests/template_backends/apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/template_backends/apps/good/__init__.py b/tests/template_backends/apps/good/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/template_backends/apps/good/templatetags/__init__.py b/tests/template_backends/apps/good/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/template_backends/apps/good/templatetags/empty.py b/tests/template_backends/apps/good/templatetags/empty.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/template_backends/apps/good/templatetags/good_tags.py b/tests/template_backends/apps/good/templatetags/good_tags.py new file mode 100644 index 0000000000..9bec93d8e5 --- /dev/null +++ b/tests/template_backends/apps/good/templatetags/good_tags.py @@ -0,0 +1,3 @@ +from django.template import Library + +register = Library() diff --git a/tests/template_backends/apps/good/templatetags/override.py b/tests/template_backends/apps/good/templatetags/override.py new file mode 100644 index 0000000000..9bec93d8e5 --- /dev/null +++ b/tests/template_backends/apps/good/templatetags/override.py @@ -0,0 +1,3 @@ +from django.template import Library + +register = Library() diff --git a/tests/template_backends/apps/good/templatetags/subpackage/__init__.py b/tests/template_backends/apps/good/templatetags/subpackage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/template_backends/apps/good/templatetags/subpackage/tags.py b/tests/template_backends/apps/good/templatetags/subpackage/tags.py new file mode 100644 index 0000000000..9bec93d8e5 --- /dev/null +++ b/tests/template_backends/apps/good/templatetags/subpackage/tags.py @@ -0,0 +1,3 @@ +from django.template import Library + +register = Library() diff --git a/tests/template_backends/apps/importerror/__init__.py b/tests/template_backends/apps/importerror/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/template_backends/apps/importerror/templatetags/__init__.py b/tests/template_backends/apps/importerror/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/template_backends/apps/importerror/templatetags/broken_tags.py b/tests/template_backends/apps/importerror/templatetags/broken_tags.py new file mode 100644 index 0000000000..4f240e5923 --- /dev/null +++ b/tests/template_backends/apps/importerror/templatetags/broken_tags.py @@ -0,0 +1 @@ +import DoesNotExist # noqa diff --git a/tests/template_backends/test_django.py b/tests/template_backends/test_django.py index a8cc0d58b8..2a21b04b5a 100644 --- a/tests/template_backends/test_django.py +++ b/tests/template_backends/test_django.py @@ -2,7 +2,8 @@ from template_tests.test_response import test_processor_name from django.template import RequestContext from django.template.backends.django import DjangoTemplates -from django.test import RequestFactory, ignore_warnings +from django.template.library import InvalidTemplateLibrary +from django.test import RequestFactory, ignore_warnings, override_settings from django.utils.deprecation import RemovedInDjango20Warning from .test_dummy import TemplateStringsTests @@ -51,3 +52,78 @@ class DjangoTemplatesTests(TemplateStringsTests): "the two arguments refer to the same request.") with self.assertRaisesMessage(ValueError, msg): template.render(request_context, other_request) + + @override_settings(INSTALLED_APPS=['template_backends.apps.good']) + def test_templatetag_discovery(self): + engine = DjangoTemplates({ + 'DIRS': [], + 'APP_DIRS': False, + 'NAME': 'django', + 'OPTIONS': { + 'libraries': { + 'alternate': 'template_backends.apps.good.templatetags.good_tags', + 'override': 'template_backends.apps.good.templatetags.good_tags', + }, + }, + }) + + # libraries are discovered from installed applications + self.assertEqual( + engine.engine.libraries['good_tags'], + 'template_backends.apps.good.templatetags.good_tags', + ) + self.assertEqual( + engine.engine.libraries['subpackage.tags'], + 'template_backends.apps.good.templatetags.subpackage.tags', + ) + # libraries are discovered from django.templatetags + self.assertEqual( + engine.engine.libraries['static'], + 'django.templatetags.static', + ) + # libraries passed in OPTIONS are registered + self.assertEqual( + engine.engine.libraries['alternate'], + 'template_backends.apps.good.templatetags.good_tags', + ) + # libraries passed in OPTIONS take precedence over discovered ones + self.assertEqual( + engine.engine.libraries['override'], + 'template_backends.apps.good.templatetags.good_tags', + ) + + @override_settings(INSTALLED_APPS=['template_backends.apps.importerror']) + def test_templatetag_discovery_import_error(self): + """ + Import errors in tag modules should be reraised with a helpful message. + """ + with self.assertRaisesMessage( + InvalidTemplateLibrary, + "ImportError raised when trying to load " + "'template_backends.apps.importerror.templatetags.broken_tags'" + ): + DjangoTemplates({ + 'DIRS': [], + 'APP_DIRS': False, + 'NAME': 'django', + 'OPTIONS': {}, + }) + + def test_builtins_discovery(self): + engine = DjangoTemplates({ + 'DIRS': [], + 'APP_DIRS': False, + 'NAME': 'django', + 'OPTIONS': { + 'builtins': ['template_backends.apps.good.templatetags.good_tags'], + }, + }) + + self.assertEqual( + engine.engine.builtins, [ + 'django.template.defaulttags', + 'django.template.defaultfilters', + 'django.template.loader_tags', + 'template_backends.apps.good.templatetags.good_tags', + ] + ) diff --git a/tests/template_tests/templatetags/broken_tag.py b/tests/template_tests/broken_tag.py similarity index 100% rename from tests/template_tests/templatetags/broken_tag.py rename to tests/template_tests/broken_tag.py diff --git a/tests/template_tests/syntax_tests/test_cache.py b/tests/template_tests/syntax_tests/test_cache.py index f088510d5d..2039e7808c 100644 --- a/tests/template_tests/syntax_tests/test_cache.py +++ b/tests/template_tests/syntax_tests/test_cache.py @@ -6,6 +6,10 @@ from ..utils import setup class CacheTagTests(SimpleTestCase): + libraries = { + 'cache': 'django.templatetags.cache', + 'custom': 'template_tests.templatetags.custom', + } def tearDown(self): cache.clear() @@ -121,7 +125,7 @@ class CacheTests(SimpleTestCase): @classmethod def setUpClass(cls): - cls.engine = Engine() + cls.engine = Engine(libraries={'cache': 'django.templatetags.cache'}) super(CacheTests, cls).setUpClass() def test_cache_regression_20130(self): diff --git a/tests/template_tests/syntax_tests/test_cycle.py b/tests/template_tests/syntax_tests/test_cycle.py index e2edf94c8d..c478faff52 100644 --- a/tests/template_tests/syntax_tests/test_cycle.py +++ b/tests/template_tests/syntax_tests/test_cycle.py @@ -6,6 +6,7 @@ from ..utils import setup class CycleTagTests(SimpleTestCase): + libraries = {'future': 'django.templatetags.future'} @setup({'cycle01': '{% cycle a %}'}) def test_cycle01(self): diff --git a/tests/template_tests/syntax_tests/test_extends.py b/tests/template_tests/syntax_tests/test_extends.py index 1b0ff36a3c..3e65f4bf90 100644 --- a/tests/template_tests/syntax_tests/test_extends.py +++ b/tests/template_tests/syntax_tests/test_extends.py @@ -56,6 +56,7 @@ inheritance_templates = { class InheritanceTests(SimpleTestCase): + libraries = {'testtags': 'template_tests.templatetags.testtags'} @setup(inheritance_templates) def test_inheritance01(self): diff --git a/tests/template_tests/syntax_tests/test_firstof.py b/tests/template_tests/syntax_tests/test_firstof.py index a3c8c83151..79eb381c69 100644 --- a/tests/template_tests/syntax_tests/test_firstof.py +++ b/tests/template_tests/syntax_tests/test_firstof.py @@ -6,6 +6,7 @@ from ..utils import setup class FirstOfTagTests(SimpleTestCase): + libraries = {'future': 'django.templatetags.future'} @setup({'firstof01': '{% firstof a b c %}'}) def test_firstof01(self): diff --git a/tests/template_tests/syntax_tests/test_for.py b/tests/template_tests/syntax_tests/test_for.py index 566b86f3d8..8045ea4990 100644 --- a/tests/template_tests/syntax_tests/test_for.py +++ b/tests/template_tests/syntax_tests/test_for.py @@ -6,6 +6,7 @@ from ..utils import setup class ForTagTests(SimpleTestCase): + libraries = {'custom': 'template_tests.templatetags.custom'} @setup({'for-tag01': '{% for val in values %}{{ val }}{% endfor %}'}) def test_for_tag01(self): diff --git a/tests/template_tests/syntax_tests/test_i18n.py b/tests/template_tests/syntax_tests/test_i18n.py index da53a8c279..1d0f83478a 100644 --- a/tests/template_tests/syntax_tests/test_i18n.py +++ b/tests/template_tests/syntax_tests/test_i18n.py @@ -10,6 +10,10 @@ from ..utils import setup class I18nTagTests(SimpleTestCase): + libraries = { + 'custom': 'template_tests.templatetags.custom', + 'i18n': 'django.templatetags.i18n', + } @setup({'i18n01': '{% load i18n %}{% trans \'xxxyyyxxx\' %}'}) def test_i18n01(self): diff --git a/tests/template_tests/syntax_tests/test_if_changed.py b/tests/template_tests/syntax_tests/test_if_changed.py index 62f9563020..09cdc34a88 100644 --- a/tests/template_tests/syntax_tests/test_if_changed.py +++ b/tests/template_tests/syntax_tests/test_if_changed.py @@ -5,6 +5,7 @@ from ..utils import setup class IfChangedTagTests(SimpleTestCase): + libraries = {'custom': 'template_tests.templatetags.custom'} @setup({'ifchanged01': '{% for n in num %}{% ifchanged %}{{ n }}{% endifchanged %}{% endfor %}'}) def test_ifchanged01(self): diff --git a/tests/template_tests/syntax_tests/test_include.py b/tests/template_tests/syntax_tests/test_include.py index 5a05b32c33..fc1554a17e 100644 --- a/tests/template_tests/syntax_tests/test_include.py +++ b/tests/template_tests/syntax_tests/test_include.py @@ -13,6 +13,7 @@ include_fail_templates = { class IncludeTagTests(SimpleTestCase): + libraries = {'bad_tag': 'template_tests.templatetags.bad_tag'} @setup({'include01': '{% include "basic-syntax01" %}'}, basic_templates) def test_include01(self): diff --git a/tests/template_tests/syntax_tests/test_invalid_string.py b/tests/template_tests/syntax_tests/test_invalid_string.py index 2bb8c71fd1..18520aa7bf 100644 --- a/tests/template_tests/syntax_tests/test_invalid_string.py +++ b/tests/template_tests/syntax_tests/test_invalid_string.py @@ -4,6 +4,7 @@ from ..utils import setup class InvalidStringTests(SimpleTestCase): + libraries = {'i18n': 'django.templatetags.i18n'} @setup({'invalidstr01': '{{ var|default:"Foo" }}'}) def test_invalidstr01(self): diff --git a/tests/template_tests/syntax_tests/test_load.py b/tests/template_tests/syntax_tests/test_load.py index 711a9da632..919bbf1346 100644 --- a/tests/template_tests/syntax_tests/test_load.py +++ b/tests/template_tests/syntax_tests/test_load.py @@ -5,6 +5,10 @@ from ..utils import setup class LoadTagTests(SimpleTestCase): + libraries = { + 'subpackage.echo': 'template_tests.templatetags.subpackage.echo', + 'testtags': 'template_tests.templatetags.testtags', + } @setup({'load01': '{% load testtags subpackage.echo %}{% echo test %} {% echo2 "test" %}'}) def test_load01(self): @@ -42,30 +46,30 @@ class LoadTagTests(SimpleTestCase): # {% load %} tag errors @setup({'load07': '{% load echo other_echo bad_tag from testtags %}'}) def test_load07(self): - with self.assertRaises(TemplateSyntaxError): + msg = "'bad_tag' is not a valid tag or filter in tag library 'testtags'" + with self.assertRaisesMessage(TemplateSyntaxError, msg): self.engine.get_template('load07') @setup({'load08': '{% load echo other_echo bad_tag from %}'}) def test_load08(self): - with self.assertRaises(TemplateSyntaxError): + msg = "'echo' is not a registered tag library. Must be one of:\nsubpackage.echo\ntesttags" + with self.assertRaisesMessage(TemplateSyntaxError, msg): self.engine.get_template('load08') @setup({'load09': '{% load from testtags %}'}) def test_load09(self): - with self.assertRaises(TemplateSyntaxError): + msg = "'from' is not a registered tag library. Must be one of:\nsubpackage.echo\ntesttags" + with self.assertRaisesMessage(TemplateSyntaxError, msg): self.engine.get_template('load09') @setup({'load10': '{% load echo from bad_library %}'}) def test_load10(self): - with self.assertRaises(TemplateSyntaxError): + msg = "'bad_library' is not a registered tag library. Must be one of:\nsubpackage.echo\ntesttags" + with self.assertRaisesMessage(TemplateSyntaxError, msg): self.engine.get_template('load10') - @setup({'load11': '{% load subpackage.echo_invalid %}'}) - def test_load11(self): - with self.assertRaises(TemplateSyntaxError): - self.engine.get_template('load11') - @setup({'load12': '{% load subpackage.missing %}'}) def test_load12(self): - with self.assertRaises(TemplateSyntaxError): + msg = "'subpackage.missing' is not a registered tag library. Must be one of:\nsubpackage.echo\ntesttags" + with self.assertRaisesMessage(TemplateSyntaxError, msg): self.engine.get_template('load12') diff --git a/tests/template_tests/syntax_tests/test_simple_tag.py b/tests/template_tests/syntax_tests/test_simple_tag.py index b89af4c72b..5e6ce4733e 100644 --- a/tests/template_tests/syntax_tests/test_simple_tag.py +++ b/tests/template_tests/syntax_tests/test_simple_tag.py @@ -5,6 +5,7 @@ from ..utils import setup class SimpleTagTests(SimpleTestCase): + libraries = {'custom': 'template_tests.templatetags.custom'} @setup({'simpletag-renamed01': '{% load custom %}{% minusone 7 %}'}) def test_simpletag_renamed01(self): diff --git a/tests/template_tests/syntax_tests/test_static.py b/tests/template_tests/syntax_tests/test_static.py index b646ac2bb8..8ff90f5a5c 100644 --- a/tests/template_tests/syntax_tests/test_static.py +++ b/tests/template_tests/syntax_tests/test_static.py @@ -7,6 +7,7 @@ from ..utils import setup @override_settings(MEDIA_URL="/media/", STATIC_URL="/static/") class StaticTagTests(SimpleTestCase): + libraries = {'static': 'django.templatetags.static'} @setup({'static-prefixtag01': '{% load static %}{% get_static_prefix %}'}) def test_static_prefixtag01(self): diff --git a/tests/template_tests/syntax_tests/test_width_ratio.py b/tests/template_tests/syntax_tests/test_width_ratio.py index 9799b7d6ab..8206b83c58 100644 --- a/tests/template_tests/syntax_tests/test_width_ratio.py +++ b/tests/template_tests/syntax_tests/test_width_ratio.py @@ -6,6 +6,7 @@ from ..utils import setup class WidthRatioTagTests(SimpleTestCase): + libraries = {'custom': 'template_tests.templatetags.custom'} @setup({'widthratio01': '{% widthratio a b 0 %}'}) def test_widthratio01(self): diff --git a/tests/template_tests/templatetags/subpackage/echo_invalid.py b/tests/template_tests/templatetags/subpackage/echo_invalid.py deleted file mode 100644 index 0764b9c8fa..0000000000 --- a/tests/template_tests/templatetags/subpackage/echo_invalid.py +++ /dev/null @@ -1 +0,0 @@ -import nonexistent.module # NOQA diff --git a/tests/template_tests/templatetags/testtags.py b/tests/template_tests/templatetags/testtags.py new file mode 100644 index 0000000000..41b65a2ed1 --- /dev/null +++ b/tests/template_tests/templatetags/testtags.py @@ -0,0 +1,22 @@ +from django.template import Library, Node + +register = Library() + + +class EchoNode(Node): + def __init__(self, contents): + self.contents = contents + + def render(self, context): + return ' '.join(self.contents) + + +@register.tag +def echo(parser, token): + return EchoNode(token.contents.split()[1:]) +register.tag('other_echo', echo) + + +@register.filter +def upper(value): + return value.upper() diff --git a/tests/template_tests/test_custom.py b/tests/template_tests/test_custom.py index b5c125c61c..11c04f870c 100644 --- a/tests/template_tests/test_custom.py +++ b/tests/template_tests/test_custom.py @@ -4,18 +4,26 @@ import os from django.template import Context, Engine, TemplateSyntaxError from django.template.base import Node +from django.template.library import InvalidTemplateLibrary from django.test import SimpleTestCase, ignore_warnings from django.test.utils import extend_sys_path +from django.utils import six from django.utils.deprecation import RemovedInDjango20Warning from .templatetags import custom, inclusion from .utils import ROOT +LIBRARIES = { + 'custom': 'template_tests.templatetags.custom', + 'inclusion': 'template_tests.templatetags.inclusion', +} + class CustomFilterTests(SimpleTestCase): def test_filter(self): - t = Engine().from_string("{% load custom %}{{ string|trim:5 }}") + engine = Engine(libraries=LIBRARIES) + t = engine.from_string("{% load custom %}{{ string|trim:5 }}") self.assertEqual( t.render(Context({"string": "abcdefghijklmnopqrstuvwxyz"})), "abcde" @@ -26,7 +34,7 @@ class TagTestCase(SimpleTestCase): @classmethod def setUpClass(cls): - cls.engine = Engine(app_dirs=True) + cls.engine = Engine(app_dirs=True, libraries=LIBRARIES) super(TagTestCase, cls).setUpClass() def verify_tag(self, tag, name): @@ -269,7 +277,7 @@ class InclusionTagTests(TagTestCase): """ #23441 -- InclusionNode shouldn't modify its nodelist at render time. """ - engine = Engine(app_dirs=True) + engine = Engine(app_dirs=True, libraries=LIBRARIES) template = engine.from_string('{% load inclusion %}{% inclusion_no_params %}') count = template.nodelist.get_nodes_by_type(Node) template.render(Context({})) @@ -281,7 +289,7 @@ class InclusionTagTests(TagTestCase): when rendering. Otherwise, leftover values such as blocks from extending can interfere with subsequent rendering. """ - engine = Engine(app_dirs=True) + engine = Engine(app_dirs=True, libraries=LIBRARIES) template = engine.from_string('{% load inclusion %}{% inclusion_extends1 %}{% inclusion_extends2 %}') self.assertEqual(template.render(Context({})).strip(), 'one\ntwo') @@ -313,34 +321,37 @@ class TemplateTagLoadingTests(SimpleTestCase): @classmethod def setUpClass(cls): cls.egg_dir = os.path.join(ROOT, 'eggs') - cls.engine = Engine() super(TemplateTagLoadingTests, cls).setUpClass() def test_load_error(self): - ttext = "{% load broken_tag %}" - with self.assertRaises(TemplateSyntaxError) as e: - self.engine.from_string(ttext) - - self.assertIn('ImportError', e.exception.args[0]) - self.assertIn('Xtemplate', e.exception.args[0]) + msg = ( + "Invalid template library specified. ImportError raised when " + "trying to load 'template_tests.broken_tag': cannot import name " + "'?Xtemplate'?" + ) + with six.assertRaisesRegex(self, InvalidTemplateLibrary, msg): + Engine(libraries={ + 'broken_tag': 'template_tests.broken_tag', + }) def test_load_error_egg(self): - ttext = "{% load broken_egg %}" egg_name = '%s/tagsegg.egg' % self.egg_dir + msg = ( + "Invalid template library specified. ImportError raised when " + "trying to load 'tagsegg.templatetags.broken_egg': cannot " + "import name '?Xtemplate'?" + ) with extend_sys_path(egg_name): - with self.assertRaises(TemplateSyntaxError): - with self.settings(INSTALLED_APPS=['tagsegg']): - self.engine.from_string(ttext) - try: - with self.settings(INSTALLED_APPS=['tagsegg']): - self.engine.from_string(ttext) - except TemplateSyntaxError as e: - self.assertIn('ImportError', e.args[0]) - self.assertIn('Xtemplate', e.args[0]) + with six.assertRaisesRegex(self, InvalidTemplateLibrary, msg): + Engine(libraries={ + 'broken_egg': 'tagsegg.templatetags.broken_egg', + }) def test_load_working_egg(self): ttext = "{% load working_egg %}" egg_name = '%s/tagsegg.egg' % self.egg_dir with extend_sys_path(egg_name): - with self.settings(INSTALLED_APPS=['tagsegg']): - self.engine.from_string(ttext) + engine = Engine(libraries={ + 'working_egg': 'tagsegg.templatetags.working_egg', + }) + engine.from_string(ttext) diff --git a/tests/template_tests/test_engine.py b/tests/template_tests/test_engine.py index d9929660cc..a72a430e3e 100644 --- a/tests/template_tests/test_engine.py +++ b/tests/template_tests/test_engine.py @@ -14,7 +14,10 @@ OTHER_DIR = os.path.join(ROOT, 'other_templates') class DeprecatedRenderToStringTest(SimpleTestCase): def setUp(self): - self.engine = Engine(dirs=[TEMPLATE_DIR]) + self.engine = Engine( + dirs=[TEMPLATE_DIR], + libraries={'custom': 'template_tests.templatetags.custom'}, + ) def test_basic_context(self): self.assertEqual( diff --git a/tests/template_tests/test_library.py b/tests/template_tests/test_library.py new file mode 100644 index 0000000000..50a00ca082 --- /dev/null +++ b/tests/template_tests/test_library.py @@ -0,0 +1,132 @@ +from django.template import Library +from django.template.base import Node +from django.test import TestCase + + +class FilterRegistrationTests(TestCase): + + def setUp(self): + self.library = Library() + + def test_filter(self): + @self.library.filter + def func(): + return '' + self.assertEqual(self.library.filters['func'], func) + + def test_filter_parens(self): + @self.library.filter() + def func(): + return '' + self.assertEqual(self.library.filters['func'], func) + + def test_filter_name_arg(self): + @self.library.filter('name') + def func(): + return '' + self.assertEqual(self.library.filters['name'], func) + + def test_filter_name_kwarg(self): + @self.library.filter(name='name') + def func(): + return '' + self.assertEqual(self.library.filters['name'], func) + + def test_filter_call(self): + def func(): + return '' + self.library.filter('name', func) + self.assertEqual(self.library.filters['name'], func) + + def test_filter_invalid(self): + msg = "Unsupported arguments to Library.filter: (None, '')" + with self.assertRaisesMessage(ValueError, msg): + self.library.filter(None, '') + + +class InclusionTagRegistrationTests(TestCase): + + def setUp(self): + self.library = Library() + + def test_inclusion_tag(self): + @self.library.inclusion_tag('template.html') + def func(): + return '' + self.assertIn('func', self.library.tags) + + def test_inclusion_tag_name(self): + @self.library.inclusion_tag('template.html', name='name') + def func(): + return '' + self.assertIn('name', self.library.tags) + + +class SimpleTagRegistrationTests(TestCase): + + def setUp(self): + self.library = Library() + + def test_simple_tag(self): + @self.library.simple_tag + def func(): + return '' + self.assertIn('func', self.library.tags) + + def test_simple_tag_parens(self): + @self.library.simple_tag() + def func(): + return '' + self.assertIn('func', self.library.tags) + + def test_simple_tag_name_kwarg(self): + @self.library.simple_tag(name='name') + def func(): + return '' + self.assertIn('name', self.library.tags) + + def test_simple_tag_invalid(self): + msg = "Invalid arguments provided to simple_tag" + with self.assertRaisesMessage(ValueError, msg): + self.library.simple_tag('invalid') + + +class TagRegistrationTests(TestCase): + + def setUp(self): + self.library = Library() + + def test_tag(self): + @self.library.tag + def func(parser, token): + return Node() + self.assertEqual(self.library.tags['func'], func) + + def test_tag_parens(self): + @self.library.tag() + def func(parser, token): + return Node() + self.assertEqual(self.library.tags['func'], func) + + def test_tag_name_arg(self): + @self.library.tag('name') + def func(parser, token): + return Node() + self.assertEqual(self.library.tags['name'], func) + + def test_tag_name_kwarg(self): + @self.library.tag(name='name') + def func(parser, token): + return Node() + self.assertEqual(self.library.tags['name'], func) + + def test_tag_call(self): + def func(parser, token): + return Node() + self.library.tag('name', func) + self.assertEqual(self.library.tags['name'], func) + + def test_tag_invalid(self): + msg = "Unsupported arguments to Library.tag: (None, '')" + with self.assertRaisesMessage(ValueError, msg): + self.library.tag(None, '') diff --git a/tests/template_tests/test_nodelist.py b/tests/template_tests/test_nodelist.py index 3f28aff92c..3070c9d68a 100644 --- a/tests/template_tests/test_nodelist.py +++ b/tests/template_tests/test_nodelist.py @@ -49,7 +49,7 @@ class ErrorIndexTest(TestCase): 'range': range(5), 'five': 5, }) - engine = Engine(debug=True) + engine = Engine(debug=True, libraries={'bad_tag': 'template_tests.templatetags.bad_tag'}) for source, expected_error_source_index in tests: template = engine.from_string(source) try: diff --git a/tests/template_tests/test_parser.py b/tests/template_tests/test_parser.py index dd22c83836..6ff57c2cf5 100644 --- a/tests/template_tests/test_parser.py +++ b/tests/template_tests/test_parser.py @@ -9,6 +9,7 @@ from django.template import Library, TemplateSyntaxError from django.template.base import ( TOKEN_BLOCK, FilterExpression, Parser, Token, Variable, ) +from django.template.defaultfilters import register as filter_library from django.utils import six @@ -24,7 +25,7 @@ class ParserTests(TestCase): def test_filter_parsing(self): c = {"article": {"section": "News"}} - p = Parser("") + p = Parser("", builtins=[filter_library]) def fe_test(s, val): self.assertEqual(FilterExpression(s, p).resolve(c), val) diff --git a/tests/template_tests/tests.py b/tests/template_tests/tests.py index 436502305e..39f0b427d6 100644 --- a/tests/template_tests/tests.py +++ b/tests/template_tests/tests.py @@ -97,7 +97,10 @@ class TemplateTests(SimpleTestCase): Errors raised while compiling nodes should include the token information. """ - engine = Engine(debug=True) + engine = Engine( + debug=True, + libraries={'bad_tag': 'template_tests.templatetags.bad_tag'}, + ) with self.assertRaises(RuntimeError) as e: engine.from_string("{% load bad_tag %}{% badtag %}") self.assertEqual(e.exception.template_debug['during'], '{% badtag %}') diff --git a/tests/template_tests/utils.py b/tests/template_tests/utils.py index 36f4d95678..d78f0eac6c 100644 --- a/tests/template_tests/utils.py +++ b/tests/template_tests/utils.py @@ -5,9 +5,6 @@ from __future__ import unicode_literals import functools import os -from django import template -from django.template import Library -from django.template.base import libraries from django.template.engine import Engine from django.test.utils import override_settings from django.utils._os import upath @@ -49,14 +46,17 @@ def setup(templates, *args, **kwargs): ] def decorator(func): - @register_test_tags # Make Engine.get_default() raise an exception to ensure that tests # are properly isolated from Django's global settings. @override_settings(TEMPLATES=None) @functools.wraps(func) def inner(self): + # Set up custom template tag libraries if specified + libraries = getattr(self, 'libraries', {}) + self.engine = Engine( allowed_include_roots=[ROOT], + libraries=libraries, loaders=loaders, ) func(self) @@ -66,6 +66,7 @@ def setup(templates, *args, **kwargs): self.engine = Engine( allowed_include_roots=[ROOT], + libraries=libraries, loaders=loaders, string_if_invalid='INVALID', ) @@ -75,6 +76,7 @@ def setup(templates, *args, **kwargs): self.engine = Engine( allowed_include_roots=[ROOT], debug=True, + libraries=libraries, loaders=loaders, ) func(self) @@ -85,43 +87,9 @@ def setup(templates, *args, **kwargs): return decorator -# Custom template tag for tests - -register = Library() - - -class EchoNode(template.Node): - def __init__(self, contents): - self.contents = contents - - def render(self, context): - return ' '.join(self.contents) - - -@register.tag -def echo(parser, token): - return EchoNode(token.contents.split()[1:]) -register.tag('other_echo', echo) - - -@register.filter -def upper(value): - return value.upper() - - -def register_test_tags(func): - @functools.wraps(func) - def inner(self): - libraries['testtags'] = register - try: - func(self) - finally: - del libraries['testtags'] - return inner - - # Helper objects + class SomeException(Exception): silent_variable_failure = True