Skip to content

Commit 034c91c

Browse files
committed
Add Environment.extract_parsed_names to support tracking dynamic inheritance or inclusion
1 parent ae53ea5 commit 034c91c

File tree

5 files changed

+63
-1
lines changed

5 files changed

+63
-1
lines changed

CHANGES.rst

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ Version 3.2.0
55

66
Unreleased
77

8+
- Add ``Environment.extract_parsed_names`` to support tracking dynamic
9+
inheritance or inclusion. :issue:`1776`
10+
811

912
Version 3.1.2
1013
-------------

docs/api.rst

+2
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ useful if you want to dig deeper into Jinja or :ref:`develop extensions
140140

141141
.. automethod:: overlay([options])
142142

143+
.. automethod:: extract_parsed_names()
144+
143145
.. method:: undefined([hint, obj, name, exc])
144146

145147
Creates a new :class:`Undefined` object for `name`. This is useful

src/jinja2/environment.py

+37
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ class Environment:
208208
`optimized`
209209
should the optimizer be enabled? Default is ``True``.
210210
211+
`remember_parsed_names`
212+
Should we remember parsed names? This is useful for dynamic
213+
dependency tracking, see `extract_parsed_names` for details.
214+
Default is ``False``.
215+
211216
`undefined`
212217
:class:`Undefined` or a subclass of it that is used to represent
213218
undefined values in the template.
@@ -313,6 +318,7 @@ def __init__(
313318
auto_reload: bool = True,
314319
bytecode_cache: t.Optional["BytecodeCache"] = None,
315320
enable_async: bool = False,
321+
remember_parsed_names: bool = False,
316322
):
317323
# !!Important notice!!
318324
# The constructor accepts quite a few arguments that should be
@@ -344,6 +350,9 @@ def __init__(
344350
self.optimized = optimized
345351
self.finalize = finalize
346352
self.autoescape = autoescape
353+
self.parsed_names: t.Optional[t.List[str]] = (
354+
[] if remember_parsed_names else None
355+
)
347356

348357
# defaults
349358
self.filters = DEFAULT_FILTERS.copy()
@@ -614,8 +623,36 @@ def _parse(
614623
self, source: str, name: t.Optional[str], filename: t.Optional[str]
615624
) -> nodes.Template:
616625
"""Internal parsing function used by `parse` and `compile`."""
626+
if name is not None and self.parsed_names is not None:
627+
self.parsed_names.append(name)
617628
return Parser(self, source, name, filename).parse()
618629

630+
def extract_parsed_names(self) -> t.Optional[t.List[str]]:
631+
"""Return all template names that have been parsed so far, and clear the list.
632+
633+
This is enabled if `remember_parsed_names = True` was passed to the
634+
`Environment` constructor, otherwise it returns `None`. It can be used
635+
after `Template.render()` to extract dependency information. Compared
636+
to `jinja2.meta.find_referenced_templates()`, it:
637+
638+
a. works on dynamic inheritance and includes
639+
b. does not work unless and until you actually render the template
640+
641+
Many buildsystems are unable to support (b), but some do e.g. [1], the
642+
key point being that if the target file does not exist, dependency
643+
information is not needed since the target file must be built anyway.
644+
In such cases, you may prefer this function due to (a).
645+
646+
[1] https://make.mad-scientist.net/papers/advanced-auto-dependency-generation/
647+
648+
.. versionadded:: 3.2
649+
"""
650+
if self.parsed_names is None:
651+
return None
652+
names = self.parsed_names[:]
653+
self.parsed_names.clear()
654+
return names
655+
619656
def lex(
620657
self,
621658
source: str,

src/jinja2/meta.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ def find_referenced_templates(ast: nodes.Template) -> t.Iterator[t.Optional[str]
7171
['layout.html', None]
7272
7373
This function is useful for dependency tracking. For example if you want
74-
to rebuild parts of the website after a layout template has changed.
74+
to rebuild parts of the website after a layout template has changed. For
75+
an alternative method with different pros and cons, see
76+
`Environment.extract_parsed_names()`.
7577
"""
7678
template_name: t.Any
7779

tests/test_api.py

+18
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ def test_item_and_attribute(self, env):
3636
tmpl = env.from_string('{{ foo["items"] }}')
3737
assert tmpl.render(foo={"items": 42}) == "42"
3838

39+
def test_extract_parsed_names(self, env):
40+
templates = DictLoader(
41+
{
42+
"main": "{% set tpl = 'ba' + 'se' %}{% extends tpl %}",
43+
"base": "{% set tpl = 'INC' %}{% include tpl.lower() %}",
44+
"inc": "whatever",
45+
}
46+
)
47+
env.loader = templates
48+
assert env.get_template("main").render() == "whatever"
49+
assert env.extract_parsed_names() == None
50+
51+
env = Environment(remember_parsed_names=True)
52+
env.loader = templates
53+
assert env.get_template("main").render() == "whatever"
54+
assert env.extract_parsed_names() == ["main", "base", "inc"]
55+
assert env.extract_parsed_names() == []
56+
3957
def test_finalize(self):
4058
e = Environment(finalize=lambda v: "" if v is None else v)
4159
t = e.from_string("{% for item in seq %}|{{ item }}{% endfor %}")

0 commit comments

Comments
 (0)