From 4e67e51fd30af252498d1639212e761511fbf5db Mon Sep 17 00:00:00 2001 From: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> Date: Tue, 10 Sep 2024 08:35:39 -0600 Subject: [PATCH 1/3] UW-653 controller key path (#604) --- src/uwtools/drivers/driver.py | 37 +++++++++++++++--------- src/uwtools/tests/drivers/test_driver.py | 11 +++++-- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/uwtools/drivers/driver.py b/src/uwtools/drivers/driver.py index 750a44b3d..c98775fb7 100644 --- a/src/uwtools/drivers/driver.py +++ b/src/uwtools/drivers/driver.py @@ -49,7 +49,7 @@ def __init__( dry_run: bool = False, key_path: Optional[list[str]] = None, schema_file: Optional[Path] = None, - controller: Optional[str] = None, + controller: Optional[list[str]] = None, ) -> None: config_input = config if isinstance(config, YAMLConfig) else YAMLConfig(config=config) config_input.dereference( @@ -65,8 +65,7 @@ def __init__( self._config: dict = self._config_intermediate[self.driver_name()] except KeyError as e: raise UWConfigError("Required '%s' block missing in config" % self.driver_name()) from e - if controller: - self._config[STR.rundir] = self._config_intermediate[controller][STR.rundir] + self._delegate(controller, STR.rundir) self.schema_file = schema_file self._validate() dryrun(enable=dry_run) @@ -167,6 +166,19 @@ def _create_user_updated_config( else: log.debug(f"Failed to validate {path}") + def _delegate(self, controller: Optional[list[str]], config_key: str) -> None: + """ + Selectively delegate config to controller. + + :param controller: Key(s) leading to block in config controlling run-time values. + :param config_key: Name of config item to delegate to controller. + """ + if controller: + val = self._config_intermediate[controller[0]] + for key in controller[1:]: + val = val[key] + self._config[config_key] = val[config_key] + # Public helper methods @classmethod @@ -241,7 +253,7 @@ def __init__( dry_run: bool = False, key_path: Optional[list[str]] = None, schema_file: Optional[Path] = None, - controller: Optional[str] = None, + controller: Optional[list[str]] = None, ): super().__init__( cycle=cycle, @@ -274,7 +286,7 @@ def __init__( dry_run: bool = False, key_path: Optional[list[str]] = None, schema_file: Optional[Path] = None, - controller: Optional[str] = None, + controller: Optional[list[str]] = None, ): super().__init__( cycle=cycle, @@ -314,7 +326,7 @@ def __init__( dry_run: bool = False, key_path: Optional[list[str]] = None, schema_file: Optional[Path] = None, - controller: Optional[str] = None, + controller: Optional[list[str]] = None, ): super().__init__( config=config, @@ -339,7 +351,7 @@ def __init__( key_path: Optional[list[str]] = None, batch: bool = False, schema_file: Optional[Path] = None, - controller: Optional[str] = None, + controller: Optional[list[str]] = None, ): super().__init__( cycle=cycle, @@ -351,8 +363,7 @@ def __init__( controller=controller, ) self._batch = batch - if controller: - self._config[STR.execution] = self.config_full[controller][STR.execution] + self._delegate(controller, STR.execution) # Workflow tasks @@ -541,7 +552,7 @@ def __init__( key_path: Optional[list[str]] = None, batch: bool = False, schema_file: Optional[Path] = None, - controller: Optional[str] = None, + controller: Optional[list[str]] = None, ): super().__init__( cycle=cycle, @@ -576,7 +587,7 @@ def __init__( key_path: Optional[list[str]] = None, batch: bool = False, schema_file: Optional[Path] = None, - controller: Optional[str] = None, + controller: Optional[list[str]] = None, ): super().__init__( cycle=cycle, @@ -618,7 +629,7 @@ def __init__( key_path: Optional[list[str]] = None, batch: bool = False, schema_file: Optional[Path] = None, - controller: Optional[str] = None, + controller: Optional[list[str]] = None, ): super().__init__( config=config, @@ -650,7 +661,7 @@ def _add_docstring(class_: type, omit: Optional[list[str]] = None) -> None: :param key_path: Keys leading through the config to the driver's configuration block. :param batch: Run component via the batch system? :param schema_file: Path to schema file to use to validate an external driver. - :param controller: Name of block in config controlling run-time values. + :param controller: Key(s) leading to block in config controlling run-time values. """ setattr( class_, diff --git a/src/uwtools/tests/drivers/test_driver.py b/src/uwtools/tests/drivers/test_driver.py index 48d72c67e..4a910f9bc 100644 --- a/src/uwtools/tests/drivers/test_driver.py +++ b/src/uwtools/tests/drivers/test_driver.py @@ -195,7 +195,7 @@ def test_Assets_controller(config, controller_schema): with raises(UWConfigError): ConcreteAssetsTimeInvariant(config=config, schema_file=controller_schema) assert ConcreteAssetsTimeInvariant( - config=config, schema_file=controller_schema, controller="controller" + config=config, schema_file=controller_schema, controller=["controller"] ) @@ -285,6 +285,13 @@ def test_Assets__create_user_updated_config_base_file( assert updated == expected +def test_Assets__delegate(driverobj): + assert "roses" not in driverobj.config + driverobj._config_intermediate["plants"] = {"flowers": {"roses": "red"}} + driverobj._delegate(["plants", "flowers"], "roses") + assert driverobj.config["roses"] == "red" + + def test_Assets__rundir(assetsobj): assert assetsobj.rundir == Path(assetsobj.config["rundir"]) @@ -342,7 +349,7 @@ def test_Driver_controller(config, controller_schema): with raises(UWConfigError): ConcreteDriverTimeInvariant(config=config, schema_file=controller_schema) assert ConcreteDriverTimeInvariant( - config=config, schema_file=controller_schema, controller="controller" + config=config, schema_file=controller_schema, controller=["controller"] ) From afce2f2b57a171edb7ade1c55f1c7ecf25ee4c3d Mon Sep 17 00:00:00 2001 From: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com> Date: Thu, 12 Sep 2024 13:52:57 -0600 Subject: [PATCH 2/3] UW-639 Mixed cyclestr / text support (#606) --- docs/sections/user_guide/yaml/rocoto.rst | 24 +++++++- .../resources/jsonschema/rocoto.jsonschema | 56 ++++++++++++------- src/uwtools/rocoto.py | 17 +++--- src/uwtools/tests/test_rocoto.py | 37 +++++++++--- 4 files changed, 93 insertions(+), 41 deletions(-) diff --git a/docs/sections/user_guide/yaml/rocoto.rst b/docs/sections/user_guide/yaml/rocoto.rst index a26c334a5..a655b7e11 100644 --- a/docs/sections/user_guide/yaml/rocoto.rst +++ b/docs/sections/user_guide/yaml/rocoto.rst @@ -86,12 +86,30 @@ In the example, the resulting log would appear in the XML file as: .. code-block:: xml - - /some/path/to/&FOO; - + /some/path/to/&FOO; The ``attrs:`` block is optional within the ``cyclestr:`` block and can be used to specify the cycle offset. +Wherever a ``cyclestr:`` block is accepted, a YAML sequence mixing text and ``cyclestr:`` blocks may also be provided. For example, + +.. code-block:: yaml + + log: + - cyclestr: + value: "%Y%m%d%H" + - -through- + - cyclestr: + attrs: + offset: "06:00:00" + value: "%Y%m%d%H" + - .log + +would be rendered as + +.. code-block:: xml + + %Y%m%d%H-through-%Y%m%d%H.log + Tasks Section ------------- diff --git a/src/uwtools/resources/jsonschema/rocoto.jsonschema b/src/uwtools/resources/jsonschema/rocoto.jsonschema index 1b3bcbf0f..882ca19b7 100644 --- a/src/uwtools/resources/jsonschema/rocoto.jsonschema +++ b/src/uwtools/resources/jsonschema/rocoto.jsonschema @@ -1,44 +1,60 @@ { "$defs": { "compoundTimeString": { - "anyOf": [ + "oneOf": [ { - "type": "integer" + "$ref": "#/$defs/compoundTimeStringElement" }, { - "type": "string" + "items": { + "$ref": "#/$defs/compoundTimeStringElement" + }, + "type": "array" + } + ] + }, + "compoundTimeStringElement": { + "oneOf": [ + { + "$ref": "#/$defs/cycleString" }, { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "cycleString": { + "additionalProperties": false, + "properties": { + "cyclestr": { "additionalProperties": false, "properties": { - "cyclestr": { + "attrs": { "additionalProperties": false, "properties": { - "attrs": { - "additionalProperties": false, - "properties": { - "offset": { - "$ref": "#/$defs/time" - } - }, - "type": "object" - }, - "value": { - "type": "string" + "offset": { + "$ref": "#/$defs/time" } }, - "required": [ - "value" - ], "type": "object" + }, + "value": { + "type": "string" } }, "required": [ - "cyclestr" + "value" ], "type": "object" } - ] + }, + "required": [ + "cyclestr" + ], + "type": "object" }, "dependency": { "additionalProperties": false, diff --git a/src/uwtools/rocoto.py b/src/uwtools/rocoto.py index 6d5c1c6e3..73d8ee39f 100644 --- a/src/uwtools/rocoto.py +++ b/src/uwtools/rocoto.py @@ -9,6 +9,7 @@ from typing import Any, Optional, Union from lxml import etree +from lxml.builder import E # type: ignore from lxml.etree import Element, SubElement, _Element from uwtools.config.formats.yaml import YAMLConfig @@ -113,16 +114,12 @@ def _add_compound_time_string(self, e: _Element, config: Any, tag: str) -> _Elem :param tag: Name of child element to add. :return: The child element. """ - e = SubElement(e, tag) - if isinstance(config, dict): - self._set_attrs(e, config) - if subconfig := config.get(STR.cyclestr, {}): - cyclestr = SubElement(e, STR.cyclestr) - cyclestr.text = subconfig[STR.value] - self._set_attrs(cyclestr, subconfig) - else: - e.text = str(config) - return e + config = config if isinstance(config, list) else [config] + cyclestr = lambda x: E.cyclestr(x["cyclestr"]["value"], **x["cyclestr"].get("attrs", {})) + items = [cyclestr(x) if isinstance(x, dict) else str(x) for x in [tag, *config]] + child: _Element = E(*items) # pylint: disable=not-callable + e.append(child) + return child def _add_metatask(self, e: _Element, config: dict, name_attr: str) -> None: """ diff --git a/src/uwtools/tests/test_rocoto.py b/src/uwtools/tests/test_rocoto.py index d7e5323b2..9bd86eb96 100644 --- a/src/uwtools/tests/test_rocoto.py +++ b/src/uwtools/tests/test_rocoto.py @@ -6,6 +6,7 @@ from unittest.mock import DEFAULT as D from unittest.mock import PropertyMock, patch +from lxml import etree from pytest import fixture, mark, raises from uwtools import rocoto @@ -110,22 +111,42 @@ def test_instantiate_from_cfgobj(self, assets): cfgfile, _ = assets assert rocoto._RocotoXML(config=YAMLConfig(cfgfile))._root.tag == "workflow" - def test__add_compound_time_string_basic(self, instance, root): - config = "bar" + @mark.parametrize("config", ["bar", 42]) + def test__add_compound_time_string_basic(self, config, instance, root): instance._add_compound_time_string(e=root, config=config, tag="foo") child = root[0] assert child.tag == "foo" - assert child.text == "bar" + assert child.text == str(config) def test__add_compound_time_string_cyclestr(self, instance, root): - config = {"attrs": {"bar": "42"}, "cyclestr": {"attrs": {"baz": "43"}, "value": "qux"}} + config = {"cyclestr": {"attrs": {"baz": "42"}, "value": "qux"}} instance._add_compound_time_string(e=root, config=config, tag="foo") - child = root[0] - assert child.get("bar") == "42" - cyclestr = child[0] - assert cyclestr.get("baz") == "43" + cyclestr = root[0][0] + assert cyclestr.get("baz") == "42" assert cyclestr.text == "qux" + def test__add_compound_time_string_list(self, instance, root): + config = [ + "cycle-", + {"cyclestr": {"value": "%s"}}, + "-valid-", + {"cyclestr": {"value": "%s", "attrs": {"offset": "00:06:00"}}}, + ".log", + ] + xml = "{}".format( + "".join( + [ + "cycle-", + "%s", + "-valid-", + '%s', + ".log", + ] + ) + ) + instance._add_compound_time_string(e=root, config=config, tag="a") + assert etree.tostring(root[0]).decode("utf-8") == xml + def test__add_metatask(self, instance, root): config = { "metatask_foo": "1", From b7ed02f20e87a92cc5a17facff17bafb53874e1b Mon Sep 17 00:00:00 2001 From: Christina Holt <56881914+christinaholtNOAA@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:50:37 -0600 Subject: [PATCH 3/3] Fixes from integration with SRW's make_orog (#603) A set of changes to the drivers run as part of SRW's make_orog task. - Run commands seemed not to work as expected, so changed them to use config files. - More information was needed in the UW YAML in a couple of instances. Also ordered the tests in each of the drivers. --- .../cli/drivers/filter_topo/help.out | 2 + .../cli/drivers/filter_topo/show-schema.out | 2 +- .../user_guide/cli/drivers/orog_gsl/help.out | 2 + .../user_guide/cli/drivers/shave/help.out | 2 + .../yaml/components/filter_topo.rst | 8 ++++ .../user_guide/yaml/components/shave.rst | 6 ++- docs/shared/filter_topo.yaml | 2 + docs/shared/orog.yaml | 12 +++--- docs/shared/shave.yaml | 3 +- src/uwtools/drivers/filter_topo.py | 16 ++++++- src/uwtools/drivers/orog.py | 23 ++++++++-- src/uwtools/drivers/orog_gsl.py | 25 ++++++++++- src/uwtools/drivers/shave.py | 43 +++++++++++++++---- .../jsonschema/filter-topo.jsonschema | 10 ++++- .../resources/jsonschema/orog.jsonschema | 2 +- .../resources/jsonschema/shave.jsonschema | 12 ++++-- src/uwtools/tests/drivers/test_cdeps.py | 8 ++-- src/uwtools/tests/drivers/test_chgres_cube.py | 8 ++-- src/uwtools/tests/drivers/test_driver.py | 38 ++++++++-------- src/uwtools/tests/drivers/test_esg_grid.py | 8 ++-- src/uwtools/tests/drivers/test_filter_topo.py | 23 +++++++--- src/uwtools/tests/drivers/test_fv3.py | 8 ++-- .../tests/drivers/test_global_equiv_resol.py | 8 ++-- src/uwtools/tests/drivers/test_ioda.py | 12 +++--- src/uwtools/tests/drivers/test_jedi.py | 16 +++---- src/uwtools/tests/drivers/test_make_hgrid.py | 8 ++-- .../tests/drivers/test_make_solo_mosaic.py | 12 +++--- src/uwtools/tests/drivers/test_mpas.py | 26 +++++------ src/uwtools/tests/drivers/test_mpas_init.py | 8 ++-- src/uwtools/tests/drivers/test_orog.py | 27 ++++++++---- src/uwtools/tests/drivers/test_orog_gsl.py | 30 +++++++++---- src/uwtools/tests/drivers/test_schism.py | 8 ++-- .../tests/drivers/test_sfc_climo_gen.py | 8 ++-- src/uwtools/tests/drivers/test_shave.py | 37 ++++++++++------ src/uwtools/tests/drivers/test_support.py | 8 ++-- src/uwtools/tests/drivers/test_ungrib.py | 16 +++---- src/uwtools/tests/drivers/test_upp.py | 12 +++--- src/uwtools/tests/drivers/test_ww3.py | 8 ++-- src/uwtools/tests/test_schemas.py | 18 +++++--- 39 files changed, 345 insertions(+), 180 deletions(-) diff --git a/docs/sections/user_guide/cli/drivers/filter_topo/help.out b/docs/sections/user_guide/cli/drivers/filter_topo/help.out index 1c426c0f4..80ff221b5 100644 --- a/docs/sections/user_guide/cli/drivers/filter_topo/help.out +++ b/docs/sections/user_guide/cli/drivers/filter_topo/help.out @@ -12,6 +12,8 @@ Optional arguments: Positional arguments: TASK + filtered_output_file + The filtered output file staged from raw input input_grid_file The input grid file namelist_file diff --git a/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.out b/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.out index d1ff44ffc..990d2cbed 100644 --- a/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.out +++ b/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.out @@ -6,7 +6,7 @@ "config": { "additionalProperties": false, "properties": { - "input_grid_file": { + "filtered_orog": { "type": "string" ... "rundir" diff --git a/docs/sections/user_guide/cli/drivers/orog_gsl/help.out b/docs/sections/user_guide/cli/drivers/orog_gsl/help.out index b49285d3d..fde960dcd 100644 --- a/docs/sections/user_guide/cli/drivers/orog_gsl/help.out +++ b/docs/sections/user_guide/cli/drivers/orog_gsl/help.out @@ -12,6 +12,8 @@ Optional arguments: Positional arguments: TASK + input_config_file + The input config file input_grid_file The input grid file provisioned_rundir diff --git a/docs/sections/user_guide/cli/drivers/shave/help.out b/docs/sections/user_guide/cli/drivers/shave/help.out index 7cd91374e..75ffdf963 100644 --- a/docs/sections/user_guide/cli/drivers/shave/help.out +++ b/docs/sections/user_guide/cli/drivers/shave/help.out @@ -12,6 +12,8 @@ Optional arguments: Positional arguments: TASK + input_config_file + The input config file provisioned_rundir Run directory provisioned with all required content run diff --git a/docs/sections/user_guide/yaml/components/filter_topo.rst b/docs/sections/user_guide/yaml/components/filter_topo.rst index 638a76c73..4932c7235 100644 --- a/docs/sections/user_guide/yaml/components/filter_topo.rst +++ b/docs/sections/user_guide/yaml/components/filter_topo.rst @@ -20,10 +20,18 @@ config: Configuration parameters for the ``orog_gsl`` component. + **filtered_orog:** + + Name of the filtered output file. + **input_grid_file:** Path to the tiled input grid file. + **input_raw_orog:** + + Path to the raw orography file. The output of the ``orog`` driver. + namelist: ^^^^^^^^^ diff --git a/docs/sections/user_guide/yaml/components/shave.rst b/docs/sections/user_guide/yaml/components/shave.rst index e29ad3920..9c3ff4c06 100644 --- a/docs/sections/user_guide/yaml/components/shave.rst +++ b/docs/sections/user_guide/yaml/components/shave.rst @@ -27,7 +27,7 @@ Describes the required parameters to run a ``shave`` configuration. Name of the grid file with extra points to be shaved. - **nh4:** + **nhalo:** The number of halo rows/columns. @@ -39,6 +39,10 @@ Describes the required parameters to run a ``shave`` configuration. The j/y dimensions of the compute domain (not including halo) + **output_grid_file:** + + The path to the output file. + rundir: ^^^^^^^ diff --git a/docs/shared/filter_topo.yaml b/docs/shared/filter_topo.yaml index c3be0fa05..73fb28bbb 100644 --- a/docs/shared/filter_topo.yaml +++ b/docs/shared/filter_topo.yaml @@ -1,6 +1,8 @@ filter_topo: config: + filtered_orog: C403_filtered_orog.tile7.nc input_grid_file: /path/to/C403_grid.tile7.halo6.nc + input_raw_orog: /path/to/out.oro.nc execution: batchargs: cores: 1 diff --git a/docs/shared/orog.yaml b/docs/shared/orog.yaml index 46df4f687..9fbdef97d 100644 --- a/docs/shared/orog.yaml +++ b/docs/shared/orog.yaml @@ -4,12 +4,12 @@ orog: cores: 1 walltime: 00:05:00 executable: /path/to/orog - files_to_link: - fort.15: /path/to/fix/thirty.second.antarctic.new.bin - landcover30.fixed: /path/to/fix/landcover30.fixed - fort.235: /path/to/fix/gmted2010.30sec.int - grid_file: /path/to/netcdf/grid/file - rundir: /path/to/run/dir + files_to_link: + fort.15: /path/to/fix/thirty.second.antarctic.new.bin + landcover30.fixed: /path/to/fix/landcover30.fixed + fort.235: /path/to/fix/gmted2010.30sec.int + grid_file: /path/to/netcdf/grid/file + rundir: /path/to/run/dir platform: account: me scheduler: slurm diff --git a/docs/shared/shave.yaml b/docs/shared/shave.yaml index 18b55ea46..feacb1941 100644 --- a/docs/shared/shave.yaml +++ b/docs/shared/shave.yaml @@ -1,9 +1,10 @@ shave: config: input_grid_file: /path/to/input/grid/file - nh4: 1 + nhalo: 0 nx: 214 ny: 128 + output_grid_file: /path/to/C403_oro_data.tile7.halo0.nc execution: batchargs: cores: 1 diff --git a/src/uwtools/drivers/filter_topo.py b/src/uwtools/drivers/filter_topo.py index 68261a5f7..849942034 100644 --- a/src/uwtools/drivers/filter_topo.py +++ b/src/uwtools/drivers/filter_topo.py @@ -10,7 +10,7 @@ from uwtools.drivers.driver import DriverTimeInvariant from uwtools.drivers.support import set_driver_docstring from uwtools.strings import STR -from uwtools.utils.tasks import symlink +from uwtools.utils.tasks import filecopy, symlink class FilterTopo(DriverTimeInvariant): @@ -27,10 +27,21 @@ def input_grid_file(self): """ src = Path(self.config["config"]["input_grid_file"]) dst = Path(self.config[STR.rundir], src.name) - yield self.taskname("Input grid") + yield self.taskname(f"Input grid {str(src)}") yield asset(dst, dst.is_file) yield symlink(target=src, linkname=dst) + @task + def filtered_output_file(self): + """ + The filtered output file staged from raw input. + """ + src = Path(self.config["config"]["input_raw_orog"]) + dst = self.rundir / self.config["config"]["filtered_orog"] + yield self.taskname(f"Raw orog input {str(dst)}") + yield asset(dst, dst.is_file) + yield filecopy(src=src, dst=dst) + @task def namelist_file(self): """ @@ -56,6 +67,7 @@ def provisioned_rundir(self): yield self.taskname("provisioned run directory") yield [ self.input_grid_file(), + self.filtered_output_file(), self.namelist_file(), self.runscript(), ] diff --git a/src/uwtools/drivers/orog.py b/src/uwtools/drivers/orog.py index b82c56857..6a1d58d96 100644 --- a/src/uwtools/drivers/orog.py +++ b/src/uwtools/drivers/orog.py @@ -37,7 +37,7 @@ def grid_file(self): The input grid file. """ grid_file = Path(self.config["grid_file"]) - yield self.taskname("Input grid file") + yield self.taskname(f"Input grid file {grid_file}") yield asset(grid_file, grid_file.is_file) if str(grid_file) != "none" else None @task @@ -62,8 +62,9 @@ def input_config_file(self): "blat", ] inputs = " ".join([str(inputs[i]) for i in ordered_entries]) - outgrid = self.config["grid_file"] - orogfile = self.config.get("orog_file") + outgrid = "'{}'".format(self.config["grid_file"]) + if orogfile := self.config.get("orog_file"): + orogfile = "'{}'".format(orogfile) mask_only = ".true." if self.config.get("mask") else ".false." merge_file = self.config.get("merge", "none") # string none is intentional content = [i for i in [inputs, outgrid, orogfile, mask_only, merge_file] if i is not None] @@ -82,6 +83,22 @@ def provisioned_rundir(self): self.runscript(), ] + @task + def runscript(self): + """ + The runscript. + """ + path = self._runscript_path + yield self.taskname(path.name) + yield asset(path, path.is_file) + yield None + envvars = { + "KMP_AFFINITY": "disabled", + "OMP_NUM_THREADS": self.config.get(STR.execution, {}).get(STR.threads, 1), + "OMP_STACKSIZE": "2048m", + } + self._write_runscript(path=path, envvars=envvars) + # Public helper methods @classmethod diff --git a/src/uwtools/drivers/orog_gsl.py b/src/uwtools/drivers/orog_gsl.py index f9dcdb6fe..8bf41c19a 100644 --- a/src/uwtools/drivers/orog_gsl.py +++ b/src/uwtools/drivers/orog_gsl.py @@ -9,6 +9,7 @@ from uwtools.drivers.driver import DriverTimeInvariant from uwtools.drivers.support import set_driver_docstring from uwtools.strings import STR +from uwtools.utils.file import writable from uwtools.utils.tasks import symlink @@ -19,6 +20,19 @@ class OrogGSL(DriverTimeInvariant): # Workflow tasks + @task + def input_config_file(self): + """ + The input config file. + """ + path = self._input_config_path + yield self.taskname(str(path)) + yield asset(path, path.is_file) + yield None + inputs = [str(self.config["config"][k]) for k in ("tile", "resolution", "halo")] + with writable(path) as f: + print("\n".join(inputs), file=f) + @task def input_grid_file(self): """ @@ -40,6 +54,7 @@ def provisioned_rundir(self): """ yield self.taskname("provisioned run directory") yield [ + self.input_config_file(), self.input_grid_file(), self.runscript(), self.topo_data_2p5m(), @@ -81,14 +96,20 @@ def driver_name(cls) -> str: # Private helper methods + @property + def _input_config_path(self) -> Path: + """ + Path to the input config file. + """ + return self.rundir / "orog_gsl.cfg" + @property def _runcmd(self): """ The full command-line component invocation. """ - inputs = [str(self.config["config"][k]) for k in ("tile", "resolution", "halo")] executable = self.config[STR.execution][STR.executable] - return "echo '%s' | %s" % ("\n".join(inputs), executable) + return "%s < %s" % (executable, self._input_config_path.name) set_driver_docstring(OrogGSL) diff --git a/src/uwtools/drivers/shave.py b/src/uwtools/drivers/shave.py index 868d38982..1f809e714 100644 --- a/src/uwtools/drivers/shave.py +++ b/src/uwtools/drivers/shave.py @@ -2,11 +2,15 @@ A driver for shave. """ -from iotaa import tasks +from pathlib import Path + +from iotaa import asset, task, tasks from uwtools.drivers.driver import DriverTimeInvariant from uwtools.drivers.support import set_driver_docstring from uwtools.strings import STR +from uwtools.utils.file import writable +from uwtools.utils.tasks import file class Shave(DriverTimeInvariant): @@ -16,13 +20,34 @@ class Shave(DriverTimeInvariant): # Workflow tasks + @task + def input_config_file(self): + """ + The input config file. + """ + path = self._input_config_path + yield self.taskname(str(path)) + yield asset(path, path.is_file) + config = self.config["config"] + input_file = Path(config["input_grid_file"]) + yield file(path=input_file) + flags = [ + config[key] for key in ["nx", "ny", "nhalo", "input_grid_file", "output_grid_file"] + ] + content = "{} {} {} '{}' '{}'".format(*flags) + with writable(path) as f: + print(content, file=f) + @tasks def provisioned_rundir(self): """ Run directory provisioned with all required content. """ yield self.taskname("provisioned run directory") - yield self.runscript() + yield [ + self.input_config_file(), + self.runscript(), + ] # Public helper methods @@ -35,18 +60,20 @@ def driver_name(cls) -> str: # Private helper methods + @property + def _input_config_path(self) -> Path: + """ + Path to the input config file. + """ + return self.rundir / "shave.cfg" + @property def _runcmd(self): """ The full command-line component invocation. """ executable = self.config[STR.execution][STR.executable] - config = self.config["config"] - input_file = config["input_grid_file"] - output_file = input_file.replace(".nc", "_NH0.nc") - flags = [config[key] for key in ["nx", "ny", "nh4", "input_grid_file"]] - flags.append(output_file) - return f"{executable} {' '.join(str(flag) for flag in flags)}" + return "%s < %s" % (executable, self._input_config_path.name) set_driver_docstring(Shave) diff --git a/src/uwtools/resources/jsonschema/filter-topo.jsonschema b/src/uwtools/resources/jsonschema/filter-topo.jsonschema index 0f88216b3..a988a1fde 100644 --- a/src/uwtools/resources/jsonschema/filter-topo.jsonschema +++ b/src/uwtools/resources/jsonschema/filter-topo.jsonschema @@ -6,12 +6,20 @@ "config": { "additionalProperties": false, "properties": { + "filtered_orog": { + "type": "string" + }, "input_grid_file": { "type": "string" + }, + "input_raw_orog": { + "type": "string" } }, "required": [ - "input_grid_file" + "filtered_orog", + "input_grid_file", + "input_raw_orog" ] }, "execution": { diff --git a/src/uwtools/resources/jsonschema/orog.jsonschema b/src/uwtools/resources/jsonschema/orog.jsonschema index 6798fb1cd..6474b0445 100644 --- a/src/uwtools/resources/jsonschema/orog.jsonschema +++ b/src/uwtools/resources/jsonschema/orog.jsonschema @@ -4,7 +4,7 @@ "additionalProperties": false, "properties": { "execution": { - "$ref": "urn:uwtools:execution-serial" + "$ref": "urn:uwtools:execution" }, "files_to_link": { "$ref": "urn:uwtools:files-to-stage" diff --git a/src/uwtools/resources/jsonschema/shave.jsonschema b/src/uwtools/resources/jsonschema/shave.jsonschema index 7426e0b25..ad3b9f741 100644 --- a/src/uwtools/resources/jsonschema/shave.jsonschema +++ b/src/uwtools/resources/jsonschema/shave.jsonschema @@ -9,8 +9,8 @@ "input_grid_file": { "type": "string" }, - "nh4": { - "minimum": 1, + "nhalo": { + "minimum": 0, "type": "integer" }, "nx": { @@ -20,13 +20,17 @@ "ny": { "minimum": 1, "type": "integer" + }, + "output_grid_file": { + "type": "string" } }, "required": [ "input_grid_file", - "nh4", + "nhalo", "nx", - "ny" + "ny", + "output_grid_file" ] }, "execution": { diff --git a/src/uwtools/tests/drivers/test_cdeps.py b/src/uwtools/tests/drivers/test_cdeps.py index 66ad90867..6d7d5a378 100644 --- a/src/uwtools/tests/drivers/test_cdeps.py +++ b/src/uwtools/tests/drivers/test_cdeps.py @@ -43,6 +43,10 @@ def test_CDEPS_atm(driverobj): atm_nml.assert_called_once_with() +def test_CDEPS_driver_name(driverobj): + assert driverobj.driver_name() == CDEPS.driver_name() == "cdeps" + + @mark.parametrize("group", ["atm", "ocn"]) def test_CDEPS_nml(caplog, driverobj, group): log.setLevel(logging.DEBUG) @@ -111,10 +115,6 @@ def test_CDEPS_streams(driverobj, group): assert f.read().strip() == dedent(expected).strip() -def test_CDEPS_driver_name(driverobj): - assert driverobj.driver_name() == CDEPS.driver_name() == "cdeps" - - def test_CDEPS__model_namelist_file(driverobj): group = "atm_in" path = Path("/path/to/some.nml") diff --git a/src/uwtools/tests/drivers/test_chgres_cube.py b/src/uwtools/tests/drivers/test_chgres_cube.py index de399e7fe..447037cca 100644 --- a/src/uwtools/tests/drivers/test_chgres_cube.py +++ b/src/uwtools/tests/drivers/test_chgres_cube.py @@ -103,6 +103,10 @@ def test_ChgresCube(method): assert getattr(ChgresCube, method) is getattr(Driver, method) +def test_ChgresCube_driver_name(driverobj): + assert driverobj.driver_name() == ChgresCube.driver_name() == "chgres_cube" + + def test_ChgresCube_namelist_file(caplog, driverobj): log.setLevel(logging.DEBUG) dst = driverobj.rundir / "fort.41" @@ -147,9 +151,5 @@ def test_ChgresCube_runscript(driverobj): assert [type(runscript.call_args.kwargs[x]) for x in args] == types -def test_ChgresCube_driver_name(driverobj): - assert driverobj.driver_name() == ChgresCube.driver_name() == "chgres_cube" - - def test_ChgresCube_taskname(driverobj): assert driverobj.taskname("foo") == "20240201 18Z chgres_cube foo" diff --git a/src/uwtools/tests/drivers/test_driver.py b/src/uwtools/tests/drivers/test_driver.py index 4a910f9bc..9b5b11a3f 100644 --- a/src/uwtools/tests/drivers/test_driver.py +++ b/src/uwtools/tests/drivers/test_driver.py @@ -36,13 +36,13 @@ def atask(self): yield "atask" yield asset("atask", lambda: True) - def provisioned_rundir(self): - pass - @classmethod def driver_name(cls) -> str: return "concrete" + def provisioned_rundir(self): + pass + def _validate(self, schema_file: Optional[Path] = None) -> None: pass @@ -532,6 +532,10 @@ def test_Driver__runscript(driverobj): ) +def test_Driver__runscript_done_file(driverobj): + assert driverobj._runscript_done_file == "runscript.concrete.done" + + def test_Driver__runscript_execution_only(driverobj): expected = """ #!/bin/bash @@ -542,10 +546,6 @@ def test_Driver__runscript_execution_only(driverobj): assert driverobj._runscript(execution=["foo", "bar"]) == dedent(expected).strip() -def test_Driver__runscript_done_file(driverobj): - assert driverobj._runscript_done_file == "runscript.concrete.done" - - def test_Driver__runscript_path(driverobj): rundir = Path(driverobj.config["rundir"]) assert driverobj._runscript_path == rundir / "runscript.concrete" @@ -558,6 +558,18 @@ def test_Driver__scheduler(driverobj): JobScheduler.get_scheduler.assert_called_with(driverobj._run_resources) +def test_Driver__validate_external(config): + schema_file = Path("/path/to/jsonschema") + with patch.object(ConcreteAssetsTimeInvariant, "_validate", driver.Driver._validate): + with patch.object(driver, "validate_external") as validate_external: + assetsobj = ConcreteAssetsTimeInvariant(schema_file=schema_file, config=config) + assert validate_external.call_args_list[0].kwargs == { + "schema_file": schema_file, + "desc": "concrete config", + "config": assetsobj.config_full, + } + + def test_Driver__validate_internal(assetsobj): with patch.object(assetsobj, "_validate", driver.Driver._validate): with patch.object(driver, "validate_internal") as validate_internal: @@ -574,18 +586,6 @@ def test_Driver__validate_internal(assetsobj): } -def test_Driver__validate_external(config): - schema_file = Path("/path/to/jsonschema") - with patch.object(ConcreteAssetsTimeInvariant, "_validate", driver.Driver._validate): - with patch.object(driver, "validate_external") as validate_external: - assetsobj = ConcreteAssetsTimeInvariant(schema_file=schema_file, config=config) - assert validate_external.call_args_list[0].kwargs == { - "schema_file": schema_file, - "desc": "concrete config", - "config": assetsobj.config_full, - } - - def test_Driver__write_runscript(driverobj): rundir = driverobj.config["rundir"] path = Path(rundir, "runscript") diff --git a/src/uwtools/tests/drivers/test_esg_grid.py b/src/uwtools/tests/drivers/test_esg_grid.py index 950ce2d6d..f7d891698 100644 --- a/src/uwtools/tests/drivers/test_esg_grid.py +++ b/src/uwtools/tests/drivers/test_esg_grid.py @@ -84,6 +84,10 @@ def test_ESGGrid(method): assert getattr(ESGGrid, method) is getattr(Driver, method) +def test_ESGGrid_driver_name(driverobj): + assert driverobj.driver_name() == ESGGrid.driver_name() == "esg_grid" + + def test_ESGGrid_namelist_file(caplog, driverobj): log.setLevel(logging.DEBUG) dst = driverobj.rundir / "regional_grid.nml" @@ -121,7 +125,3 @@ def test_ESGGrid_provisioned_rundir(driverobj): driverobj.provisioned_rundir() for m in mocks: mocks[m].assert_called_once_with() - - -def test_FilterTopo_driver_name(driverobj): - assert driverobj.driver_name() == ESGGrid.driver_name() == "esg_grid" diff --git a/src/uwtools/tests/drivers/test_filter_topo.py b/src/uwtools/tests/drivers/test_filter_topo.py index 54b243fdf..490ca3695 100644 --- a/src/uwtools/tests/drivers/test_filter_topo.py +++ b/src/uwtools/tests/drivers/test_filter_topo.py @@ -21,10 +21,14 @@ def config(tmp_path): input_grid_file = tmp_path / "C403_grid.tile7.halo4.nc" input_grid_file.touch() + orog_output = tmp_path / "out.oro.nc" + orog_output.touch() return { "filter_topo": { "config": { + "filtered_orog": "C403_filtered_orog.tile7.nc", "input_grid_file": str(input_grid_file), + "input_raw_orog": str(orog_output), }, "execution": { "executable": "/path/to/orog_gsl", @@ -79,6 +83,17 @@ def test_FilterTopo(method): assert getattr(FilterTopo, method) is getattr(Driver, method) +def test_FilterTopo_driver_name(driverobj): + assert driverobj.driver_name() == FilterTopo.driver_name() == "filter_topo" + + +def test_FilterTopo_filtered_output_file(driverobj): + path = Path(driverobj.config["rundir"], "C403_filtered_orog.tile7.nc") + assert not path.is_file() + driverobj.filtered_output_file() + assert path.is_file() + + def test_FilterTopo_input_grid_file(driverobj): path = Path(driverobj.config["rundir"], "C403_grid.tile7.halo4.nc") assert not path.is_file() @@ -94,11 +109,9 @@ def test_FilterTopo_namelist_file(driverobj): def test_FilterTopo_provisioned_rundir(driverobj): - with patch.multiple(driverobj, input_grid_file=D, namelist_file=D, runscript=D) as mocks: + with patch.multiple( + driverobj, input_grid_file=D, filtered_output_file=D, namelist_file=D, runscript=D + ) as mocks: driverobj.provisioned_rundir() for m in mocks: mocks[m].assert_called_once_with() - - -def test_FilterTopo_driver_name(driverobj): - assert driverobj.driver_name() == FilterTopo.driver_name() == "filter_topo" diff --git a/src/uwtools/tests/drivers/test_fv3.py b/src/uwtools/tests/drivers/test_fv3.py index ebc2a08bf..2c7cf069a 100644 --- a/src/uwtools/tests/drivers/test_fv3.py +++ b/src/uwtools/tests/drivers/test_fv3.py @@ -126,6 +126,10 @@ def test_FV3_diag_table_warn(caplog, driverobj): assert logged(caplog, "No 'diag_table' defined in config") +def test_FV3_driver_name(driverobj): + assert driverobj.driver_name() == FV3.driver_name() == "fv3" + + def test_FV3_field_table(driverobj): src = driverobj.rundir / "field_table.in" src.touch() @@ -247,9 +251,5 @@ def test_FV3_runscript(driverobj): assert [type(runscript.call_args.kwargs[x]) for x in args] == types -def test_FV3_driver_name(driverobj): - assert driverobj.driver_name() == FV3.driver_name() == "fv3" - - def test_FV3_taskname(driverobj): assert driverobj.taskname("foo") == "20240201 18Z fv3 foo" diff --git a/src/uwtools/tests/drivers/test_global_equiv_resol.py b/src/uwtools/tests/drivers/test_global_equiv_resol.py index f95201940..bd993f14a 100644 --- a/src/uwtools/tests/drivers/test_global_equiv_resol.py +++ b/src/uwtools/tests/drivers/test_global_equiv_resol.py @@ -64,6 +64,10 @@ def test_GlobalEquivResol(method): assert getattr(GlobalEquivResol, method) is getattr(Driver, method) +def test_GlobalEquivResol_driver_name(driverobj): + assert driverobj.driver_name() == GlobalEquivResol.driver_name() == "global_equiv_resol" + + def test_GlobalEquivResol_input_file(driverobj): path = Path(driverobj.config["input_grid_file"]) assert not driverobj.input_file().ready() @@ -83,10 +87,6 @@ def test_GlobalEquivResol_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() -def test_FilterTopo_driver_name(driverobj): - assert driverobj.driver_name() == GlobalEquivResol.driver_name() == "global_equiv_resol" - - def test_GlobalEquivResol__runcmd(driverobj): cmd = driverobj._runcmd input_file_path = driverobj.config["input_grid_file"] diff --git a/src/uwtools/tests/drivers/test_ioda.py b/src/uwtools/tests/drivers/test_ioda.py index 65527f86c..50936d051 100644 --- a/src/uwtools/tests/drivers/test_ioda.py +++ b/src/uwtools/tests/drivers/test_ioda.py @@ -87,6 +87,10 @@ def test_IODA(method): assert getattr(IODA, method) is getattr(JEDIBase, method) +def test_IODA_driver_name(driverobj): + assert driverobj.driver_name() == IODA.driver_name() == "ioda" + + def test_IODA_provisioned_rundir(driverobj): with patch.multiple( driverobj, @@ -100,8 +104,8 @@ def test_IODA_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() -def test_IODA_driver_name(driverobj): - assert driverobj.driver_name() == IODA.driver_name() == "ioda" +def test_IODA_taskname(driverobj): + assert driverobj.taskname("foo") == "20240501 06Z ioda foo" def test_IODA__config_fn(driverobj): @@ -111,7 +115,3 @@ def test_IODA__config_fn(driverobj): def test_IODA__runcmd(driverobj): config = str(driverobj.rundir / driverobj._config_fn) assert driverobj._runcmd == f"/path/to/bufr2ioda.x {config}" - - -def test_IODA_taskname(driverobj): - assert driverobj.taskname("foo") == "20240501 06Z ioda foo" diff --git a/src/uwtools/tests/drivers/test_jedi.py b/src/uwtools/tests/drivers/test_jedi.py index ebd7ecfcc..d5378e281 100644 --- a/src/uwtools/tests/drivers/test_jedi.py +++ b/src/uwtools/tests/drivers/test_jedi.py @@ -121,6 +121,10 @@ def test_JEDI_configuration_file_missing_base_file(caplog, driverobj): assert regex_logged(caplog, f"{base_file}: State: Not Ready (external asset)") +def test_JEDI_driver_name(driverobj): + assert driverobj.driver_name() == JEDI.driver_name() == "jedi" + + def test_JEDI_files_copied(driverobj): with patch.object(jedi_base, "filecopy") as filecopy: driverobj._config["rundir"] = "/path/to/run" @@ -164,6 +168,10 @@ def test_JEDI_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() +def test_JEDI_taskname(driverobj): + assert driverobj.taskname("foo") == "20240201 18Z jedi foo" + + def test_JEDI_validate_only(caplog, driverobj): @external @@ -188,10 +196,6 @@ def file(path: Path): assert regex_logged(caplog, "Config is valid") -def test_JEDI_driver_name(driverobj): - assert driverobj.driver_name() == JEDI.driver_name() == "jedi" - - def test_JEDI__config_fn(driverobj): assert driverobj._config_fn == "jedi.yaml" @@ -202,7 +206,3 @@ def test_JEDI__runcmd(driverobj): assert ( driverobj._runcmd == f"srun --export=ALL --ntasks $SLURM_CPUS_ON_NODE {executable} {config}" ) - - -def test_JEDI_taskname(driverobj): - assert driverobj.taskname("foo") == "20240201 18Z jedi foo" diff --git a/src/uwtools/tests/drivers/test_make_hgrid.py b/src/uwtools/tests/drivers/test_make_hgrid.py index 74874e40d..bcf90d86c 100644 --- a/src/uwtools/tests/drivers/test_make_hgrid.py +++ b/src/uwtools/tests/drivers/test_make_hgrid.py @@ -69,6 +69,10 @@ def test_MakeHgrid(method): assert getattr(MakeHgrid, method) is getattr(Driver, method) +def test_MakeHgrid_driver_name(driverobj): + assert driverobj.driver_name() == MakeHgrid.driver_name() == "make_hgrid" + + def test_MakeHgrid_provisioned_rundir(driverobj): with patch.multiple(driverobj, runscript=D) as mocks: driverobj.provisioned_rundir() @@ -76,10 +80,6 @@ def test_MakeHgrid_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() -def test_MakeHgrid_driver_name(driverobj): - assert driverobj.driver_name() == MakeHgrid.driver_name() == "make_hgrid" - - def test_MakeHgrid__runcmd(driverobj): expected = [ "/path/to/make_hgrid", diff --git a/src/uwtools/tests/drivers/test_make_solo_mosaic.py b/src/uwtools/tests/drivers/test_make_solo_mosaic.py index bdd0f4573..54ad6f5a6 100644 --- a/src/uwtools/tests/drivers/test_make_solo_mosaic.py +++ b/src/uwtools/tests/drivers/test_make_solo_mosaic.py @@ -64,14 +64,18 @@ def test_MakeSoloMosaic(method): assert getattr(MakeSoloMosaic, method) is getattr(Driver, method) +def test_MakeSoloMosaic_driver_name(driverobj): + assert driverobj.driver_name() == MakeSoloMosaic.driver_name() == "make_solo_mosaic" + + def test_MakeSoloMosaic_provisioned_rundir(driverobj): with patch.object(driverobj, "runscript") as runscript: driverobj.provisioned_rundir() runscript.assert_called_once_with() -def test_MakeSoloMosaic_driver_name(driverobj): - assert driverobj.driver_name() == MakeSoloMosaic.driver_name() == "make_solo_mosaic" +def test_MakeSoloMosaic_taskname(driverobj): + assert driverobj.taskname("foo") == "make_solo_mosaic foo" def test_MakeSoloMosaic__runcmd(driverobj): @@ -80,9 +84,5 @@ def test_MakeSoloMosaic__runcmd(driverobj): assert cmd == f"/path/to/make_solo_mosaic.exe --dir {dir_path} --num_tiles 1" -def test_MakeSoloMosaic_taskname(driverobj): - assert driverobj.taskname("foo") == "make_solo_mosaic foo" - - def test_MakeSoloMosaic__validate(driverobj): driverobj._validate() diff --git a/src/uwtools/tests/drivers/test_mpas.py b/src/uwtools/tests/drivers/test_mpas.py index b85a55588..d1a85ac23 100644 --- a/src/uwtools/tests/drivers/test_mpas.py +++ b/src/uwtools/tests/drivers/test_mpas.py @@ -156,6 +156,10 @@ def test_MPAS_boundary_files(driverobj, cycle): assert all(link.is_symlink() for link in links) +def test_MPAS_driver_name(driverobj): + assert driverobj.driver_name() == MPAS.driver_name() == "mpas" + + @mark.parametrize( "key,task,test", [("files_to_copy", "files_copied", "is_file"), ("files_to_link", "files_linked", "is_symlink")], @@ -189,6 +193,15 @@ def test_MPAS_namelist_file(caplog, driverobj): assert isinstance(nml, f90nml.Namelist) +def test_MPAS_namelist_file_fails_validation(caplog, driverobj): + log.setLevel(logging.DEBUG) + driverobj._config["namelist"]["update_values"]["nhyd_model"]["foo"] = None + path = Path(refs(driverobj.namelist_file())) + assert not path.exists() + assert logged(caplog, f"Failed to validate {path}") + assert logged(caplog, " None is not of type 'array', 'boolean', 'number', 'string'") + + def test_MPAS_namelist_file_long_duration(caplog, config, cycle): log.setLevel(logging.DEBUG) config["mpas"]["length"] = 120 @@ -203,15 +216,6 @@ def test_MPAS_namelist_file_long_duration(caplog, config, cycle): assert nml["nhyd_model"]["config_run_duration"] == "5_0:00:00" -def test_MPAS_namelist_file_fails_validation(caplog, driverobj): - log.setLevel(logging.DEBUG) - driverobj._config["namelist"]["update_values"]["nhyd_model"]["foo"] = None - path = Path(refs(driverobj.namelist_file())) - assert not path.exists() - assert logged(caplog, f"Failed to validate {path}") - assert logged(caplog, " None is not of type 'array', 'boolean', 'number', 'string'") - - def test_MPAS_namelist_file_missing_base_file(caplog, driverobj): log.setLevel(logging.DEBUG) base_file = str(Path(driverobj.config["rundir"], "missing.nml")) @@ -236,10 +240,6 @@ def test_MPAS_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() -def test_MPAS_driver_name(driverobj): - assert driverobj.driver_name() == MPAS.driver_name() == "mpas" - - def test_MPAS_streams_file(config, driverobj): streams_file(config, driverobj, "mpas") diff --git a/src/uwtools/tests/drivers/test_mpas_init.py b/src/uwtools/tests/drivers/test_mpas_init.py index f9784755c..cac08798a 100644 --- a/src/uwtools/tests/drivers/test_mpas_init.py +++ b/src/uwtools/tests/drivers/test_mpas_init.py @@ -137,6 +137,10 @@ def test_MPASInit_boundary_files(cycle, driverobj): assert all(link.is_symlink() for link in links) +def test_MPASInit_driver_name(driverobj): + assert driverobj.driver_name() == MPASInit.driver_name() == "mpas_init" + + @mark.parametrize( "key,task,test", [("files_to_copy", "files_copied", "is_file"), ("files_to_link", "files_linked", "is_symlink")], @@ -211,10 +215,6 @@ def test_MPASInit_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() -def test_MPASInit_driver_name(driverobj): - assert driverobj.driver_name() == MPASInit.driver_name() == "mpas_init" - - def test_MPASInit_streams_file(config, driverobj): streams_file(config, driverobj, "mpas_init") diff --git a/src/uwtools/tests/drivers/test_orog.py b/src/uwtools/tests/drivers/test_orog.py index 2092dc82e..d4a231d56 100644 --- a/src/uwtools/tests/drivers/test_orog.py +++ b/src/uwtools/tests/drivers/test_orog.py @@ -12,6 +12,7 @@ from uwtools.drivers.driver import Driver from uwtools.drivers.orog import Orog from uwtools.logging import log +from uwtools.scheduler import Slurm from uwtools.tests.support import regex_logged # Fixtures @@ -76,7 +77,6 @@ def driverobj(config): "_validate", "_write_runscript", "run", - "runscript", "taskname", ], ) @@ -84,6 +84,10 @@ def test_Orog(method): assert getattr(Orog, method) is getattr(Driver, method) +def test_Orog_driver_name(driverobj): + assert driverobj.driver_name() == Orog.driver_name() == "orog" + + def test_Orog_files_linked(driverobj): for _, src in driverobj.config["files_to_link"].items(): Path(src).touch() @@ -98,10 +102,10 @@ def test_Orog_files_linked(driverobj): def test_Orog_grid_file_existence(caplog, driverobj, exist): log.setLevel(logging.DEBUG) grid_file = Path(driverobj.config["grid_file"]) - status = "Input grid file: State: Not Ready (external asset)" + status = f"Input grid file {str(grid_file)}: State: Not Ready (external asset)" if exist: grid_file.touch() - status = "Input grid file: State: Ready" + status = f"Input grid file {str(grid_file)}: State: Ready" driverobj.grid_file() assert regex_logged(caplog, status) @@ -110,7 +114,7 @@ def test_Orog_grid_file_nonexistence(caplog, driverobj): log.setLevel(logging.INFO) driverobj._config["grid_file"] = "none" driverobj.grid_file() - assert regex_logged(caplog, "Input grid file: State: Ready") + assert regex_logged(caplog, "Input grid file none: State: Ready") def test_Orog_input_config_file_new(driverobj): @@ -123,7 +127,7 @@ def test_Orog_input_config_file_new(driverobj): content = inps.readlines() content = [l.strip("\n") for l in content] assert len(content) == 3 - assert content[0] == driverobj.config["grid_file"] + assert content[0] == "'{}'".format(driverobj.config["grid_file"]) assert content[1] == ".false." assert content[2] == "none" @@ -137,8 +141,8 @@ def test_Orog_input_config_file_old(driverobj): content = [l.strip("\n") for l in content] assert len(content) == 5 assert len(content[0].split()) == 9 - assert content[1] == driverobj.config["grid_file"] - assert content[2] == driverobj.config.get("orog_file") + assert content[1] == "'{}'".format(driverobj.config["grid_file"]) + assert content[2] == "'{}'".format(driverobj.config["orog_file"]) assert content[3] == ".false." assert content[4] == "none" @@ -150,8 +154,13 @@ def test_Orog_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() -def test_Orog_driver_name(driverobj): - assert driverobj.driver_name() == Orog.driver_name() == "orog" +def test_Orog_runscript(driverobj): + with patch.object(driverobj, "_runscript") as runscript: + driverobj.runscript() + runscript.assert_called_once() + args = ("envcmds", "envvars", "execution", "scheduler") + types = [list, dict, list, Slurm] + assert [type(runscript.call_args.kwargs[x]) for x in args] == types def test_Orog__runcmd(driverobj): diff --git a/src/uwtools/tests/drivers/test_orog_gsl.py b/src/uwtools/tests/drivers/test_orog_gsl.py index a942aa687..3fa2fc6ad 100644 --- a/src/uwtools/tests/drivers/test_orog_gsl.py +++ b/src/uwtools/tests/drivers/test_orog_gsl.py @@ -72,6 +72,20 @@ def test_OrogGSL(method): assert getattr(OrogGSL, method) is getattr(Driver, method) +def test_OrogGSL_driver_name(driverobj): + assert driverobj.driver_name() == OrogGSL.driver_name() == "orog_gsl" + + +def test_OrogGSL_input_config_file(driverobj): + driverobj.input_config_file() + inputs = [str(driverobj.config["config"][k]) for k in ("tile", "resolution", "halo")] + with open(driverobj._input_config_path, "r", encoding="utf-8") as cfg_file: + content = cfg_file.readlines() + content = [l.strip("\n") for l in content] + assert len(content) == 3 + assert content == inputs + + def test_OrogGSL_input_grid_file(driverobj): path = Path(driverobj.config["rundir"], "C403_grid.tile7.halo4.nc") assert not path.is_file() @@ -81,7 +95,12 @@ def test_OrogGSL_input_grid_file(driverobj): def test_OrogGSL_provisioned_rundir(driverobj): with patch.multiple( - driverobj, input_grid_file=D, runscript=D, topo_data_2p5m=D, topo_data_30s=D + driverobj, + input_config_file=D, + input_grid_file=D, + runscript=D, + topo_data_2p5m=D, + topo_data_30s=D, ) as mocks: driverobj.provisioned_rundir() for m in mocks: @@ -102,13 +121,8 @@ def test_OrogGSL_topo_data_3os(driverobj): assert path.is_symlink() -def test_OrogGSL_driver_name(driverobj): - assert driverobj.driver_name() == OrogGSL.driver_name() == "orog_gsl" - - def test_OrogGSL__runcmd(driverobj): - inputs = [str(driverobj.config["config"][k]) for k in ("tile", "resolution", "halo")] - assert driverobj._runcmd == "echo '%s' | %s" % ( - "\n".join(inputs), + assert driverobj._runcmd == "%s < %s" % ( driverobj.config["execution"]["executable"], + driverobj._input_config_path.name, ) diff --git a/src/uwtools/tests/drivers/test_schism.py b/src/uwtools/tests/drivers/test_schism.py index 0c5fea845..8800174f1 100644 --- a/src/uwtools/tests/drivers/test_schism.py +++ b/src/uwtools/tests/drivers/test_schism.py @@ -51,6 +51,10 @@ def test_SCHISM(method): assert getattr(SCHISM, method) is getattr(AssetsCycleBased, method) +def test_SCHISM_driver_name(driverobj): + assert driverobj.driver_name() == SCHISM.driver_name() == "schism" + + def test_SCHISM_namelist_file(driverobj): src = driverobj.config["namelist"]["template_file"] with open(src, "w", encoding="utf-8") as f: @@ -69,7 +73,3 @@ def test_SCHISM_provisioned_rundir(driverobj): driverobj.provisioned_rundir() for m in mocks: mocks[m].assert_called_once_with() - - -def test_SCHISM_driver_name(driverobj): - assert driverobj.driver_name() == SCHISM.driver_name() == "schism" diff --git a/src/uwtools/tests/drivers/test_sfc_climo_gen.py b/src/uwtools/tests/drivers/test_sfc_climo_gen.py index a9355f42d..c5c9d8d21 100644 --- a/src/uwtools/tests/drivers/test_sfc_climo_gen.py +++ b/src/uwtools/tests/drivers/test_sfc_climo_gen.py @@ -106,6 +106,10 @@ def test_SfcClimoGen(method): assert getattr(SfcClimoGen, method) is getattr(Driver, method) +def test_SfcClimoGen_driver_name(driverobj): + assert driverobj.driver_name() == SfcClimoGen.driver_name() == "sfc_climo_gen" + + def test_SfcClimoGen_namelist_file(caplog, driverobj): log.setLevel(logging.DEBUG) dst = driverobj.rundir / "fort.41" @@ -136,7 +140,3 @@ def test_SfcClimoGen_provisioned_rundir(driverobj): driverobj.provisioned_rundir() for m in mocks: mocks[m].assert_called_once_with() - - -def test_SfcClimoGen_driver_name(driverobj): - assert driverobj.driver_name() == SfcClimoGen.driver_name() == "sfc_climo_gen" diff --git a/src/uwtools/tests/drivers/test_shave.py b/src/uwtools/tests/drivers/test_shave.py index b6ac9c2a2..951594f32 100644 --- a/src/uwtools/tests/drivers/test_shave.py +++ b/src/uwtools/tests/drivers/test_shave.py @@ -2,6 +2,7 @@ """ Shave driver tests. """ +from pathlib import Path from unittest.mock import DEFAULT as D from unittest.mock import patch @@ -27,8 +28,9 @@ def config(tmp_path): "executable": "/path/to/shave", }, "config": { - "input_grid_file": "/path/to/input/grid/file.nc", - "nh4": 1, + "input_grid_file": str(tmp_path / "input_file.nc"), + "output_grid_file": "/path/to/input/grid/file.nc", + "nhalo": 1, "nx": 214, "ny": 128, }, @@ -70,9 +72,29 @@ def test_Shave(method): assert getattr(Shave, method) is getattr(Driver, method) +def test_Shave_driver_name(driverobj): + assert driverobj.driver_name() == Shave.driver_name() == "shave" + + +def test_Shave_input_config_file(driverobj): + nx = driverobj.config["config"]["nx"] + ny = driverobj.config["config"]["ny"] + nhalo = driverobj.config["config"]["nhalo"] + input_file_path = driverobj._config["config"]["input_grid_file"] + Path(input_file_path).touch() + output_file_path = driverobj._config["config"]["output_grid_file"] + driverobj.input_config_file() + with open(driverobj._input_config_path, "r", encoding="utf-8") as cfg_file: + content = cfg_file.readlines() + content = [l.strip("\n") for l in content] + assert len(content) == 1 + assert content[0] == f"{nx} {ny} {nhalo} '{input_file_path}' '{output_file_path}'" + + def test_Shave_provisioned_rundir(driverobj): with patch.multiple( driverobj, + input_config_file=D, runscript=D, ) as mocks: driverobj.provisioned_rundir() @@ -80,15 +102,6 @@ def test_Shave_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() -def test_Shave_driver_name(driverobj): - assert driverobj.driver_name() == Shave.driver_name() == "shave" - - def test_Shave__runcmd(driverobj): cmd = driverobj._runcmd - nx = driverobj.config["config"]["nx"] - ny = driverobj.config["config"]["ny"] - nh4 = driverobj.config["config"]["nh4"] - input_file_path = driverobj._config["config"]["input_grid_file"] - output_file_path = input_file_path.replace(".nc", "_NH0.nc") - assert cmd == f"/path/to/shave {nx} {ny} {nh4} {input_file_path} {output_file_path}" + assert cmd == "/path/to/shave < shave.cfg" diff --git a/src/uwtools/tests/drivers/test_support.py b/src/uwtools/tests/drivers/test_support.py index d9cf9545b..05886c0ac 100644 --- a/src/uwtools/tests/drivers/test_support.py +++ b/src/uwtools/tests/drivers/test_support.py @@ -41,6 +41,10 @@ class Child(Parent): def test_tasks(): class SomeDriver(DriverTimeInvariant): + @classmethod + def driver_name(cls): + pass + def provisioned_rundir(self): pass @@ -59,10 +63,6 @@ def t2(self): def t3(self): "@tasks t3" - @classmethod - def driver_name(cls): - pass - @property def _resources(self): pass diff --git a/src/uwtools/tests/drivers/test_ungrib.py b/src/uwtools/tests/drivers/test_ungrib.py index be2a15388..9827483ae 100644 --- a/src/uwtools/tests/drivers/test_ungrib.py +++ b/src/uwtools/tests/drivers/test_ungrib.py @@ -77,6 +77,10 @@ def test_Ungrib(method): assert getattr(Ungrib, method) is getattr(Driver, method) +def test_Ungrib_driver_name(driverobj): + assert driverobj.driver_name() == Ungrib.driver_name() == "ungrib" + + def test_Ungrib_gribfiles(driverobj, tmp_path): links = [] cycle_hr = 12 @@ -115,6 +119,10 @@ def test_Ungrib_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() +def test_Ungrib_taskname(driverobj): + assert driverobj.taskname("foo") == "20240201 18Z ungrib foo" + + def test_Ungrib_vtable(driverobj): src = driverobj.rundir / "Vtable.GFS.in" src.touch() @@ -125,10 +133,6 @@ def test_Ungrib_vtable(driverobj): assert dst.is_symlink() -def test_Ungrib_driver_name(driverobj): - assert driverobj.driver_name() == Ungrib.driver_name() == "ungrib" - - def test_Ungrib__gribfile(driverobj): src = driverobj.rundir / "GRIBFILE.AAA.in" src.touch() @@ -138,10 +142,6 @@ def test_Ungrib__gribfile(driverobj): assert dst.is_symlink() -def test_Ungrib_taskname(driverobj): - assert driverobj.taskname("foo") == "20240201 18Z ungrib foo" - - def test__ext(): assert ungrib._ext(0) == "AAA" assert ungrib._ext(26) == "ABA" diff --git a/src/uwtools/tests/drivers/test_upp.py b/src/uwtools/tests/drivers/test_upp.py index bd7712c0f..1d869a5c4 100644 --- a/src/uwtools/tests/drivers/test_upp.py +++ b/src/uwtools/tests/drivers/test_upp.py @@ -97,6 +97,10 @@ def test_UPP(method): assert getattr(UPP, method) is getattr(Driver, method) +def test_UPP_driver_name(driverobj): + assert driverobj.driver_name() == UPP.driver_name() == "upp" + + def test_UPP_files_copied(driverobj): for _, src in driverobj.config["files_to_copy"].items(): Path(src).touch() @@ -167,8 +171,8 @@ def test_UPP_provisioned_rundir(driverobj): mocks[m].assert_called_once_with() -def test_UPP_driver_name(driverobj): - assert driverobj.driver_name() == UPP.driver_name() == "upp" +def test_UPP_taskname(driverobj): + assert driverobj.taskname("foo") == "20240507 12:00:00 upp foo" def test_UPP__namelist_path(driverobj): @@ -177,7 +181,3 @@ def test_UPP__namelist_path(driverobj): def test_UPP__runcmd(driverobj): assert driverobj._runcmd == "%s < itag" % driverobj.config["execution"]["executable"] - - -def test_UPP_taskname(driverobj): - assert driverobj.taskname("foo") == "20240507 12:00:00 upp foo" diff --git a/src/uwtools/tests/drivers/test_ww3.py b/src/uwtools/tests/drivers/test_ww3.py index 253080387..04a4aea90 100644 --- a/src/uwtools/tests/drivers/test_ww3.py +++ b/src/uwtools/tests/drivers/test_ww3.py @@ -51,6 +51,10 @@ def test_WaveWatchIII(method): assert getattr(WaveWatchIII, method) is getattr(AssetsCycleBased, method) +def test_WaveWatchIII_driver_name(driverobj): + assert driverobj.driver_name() == WaveWatchIII.driver_name() == "ww3" + + def test_WaveWatchIII_namelist_file(driverobj): src = driverobj.config["namelist"]["template_file"] with open(src, "w", encoding="utf-8") as f: @@ -77,7 +81,3 @@ def test_WaveWatchIII_restart_directory(driverobj): assert not path.is_dir() driverobj.restart_directory() assert path.is_dir() - - -def test_WaveWatchIII_driver_name(driverobj): - assert driverobj.driver_name() == WaveWatchIII.driver_name() == "ww3" diff --git a/src/uwtools/tests/test_schemas.py b/src/uwtools/tests/test_schemas.py index b2d616209..811f24776 100644 --- a/src/uwtools/tests/test_schemas.py +++ b/src/uwtools/tests/test_schemas.py @@ -758,6 +758,8 @@ def test_schema_filter_topo(): config = { "config": { "input_grid_file": "/path/to/grid/file", + "filtered_orog": "/path/to/filtered/orog/file", + "input_raw_orog": "/path/to/raw/orog/file", }, "execution": { "executable": "/path/to/filter_topo", @@ -791,7 +793,7 @@ def test_schema_filter_topo(): # Top-level rundir key requires a string value: assert "is not of type 'string'\n" in errors(with_set(config, None, "rundir")) # All config keys are requried: - for key in ["input_grid_file"]: + for key in ["filtered_orog", "input_grid_file", "input_raw_orog"]: assert f"'{key}' is a required property" in errors(with_del(config, "config", key)) # Other config keys are not allowed: assert "Additional properties are not allowed" in errors( @@ -1869,9 +1871,10 @@ def test_schema_shave(): config = { "config": { "input_grid_file": "/path/to/input_grid_file", + "output_grid_file": "/path/to/output_grid_file", "nx": 42, "ny": 42, - "nh4": 1, + "nhalo": 1, }, "execution": {"executable": "shave"}, "rundir": "/tmp", @@ -1889,16 +1892,19 @@ def test_schema_shave(): def test_schema_shave_config_properties(): # Get errors function from schema_validator errors = schema_validator("shave", "properties", "shave", "properties", "config") - for key in ("input_grid_file", "nx", "ny", "nh4"): + for key in ("input_grid_file", "nx", "ny", "nhalo"): # All config keys are required: assert f"'{key}' is a required property" in errors({}) # A string value is ok for input_grid_file: if key == "input_grid_file": assert "not of type 'string'" in str(errors({key: 42})) - # nx, ny, and nh4 must be positive integers: - elif key in ["nx", "ny", "nh4"]: + # nx, ny, and nhalo must be integers >= their respective minimum values: + elif key in (keyvals := {"nx": 1, "ny": 1, "nhalo": 0}): + minval = keyvals[key] assert "not of type 'integer'" in str(errors({key: "/path/"})) - assert "0 is less than the minimum of 1" in str(errors({key: 0})) + assert f"{minval - 1} is less than the minimum of {minval}" in str( + errors({key: minval - 1}) + ) # It is an error for the value to be a floating-point value: assert "not of type" in str(errors({key: 3.14})) # It is an error not to supply a value: