diff --git a/CHANGES.rst b/CHANGES.rst index 7ee75a6a7..42f2bd2d4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Version 3.2.0 Unreleased +- Add ``Environment.extract_parsed_names`` to support tracking dynamic + inheritance or inclusion. :issue:`1776` + Version 3.1.2 ------------- diff --git a/docs/api.rst b/docs/api.rst index e2c9bd526..26488c060 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -140,6 +140,8 @@ useful if you want to dig deeper into Jinja or :ref:`develop extensions .. automethod:: overlay([options]) + .. automethod:: extract_parsed_names() + .. method:: undefined([hint, obj, name, exc]) Creates a new :class:`Undefined` object for `name`. This is useful diff --git a/src/jinja2/environment.py b/src/jinja2/environment.py index 88b26662f..1dd0d90be 100644 --- a/src/jinja2/environment.py +++ b/src/jinja2/environment.py @@ -259,6 +259,11 @@ class Environment: `enable_async` If set to true this enables async template execution which allows using async functions and generators. + + `remember_parsed_names` + Should we remember parsed names? This is useful for dynamic + dependency tracking, see `extract_parsed_names` for details. + Default is ``False``. """ #: if this environment is sandboxed. Modifying this variable won't make @@ -313,6 +318,7 @@ def __init__( auto_reload: bool = True, bytecode_cache: t.Optional["BytecodeCache"] = None, enable_async: bool = False, + remember_parsed_names: bool = False, ): # !!Important notice!! # The constructor accepts quite a few arguments that should be @@ -344,6 +350,9 @@ def __init__( self.optimized = optimized self.finalize = finalize self.autoescape = autoescape + self.parsed_names: t.Optional[t.List[str]] = ( + [] if remember_parsed_names else None + ) # defaults self.filters = DEFAULT_FILTERS.copy() @@ -614,8 +623,36 @@ def _parse( self, source: str, name: t.Optional[str], filename: t.Optional[str] ) -> nodes.Template: """Internal parsing function used by `parse` and `compile`.""" + if name is not None and self.parsed_names is not None: + self.parsed_names.append(name) return Parser(self, source, name, filename).parse() + def extract_parsed_names(self) -> t.Optional[t.List[str]]: + """Return all template names that have been parsed so far, and clear the list. + + This is enabled if `remember_parsed_names = True` was passed to the + `Environment` constructor, otherwise it returns `None`. It can be used + after `Template.render()` to extract dependency information. Compared + to `jinja2.meta.find_referenced_templates()`, it: + + a. works on dynamic inheritance and includes + b. does not work unless and until you actually render the template + + Many buildsystems are unable to support (b), but some do e.g. [1], the + key point being that if the target file does not exist, dependency + information is not needed since the target file must be built anyway. + In such cases, you may prefer this function due to (a). + + [1] https://make.mad-scientist.net/papers/advanced-auto-dependency-generation/ + + .. versionadded:: 3.2 + """ + if self.parsed_names is None: + return None + names = self.parsed_names[:] + self.parsed_names.clear() + return names + def lex( self, source: str, diff --git a/src/jinja2/meta.py b/src/jinja2/meta.py index 0057d6eab..852fd637e 100644 --- a/src/jinja2/meta.py +++ b/src/jinja2/meta.py @@ -71,7 +71,9 @@ def find_referenced_templates(ast: nodes.Template) -> t.Iterator[t.Optional[str] ['layout.html', None] This function is useful for dependency tracking. For example if you want - to rebuild parts of the website after a layout template has changed. + to rebuild parts of the website after a layout template has changed. For + an alternative method with different pros and cons, see + `Environment.extract_parsed_names()`. """ template_name: t.Any diff --git a/tests/test_api.py b/tests/test_api.py index 4db3b4a96..dcb9dbb4b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -36,6 +36,24 @@ def test_item_and_attribute(self, env): tmpl = env.from_string('{{ foo["items"] }}') assert tmpl.render(foo={"items": 42}) == "42" + def test_extract_parsed_names(self, env): + templates = DictLoader( + { + "main": "{% set tpl = 'ba' + 'se' %}{% extends tpl %}", + "base": "{% set tpl = 'INC' %}{% include tpl.lower() %}", + "inc": "whatever", + } + ) + env.loader = templates + assert env.get_template("main").render() == "whatever" + assert env.extract_parsed_names() is None + + env = Environment(remember_parsed_names=True) + env.loader = templates + assert env.get_template("main").render() == "whatever" + assert env.extract_parsed_names() == ["main", "base", "inc"] + assert env.extract_parsed_names() == [] + def test_finalize(self): e = Environment(finalize=lambda v: "" if v is None else v) t = e.from_string("{% for item in seq %}|{{ item }}{% endfor %}")