From c75f40b8356511f172174f17cc48997210b84236 Mon Sep 17 00:00:00 2001 From: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> Date: Fri, 29 Mar 2024 08:29:06 -0600 Subject: [PATCH] UW-522 Improve handling of missing variables in template render mode (#446) --- .../user_guide/cli/tools/template.rst | 4 +- src/uwtools/cli.py | 2 +- src/uwtools/config/jinja2.py | 13 ++-- src/uwtools/tests/config/test_jinja2.py | 64 +++++++++++-------- 4 files changed, 50 insertions(+), 33 deletions(-) diff --git a/docs/sections/user_guide/cli/tools/template.rst b/docs/sections/user_guide/cli/tools/template.rst index c9f1044ab..6fa440378 100644 --- a/docs/sections/user_guide/cli/tools/template.rst +++ b/docs/sections/user_guide/cli/tools/template.rst @@ -202,7 +202,7 @@ and a YAML file called ``values.yaml`` with the following contents: $ uw template render --input-file template --values-file values.yaml [2024-03-02T16:42:48] ERROR Required value(s) not provided: [2024-03-02T16:42:48] ERROR recipient - [2024-03-02T16:42:48] ERROR Template could not be rendered. + [2024-03-02T16:42:48] ERROR Template could not be rendered But the ``--partial`` switch may be used to render as much as possible while passing expressions containing missing values through unchanged: @@ -218,7 +218,7 @@ and a YAML file called ``values.yaml`` with the following contents: $ uw template render --input-file template --values-file values.yaml recipient=Reader Hello, Reader! - The optional ``-env`` switch allows environment variables to be used to supply values: + The optional ``--env`` switch allows environment variables to be used to supply values: .. code-block:: text diff --git a/src/uwtools/cli.py b/src/uwtools/cli.py index cade921ca..30adb7114 100644 --- a/src/uwtools/cli.py +++ b/src/uwtools/cli.py @@ -643,7 +643,7 @@ def _dispatch_template_render(args: Args) -> bool: except UWTemplateRenderError: if args[STR.valsneeded]: return True - log.error("Template could not be rendered.") + log.error("Template could not be rendered") return False return True diff --git a/src/uwtools/config/jinja2.py b/src/uwtools/config/jinja2.py index f435e2b2b..2573f6f10 100644 --- a/src/uwtools/config/jinja2.py +++ b/src/uwtools/config/jinja2.py @@ -55,7 +55,8 @@ def __init__( else [] ) ) - ) + ), + undefined=StrictUndefined, ) _register_filters(self._j2env) self._template = self._j2env.from_string(self._template_str) @@ -195,7 +196,11 @@ def render( if missing: _log_missing_values(missing) return None - rendered = template.render() + try: + rendered = template.render() + except UndefinedError as e: + log.error("Render failed with error: %s", str(e)) + return None # Log (dry-run mode) or write the rendered template. @@ -308,9 +313,7 @@ def path_join(path_components: List[str]) -> str: raise UndefinedError() return os.path.join(*path_components) - filters = dict( - path_join=path_join, - ) + filters = dict(path_join=path_join) env.filters.update(filters) return env diff --git a/src/uwtools/tests/config/test_jinja2.py b/src/uwtools/tests/config/test_jinja2.py index 728eb5f16..d87c2ac34 100644 --- a/src/uwtools/tests/config/test_jinja2.py +++ b/src/uwtools/tests/config/test_jinja2.py @@ -40,7 +40,7 @@ def supplemental_values(tmp_path): @fixture -def template(tmp_path): +def template_file(tmp_path): path = tmp_path / "template.jinja2" with open(path, "w", encoding="utf-8") as f: f.write("roses are {{roses_color}}, violets are {{violets_color}}") @@ -171,25 +171,25 @@ def test_register_filters_path_join(key): template.render(**context) # path_join filter fails -def test_render(values_file, template, tmp_path): +def test_render(values_file, template_file, tmp_path): outfile = str(tmp_path / "out.txt") expected = "roses are red, violets are blue" - result = render_helper(input_file=template, values_file=values_file, output_file=outfile) + result = render_helper(input_file=template_file, values_file=values_file, output_file=outfile) assert result == expected with open(outfile, "r", encoding="utf-8") as f: assert f.read().strip() == expected -def test_render_calls__dry_run(template, tmp_path, values_file): +def test_render_calls__dry_run(template_file, tmp_path, values_file): outfile = str(tmp_path / "out.txt") with patch.object(jinja2, "_dry_run_template") as dr: render_helper( - input_file=template, values_file=values_file, output_file=outfile, dry_run=True + input_file=template_file, values_file=values_file, output_file=outfile, dry_run=True ) dr.assert_called_once_with("roses are red, violets are blue") -def test_render_calls__log_missing(template, tmp_path, values_file): +def test_render_calls__log_missing(template_file, tmp_path, values_file): outfile = str(tmp_path / "out.txt") with open(values_file, "r", encoding="utf-8") as f: cfgobj = yaml.safe_load(f.read()) @@ -198,38 +198,49 @@ def test_render_calls__log_missing(template, tmp_path, values_file): f.write(yaml.dump(cfgobj)) with patch.object(jinja2, "_log_missing_values") as lmv: - render_helper( - input_file=template, values_file=values_file, output_file=outfile, dry_run=True - ) + render_helper(input_file=template_file, values_file=values_file, output_file=outfile) lmv.assert_called_once_with(["roses_color"]) -def test_render_calls__values_needed(template, tmp_path, values_file): +def test_render_calls__values_needed(template_file, tmp_path, values_file): outfile = str(tmp_path / "out.txt") with patch.object(jinja2, "_values_needed") as vn: render_helper( - input_file=template, values_file=values_file, output_file=outfile, values_needed=True + input_file=template_file, + values_file=values_file, + output_file=outfile, + values_needed=True, ) vn.assert_called_once_with({"roses_color", "violets_color"}) -def test_render_calls__write(template, tmp_path, values_file): +def test_render_calls__write(template_file, tmp_path, values_file): outfile = str(tmp_path / "out.txt") with patch.object(jinja2, "_write_template") as write: - render_helper(input_file=template, values_file=values_file, output_file=outfile) + render_helper(input_file=template_file, values_file=values_file, output_file=outfile) write.assert_called_once_with(outfile, "roses are red, violets are blue") -def test_render_dry_run(caplog, template, values_file): +def test_render_dry_run(caplog, template_file, values_file): log.setLevel(logging.INFO) expected = "roses are red, violets are blue" - result = render_helper( - input_file=template, values_file=values_file, output_file="/dev/null", dry_run=True - ) + result = render_helper(input_file=template_file, values_file=values_file, dry_run=True) assert result == expected assert logged(caplog, expected) +def test_render_fails(caplog, tmp_path): + log.setLevel(logging.INFO) + input_file = tmp_path / "template.yaml" + with open(input_file, "w", encoding="utf-8") as f: + print("{{ constants.pi }} {{ constants.e }}", file=f) + values_file = tmp_path / "values.yaml" + with open(values_file, "w", encoding="utf-8") as f: + print("constants: {pi: 3.14}", file=f) + assert render_helper(input_file=input_file, values_file=values_file) is None + assert logged(caplog, "Render failed with error: 'dict object' has no attribute 'e'") + + @pytest.mark.parametrize("partial", [False, True]) def test_render_partial(caplog, capsys, partial): log.setLevel(logging.INFO) @@ -244,7 +255,7 @@ def test_render_partial(caplog, capsys, partial): assert logged(caplog, " recipient") -def test_render_values_missing(caplog, template, values_file): +def test_render_values_missing(caplog, template_file, values_file): log.setLevel(logging.INFO) # Read in the config, remove the "roses" key, then re-write it. with open(values_file, "r", encoding="utf-8") as f: @@ -252,16 +263,14 @@ def test_render_values_missing(caplog, template, values_file): del cfgobj["roses_color"] with open(values_file, "w", encoding="utf-8") as f: f.write(yaml.dump(cfgobj)) - render_helper(input_file=template, values_file=values_file, output_file="/dev/null") + render_helper(input_file=template_file, values_file=values_file) assert logged(caplog, "Required value(s) not provided:") assert logged(caplog, " roses_color") -def test_render_values_needed(caplog, template, values_file): +def test_render_values_needed(caplog, template_file, values_file): log.setLevel(logging.INFO) - render_helper( - input_file=template, values_file=values_file, output_file="/dev/null", values_needed=True - ) + render_helper(input_file=template_file, values_file=values_file, values_needed=True) for var in ("roses_color", "violets_color"): assert logged(caplog, f" {var}") @@ -444,9 +453,9 @@ def test__write_template_stdout(capsys): assert actual.strip() == expected -class Test_Jinja2Template: +class Test_J2Template: """ - Tests for class uwtools.config.jinja2.Jinja2Template. + Tests for class uwtools.config.jinja2.J2Template. """ @fixture @@ -529,3 +538,8 @@ def test_searchpath_stdin_explicit(self, searchpath_assets): with patch.object(jinja2, "readable") as readable: readable.return_value.__enter__.return_value = StringIO(a.s1) assert J2Template(values={}, searchpath=[a.d1]).render() == "2" + + def test_undeclared_variables(self): + s = "{{ a }} {{ b.c }} {{ d.e.f[g] }} {{ h[i] }} {{ j[88] }} {{ k|default(l) }}" + uvs = {"a", "b", "d", "g", "h", "i", "j", "k", "l"} + assert J2Template(values={}, template_source=s).undeclared_variables == uvs