diff --git a/docs/sections/user_guide/cli/tools/mode_template.rst b/docs/sections/user_guide/cli/tools/mode_template.rst index 82bc72493..c5d16e98b 100644 --- a/docs/sections/user_guide/cli/tools/mode_template.rst +++ b/docs/sections/user_guide/cli/tools/mode_template.rst @@ -30,8 +30,8 @@ The ``uw`` mode for handling :jinja2:`Jinja2 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 @@ -49,6 +49,8 @@ The ``uw`` mode for handling :jinja2:`Jinja2 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 @@ -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 diff --git a/src/uwtools/api/template.py b/src/uwtools/api/template.py index 3524cc6b9..e3f7f99f8 100644 --- a/src/uwtools/api/template.py +++ b/src/uwtools/api/template.py @@ -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: """ @@ -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 """ @@ -44,6 +46,7 @@ def render( output_file=output_file, overrides=overrides, values_needed=values_needed, + partial=partial, dry_run=dry_run, ) diff --git a/src/uwtools/cli.py b/src/uwtools/cli.py index 0fbb78584..3740a6c8b 100644 --- a/src/uwtools/cli.py +++ b/src/uwtools/cli.py @@ -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] @@ -430,6 +430,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) @@ -463,6 +464,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], ) @@ -613,6 +615,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), @@ -854,6 +864,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" diff --git a/src/uwtools/config/jinja2.py b/src/uwtools/config/jinja2.py index 3f55e0008..f9e5c845e 100644 --- a/src/uwtools/config/jinja2.py +++ b/src/uwtools/config/jinja2.py @@ -8,6 +8,7 @@ from jinja2 import ( BaseLoader, + DebugUndefined, Environment, FileSystemLoader, StrictUndefined, @@ -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: """ @@ -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( @@ -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 diff --git a/src/uwtools/tests/api/test_template.py b/src/uwtools/tests/api/test_template.py index d18f5d9fe..47f0cefc6 100644 --- a/src/uwtools/tests/api/test_template.py +++ b/src/uwtools/tests/api/test_template.py @@ -12,6 +12,7 @@ def test_render(): "values": "valsfile", "values_format": "format", "overrides": {"key": "val"}, + "partial": True, "values_needed": True, "dry_run": True, } diff --git a/src/uwtools/tests/config/test_jinja2.py b/src/uwtools/tests/config/test_jinja2.py index 9dfd784d9..a2a84bcdd 100644 --- a/src/uwtools/tests/config/test_jinja2.py +++ b/src/uwtools/tests/config/test_jinja2.py @@ -5,6 +5,7 @@ import logging import os +from io import StringIO from types import SimpleNamespace as ns from unittest.mock import patch @@ -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"] diff --git a/src/uwtools/tests/test_cli.py b/src/uwtools/tests/test_cli.py index 01f521886..9b27cbf07 100644 --- a/src/uwtools/tests/test_cli.py +++ b/src/uwtools/tests/test_cli.py @@ -384,6 +384,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: @@ -395,6 +396,7 @@ def test__dispatch_template_render_no_optional(): values_format=None, overrides={}, values_needed=False, + partial=False, dry_run=False, ) @@ -407,7 +409,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) @@ -418,7 +421,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, )