import builtins import os.path as path import re from collections.abc import Sized import jinja2 import jinja2.ext from jinja2.lexer import Token from markupsafe import Markup def njk_to_j2(template): # Some component templates (such as radios) use `items` as the key of # an object element. However `items` is also the name of a dictionary # method in Python, and Jinja2 will prefer to return this attribute # over the dict item. Handle specially. template = re.sub(r"\.items\b", ".items__njk", template) # Some component templates (such as radios) append the loop index to a # string. As the loop index is an integer this causes a TypeError in # Python. Jinja2 has an operator `~` for string concatenation that # converts integers to strings. template = template.replace("+ loop.index", "~ loop.index") # The Character Count component in version 3 concatenates the word count # with the hint text. As the word count is an integer this causes a # TypeError in Python. Jinja2 has an operator `~` for string # concatenation that converts integers to strings. template = template.replace( "+ (params.maxlength or params.maxwords) +", "~ (params.maxlength or params.maxwords) ~", ) # Nunjucks uses elseif, Jinja uses elif template = template.replace("elseif", "elif") # Some component templates (such as input) call macros with params as # an object which has unqoted keys. This causes Jinja to silently # ignore the values. template = re.sub( r"""^([ ]*)([^ '"#\r\n:]+?)\s*:""", r"\1'\2':", template, flags=re.M ) # govukFieldset can accept a call block argument, however the Jinja # compiler does not detect this as the macro body is included from # the template file. A workaround is to patch the declaration of the # macro to include an explicit caller argument. template = template.replace( "macro govukFieldset(params)", "macro govukFieldset(params, caller=none)" ) # Many components feature an attributes field, which is supposed to be # a dictionary. In the template for these components, the keys and values # are iterated. In Python, the default iterator for a dict is .keys(), but # we want .items(). # This only works because our undefined implements .items() # We've tested this explicitly with: govukInput, govukCheckbox, govukTable, # govukSummaryList template = re.sub( r"for attribute, value in (params|item|cell|action).attributes", r"for attribute, value in \1.attributes.items()", template, flags=re.M, ) # Some templates try to set a variable in an outer block, which is not # supported in Jinja. We create a namespace in those templates to get # around this. template = re.sub( r"""^([ ]*)({% set describedBy =( params.*describedBy if params.*describedBy else)? "" %})""", r"\1{%- set nonlocal = namespace() -%}\n\1\2", template, flags=re.M, ) # Change any references to describedBy to be nonlocal.describedBy, # unless describedBy is a dictionary key (i.e. quoted or dotted). template = re.sub(r"""(?>> foo = ChainableUndefined(name='foo') >>> str(foo.bar['baz']) '' >>> foo.bar['baz'] + 42 Traceback (most recent call last): ... jinja2.exceptions.UndefinedError: 'foo' is undefined """ return self __getitem__ = __getattr__ # Allow treating undefined as an (empty) dictionary. # This works because Undefined is an iterable. def items(self): return self # Allow escaping. This is required when # autoescape is enabled. Debugging this issue was # annoying; the error messages were not clear as to # the cause of the issue (see upstream pull request # for info https://github.com/pallets/jinja/pull/1047) def __html__(self): return str(self) # attempt to behave a bit like js's `undefined` when concatenation is attempted def __add__(self, other): if isinstance(other, str): return "undefined" + other return super().__add__(other) def __radd__(self, other): if isinstance(other, str): return other + "undefined" return super().__radd__(other) class NunjucksCodeGenerator(jinja2.compiler.CodeGenerator): def visit_CondExpr(self, node, frame): if not (self.filename or "").endswith(".njk"): return super().visit_CondExpr(node, frame) # else our replacement, which is based on that in # https://github.com/pallets/jinja/blob/c4c4088945a2c12535f539be7f5453b9ca94666c/jinja2/compiler.py#L1613 def write_expr2(): if node.expr2 is not None: return self.visit(node.expr2, frame) # rather than complaining about a missing else # clause we just assume it to be the empty # string for nunjucks compatibility return self.write('""') self.write("(") self.visit(node.expr1, frame) self.write(" if ") self.visit(node.test, frame) self.write(" else ") write_expr2() self.write(")") _njk_signature = "__njk" _builtin_function_or_method_type = type({}.keys) class Environment(jinja2.Environment): code_generator_class = NunjucksCodeGenerator def __init__(self, *args, **kwargs): kwargs.setdefault("extensions", [NunjucksExtension]) kwargs.setdefault("undefined", NunjucksUndefined) super().__init__(*args, **kwargs) self.filters["indent_njk"] = indent_njk def join_path(self, template, parent): """Enable the use of relative paths in template import statements""" if template.startswith(("./", "../")): return path.normpath(path.join(path.dirname(parent), template)) else: return template def _handle_njk(method_name): def inner(self, obj, argument): if isinstance(argument, str) and argument.endswith(_njk_signature): # a njk-originated access will always be assuming a dict lookup before an attr final_method_name = "getitem" final_argument = argument[: -len(_njk_signature)] else: final_argument = argument final_method_name = method_name # pleasantly surprised that super() works in this context retval = builtins.getattr(super(), final_method_name)(obj, final_argument) if ( argument == f"length{_njk_signature}" and isinstance(retval, jinja2.runtime.Undefined) and isinstance(obj, Sized) ): return len(obj) if ( isinstance(argument, str) and argument.endswith(_njk_signature) and isinstance(retval, _builtin_function_or_method_type) ): # the lookup has probably gone looking for attributes and found a builtin method. because # any njk-originated lookup will have been made to prefer dict lookups over attributes, we # can be fairly sure there isn't a dict key matching this - so we should just call this a # failure. return self.undefined(obj=obj, name=final_argument) return retval return inner getitem = _handle_njk("getitem") getattr = _handle_njk("getattr")