Skip to content

Commit

Permalink
UW-522 Improve handling of missing variables in template render mode (u…
Browse files Browse the repository at this point in the history
  • Loading branch information
maddenp-noaa authored Mar 29, 2024
1 parent 59bc295 commit c75f40b
Show file tree
Hide file tree
Showing 4 changed files with 50 additions and 33 deletions.
4 changes: 2 additions & 2 deletions docs/sections/user_guide/cli/tools/template.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/uwtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions src/uwtools/config/jinja2.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ def __init__(
else []
)
)
)
),
undefined=StrictUndefined,
)
_register_filters(self._j2env)
self._template = self._j2env.from_string(self._template_str)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
64 changes: 39 additions & 25 deletions src/uwtools/tests/config/test_jinja2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}")
Expand Down Expand Up @@ -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())
Expand All @@ -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)
Expand All @@ -244,24 +255,22 @@ 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:
cfgobj = yaml.safe_load(f.read())
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}")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

0 comments on commit c75f40b

Please sign in to comment.