More fun with Jinja2 templates

When last I left this discussion, I was advocating using Python 3 dataclasses to wrap Jinja2 templates. I had another idea and a chance to experiment with it, and I was reasonably happy with the results.

Can the dataclass corresponding to the Jinja2 template be used by the test suite to check that all required parameters for a template are present in the dataclass?

The answer is mostly yes, although unfortunately there are some substantial caveats because Jinja2 doesn't provide all of the tools that one would like to analyze parsed templates.

The basic idea is to ask Jinja2 for all the variables used in a template. It provides a function for doing this:

from jinja2.meta import find_undeclared_variables

def get_template_variables(environment: Environment, name: str) -> Set[str]:
    source = environment.loader.get_source(environment, name)[0]
    ast = environment.parse(source)
    return find_undeclared_variables(ast)

Then, use the dataclass introspection features to see what variables it defines, and compare that to the variables used in the template:

from dataclasses import fields

template_fields = fields(template_class)
expected: Set[str] = set()
for template_field in template_fields:
    if template_field.name == "template":
        template = template_field.default
    else:
        expected.add(template_field.name)
assert template

wanted = get_template_variables(environment, template)
assert expected == wanted, f"fields for {template}"

This uses the convention (defined in the previous post) that the name of the template is stored in an InitVar field of the dataclass named template as the default value.

Then, this can be turned into a test by putting it in a for loop:

for template_class in BaseTemplate.__subclasses__():

This works and diagnoses template variables that aren't defined by the dataclass, catching problems that otherwise would have to rely on test coverage and allowing more confidence in enabling the Jinja2 StrictUndefined option for all your templates if (like this project) you made the mistake of not starting that way.

In my actual code, I pass in a set of variables into every template in addition to the ones in the dataclass. If you also use that (common) pattern, the easiest way to handle it is to have the test include a set of names of variables that are always set and remove those from the result of get_template_variables before comparing it against the expected list.

There are just a few problems, all of which I think are bugs in Jinja2 (although I've not yet written them up properly):

  1. find_undeclared_variables does not understand macro inclusion (such as from 'macros.html' import dropdown). All the macros are returned as undeclared variables. I tried various approaches to fix this, such as concatenating the files that define macros together with the files that use them before doing the parse, none of which worked. I had to list all the macros and exclude them from the returned template variables to work around this.

  2. The approach above only parses a file in isolation, so it doesn't expand include directives and doesn't support extends. The latter isn't much of a problem for this code base because there is only one level of extends, and the base template only uses default variables. I worked around the lack of support for include by inlining the previously-included templates, which is a bit irritating from a code reuse standpoint.

These are unfortunate problems and make this technique a bit less clean than it otherwise would be, but they're relatively minor. I think it's worth it for being able to test the consistency between dataclass wrappers and underlying templates. Hopefully Jinja2 will provide some better utility functions for doing this sort of testing in the future.

Posted: 2019-12-09 20:42 — Why no comments?

Last spun 2022-02-06 from thread modified 2019-12-10