Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UW-510 Add uw template render --partial switch #418

Merged
merged 13 commits into from
Feb 29, 2024
15 changes: 12 additions & 3 deletions docs/sections/user_guide/cli/tools/mode_template.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ The ``uw`` mode for handling :jinja2:`Jinja2 templates<templates>`.

$ uw template render --help
usage: uw template render [-h] [--input-file PATH] [--output-file PATH] [--values-file PATH]
[--values-format {ini,nml,sh,yaml}] [--values-needed] [--dry-run]
[--quiet] [--verbose]
[--values-format {ini,nml,sh,yaml}] [--values-needed] [--partial]
[--dry-run] [--debug] [--quiet] [--verbose]
[KEY=VALUE ...]

Render a template
Expand All @@ -49,6 +49,8 @@ The ``uw`` mode for handling :jinja2:`Jinja2 templates<templates>`.
Values format
--values-needed
Print report of values needed to render template
--partial
Permit partial template rendering
--dry-run
Only log info, making no changes
--debug
Expand Down Expand Up @@ -142,7 +144,14 @@ and a YAML file called ``values.yaml`` with the following contents:
[2023-12-18T19:30:05] ERROR Required value(s) not provided:
[2023-12-18T19:30:05] ERROR recipient

But values may be supplemented by ``key=value`` command-line arguments. For example:
But the ``--partial`` switch may be used to render as much as possible while passing expressions containing missing values through unchanged:

.. code-block:: text

$ uw template render --input-file template --values-file values.yaml --partial
Hello, {{ recipient }}!

Values may also be supplemented by ``key=value`` command-line arguments. For example:

.. code-block:: text

Expand Down
3 changes: 3 additions & 0 deletions src/uwtools/api/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def render(
output_file: Optional[Path] = None,
overrides: Optional[Dict[str, str]] = None,
values_needed: bool = False,
partial: bool = False,
dry_run: bool = False,
) -> bool:
"""
Expand All @@ -34,6 +35,7 @@ def render(
to ``stdout``)
:param overrides: Supplemental override values
:param values_needed: Just report variables needed to render the template?
:param partial: Permit unrendered expressions in output?
:param dry_run: Run in dry-run mode?
:return: ``True`` if Jinja2 template was successfully rendered, ``False`` otherwise
"""
Expand All @@ -44,6 +46,7 @@ def render(
output_file=output_file,
overrides=overrides,
values_needed=values_needed,
partial=partial,
dry_run=dry_run,
)

Expand Down
13 changes: 12 additions & 1 deletion src/uwtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from uwtools.logging import log, setup_logging
from uwtools.utils.file import FORMAT, get_file_format

FORMATS = list(FORMAT.formats().keys())
FORMATS = FORMAT.extensions()
TITLE_REQ_ARG = "Required arguments"

Args = Dict[str, Any]
Expand Down Expand Up @@ -426,6 +426,7 @@ def _add_subparser_template_render(subparsers: Subparsers) -> ActionChecks:
_add_arg_values_file(optional)
_add_arg_values_format(optional, choices=FORMATS)
_add_arg_values_needed(optional)
_add_arg_partial(optional)
_add_arg_dry_run(optional)
checks = _add_args_verbosity(optional)
_add_arg_key_eq_val_pairs(optional)
Expand Down Expand Up @@ -459,6 +460,7 @@ def _dispatch_template_render(args: Args) -> bool:
output_file=args[STR.outfile],
overrides=_dict_from_key_eq_val_strings(args[STR.keyvalpairs]),
values_needed=args[STR.valsneeded],
partial=args[STR.partial],
dry_run=args[STR.dryrun],
)

Expand Down Expand Up @@ -600,6 +602,14 @@ def _add_arg_output_format(group: Group, choices: List[str], required: bool = Fa
)


def _add_arg_partial(group: Group) -> None:
group.add_argument(
_switch(STR.partial),
action="store_true",
help="Permit partial template rendering",
)


def _add_arg_quiet(group: Group) -> None:
group.add_argument(
_switch(STR.quiet),
Expand Down Expand Up @@ -840,6 +850,7 @@ class STR:
model: str = "model"
outfile: str = "output_file"
outfmt: str = "output_format"
partial: str = "partial"
quiet: str = "quiet"
realize: str = "realize"
render: str = "render"
Expand Down
30 changes: 15 additions & 15 deletions src/uwtools/config/jinja2.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from jinja2 import (
BaseLoader,
DebugUndefined,
Environment,
FileSystemLoader,
StrictUndefined,
Expand Down Expand Up @@ -149,6 +150,7 @@ def render(
output_file: Optional[Path] = None,
overrides: Optional[Dict[str, str]] = None,
values_needed: bool = False,
partial: bool = False,
dry_run: bool = False,
) -> bool:
"""
Expand All @@ -160,12 +162,10 @@ def render(
:param output_file: Path to write rendered Jinja2 template to (None => write to stdout).
:param overrides: Supplemental override values.
:param values_needed: Just report variables needed to render the template?
:param partial: Permit unrendered expressions in output?
:param dry_run: Run in dry-run mode?
:return: Jinja2 template was successfully rendered.
:return: True if Jinja2 template was successfully rendered, False otherwise.
"""

# Render template.

_report(locals())
if not isinstance(values, dict):
values = _set_up_values_obj(
Expand All @@ -182,20 +182,20 @@ def render(
if values_needed:
return _values_needed(undeclared_variables)

# Check for missing values required to render the template. If found, report them and raise an
# exception.
# If partial rendering has been requested, do a best-effort render. Otherwise, report any
# missing values and return an error to the caller.

missing = [var for var in undeclared_variables if var not in values.keys()]
if missing:
return _log_missing_values(missing)
if partial:
rendered = Environment(undefined=DebugUndefined).from_string(template_str).render(values)
else:
missing = [var for var in undeclared_variables if var not in values.keys()]
if missing:
return _log_missing_values(missing)
rendered = template.render()

# In dry-run mode, log the rendered template. Otherwise, write the rendered template.
# Log (dry-run mode) or write the rendered template.

return (
_dry_run_template(template.render())
if dry_run
else _write_template(output_file, template.render())
)
return _dry_run_template(rendered) if dry_run else _write_template(output_file, rendered)


# Private functions
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/tests/api/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def test_render():
"values": "valsfile",
"values_format": "format",
"overrides": {"key": "val"},
"partial": True,
"values_needed": True,
"dry_run": True,
}
Expand Down
17 changes: 16 additions & 1 deletion src/uwtools/tests/config/test_jinja2.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import logging
import os
from io import StringIO
from types import SimpleNamespace as ns
from unittest.mock import patch

Expand Down Expand Up @@ -215,9 +216,23 @@ def test_render_dry_run(caplog, template, values_file):
assert logged(caplog, "roses are red, violets are blue")


@pytest.mark.parametrize("partial", [False, True])
def test_render_partial(caplog, capsys, partial):
log.setLevel(logging.INFO)
content = StringIO(initial_value="{{ greeting }} {{ recipient }}")
with patch.object(jinja2, "readable") as readable:
readable.return_value.__enter__.return_value = content
assert jinja2.render(values={"greeting": "Hello"}, partial=partial) is partial
if partial:
assert "Hello {{ recipient }}" in capsys.readouterr().out
else:
assert logged(caplog, "Required value(s) not provided:")
assert logged(caplog, "recipient")


def test_render_values_missing(caplog, template, values_file):
# Read in the config, remove the "roses" key, then re-write it.
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"]
Expand Down
8 changes: 6 additions & 2 deletions src/uwtools/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ def test__dispatch_template_render_no_optional():
STR.valsfmt: None,
STR.keyvalpairs: [],
STR.valsneeded: False,
STR.partial: False,
STR.dryrun: False,
}
with patch.object(uwtools.api.template, "render") as render:
Expand All @@ -393,6 +394,7 @@ def test__dispatch_template_render_no_optional():
values_format=None,
overrides={},
values_needed=False,
partial=False,
dry_run=False,
)

Expand All @@ -405,7 +407,8 @@ def test__dispatch_template_render_yaml():
STR.valsfmt: 4,
STR.keyvalpairs: ["foo=88", "bar=99"],
STR.valsneeded: 6,
STR.dryrun: 7,
STR.partial: 7,
STR.dryrun: 8,
}
with patch.object(uwtools.api.template, "render") as render:
cli._dispatch_template_render(args)
Expand All @@ -416,7 +419,8 @@ def test__dispatch_template_render_yaml():
values_format=4,
overrides={"foo": "88", "bar": "99"},
values_needed=6,
dry_run=7,
partial=7,
dry_run=8,
)


Expand Down
Loading