moth
moth

Reputation: 467

How to get ALL undefined variables from a Jinja2 template?

I am trying to get all undefined variables from a Jinja2 template. Assume that I have a template like below.

tmpstr = """
{% for row in csv %}
sample {{row.field1}} stuff {{row.field2}} morestuff {{row.field3}}
{% endfor %}
"""

and input dictionary as below

cxt = {'csv': [
    {'field3': 1234, 'field4': 12314},
    {'field3': 2222, 'field4': 1213}
]}

Here is how I try to render it.

env = Environment(undefined=Undefined)
tmp = env.from_string(tmpstr)
tmpsrc = tmp.render(cxt)
print(tmpsrc)

Template expect variables field1, field2 and field3 to be present. However field1 and field2 are not present. My aim is to find all missing variables.

Jinja2 silently ignores missing variables. Therefore I tried to add StrictUndefined option:

errs = []
try:
    env = Environment(undefined=StrictUndefined)
    tmp = env.from_string(tmpstr)
    tmpsrc = tmp.render(cxt)
except Exception as e:
    errs.append(str(e))
print(errs)

However this time jinja2 complains about only the first missing variable which is field1.

Therefore I tried another option which is DebugUndefined. This option does not raise an exception and leaves missing variables placeholder in the template output untouched. Thus I can not collect missing variables.

Can you please suggest how can I get missing variables in a jinja2 template?

Here is runnable code if anyone wants to try it out:

from jinja2 import BaseLoader,Environment,StrictUndefined,DebugUndefined,Undefined
tmpstr = """
{% for row in csv %}
sample {{row.field1}} stuff {{row.field2}} morestuff {{row.field3}}
{% endfor %}
"""
cxt = {'csv': [
    {'field3': 1234, 'field4': 12314},
    {'field3': 2222, 'field4': 1213}
]}
env = Environment(undefined=Undefined)
tmp = env.from_string(tmpstr)
tmpsrc = tmp.render(cxt)
print('CASE 1: undefined=Undefined')
print(tmpsrc)

errs = []
try:
    env = Environment(undefined=StrictUndefined)
    tmp = env.from_string(tmpstr)
    tmpsrc = tmp.render(cxt)
except Exception as e:
    errs.append(str(e))
print('CASE 2: undefined=StrictUndefined')
print(errs)

errs = []
try:
    env = Environment(undefined=DebugUndefined)
    tmp = env.from_string(tmpstr)
    tmpsrc = tmp.render(cxt)
except Exception as e:
    errs.append(str(e))

print('CASE 3: undefined=DebugUndefined')
print(errs)
print(tmpsrc)

Upvotes: 12

Views: 14408

Answers (6)

Derlin
Derlin

Reputation: 9881

Here is an easy piece of code tested with Jinja2 version 3.1.2 (inspired from @yahelck's answer):

from jinja2 import Environment, Undefined


def create_collector():
    collected_variables = set()

    class CollectUndefined(Undefined):
        def __init__(self, name, parent=None):
            self.name = name
            self.parent = parent
            collected_variables.add(str(self))

        def __str__(self):
            if self.parent is not None:
                return f"{self.parent}.{self.name}"
            return self.name

        def __getattr__(self, name: str):
            return CollectUndefined(name, parent=self)

    return collected_variables, CollectUndefined


def find_all_vars(template_content):
    vars, undefined_cls = create_collector()
    env = Environment(undefined=undefined_cls)
    tpl = env.from_string(template_content)
    tpl.render({})  # empty so all variables are undefined

    return vars

Here is an example on how to use it:

template_content = "{{ foo.bar.buzz }} {% if x.y %}{{ a.b }}{% endif %}"
print(find_all_vars(template_content))

The result will be:

{'foo.bar.buzz', 'x.y', 'x', 'foo.bar', 'foo'}

UPDATE: if you want a "nested" output, assuming your have pyyaml installed, here is a slightly different version that will output the yaml file you need to fill out:

from jinja2 import Environment, Undefined
import yaml


def create_collector():
    collected_variables = {}

    class CollectUndefined(Undefined):
        def __init__(self, name, parent=None):
            self.name = name
            self.parent = parent
            self.register()

        def __str__(self):
            if self.parent is not None:
                return f"{self.parent}.{self.name}"
            return self.name

        def register(self):
            d = (
                collected_variables
                if self.parent is None
                else self.parent.register()
            )
            d[self.name] = d.get(self.name, {})
            return d[self.name]

        def __getattr__(self, name: str):
            return CollectUndefined(name, parent=self)

    return collected_variables, CollectUndefined


def find_all_vars(template_content):
    vars, undefined_cls = create_collector()
    env = Environment(undefined=undefined_cls)
    tpl = env.from_string(template_content)
    tpl.render({})  # empty so all variables are undefined
    return yaml.dump(vars).replace("{}", "<TODO>")


template_content = "{{ foo.bar.buzz }} {% if x.y %}{{ a.b }}{% endif %}"
print(find_all_vars(template_content))

This version outputs:

foo:
  bar:
    buzz: <TODO>
x:
  y: <TODO>

Upvotes: 1

yahelck
yahelck

Reputation: 41

A good way to achieve that in my opinion is to define your own Undefined class, similarly to Michael Wyraz's anwer:

class CollectUndefined(object):
    def __init__(self, undefined_cls=Undefined):
        self.undefined_cls = undefined_cls
        self.missing_vars = []

    def __call__(self, *args, **kwds):
        undefined = self.undefined_cls(*args, **kwds)
        self.missing_vars.append(undefined._undefined_name)
        return undefined

    def assert_no_missing_vars(self):
        if len(self.missing_vars) > 0:
            raise MissingVariablesError(self.missing_vars)


class MissingVariablesError(Exception):
    def __init__(self, missing_vars, *args):
        super().__init__(*args)
        self.missing_vars = missing_vars

    def __str__(self):
        return 'Missing variables: {}'.format(self.missing_vars)

Then you can use it like so:

env = Environment(undefined=CollectUndefind())
tmp = env.from_string(tmpstr)
tmpsrc = tmp.render(cxt)
print(env.undefined.missing_vars)

Or with try-except:

env = Environment(undefined=CollectUndefind())
tmp = env.from_string(tmpstr)
try:
    tmpsrc = tmp.render(cxt)
    env.undefined.assert_no_missing_vars():
except MissingVariablesError as e:
    print(e.missing_vars)
    

Upvotes: 0

user14050185
user14050185

Reputation: 7

Concerning your first attempt (reposted here)

errs = []
try:
    env = Environment(undefined=StrictUndefined)
    tmp = env.from_string(tmpstr)
    tmpsrc = tmp.render(cxt)
except Exception as e:
    errs.append(str(e))
print(errs)

I believe the problem is that 1) You're trying to loop in the template when you should try to loop in the script and 2) you aren't updating the cxt after each exception.

I needed to do the same thing with a template using custom delimiters ( for which find_undeclared_variables won't work)

and I used something like this:

def findAllUndefined(target):
    jinja_env = jinja2.Environment(undefined=jinja2.StrictUndefined)
    doc = DocxTemplate(target)
    context = {}
    finished = False
    while finished == False:
        try:
            doc.render(context, jinja_env)
            finished = True
        except Exception as e:
            tag = re.sub(" is undefined", "", str(e)) # extracting tag name from error message
            tag = re.sub("'", "", tag)
            context[str(tag)] = "FOUND"
    return context.keys()

The idea being that every time an undefined variable is encountered, the tag name is inserted into the context with a fluff value, and the rendering is attempted again until all variables are known and catalogued.

Upvotes: 1

Michael Wyraz
Michael Wyraz

Reputation: 3818

You can simply create your own "Undefined" so you can e.g. process the list of undefined variables programmatically. Here's an example:


missing_vars=[]
class CollectingUndefined(jinja2.Undefined):

    def _add_missing_var(self):
        missing_vars.append(self._undefined_name)

    def __iter__(self):
        self._add_missing_var()
        return super().__iter__();

    def __str__(self):
        self._add_missing_var()
        return super().__str__();

    def __len__(self):
        self._add_missing_var()
        return super().__len__();

    def __eq__(self):
        self._add_missing_var()
        return super().__eq__();

    def __ne__(self):
        self._add_missing_var()
        return super().__eq__();

    def __bool__(self):
        self._add_missing_var()
        return super().__e__bool__q__();

    def __hash__(self):
        self._add_missing_var()
        return super().__hash__();

Upvotes: 1

Gustavo Bezerra
Gustavo Bezerra

Reputation: 11044

Using find_undeclared_variables with DebugUndefined you can properly raise an exception mentioning all variables that are missing:

import jinja2
from jinja2.meta import find_undeclared_variables

env = jinja2.Environment(undefined=jinja2.DebugUndefined)
template = env.from_string('foo={{ foo }}, bar={{ bar}}, baz={{ baz }}')

# Render template without passing all variables
rendered = template.render(foo=1)

# Check if rendering was done correctly
ast = env.parse(rendered)
undefined = find_undeclared_variables(ast)  # {'bar', 'baz'}
if undefined:
    raise jinja2.UndefinedError(f'The following variables are undefined: {undefined!r}')

If you prefer logging, you can replace the exception raising with your own logging calls using the contents of undefined.

PS: I am relatively new to Jinja, but I am quite surprised this is not the default behavior of env.render. I wonder why authors/maintainers think having missing variables silently ignored by default being a good thing...

Upvotes: 10

Balitong
Balitong

Reputation: 111

I found the solution for your question, using jinja2.make_logging_undefined. I was in the same boat as you are and have been search high and low for an answer. Most of the answer pointing me to use parsed templates, however I couldn't figure out how to get the context into the parsed templates.

I finally able to make this work using the make_logging_undefined. If you want to find all the undefined variables, make sure to use just the Undefined base class rather than StrictUndefined. Using StrictUndefined will cause jinja to throw exception at the first encounter of the undefine.

Just a disclaimer: I'm not an python nor jinja expert, so the code is not the most efficient nor structured. But it serves my purpose. This is just POC code.

Here's the code:

import jinja2
import logging
from jinja2 import Environment, Undefined
from jinja2.exceptions import UndefinedError

def main():
    templateLoader = jinja2.FileSystemLoader( searchpath="D:\\somelocation\\" )

    logging.basicConfig()
    logger = logging.getLogger('logger')
    LoggingUndefined = jinja2.make_logging_undefined(logger=logger,base=jinja2.Undefined)

    templateEnv = jinja2.Environment( loader=templateLoader, undefined=LoggingUndefined)

    TEMPLATE_FILE = "./example1.jinja"

    template = templateEnv.get_template( TEMPLATE_FILE )

    FAVORITES = [ "chocolates", "lunar eclipses", "rabbits" ]
    # Specify any input variables to the template as a dictionary.
    templateVars = { "title" : "Test Example",
                     "description" : "A simple inquiry of function.",
                     "favorites" : FAVORITES,
                     "whatever" : "1"
                   }    
    # Finally, process the template to produce our final text.
    try:
        outputText = template.render( templateVars )
    except ( UndefinedError) as err:
        print err

if __name__ == '__main__':
    main()

example1.jinja:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />

  <title>{{ title }}</title>
  <meta name="description" content="{{ description }}" />
</head>

<body>

<div id="content">
  <p>Greetings visitor!  These are a list of my favorite things:</p>

  <ul>
  {% for item in favorites %}
    <li>{{ item }}</li>

  <li>My favorites: {{ favorites[1] }} </li>
  {% endfor %}
  {{ undefined_var1 }}
  {{ underfined_var2 }}
  </ul>
</div>

</body>
</html>

Here's the sample output:

WARNING:logger:Template variable warning: undefined_var1 is undefined
WARNING:logger:Template variable warning: underfined_var2 is undefined

Upvotes: 11

Related Questions