diff --git a/django/template/library.py b/django/template/library.py index c319a04340..f88c2af822 100644 --- a/django/template/library.py +++ b/django/template/library.py @@ -106,7 +106,7 @@ class Library: return 'world' """ def dec(func): - params, varargs, varkw, defaults, _, _, _ = getfullargspec(func) + params, varargs, varkw, defaults, kwonly, kwonly_defaults, _ = getfullargspec(func) function_name = (name or getattr(func, '_decorated_function', func).__name__) @functools.wraps(func) @@ -118,7 +118,7 @@ class Library: bits = bits[:-2] args, kwargs = parse_bits( parser, bits, params, varargs, varkw, defaults, - takes_context, function_name + kwonly, kwonly_defaults, takes_context, function_name, ) return SimpleNode(func, takes_context, args, kwargs, target_var) self.tag(function_name, compile_func) @@ -143,7 +143,7 @@ class Library: return {'choices': choices} """ def dec(func): - params, varargs, varkw, defaults, _, _, _ = getfullargspec(func) + params, varargs, varkw, defaults, kwonly, kwonly_defaults, _ = getfullargspec(func) function_name = (name or getattr(func, '_decorated_function', func).__name__) @functools.wraps(func) @@ -151,7 +151,7 @@ class Library: bits = token.split_contents()[1:] args, kwargs = parse_bits( parser, bits, params, varargs, varkw, defaults, - takes_context, function_name, + kwonly, kwonly_defaults, takes_context, function_name, ) return InclusionNode( func, takes_context, args, kwargs, filename, @@ -235,7 +235,7 @@ class InclusionNode(TagHelperNode): def parse_bits(parser, bits, params, varargs, varkw, defaults, - takes_context, name): + kwonly, kwonly_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 @@ -251,13 +251,17 @@ def parse_bits(parser, bits, params, varargs, varkw, defaults, args = [] kwargs = {} unhandled_params = list(params) + unhandled_kwargs = [ + kwarg for kwarg in kwonly + if not kwonly_defaults or kwarg not in kwonly_defaults + ] 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: + if param not in params and param not in unhandled_kwargs and varkw is None: # An unexpected keyword argument was supplied raise TemplateSyntaxError( "'%s' received unexpected keyword argument '%s'" % @@ -274,6 +278,9 @@ def parse_bits(parser, bits, params, varargs, varkw, defaults, # If using the keyword syntax for a positional arg, then # consume it. unhandled_params.remove(param) + elif param in unhandled_kwargs: + # Same for keyword-only arguments + unhandled_kwargs.remove(param) else: if kwargs: raise TemplateSyntaxError( @@ -294,11 +301,11 @@ def parse_bits(parser, bits, params, varargs, varkw, defaults, # Consider the last n params handled, where n is the # number of defaults. unhandled_params = unhandled_params[:-len(defaults)] - if unhandled_params: + if unhandled_params or unhandled_kwargs: # 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))) + (name, ", ".join("'%s'" % p for p in unhandled_params + unhandled_kwargs))) return args, kwargs diff --git a/docs/releases/2.0.txt b/docs/releases/2.0.txt index 450d8bd3da..323b51fbf4 100644 --- a/docs/releases/2.0.txt +++ b/docs/releases/2.0.txt @@ -212,6 +212,8 @@ Templates apps, it now returns the first engine if multiple ``DjangoTemplates`` engines are configured in ``TEMPLATES`` rather than raising ``ImproperlyConfigured``. +* Custom template tags may now accept keyword-only arguments. + Tests ~~~~~ diff --git a/tests/template_tests/templatetags/custom.py b/tests/template_tests/templatetags/custom.py index 7c40bc9cea..2e2ccf3782 100644 --- a/tests/template_tests/templatetags/custom.py +++ b/tests/template_tests/templatetags/custom.py @@ -80,6 +80,16 @@ def simple_two_params(one, two): simple_two_params.anything = "Expected simple_two_params __dict__" +@register.simple_tag +def simple_keyword_only_param(*, kwarg): + return "simple_keyword_only_param - Expected result: %s" % kwarg + + +@register.simple_tag +def simple_keyword_only_default(*, kwarg=42): + return "simple_keyword_only_default - Expected result: %s" % kwarg + + @register.simple_tag def simple_one_default(one, two='hi'): """Expected simple_one_default __doc__""" diff --git a/tests/template_tests/test_custom.py b/tests/template_tests/test_custom.py index 5b788468b5..b37b5f8f1e 100644 --- a/tests/template_tests/test_custom.py +++ b/tests/template_tests/test_custom.py @@ -53,6 +53,10 @@ class SimpleTagTests(TagTestCase): ('{% load custom %}{% params_and_context 37 %}', 'params_and_context - Expected result (context value: 42): 37'), ('{% load custom %}{% simple_two_params 37 42 %}', 'simple_two_params - Expected result: 37, 42'), + ('{% load custom %}{% simple_keyword_only_param kwarg=37 %}', + 'simple_keyword_only_param - Expected result: 37'), + ('{% load custom %}{% simple_keyword_only_default %}', + 'simple_keyword_only_default - Expected result: 42'), ('{% load custom %}{% simple_one_default 37 %}', 'simple_one_default - Expected result: 37, hi'), ('{% load custom %}{% simple_one_default 37 two="hello" %}', 'simple_one_default - Expected result: 37, hello'), @@ -86,6 +90,8 @@ class SimpleTagTests(TagTestCase): '{% load custom %}{% simple_two_params 37 42 56 %}'), ("'simple_one_default' received too many positional arguments", '{% load custom %}{% simple_one_default 37 42 56 %}'), + ("'simple_keyword_only_param' did not receive value(s) for the argument(s): 'kwarg'", + '{% load custom %}{% simple_keyword_only_param %}'), ("'simple_unlimited_args_kwargs' received some positional argument(s) after some keyword argument(s)", '{% load custom %}{% simple_unlimited_args_kwargs 37 40|add:2 eggs="scrambled" 56 four=1|add:3 %}'), ("'simple_unlimited_args_kwargs' received multiple values for keyword argument 'eggs'",