From b00a12492091eb1c89e875386e2bbf9f2f99399d Mon Sep 17 00:00:00 2001 From: Christina Holt <56881914+christinaholtNOAA@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:49:43 -0700 Subject: [PATCH] Fixes related to run_fcst integration (#657) A small collection of changes that were needed or useful during the SRW run_fcst integration. They include: * A fix to the lateral boundary condition logic * The addition of a !bool tag for YAML * Treating the fv3 diag_table as a template instead of a file path. --- docs/sections/user_guide/yaml/tags.rst | 17 +++++++++++++++ src/uwtools/config/support.py | 11 +++++++--- src/uwtools/drivers/fv3.py | 21 +++++++++++-------- .../resources/jsonschema/fv3.jsonschema | 16 +++++++++++++- src/uwtools/tests/config/formats/test_base.py | 8 +++++-- src/uwtools/tests/config/test_jinja2.py | 3 ++- src/uwtools/tests/config/test_support.py | 10 +++++++++ src/uwtools/tests/drivers/test_fv3.py | 12 +++++------ src/uwtools/tests/test_schemas.py | 6 +++--- 9 files changed, 78 insertions(+), 26 deletions(-) diff --git a/docs/sections/user_guide/yaml/tags.rst b/docs/sections/user_guide/yaml/tags.rst index a3a03c5a2..8acd99caa 100644 --- a/docs/sections/user_guide/yaml/tags.rst +++ b/docs/sections/user_guide/yaml/tags.rst @@ -23,6 +23,23 @@ Or explicit: Additionally, UW defines the following tags to support use cases not covered by standard tags: +``!bool`` +^^^^^^^^^ + +Converts the tagged node to a Python ``boolean`` object. For example, given ``input.yaml``: + +.. code-block:: yaml + + flag1: True + flag2: !bool "{{ flag1 }}" + +.. code-block:: text + + % uw config realize -i ../input.yaml --output-format yaml + flag1: True + flag2: True + + ``!datetime`` ^^^^^^^^^^^^^ diff --git a/src/uwtools/config/support.py b/src/uwtools/config/support.py index c11737a4f..f6337853c 100644 --- a/src/uwtools/config/support.py +++ b/src/uwtools/config/support.py @@ -108,7 +108,7 @@ class UWYAMLConvert(UWYAMLTag): method. See the pyyaml documentation for details. """ - TAGS = ("!datetime", "!float", "!int") + TAGS = ("!bool", "!datetime", "!float", "!int") def convert(self) -> Union[datetime, float, int]: """ @@ -116,8 +116,13 @@ def convert(self) -> Union[datetime, float, int]: Will raise an exception if the value cannot be represented as the specified type. """ - converters: dict[str, Union[Callable[[str], datetime], type[float], type[int]]] = dict( - zip(self.TAGS, [datetime.fromisoformat, float, int]) + converters: dict[ + str, Union[Callable[[str], bool], Callable[[str], datetime], type[float], type[int]] + ] = dict( + zip( + self.TAGS, + [lambda x: {"True": True, "False": False}[x], datetime.fromisoformat, float, int], + ) ) return converters[self.tag](self.value) diff --git a/src/uwtools/drivers/fv3.py b/src/uwtools/drivers/fv3.py index e19843aa9..d950680fb 100644 --- a/src/uwtools/drivers/fv3.py +++ b/src/uwtools/drivers/fv3.py @@ -3,15 +3,14 @@ """ from pathlib import Path -from shutil import copy from iotaa import asset, task, tasks +from uwtools.api.template import render from uwtools.config.formats.nml import NMLConfig from uwtools.config.formats.yaml import YAMLConfig from uwtools.drivers.driver import DriverCycleBased from uwtools.drivers.support import set_driver_docstring -from uwtools.logging import log from uwtools.strings import STR from uwtools.utils.tasks import file, filecopy, symlink @@ -34,7 +33,7 @@ def boundary_files(self): endhour = self.config["length"] + offset + 1 interval = lbcs["interval_hours"] symlinks = {} - for n in [7] if self.config["domain"] == "global" else range(1, 7): + for n in [7] if self.config["domain"] == "regional" else range(1, 7): for boundary_hour in range(offset, endhour, interval): target = Path(lbcs["path"].format(tile=n, forecast_hour=boundary_hour)) linkname = ( @@ -52,12 +51,16 @@ def diag_table(self): yield self.taskname(fn) path = self.rundir / fn yield asset(path, path.is_file) - yield None - if src := self.config.get(fn): - path.parent.mkdir(parents=True, exist_ok=True) - copy(src=src, dst=path) - else: - log.warning("No '%s' defined in config", fn) + template_file = Path(self.config[fn]["template_file"]) + yield file(template_file) + render( + input_file=template_file, + output_file=path, + overrides={ + **self.config[fn].get("template_values", {}), + "cycle": self.cycle, + }, + ) @task def field_table(self): diff --git a/src/uwtools/resources/jsonschema/fv3.jsonschema b/src/uwtools/resources/jsonschema/fv3.jsonschema index 49fbc6125..e20c7259b 100644 --- a/src/uwtools/resources/jsonschema/fv3.jsonschema +++ b/src/uwtools/resources/jsonschema/fv3.jsonschema @@ -20,7 +20,20 @@ ], "properties": { "diag_table": { - "type": "string" + "additionalProperties": false, + "properties": { + "template_file": { + "type": "string" + }, + "template_values": { + "minProperties": 1, + "type": "object" + } + }, + "required": [ + "template_file" + ], + "type": "object" }, "domain": { "enum": [ @@ -142,6 +155,7 @@ } }, "required": [ + "diag_table", "domain", "execution", "field_table", diff --git a/src/uwtools/tests/config/formats/test_base.py b/src/uwtools/tests/config/formats/test_base.py index 3e926eb13..491d43a08 100644 --- a/src/uwtools/tests/config/formats/test_base.py +++ b/src/uwtools/tests/config/formats/test_base.py @@ -211,9 +211,12 @@ def test_dereference(tmp_path): - !int '42' - !float '3.14' - !datetime '{{ D }}' + - !bool "False" f: f1: !int '42' f2: !float '3.14' + f3: True +g: !bool '{{ f.f3 }}' D: 2024-10-10 00:19:00 N: "22" @@ -229,8 +232,9 @@ def test_dereference(tmp_path): "a": 44, "b": {"c": 33}, "d": "{{ X }}", - "e": [42, 3.14, datetime.fromisoformat("2024-10-10 00:19:00")], - "f": {"f1": 42, "f2": 3.14}, + "e": [42, 3.14, datetime.fromisoformat("2024-10-10 00:19:00"), False], + "f": {"f1": 42, "f2": 3.14, "f3": True}, + "g": True, "D": datetime.fromisoformat("2024-10-10 00:19:00"), "N": "22", } diff --git a/src/uwtools/tests/config/test_jinja2.py b/src/uwtools/tests/config/test_jinja2.py index 6d430bf8b..407e49f9c 100644 --- a/src/uwtools/tests/config/test_jinja2.py +++ b/src/uwtools/tests/config/test_jinja2.py @@ -281,7 +281,7 @@ def test_unrendered(s, status): assert jinja2.unrendered(s) is status -@mark.parametrize("tag", ["!datetime", "!float", "!int"]) +@mark.parametrize("tag", ["!bool", "!datetime", "!float", "!int"]) def test__deref_convert_no(caplog, tag): log.setLevel(logging.DEBUG) loader = yaml.SafeLoader(os.devnull) @@ -294,6 +294,7 @@ def test__deref_convert_no(caplog, tag): @mark.parametrize( "converted,tag,value", [ + (True, "!bool", "True"), (datetime(2024, 9, 9, 0, 0), "!datetime", "2024-09-09 00:00:00"), (3.14, "!float", "3.14"), (42, "!int", "42"), diff --git a/src/uwtools/tests/config/test_support.py b/src/uwtools/tests/config/test_support.py index f17754697..c04b27f41 100644 --- a/src/uwtools/tests/config/test_support.py +++ b/src/uwtools/tests/config/test_support.py @@ -88,6 +88,16 @@ def loader(self): # demonstrate that those nodes' convert() methods return representations in type type specified # by the tag. + def test_bool_bad(self, loader): + ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!bool", value="foo")) + with raises(KeyError): + ts.convert() + + @mark.parametrize("value, expected", [("False", False), ("True", True)]) + def test_bool_values(self, expected, loader, value): + ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!bool", value=value)) + assert ts.convert() == expected + def test_datetime_no(self, loader): ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!datetime", value="foo")) with raises(ValueError): diff --git a/src/uwtools/tests/drivers/test_fv3.py b/src/uwtools/tests/drivers/test_fv3.py index 80939b56a..0f7f3ff5e 100644 --- a/src/uwtools/tests/drivers/test_fv3.py +++ b/src/uwtools/tests/drivers/test_fv3.py @@ -26,7 +26,10 @@ def config(tmp_path): return { "fv3": { - "domain": "global", + "diag_table": { + "template_file": "/path/to/tmpl", + }, + "domain": "regional", "execution": { "batchargs": { "walltime": "00:02:00", @@ -116,18 +119,13 @@ def test_FV3_boundary_files(driverobj): def test_FV3_diag_table(driverobj): src = driverobj.rundir / "diag_table.in" src.touch() - driverobj._config["diag_table"] = src + driverobj._config["diag_table"] = {"template_file": src} dst = driverobj.rundir / "diag_table" assert not dst.is_file() driverobj.diag_table() assert dst.is_file() -def test_FV3_diag_table_warn(caplog, driverobj): - driverobj.diag_table() - assert logged(caplog, "No 'diag_table' defined in config") - - def test_FV3_driver_name(driverobj): assert driverobj.driver_name() == FV3.driver_name() == "fv3" diff --git a/src/uwtools/tests/test_schemas.py b/src/uwtools/tests/test_schemas.py index 8265423c7..83e81a307 100644 --- a/src/uwtools/tests/test_schemas.py +++ b/src/uwtools/tests/test_schemas.py @@ -862,6 +862,7 @@ def test_schema_filter_topo(): def test_schema_fv3(): config = { + "diag_table": {"template_file": "/path"}, "domain": "regional", "execution": {"executable": "fv3"}, "field_table": {"base_file": "/path"}, @@ -887,7 +888,6 @@ def test_schema_fv3(): assert not errors( { **config, - "diag_table": "/path", "files_to_copy": {"fn": "/path"}, "files_to_link": {"fn": "/path"}, "model_configure": {"base_file": "/path"}, @@ -903,9 +903,9 @@ def test_schema_fv3(): def test_schema_fv3_diag_table(fv3_prop): errors = fv3_prop("diag_table") # String value is ok: - assert not errors("/path/to/file") + assert not errors({"template_file": "/path/to/file", "template_values": {"foo": "bar"}}) # Anything else is not: - assert "42 is not of type 'string'\n" in errors(42) + assert "42 is not of type 'object'\n" in errors(42) def test_schema_fv3_domain(fv3_prop):