Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions docxtpl/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from docx.opc.part import XmlPart
import docx.oxml.ns
from docx.opc.constants import RELATIONSHIP_TYPE as REL_TYPE
from jinja2 import Environment, Template, meta
from jinja2 import Environment, Template, meta, nodes
from jinja2.exceptions import TemplateError

try:
Expand Down Expand Up @@ -895,7 +895,14 @@ def get_undeclared_template_variables(
self,
jinja_env: Optional[Environment] = None,
context: Optional[Dict[str, Any]] = None,
nested: bool = False,
) -> Set[str]:
"""Return the names of the variables the template uses but does not define.

With nested=True the attribute and string-key lookups on those variables are
returned as dotted paths, so {{ foo.bar }} and {{ foo["bar"] }} both give
"foo.bar" instead of "foo".
"""
# Create a temporary document to analyze the template without affecting the current state
temp_doc = Document(self.template_file)

Expand All @@ -920,8 +927,39 @@ def get_undeclared_template_variables(

# If context is provided, return only variables that are not in the context
if context is not None:
provided_variables = set(context.keys())
return all_variables - provided_variables
all_variables = all_variables - set(context.keys())

if nested:
return self._nested_variable_paths(parse_content, all_variables)

# If no context provided, return all variables (original behavior)
return all_variables

@staticmethod
def _nested_variable_paths(ast: nodes.Template, names: Set[str]) -> Set[str]:
def lookup_path(node: nodes.Node) -> Optional[list]:
if isinstance(node, nodes.Name):
return [node.name]
if isinstance(node, nodes.Getattr):
base = lookup_path(node.node)
return None if base is None else base + [node.attr]
if isinstance(node, nodes.Getitem):
key = node.arg
if isinstance(key, nodes.Const) and isinstance(key.value, str):
base = lookup_path(node.node)
return None if base is None else base + [key.value]
return None

paths: Set[str] = set()

def visit(node: nodes.Node) -> None:
if isinstance(node, (nodes.Name, nodes.Getattr, nodes.Getitem)):
path = lookup_path(node)
if path is not None:
if path[0] in names:
paths.add(".".join(path))
return
for child in node.iter_child_nodes():
visit(child)

visit(ast)
return paths
57 changes: 57 additions & 0 deletions tests/get_undeclared_variables_nested.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Test for the nested option of get_undeclared_template_variables
"""

import os

from docx import Document

from docxtpl import DocxTemplate

TEMPLATE_PATH = "output/get_undeclared_variables_nested_tpl.docx"


def make_template():
os.makedirs("output", exist_ok=True)
doc = Document()
doc.add_paragraph("{{ foo.bar }} {{ foo['baz'] }} {{ plain }}")
doc.add_paragraph("{{ order.customer.name }} {{ items[0] }}")
doc.add_paragraph("{% for row in rows %}{{ row.id }} {{ other.value }}{% endfor %}")
doc.save(TEMPLATE_PATH)


def test_nested():
make_template()
tpl = DocxTemplate(TEMPLATE_PATH)
expected = {
"foo.bar",
"foo.baz",
"plain",
"order.customer.name",
"items",
"rows",
"other.value",
}
found = tpl.get_undeclared_template_variables(nested=True)
assert found == expected, found

# the default behavior still returns the top level names only
assert tpl.get_undeclared_template_variables() == {
"foo",
"plain",
"order",
"items",
"rows",
"other",
}

# variables given in the context are left out
found = tpl.get_undeclared_template_variables(context={"foo": {}}, nested=True)
assert found == expected - {"foo.bar", "foo.baz"}, found


if __name__ == "__main__":
test_nested()
print("PASS: nested undeclared variables")