diff --git a/.github/scripts/format-check.sh b/.github/scripts/format-check.sh index d1dcfa9bb..d795bfbbd 100755 --- a/.github/scripts/format-check.sh +++ b/.github/scripts/format-check.sh @@ -4,6 +4,7 @@ unformatted() { set -x make format if [[ -n "$(git status --porcelain)" ]]; then + git --no-pager diff (set +x && echo UNFORMATTED CODE DETECTED) return 1 fi diff --git a/docs/sections/user_guide/yaml/components/chgres_cube.rst b/docs/sections/user_guide/yaml/components/chgres_cube.rst index 6f6923636..c9b531aca 100644 --- a/docs/sections/user_guide/yaml/components/chgres_cube.rst +++ b/docs/sections/user_guide/yaml/components/chgres_cube.rst @@ -25,6 +25,8 @@ namelist Supports ``base_file:`` and ``update_values:`` blocks (see the :ref:`updating_values` for details). Namelist options are described :ufs-utils:`here`. +.. include:: ../../../../shared/validate_namelist.rst + run_dir ^^^^^^^ diff --git a/docs/sections/user_guide/yaml/components/esg_grid.rst b/docs/sections/user_guide/yaml/components/esg_grid.rst index 072c293c2..ca6f4cfa0 100644 --- a/docs/sections/user_guide/yaml/components/esg_grid.rst +++ b/docs/sections/user_guide/yaml/components/esg_grid.rst @@ -22,6 +22,8 @@ namelist: ^^^^^^^^^ Supports ``base_file:`` and ``update_values:`` blocks (see the :ref:`updating_values` for details). Namelist options are described :ufs-utils:`regional_esg_grid`. +.. include:: ../../../../shared/validate_namelist.rst + run_dir: ^^^^^^^^ diff --git a/docs/sections/user_guide/yaml/components/fv3.rst b/docs/sections/user_guide/yaml/components/fv3.rst index 2a32aadb4..d09b416dc 100644 --- a/docs/sections/user_guide/yaml/components/fv3.rst +++ b/docs/sections/user_guide/yaml/components/fv3.rst @@ -77,6 +77,8 @@ namelist: Supports ``base_file:`` and ``update_values:`` blocks (see the :ref:`updating_values` for details). See FV3 ``model_configure`` documentation :weather-model-io:`here`. +.. include:: ../../../../shared/validate_namelist.rst + run_dir: ^^^^^^^^ diff --git a/docs/sections/user_guide/yaml/components/mpas.rst b/docs/sections/user_guide/yaml/components/mpas.rst index 79b23e620..1c1c38576 100644 --- a/docs/sections/user_guide/yaml/components/mpas.rst +++ b/docs/sections/user_guide/yaml/components/mpas.rst @@ -70,6 +70,13 @@ files_to_link: Identical to ``files_to_copy:`` except that symbolic links will be created in the run directory instead of copies. +namelist: +^^^^^^^^^ + +Supports ``base_file:`` and ``update_values:`` blocks (see the :ref:`updating_values` for details). + +.. include:: ../../../../shared/validate_namelist.rst + run_dir: ^^^^^^^^ diff --git a/docs/sections/user_guide/yaml/components/mpas_init.rst b/docs/sections/user_guide/yaml/components/mpas_init.rst index f83ba5b99..110795702 100644 --- a/docs/sections/user_guide/yaml/components/mpas_init.rst +++ b/docs/sections/user_guide/yaml/components/mpas_init.rst @@ -69,6 +69,13 @@ files_to_link: Identical to ``files_to_copy:`` except that symbolic links will be created in the run directory instead of copies. +namelist: +^^^^^^^^^ + +Supports ``base_file:`` and ``update_values:`` blocks (see the :ref:`updating_values` for details). + +.. include:: ../../../../shared/validate_namelist.rst + run_dir: ^^^^^^^^ diff --git a/docs/sections/user_guide/yaml/components/sfc_climo_gen.rst b/docs/sections/user_guide/yaml/components/sfc_climo_gen.rst index 6b8ab91d7..3bafeb26a 100644 --- a/docs/sections/user_guide/yaml/components/sfc_climo_gen.rst +++ b/docs/sections/user_guide/yaml/components/sfc_climo_gen.rst @@ -23,6 +23,8 @@ namelist: Supports ``base_file:`` and ``update_values:`` blocks (see the :ref:`updating_values` for details). Namelist options are described :ufs-utils:`here`. +.. include:: ../../../../shared/validate_namelist.rst + run_dir: ^^^^^^^^ diff --git a/docs/sections/user_guide/yaml/components/upp.rst b/docs/sections/user_guide/yaml/components/upp.rst index 38ebef069..359e66498 100644 --- a/docs/sections/user_guide/yaml/components/upp.rst +++ b/docs/sections/user_guide/yaml/components/upp.rst @@ -31,8 +31,8 @@ files_to_link: Identical to ``files_to_copy:`` except that symbolic links will be created in the run directory instead of copies. -namelist_file: -^^^^^^^^^^^^^^ +namelist: +^^^^^^^^^ Supports ``base_file:`` and ``update_values:`` blocks (see the :ref:`updating_values` for details). @@ -51,6 +51,8 @@ The following namelists and variables can be customized: Read more on the UPP namelists, including variable meanings and appropriate values, `here `_. +.. include:: ../../../../shared/validate_namelist.rst + run_dir: ^^^^^^^^ diff --git a/docs/shared/chgres_cube.yaml b/docs/shared/chgres_cube.yaml index 659a68b4d..e1df85617 100644 --- a/docs/shared/chgres_cube.yaml +++ b/docs/shared/chgres_cube.yaml @@ -23,6 +23,7 @@ chgres_cube: sfc_files_input_grid: sfc.t{{cycle.strftime('%H') }}z.nc varmap_file: /path/to/varmap_table vcoord_file_target_grid: /path/to/global_hyblev.l65.txt + validate: true run_dir: /path/to/dir platform: account: me diff --git a/docs/shared/esg_grid.yaml b/docs/shared/esg_grid.yaml index 5886e2ad4..979d6208b 100644 --- a/docs/shared/esg_grid.yaml +++ b/docs/shared/esg_grid.yaml @@ -16,6 +16,7 @@ esg_grid: pazi: 0.0 plat: 38.5 plon: -97.5 + validate: true run_dir: /path/to/esg_grid platform: account: me diff --git a/docs/shared/fv3.yaml b/docs/shared/fv3.yaml index 149c652e2..962dc71c3 100644 --- a/docs/shared/fv3.yaml +++ b/docs/shared/fv3.yaml @@ -33,6 +33,7 @@ fv3: fv_core_nml: k_split: 2 n_split: 6 + validate: true run_dir: /path/to/runs/{{ cycle.strftime('%Y%m%d%H') }} platform: account: me diff --git a/docs/shared/mpas.yaml b/docs/shared/mpas.yaml index 91f05bf9d..de59d70f5 100644 --- a/docs/shared/mpas.yaml +++ b/docs/shared/mpas.yaml @@ -41,6 +41,7 @@ mpas: config_apply_lbcs: true nhyd_model: config_dt: 60 + validate: true run_dir: /path/to/run/directory streams: path: "{{ user.mpas_app }}/MPAS-Model/streams.atmosphere" diff --git a/docs/shared/mpas_init.yaml b/docs/shared/mpas_init.yaml index 610557d51..b5cb1d566 100644 --- a/docs/shared/mpas_init.yaml +++ b/docs/shared/mpas_init.yaml @@ -47,6 +47,7 @@ mpas_init: config_static_interp: false vertical_grid: config_blend_bdy_terrain: true + validate: true run_dir: /path/to/rundir streams: path: "{{ user.mpas_app }}/MPAS-Model/streams.init_atmosphere" diff --git a/docs/shared/sfc_climo_gen.yaml b/docs/shared/sfc_climo_gen.yaml index 7eb12d523..4d231ba55 100644 --- a/docs/shared/sfc_climo_gen.yaml +++ b/docs/shared/sfc_climo_gen.yaml @@ -32,6 +32,7 @@ sfc_climo_gen: - C403_oro_data.tile7.halo4.nc snowfree_albedo_method: bilinear vegetation_greenness_method: bilinear + validate: true run_dir: /path/to/run platform: account: me diff --git a/docs/shared/upp.yaml b/docs/shared/upp.yaml index 5c8ea99d4..7bb92efa3 100644 --- a/docs/shared/upp.yaml +++ b/docs/shared/upp.yaml @@ -34,6 +34,7 @@ upp: - 1000 - 100 - 1 + validate: true run_dir: /path/to/run platform: account: me diff --git a/docs/shared/validate_namelist.rst b/docs/shared/validate_namelist.rst new file mode 100644 index 000000000..ae263cb2f --- /dev/null +++ b/docs/shared/validate_namelist.rst @@ -0,0 +1 @@ +Before the namelist file is written, its proposed content will be validated against the appropriate schema. This can be suppressed by setting ``validate: false`` in the UW YAML configuration block for the namelist. diff --git a/recipe/meta.json b/recipe/meta.json index 4cf069980..38fe47447 100644 --- a/recipe/meta.json +++ b/recipe/meta.json @@ -32,5 +32,5 @@ "pyyaml =6.0.*" ] }, - "version": "2.2.0" + "version": "2.3.0" } diff --git a/src/uwtools/config/validator.py b/src/uwtools/config/validator.py index 61364e0dd..69e3b7d3a 100644 --- a/src/uwtools/config/validator.py +++ b/src/uwtools/config/validator.py @@ -19,6 +19,33 @@ # Public functions +def get_schema_file(schema_name: str) -> Path: + """ + Returns the path to the JSON Schema file for a given name. + + :param schema_name: Name of uwtools schema to validate the config against. + """ + return resource_path("jsonschema") / f"{schema_name}.jsonschema" + + +def validate(schema: dict, config: dict) -> bool: + """ + Report any errors arising from validation of the given config against the given JSON Schema. + + :param schema: The JSON Schema to use for validation. + :param config: The config to validate. + :return: Did the YAML file conform to the schema? + """ + errors = _validation_errors(config, schema) + log_method = log.error if errors else log.info + log_msg = "%s UW schema-validation error%s found" + log_method(log_msg, len(errors), "" if len(errors) == 1 else "s") + for error in errors: + log.error("Error at %s:", " -> ".join(str(k) for k in error.path)) + log.error("%s%s", INDENT, error.message) + return not bool(errors) + + def validate_internal( schema_name: str, config: Union[dict, YAMLConfig, Optional[Path]] = None ) -> None: @@ -31,7 +58,7 @@ def validate_internal( """ log.info("Validating config against internal schema %s", schema_name) - schema_file = resource_path("jsonschema") / f"{schema_name}.jsonschema" + schema_file = get_schema_file(schema_name) log.debug("Using schema file: %s", schema_file) if not validate_yaml(config=config, schema_file=schema_file): raise UWConfigError("YAML validation errors") @@ -41,7 +68,7 @@ def validate_yaml( schema_file: Path, config: Union[dict, YAMLConfig, Optional[Path]] = None ) -> bool: """ - Report any errors arising from validation of the given config against the given JSON Schema. + Validate a YAML config against the JSON Schema in the given schema file. :param schema_file: The JSON Schema file to use for validation. :param config: The config to validate. @@ -50,14 +77,7 @@ def validate_yaml( with open(schema_file, "r", encoding="utf-8") as f: schema = json.load(f) cfgobj = _prep_config(config) - errors = _validation_errors(cfgobj.data, schema) - log_method = log.error if errors else log.info - log_msg = "%s UW schema-validation error%s found" - log_method(log_msg, len(errors), "" if len(errors) == 1 else "s") - for error in errors: - log.error("Error at %s:", " -> ".join(str(k) for k in error.path)) - log.error("%s%s", INDENT, error.message) - return not bool(errors) + return validate(schema=schema, config=cfgobj.data) # Private functions diff --git a/src/uwtools/drivers/chgres_cube.py b/src/uwtools/drivers/chgres_cube.py index 0652407e4..e429025d2 100644 --- a/src/uwtools/drivers/chgres_cube.py +++ b/src/uwtools/drivers/chgres_cube.py @@ -65,6 +65,7 @@ def namelist_file(self): config_class=NMLConfig, config_values=self._driver_config["namelist"], path=path, + schema=self._namelist_schema(), ) @tasks diff --git a/src/uwtools/drivers/driver.py b/src/uwtools/drivers/driver.py index 460d4933a..101a38929 100644 --- a/src/uwtools/drivers/driver.py +++ b/src/uwtools/drivers/driver.py @@ -2,11 +2,13 @@ An abstract class for component drivers. """ +import json import os import re import stat from abc import ABC, abstractmethod from datetime import datetime, timedelta +from functools import partial from pathlib import Path from textwrap import dedent from typing import Any, Dict, List, Optional, Type, Union @@ -15,7 +17,7 @@ from uwtools.config.formats.base import Config from uwtools.config.formats.yaml import YAMLConfig -from uwtools.config.validator import validate_internal +from uwtools.config.validator import get_schema_file, validate, validate_internal from uwtools.exceptions import UWConfigError, UWError from uwtools.logging import log from uwtools.scheduler import JobScheduler @@ -33,8 +35,8 @@ def __init__( dry_run: bool = False, batch: bool = False, cycle: Optional[datetime] = None, - key_path: Optional[List[str]] = None, leadtime: Optional[timedelta] = None, + key_path: Optional[List[str]] = None, ) -> None: """ A component driver. @@ -43,8 +45,8 @@ def __init__( :param dry_run: Run in dry-run mode? :param batch: Run component via the batch system? :param cycle: The cycle. - :param key_path: Keys leading through the config to the driver's configuration block. :param leadtime: The leadtime. + :param key_path: Keys leading through the config to the driver's configuration block. """ dryrun(enable=dry_run) self._config = YAMLConfig(config=config) @@ -115,7 +117,7 @@ def _run_via_local_execution(self): @staticmethod def _create_user_updated_config( - config_class: Type[Config], config_values: dict, path: Path + config_class: Type[Config], config_values: dict, path: Path, schema: Optional[dict] = None ) -> None: """ Create a config from a base file, user-provided values, or a combination of the two. @@ -123,17 +125,24 @@ def _create_user_updated_config( :param config_class: The Config subclass matching the config type. :param config_values: The configuration object to update base values with. :param path: Path to dump file to. + :param schema: Schema to validate final config against. """ - path.parent.mkdir(parents=True, exist_ok=True) user_values = config_values.get("update_values", {}) if base_file := config_values.get("base_file"): - config_obj = config_class(base_file) - config_obj.update_values(user_values) - config_obj.dereference() - config_obj.dump(path) + cfgobj = config_class(base_file) + cfgobj.update_values(user_values) + cfgobj.dereference() + config = cfgobj.data + dump = partial(cfgobj.dump, path) + else: + config = user_values + dump = partial(config_class.dump_dict, config, path) + if validate(schema=schema or {"type": "object"}, config=config): + path.parent.mkdir(parents=True, exist_ok=True) + dump() + log.debug(f"Wrote config to {path}") else: - config_class.dump_dict(cfg=user_values, path=path) - log.debug(f"Wrote config to {path}") + log.debug(f"Failed to validate {path}") @property def _driver_config(self) -> Dict[str, Any]: @@ -154,6 +163,34 @@ def _driver_name(self) -> str: Returns the name of this driver. """ + def _namelist_schema( + self, config_keys: Optional[List[str]] = None, schema_keys: Optional[List[str]] = None + ) -> dict: + """ + Returns the (sub)schema for validating the driver's namelist content. + + :param config_keys: Keys leading to the namelist block in the driver config. + :param schema_keys: Keys leading to the namelist-validating (sub)schema. + """ + schema: dict = {"type": "object"} + nmlcfg = self._driver_config + for config_key in config_keys or ["namelist"]: + nmlcfg = nmlcfg[config_key] + if nmlcfg.get("validate", True): + schema_file = get_schema_file(schema_name=self._driver_name.replace("_", "-")) + with open(schema_file, "r", encoding="utf-8") as f: + schema = json.load(f) + for schema_key in schema_keys or [ + "properties", + self._driver_name, + "properties", + "namelist", + "properties", + "update_values", + ]: + schema = schema[schema_key] + return schema + @property def _resources(self) -> Dict[str, Any]: """ diff --git a/src/uwtools/drivers/esg_grid.py b/src/uwtools/drivers/esg_grid.py index 4bc32ce06..1b27d8580 100644 --- a/src/uwtools/drivers/esg_grid.py +++ b/src/uwtools/drivers/esg_grid.py @@ -50,6 +50,7 @@ def namelist_file(self): config_class=NMLConfig, config_values=self._driver_config["namelist"], path=path, + schema=self._namelist_schema(schema_keys=["$defs", "namelist_content"]), ) @tasks diff --git a/src/uwtools/drivers/fv3.py b/src/uwtools/drivers/fv3.py index b8dd6313b..321655aaa 100644 --- a/src/uwtools/drivers/fv3.py +++ b/src/uwtools/drivers/fv3.py @@ -145,6 +145,7 @@ def namelist_file(self): config_class=NMLConfig, config_values=self._driver_config["namelist"], path=path, + schema=self._namelist_schema(), ) @tasks diff --git a/src/uwtools/drivers/mpas.py b/src/uwtools/drivers/mpas.py index 94efdec38..0fb9988cb 100644 --- a/src/uwtools/drivers/mpas.py +++ b/src/uwtools/drivers/mpas.py @@ -67,6 +67,7 @@ def namelist_file(self): config_class=NMLConfig, config_values=namelist, path=path, + schema=self._namelist_schema(), ) # Private helper methods diff --git a/src/uwtools/drivers/mpas_init.py b/src/uwtools/drivers/mpas_init.py index 7ec487eeb..1234f09c0 100644 --- a/src/uwtools/drivers/mpas_init.py +++ b/src/uwtools/drivers/mpas_init.py @@ -71,6 +71,7 @@ def namelist_file(self): config_class=NMLConfig, config_values=namelist, path=path, + schema=self._namelist_schema(), ) # Private helper methods diff --git a/src/uwtools/drivers/sfc_climo_gen.py b/src/uwtools/drivers/sfc_climo_gen.py index 09c080016..a04663596 100644 --- a/src/uwtools/drivers/sfc_climo_gen.py +++ b/src/uwtools/drivers/sfc_climo_gen.py @@ -55,6 +55,7 @@ def namelist_file(self): config_class=NMLConfig, config_values=self._driver_config["namelist"], path=path, + schema=self._namelist_schema(), ) @tasks diff --git a/src/uwtools/drivers/upp.py b/src/uwtools/drivers/upp.py index ccd198a02..8b76957cb 100644 --- a/src/uwtools/drivers/upp.py +++ b/src/uwtools/drivers/upp.py @@ -87,6 +87,7 @@ def namelist_file(self): config_class=NMLConfig, config_values=self._driver_config["namelist"], path=path, + schema=self._namelist_schema(), ) @tasks diff --git a/src/uwtools/resources/info.json b/src/uwtools/resources/info.json index acf9214dd..674c8cbba 100644 --- a/src/uwtools/resources/info.json +++ b/src/uwtools/resources/info.json @@ -1,4 +1,4 @@ { - "version": "2.2.0", + "version": "2.3.0", "buildnum": "0" } diff --git a/src/uwtools/resources/jsonschema/chgres-cube.jsonschema b/src/uwtools/resources/jsonschema/chgres-cube.jsonschema index f022a2d98..215b7b48d 100644 --- a/src/uwtools/resources/jsonschema/chgres-cube.jsonschema +++ b/src/uwtools/resources/jsonschema/chgres-cube.jsonschema @@ -204,6 +204,9 @@ "config" ], "type": "object" + }, + "validate": { + "type": "boolean" } }, "type": "object" diff --git a/src/uwtools/resources/jsonschema/esg-grid.jsonschema b/src/uwtools/resources/jsonschema/esg-grid.jsonschema index 0dad9c2f2..cf40735ad 100644 --- a/src/uwtools/resources/jsonschema/esg-grid.jsonschema +++ b/src/uwtools/resources/jsonschema/esg-grid.jsonschema @@ -3,31 +3,49 @@ "base_file": { "type": "string" }, - "regional_grid_nml_properties": { - "additionalProperties": false, + "namelist_content": { + "additionalproperties": false, "properties": { - "delx": { - "type": "number" - }, - "dely": { - "type": "number" - }, - "lx": { - "type": "number" - }, - "ly": { - "type": "number" - }, - "pazi": { - "type": "number" - }, - "plat": { - "type": "number" - }, - "plon": { - "type": "number" + "regional_grid_nml": { + "additionalProperties": false, + "properties": { + "delx": { + "type": "number" + }, + "dely": { + "type": "number" + }, + "lx": { + "type": "number" + }, + "ly": { + "type": "number" + }, + "pazi": { + "type": "number" + }, + "plat": { + "type": "number" + }, + "plon": { + "type": "number" + } + }, + "required": [ + "delx", + "dely", + "lx", + "ly", + "pazi", + "plat", + "plon" + ], + "type": "object" } }, + "required": [ + "regional_grid_nml" + ], "type": "object" } }, @@ -40,18 +58,6 @@ }, "namelist": { "anyOf": [ - { - "additionalProperties": false, - "properties": { - "base_file": { - "$ref": "#/$defs/base_file" - } - }, - "required": [ - "base_file" - ], - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -64,7 +70,7 @@ "regional_grid_nml": { "allOf": [ { - "$ref": "#/$defs/regional_grid_nml_properties" + "$ref": "#/$defs/namelist_content/properties/regional_grid_nml/properties" } ], "type": "object" @@ -74,45 +80,24 @@ "regional_grid_nml" ], "type": "object" + }, + "validate": { + "type": "boolean" } }, "required": [ - "base_file", - "update_values" + "base_file" ], "type": "object" }, { "additionalProperties": false, "properties": { - "base_file": { - "$ref": "#/$defs/base_file" - }, "update_values": { - "additionalProperties": false, - "properties": { - "regional_grid_nml": { - "allOf": [ - { - "$ref": "#/$defs/regional_grid_nml_properties" - } - ], - "required": [ - "delx", - "dely", - "lx", - "ly", - "pazi", - "plat", - "plon" - ], - "type": "object" - } - }, - "required": [ - "regional_grid_nml" - ], - "type": "object" + "$ref": "#/$defs/namelist_content" + }, + "validate": { + "type": "boolean" } }, "required": [ diff --git a/src/uwtools/resources/jsonschema/fv3.jsonschema b/src/uwtools/resources/jsonschema/fv3.jsonschema index a419434e4..09de14fe3 100644 --- a/src/uwtools/resources/jsonschema/fv3.jsonschema +++ b/src/uwtools/resources/jsonschema/fv3.jsonschema @@ -130,6 +130,9 @@ }, "update_values": { "$ref": "urn:uwtools:namelist" + }, + "validate": { + "type": "boolean" } }, "type": "object" diff --git a/src/uwtools/resources/jsonschema/mpas-init.jsonschema b/src/uwtools/resources/jsonschema/mpas-init.jsonschema index 8961b8495..90a51be8e 100644 --- a/src/uwtools/resources/jsonschema/mpas-init.jsonschema +++ b/src/uwtools/resources/jsonschema/mpas-init.jsonschema @@ -59,6 +59,9 @@ }, "update_values": { "$ref": "urn:uwtools:namelist" + }, + "validate": { + "type": "boolean" } }, "type": "object" diff --git a/src/uwtools/resources/jsonschema/mpas.jsonschema b/src/uwtools/resources/jsonschema/mpas.jsonschema index bb2b92559..a44569dfc 100644 --- a/src/uwtools/resources/jsonschema/mpas.jsonschema +++ b/src/uwtools/resources/jsonschema/mpas.jsonschema @@ -58,6 +58,9 @@ }, "update_values": { "$ref": "urn:uwtools:namelist" + }, + "validate": { + "type": "boolean" } }, "type": "object" diff --git a/src/uwtools/resources/jsonschema/sfc-climo-gen.jsonschema b/src/uwtools/resources/jsonschema/sfc-climo-gen.jsonschema index 2596c1c9e..872fd9f23 100644 --- a/src/uwtools/resources/jsonschema/sfc-climo-gen.jsonschema +++ b/src/uwtools/resources/jsonschema/sfc-climo-gen.jsonschema @@ -118,6 +118,9 @@ "config" ], "type": "object" + }, + "validate": { + "type": "boolean" } }, "type": "object" diff --git a/src/uwtools/resources/jsonschema/upp.jsonschema b/src/uwtools/resources/jsonschema/upp.jsonschema index ad66caf83..1acda1641 100644 --- a/src/uwtools/resources/jsonschema/upp.jsonschema +++ b/src/uwtools/resources/jsonschema/upp.jsonschema @@ -168,6 +168,9 @@ } }, "type": "object" + }, + "validate": { + "type": "boolean" } }, "type": "object" diff --git a/src/uwtools/resources/jsonschema/workflow.jsonschema b/src/uwtools/resources/jsonschema/workflow.jsonschema deleted file mode 100644 index 012908411..000000000 --- a/src/uwtools/resources/jsonschema/workflow.jsonschema +++ /dev/null @@ -1,101 +0,0 @@ -{ - "description": "This document is to validate config files from SRW, HAFS, Global", - "properties": { - "cpl_aqm_parm": { - "description": "attributes of coupled air quality", - "properties": { - "AQM_BIO_DIR": { - "format": "uri", - "type": "string" - }, - "AQM_CONFIG_DIR": { - "format": "uri", - "type": "string" - } - }, - "type": "object" - }, - "platform": { - "description": "attributes of the platform", - "properties": { - "CCPA_OBS_DIR": { - "format": "uri", - "type": "string" - }, - "DOMAIN_PREGEN_BASEDIR": { - "format": "uri", - "type": "string" - }, - "METPLUS_PATH": { - "format": "uri", - "type": "string" - }, - "MET_BIN_EXEC": { - "type": "string" - }, - "MET_INSTALL_DIR": { - "format": "uri", - "type": "string" - }, - "MRMS_OBS_DIR": { - "format": "uri", - "type": "string" - }, - "NCORES_PER_NODE": { - "type": "number" - }, - "NDAS_OBS_DIR": { - "format": "uri", - "type": "string" - }, - "PARTITION_DEFAULT": { - "type": "string" - }, - "PARTITION_FCST": { - "type": "string" - }, - "PARTITION_HPSS": { - "type": "string" - }, - "QUEUE_DEFAULT": { - "type": "string" - }, - "QUEUE_FCST": { - "type": "string" - }, - "QUEUE_HPSS": { - "type": "string" - }, - "SCHED": { - "enum": [ - "slurm", - "pbspro", - "lsf", - "lsfcray", - "none" - ], - "type": "string" - }, - "WORKFLOW_MANAGER": { - "enum": [ - "rocoto", - "none" - ], - "type": "string" - } - }, - "type": "object" - }, - "task_get_da_obs": { - "description": "task for data assimilation", - "properties": { - "OBS_SUFFIX": { - "type": "string" - } - }, - "type": "object" - } - }, - "title": "workflow config", - "type": "object" -} diff --git a/src/uwtools/tests/config/test_validator.py b/src/uwtools/tests/config/test_validator.py index 12b8ce519..566cf49f5 100644 --- a/src/uwtools/tests/config/test_validator.py +++ b/src/uwtools/tests/config/test_validator.py @@ -124,6 +124,31 @@ def write_as_json(data: Dict[str, Any], path: Path) -> Path: # Test functions +def test_get_schema_file(): + with patch.object(validator, "resource_path", return_value=Path("/foo/bar")): + assert validator.get_schema_file("baz") == Path("/foo/bar/baz.jsonschema") + + +def test_validate(config, schema): + assert validator.validate(schema=schema, config=config) + + +def test_validate_fail_bad_enum_val(caplog, config, schema): + log.setLevel(logging.INFO) + config["color"] = "yellow" # invalid enum value + assert not validator.validate(schema=schema, config=config) + assert any(x for x in caplog.records if "1 UW schema-validation error found" in x.message) + assert any(x for x in caplog.records if "'yellow' is not one of" in x.message) + + +def test_validate_fail_bad_number_val(caplog, config, schema): + log.setLevel(logging.INFO) + config["number"] = "string" # invalid number value + assert not validator.validate(schema=schema, config=config) + assert any(x for x in caplog.records if "1 UW schema-validation error found" in x.message) + assert any(x for x in caplog.records if "'string' is not of type 'number'" in x.message) + + def test_validate_internal_no(caplog, schema_file): with patch.object(validator, "resource_path", return_value=schema_file.parent): with raises(UWConfigError) as e: @@ -138,27 +163,11 @@ def test_validate_internal_ok(schema_file): validator.validate_internal(schema_name="a", config={"color": "blue"}) -def test_validate_yaml(assets): +def test_validate_yaml(assets, config, schema): schema_file, _, cfgobj = assets - assert validator.validate_yaml(schema_file=schema_file, config=cfgobj) - - -def test_validate_yaml_fail_bad_enum_val(assets, caplog): - log.setLevel(logging.INFO) - schema_file, _, cfgobj = assets - cfgobj["color"] = "yellow" # invalid enum value - assert not validator.validate_yaml(schema_file=schema_file, config=cfgobj) - assert any(x for x in caplog.records if "1 UW schema-validation error found" in x.message) - assert any(x for x in caplog.records if "'yellow' is not one of" in x.message) - - -def test_validate_yaml_fail_bad_number_val(assets, caplog): - log.setLevel(logging.INFO) - schema_file, _, cfgobj = assets - cfgobj["number"] = "string" # invalid number value - assert not validator.validate_yaml(schema_file=schema_file, config=cfgobj) - assert any(x for x in caplog.records if "1 UW schema-validation error found" in x.message) - assert any(x for x in caplog.records if "'string' is not of type 'number'" in x.message) + with patch.object(validator, "validate") as validate: + validator.validate_yaml(schema_file=schema_file, config=cfgobj) + validate.assert_called_once_with(schema=schema, config=config) def test_prep_config_cfgobj(prep_config_dict): diff --git a/src/uwtools/tests/drivers/test_chgres_cube.py b/src/uwtools/tests/drivers/test_chgres_cube.py index e7acdf914..dda9e3d32 100644 --- a/src/uwtools/tests/drivers/test_chgres_cube.py +++ b/src/uwtools/tests/drivers/test_chgres_cube.py @@ -3,60 +3,20 @@ chgres_cube driver tests. """ import datetime as dt +import logging +from pathlib import Path from unittest.mock import DEFAULT as D from unittest.mock import patch import f90nml # type: ignore import yaml -from iotaa import asset, external +from iotaa import asset, external, refs from pytest import fixture from uwtools.drivers import chgres_cube +from uwtools.logging import log from uwtools.scheduler import Slurm - -config: dict = { - "chgres_cube": { - "execution": { - "batchargs": { - "export": "NONE", - "nodes": 1, - "stdout": "/path/to/file", - "walltime": "00:02:00", - }, - "envcmds": ["cmd1", "cmd2"], - "executable": "/path/to/chgres_cube", - "mpiargs": ["--export=ALL", "--ntasks $SLURM_CPUS_ON_NODE"], - "mpicmd": "srun", - }, - "namelist": { - "update_values": { - "config": { - "atm_core_files_input_grid": "/path/to/file", - "atm_files_input_grid": "/path/to/file", - "atm_tracer_files_input_grid": "/path/to/file", - "atm_weight_file": "/path/to/file", - "convert_atm": True, - "data_dir_input_grid": "/path/to/file", - "external_model": "GFS", - "fix_dir_target_grid": "/path/to/dir", - "geogrid_file_input_grid": "/path/to/file", - "grib2_file_input_grid": "/path/to/file", - "mosaic_file_input_grid": "/path/to/file", - "mosaic_file_target_grid": "/path/to/file", - "sfc_files_input_grid": "/path/to/file", - "varmap_file": "/path/to/file", - "vcoord_file_target_grid": "/path/to/file", - } - } - }, - "run_dir": "/path/to/dir", - }, - "platform": { - "account": "me", - "scheduler": "slurm", - }, -} - +from uwtools.tests.support import logged # Fixtures @@ -68,6 +28,49 @@ def cycle(): @fixture def config_file(tmp_path): + config: dict = { + "chgres_cube": { + "execution": { + "batchargs": { + "export": "NONE", + "nodes": 1, + "stdout": "/path/to/file", + "walltime": "00:02:00", + }, + "envcmds": ["cmd1", "cmd2"], + "executable": "/path/to/chgres_cube", + "mpiargs": ["--export=ALL", "--ntasks $SLURM_CPUS_ON_NODE"], + "mpicmd": "srun", + }, + "namelist": { + "update_values": { + "config": { + "atm_core_files_input_grid": "/path/to/file", + "atm_files_input_grid": "/path/to/file", + "atm_tracer_files_input_grid": "/path/to/file", + "atm_weight_file": "/path/to/file", + "convert_atm": True, + "data_dir_input_grid": "/path/to/file", + "external_model": "GFS", + "fix_dir_target_grid": "/path/to/dir", + "geogrid_file_input_grid": "/path/to/file", + "grib2_file_input_grid": "/path/to/file", + "mosaic_file_input_grid": "/path/to/file", + "mosaic_file_target_grid": "/path/to/file", + "sfc_files_input_grid": "/path/to/file", + "varmap_file": "/path/to/file", + "vcoord_file_target_grid": "/path/to/file", + } + }, + "validate": True, + }, + "run_dir": "/path/to/dir", + }, + "platform": { + "account": "me", + "scheduler": "slurm", + }, + } path = tmp_path / "config.yaml" config["chgres_cube"]["run_dir"] = tmp_path.as_posix() with open(path, "w", encoding="utf-8") as f: @@ -80,6 +83,15 @@ def driverobj(config_file, cycle): return chgres_cube.ChgresCube(config=config_file, cycle=cycle, batch=True) +# Helpers + + +@external +def ready(x): + yield x + yield asset(x, lambda: True) + + # Tests @@ -87,20 +99,27 @@ def test_ChgresCube(driverobj): assert isinstance(driverobj, chgres_cube.ChgresCube) -def test_ChgresCube_namelist_file(driverobj): - @external - def ready(x): - yield x - yield asset(x, lambda: True) - +def test_ChgresCube_namelist_file(caplog, driverobj): + log.setLevel(logging.DEBUG) dst = driverobj._rundir / "fort.41" assert not dst.is_file() with patch.object(chgres_cube, "file", new=ready): - driverobj.namelist_file() + path = Path(refs(driverobj.namelist_file())) assert dst.is_file() + assert logged(caplog, f"Wrote config to {path}") assert isinstance(f90nml.read(dst), f90nml.Namelist) +def test_ChgresCube_namelist_file_fails_validation(caplog, driverobj): + log.setLevel(logging.DEBUG) + driverobj._driver_config["namelist"]["update_values"]["config"]["convert_atm"] = "string" + with patch.object(chgres_cube, "file", new=ready): + path = Path(refs(driverobj.namelist_file())) + assert not path.exists() + assert logged(caplog, f"Failed to validate {path}") + assert logged(caplog, " 'string' is not of type 'boolean'") + + def test_ChgresCube_provisioned_run_directory(driverobj): with patch.multiple(driverobj, namelist_file=D, runscript=D) as mocks: driverobj.provisioned_run_directory() diff --git a/src/uwtools/tests/drivers/test_driver.py b/src/uwtools/tests/drivers/test_driver.py index 1e20e2b61..10b57580b 100644 --- a/src/uwtools/tests/drivers/test_driver.py +++ b/src/uwtools/tests/drivers/test_driver.py @@ -222,6 +222,43 @@ def test_Driver__driver_config_pass(driverobj): } +def test_Driver__namelist_schema_custom(driverobj, tmp_path): + nmlschema = {"properties": {"n": {"type": "integer"}}, "type": "object"} + schema = {"foo": {"bar": nmlschema}} + schema_path = tmp_path / "test.jsonschema" + with open(schema_path, "w", encoding="utf-8") as f: + json.dump(schema, f) + with patch.object(ConcreteDriver, "_driver_config", new_callable=PropertyMock) as dc: + dc.return_value = {"baz": {"qux": {"validate": True}}} + with patch.object(driver, "get_schema_file", return_value=schema_path): + assert ( + driverobj._namelist_schema(config_keys=["baz", "qux"], schema_keys=["foo", "bar"]) + == nmlschema + ) + + +def test_Driver__namelist_schema_default(driverobj, tmp_path): + nmlschema = {"properties": {"n": {"type": "integer"}}, "type": "object"} + schema = { + "properties": { + "concrete": {"properties": {"namelist": {"properties": {"update_values": nmlschema}}}} + } + } + schema_path = tmp_path / "test.jsonschema" + with open(schema_path, "w", encoding="utf-8") as f: + json.dump(schema, f) + with patch.object(ConcreteDriver, "_driver_config", new_callable=PropertyMock) as dc: + dc.return_value = {"namelist": {"validate": True}} + with patch.object(driver, "get_schema_file", return_value=schema_path): + assert driverobj._namelist_schema() == nmlschema + + +def test_Driver__namelist_schema_default_disable(driverobj): + with patch.object(ConcreteDriver, "_driver_config", new_callable=PropertyMock) as dc: + dc.return_value = {"namelist": {"validate": False}} + assert driverobj._namelist_schema() == {"type": "object"} + + def test_Driver__resources_fail(driverobj): del driverobj._config["platform"] with raises(UWConfigError) as e: diff --git a/src/uwtools/tests/drivers/test_esg_grid.py b/src/uwtools/tests/drivers/test_esg_grid.py index c622be9cc..846bb980d 100644 --- a/src/uwtools/tests/drivers/test_esg_grid.py +++ b/src/uwtools/tests/drivers/test_esg_grid.py @@ -2,15 +2,20 @@ """ ESGGrid driver tests. """ +import logging +from pathlib import Path from unittest.mock import DEFAULT as D from unittest.mock import patch import f90nml # type: ignore import yaml +from iotaa import refs from pytest import fixture from uwtools.drivers import esg_grid +from uwtools.logging import log from uwtools.scheduler import Slurm +from uwtools.tests.support import logged # Fixtures @@ -70,14 +75,25 @@ def test_ESGGrid(driverobj): assert isinstance(driverobj, esg_grid.ESGGrid) -def test_ESGGrid_namelist_file(driverobj): +def test_ESGGrid_namelist_file(caplog, driverobj): + log.setLevel(logging.DEBUG) dst = driverobj._rundir / "regional_grid.nml" assert not dst.is_file() - driverobj.namelist_file() + path = Path(refs(driverobj.namelist_file())) assert dst.is_file() + assert logged(caplog, f"Wrote config to {path}") assert isinstance(f90nml.read(dst), f90nml.Namelist) +def test_ESGGrid_namelist_file_fails_validation(caplog, driverobj): + log.setLevel(logging.DEBUG) + driverobj._driver_config["namelist"]["update_values"]["regional_grid_nml"]["delx"] = "string" + path = Path(refs(driverobj.namelist_file())) + assert not path.exists() + assert logged(caplog, f"Failed to validate {path}") + assert logged(caplog, " 'string' is not of type 'number'") + + def test_ESGGrid_provisioned_run_directory(driverobj): with patch.multiple( driverobj, diff --git a/src/uwtools/tests/drivers/test_fv3.py b/src/uwtools/tests/drivers/test_fv3.py index 2bc195fe2..8f35bfd1c 100644 --- a/src/uwtools/tests/drivers/test_fv3.py +++ b/src/uwtools/tests/drivers/test_fv3.py @@ -3,16 +3,18 @@ FV3 driver tests. """ import datetime as dt +import logging from pathlib import Path from unittest.mock import DEFAULT as D from unittest.mock import PropertyMock, patch import pytest import yaml -from iotaa import asset, external +from iotaa import asset, external, refs from pytest import fixture from uwtools.drivers import driver, fv3 +from uwtools.logging import log from uwtools.scheduler import Slurm from uwtools.tests.support import logged @@ -159,17 +161,28 @@ def test_FV3_model_configure(driverobj): assert dst.is_file() -def test_FV3_namelist_file(driverobj): +def test_FV3_namelist_file(caplog, driverobj): + log.setLevel(logging.DEBUG) src = driverobj._rundir / "input.nml.in" with open(src, "w", encoding="utf-8") as f: yaml.dump({}, f) dst = driverobj._rundir / "input.nml" assert not dst.is_file() driverobj._driver_config["namelist_file"] = {"base_file": src} - driverobj.namelist_file() + path = Path(refs(driverobj.namelist_file())) + assert logged(caplog, f"Wrote config to {path}") assert dst.is_file() +def test_FV3_namelist_file_fails_validation(caplog, driverobj): + log.setLevel(logging.DEBUG) + driverobj._driver_config["namelist"]["update_values"]["namsfc"]["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'") + + @pytest.mark.parametrize("domain", ("global", "regional")) def test_FV3_provisioned_run_directory(domain, driverobj): driverobj._driver_config["domain"] = domain diff --git a/src/uwtools/tests/drivers/test_mpas.py b/src/uwtools/tests/drivers/test_mpas.py index 0b9b556ac..257a352ba 100644 --- a/src/uwtools/tests/drivers/test_mpas.py +++ b/src/uwtools/tests/drivers/test_mpas.py @@ -3,6 +3,7 @@ MPAS driver tests. """ import datetime as dt +import logging from pathlib import Path from unittest.mock import DEFAULT as D from unittest.mock import patch @@ -10,12 +11,14 @@ import f90nml # type: ignore import pytest import yaml +from iotaa import refs from pytest import fixture, raises from uwtools.drivers import mpas from uwtools.exceptions import UWConfigError +from uwtools.logging import log from uwtools.scheduler import Slurm -from uwtools.tests.support import fixture_path +from uwtools.tests.support import fixture_path, logged # Fixtures @@ -125,14 +128,25 @@ def test_MPAS_files_copied_and_linked(config, cycle, key, task, test, tmp_path): assert all(getattr(dst, test)() for dst in [atm_dst, sfc_dst]) -def test_MPAS_namelist_file(driverobj): +def test_MPAS_namelist_file(caplog, driverobj): + log.setLevel(logging.DEBUG) dst = driverobj._rundir / "namelist.atmosphere" assert not dst.is_file() - driverobj.namelist_file() + path = Path(refs(driverobj.namelist_file())) assert dst.is_file() + assert logged(caplog, f"Wrote config to {path}") assert isinstance(f90nml.read(dst), f90nml.Namelist) +def test_MPAS_namelist_file_fails_validation(caplog, driverobj): + log.setLevel(logging.DEBUG) + driverobj._driver_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_missing(driverobj): path = driverobj._rundir / "namelist.atmosphere" del driverobj._driver_config["namelist"] diff --git a/src/uwtools/tests/drivers/test_mpas_init.py b/src/uwtools/tests/drivers/test_mpas_init.py index 4fde9f769..2f5d5fe8e 100644 --- a/src/uwtools/tests/drivers/test_mpas_init.py +++ b/src/uwtools/tests/drivers/test_mpas_init.py @@ -3,6 +3,7 @@ MPASInit driver tests. """ import datetime as dt +import logging from pathlib import Path from unittest.mock import DEFAULT as D from unittest.mock import patch @@ -10,12 +11,14 @@ import f90nml # type: ignore import pytest import yaml +from iotaa import refs from pytest import fixture, raises from uwtools.drivers import mpas_init from uwtools.exceptions import UWConfigError +from uwtools.logging import log from uwtools.scheduler import Slurm -from uwtools.tests.support import fixture_path +from uwtools.tests.support import fixture_path, logged # Fixtures @@ -146,14 +149,25 @@ def test_MPASInit_namelist_contents(cycle, driverobj): assert nml["nhyd_model"]["config_stop_time"] == stop_time.strftime(f) -def test_MPASInit_namelist_file(driverobj): +def test_MPASInit_namelist_file(caplog, driverobj): + log.setLevel(logging.DEBUG) dst = driverobj._rundir / "namelist.init_atmosphere" assert not dst.is_file() - driverobj.namelist_file() + path = Path(refs(driverobj.namelist_file())) assert dst.is_file() + assert logged(caplog, f"Wrote config to {path}") assert isinstance(f90nml.read(dst), f90nml.Namelist) +def test_MPASInit_namelist_file_fails_validation(caplog, driverobj): + log.setLevel(logging.DEBUG) + driverobj._driver_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_MPASInit_namelist_missing(driverobj): path = driverobj._rundir / "namelist.init_atmosphere" del driverobj._driver_config["namelist"] diff --git a/src/uwtools/tests/drivers/test_sfc_climo_gen.py b/src/uwtools/tests/drivers/test_sfc_climo_gen.py index 862b0032c..b7a3e988e 100644 --- a/src/uwtools/tests/drivers/test_sfc_climo_gen.py +++ b/src/uwtools/tests/drivers/test_sfc_climo_gen.py @@ -2,65 +2,69 @@ """ sfc_climo_gen driver tests. """ +import logging +from pathlib import Path from unittest.mock import DEFAULT as D from unittest.mock import patch import f90nml # type: ignore import yaml -from iotaa import asset, external +from iotaa import asset, external, refs from pytest import fixture from uwtools.drivers import sfc_climo_gen +from uwtools.logging import log from uwtools.scheduler import Slurm +from uwtools.tests.support import logged # Fixtures -config: dict = { - "sfc_climo_gen": { - "execution": { - "batchargs": { - "export": "NONE", - "nodes": 1, - "stdout": "/path/to/file", - "walltime": "00:02:00", - }, - "envcmds": ["cmd1", "cmd2"], - "executable": "/path/to/sfc_climo_gen", - "mpiargs": ["--export=ALL", "--ntasks $SLURM_CPUS_ON_NODE"], - "mpicmd": "srun", - }, - "namelist": { - "update_values": { - "config": { - "halo": 4, - "input_facsf_file": "/path/to/file", - "input_maximum_snow_albedo_file": "/path/to/file", - "input_slope_type_file": "/path/to/file", - "input_snowfree_albedo_file": "/path/to/file", - "input_soil_type_file": "/path/to/file", - "input_substrate_temperature_file": "/path/to/file", - "input_vegetation_greenness_file": "/path/to/file", - "input_vegetation_type_file": "/path/to/file", - "maximum_snow_albedo_method": "bilinear", - "mosaic_file_mdl": "/path/to/file", - "orog_dir_mdl": "/path/to/dir", - "orog_files_mdl": ["C403_oro_data.tile7.halo4.nc"], - "snowfree_albedo_method": "bilinear", - "vegetation_greenness_method": "bilinear", - } - } - }, - "run_dir": "/path/to/dir", - }, - "platform": { - "account": "me", - "scheduler": "slurm", - }, -} - @fixture def config_file(tmp_path): + config: dict = { + "sfc_climo_gen": { + "execution": { + "batchargs": { + "export": "NONE", + "nodes": 1, + "stdout": "/path/to/file", + "walltime": "00:02:00", + }, + "envcmds": ["cmd1", "cmd2"], + "executable": "/path/to/sfc_climo_gen", + "mpiargs": ["--export=ALL", "--ntasks $SLURM_CPUS_ON_NODE"], + "mpicmd": "srun", + }, + "namelist": { + "update_values": { + "config": { + "halo": 4, + "input_facsf_file": "/path/to/file", + "input_maximum_snow_albedo_file": "/path/to/file", + "input_slope_type_file": "/path/to/file", + "input_snowfree_albedo_file": "/path/to/file", + "input_soil_type_file": "/path/to/file", + "input_substrate_temperature_file": "/path/to/file", + "input_vegetation_greenness_file": "/path/to/file", + "input_vegetation_type_file": "/path/to/file", + "maximum_snow_albedo_method": "bilinear", + "mosaic_file_mdl": "/path/to/file", + "orog_dir_mdl": "/path/to/dir", + "orog_files_mdl": ["C403_oro_data.tile7.halo4.nc"], + "snowfree_albedo_method": "bilinear", + "vegetation_greenness_method": "bilinear", + } + }, + "validate": True, + }, + "run_dir": "/path/to/dir", + }, + "platform": { + "account": "me", + "scheduler": "slurm", + }, + } path = tmp_path / "config.yaml" config["sfc_climo_gen"]["run_dir"] = tmp_path.as_posix() with open(path, "w", encoding="utf-8") as f: @@ -73,6 +77,15 @@ def driverobj(config_file): return sfc_climo_gen.SfcClimoGen(config=config_file, batch=True) +# Helpers + + +@external +def ready(x): + yield x + yield asset(x, lambda: True) + + # Tests @@ -80,20 +93,27 @@ def test_SfcClimoGen(driverobj): assert isinstance(driverobj, sfc_climo_gen.SfcClimoGen) -def test_SfcClimoGen_namelist_file(driverobj): - @external - def ready(x): - yield x - yield asset(x, lambda: True) - +def test_SfcClimoGen_namelist_file(caplog, driverobj): + log.setLevel(logging.DEBUG) dst = driverobj._rundir / "fort.41" assert not dst.is_file() with patch.object(sfc_climo_gen, "file", new=ready): - driverobj.namelist_file() + path = Path(refs(driverobj.namelist_file())) assert dst.is_file() + assert logged(caplog, f"Wrote config to {path}") assert isinstance(f90nml.read(dst), f90nml.Namelist) +def test_SfcClimoGen_namelist_file_fails_validation(caplog, driverobj): + log.setLevel(logging.DEBUG) + driverobj._driver_config["namelist"]["update_values"]["config"]["halo"] = "string" + with patch.object(sfc_climo_gen, "file", new=ready): + path = Path(refs(driverobj.namelist_file())) + assert not path.exists() + assert logged(caplog, f"Failed to validate {path}") + assert logged(caplog, " 'string' is not of type 'integer'") + + def test_SfcClimoGen_provisioned_run_directory(driverobj): with patch.multiple( driverobj, diff --git a/src/uwtools/tests/drivers/test_upp.py b/src/uwtools/tests/drivers/test_upp.py index 62be78367..2e066a6ca 100644 --- a/src/uwtools/tests/drivers/test_upp.py +++ b/src/uwtools/tests/drivers/test_upp.py @@ -3,16 +3,20 @@ UPP driver tests. """ import datetime as dt +import logging from pathlib import Path from unittest.mock import DEFAULT as D from unittest.mock import patch import f90nml # type: ignore import yaml +from iotaa import refs from pytest import fixture from uwtools.drivers import upp +from uwtools.logging import log from uwtools.scheduler import Slurm +from uwtools.tests.support import logged # Fixtures @@ -106,14 +110,16 @@ def test_UPP_files_linked(driverobj): assert Path(driverobj._rundir / dst).is_symlink() -def test_UPP_namelist_file(driverobj): +def test_UPP_namelist_file(caplog, driverobj): + log.setLevel(logging.DEBUG) datestr = "2024-05-05_12:00:00" with open(driverobj._driver_config["namelist"]["base_file"], "w", encoding="utf-8") as f: print("&model_inputs datestr='%s' / &nampgb kpv=88 /" % datestr, file=f) dst = driverobj._rundir / "itag" assert not dst.is_file() - driverobj.namelist_file() + path = Path(refs(driverobj.namelist_file())) assert dst.is_file() + assert logged(caplog, f"Wrote config to {path}") nml = f90nml.read(dst) assert isinstance(nml, f90nml.Namelist) assert nml["model_inputs"]["datestr"] == datestr @@ -122,6 +128,16 @@ def test_UPP_namelist_file(driverobj): assert nml["nampgb"]["kpv"] == 88 +def test_UPP_namelist_file_fails_validation(caplog, driverobj): + log.setLevel(logging.DEBUG) + driverobj._driver_config["namelist"]["update_values"]["nampgb"]["kpo"] = "string" + del driverobj._driver_config["namelist"]["base_file"] + path = Path(refs(driverobj.namelist_file())) + assert not path.exists() + assert logged(caplog, f"Failed to validate {path}") + assert logged(caplog, " 'string' is not of type 'integer'") + + def test_UPP_provisioned_run_directory(driverobj): with patch.multiple( driverobj, diff --git a/src/uwtools/tests/test_schemas.py b/src/uwtools/tests/test_schemas.py index 434d7e663..53a84f1c7 100644 --- a/src/uwtools/tests/test_schemas.py +++ b/src/uwtools/tests/test_schemas.py @@ -36,8 +36,9 @@ def esg_namelist(): "pazi": 0.0, "plat": 45.5, "plon": -100.5, - } + }, }, + "validate": True, } @@ -106,7 +107,7 @@ def upp_prop(): def test_schema_chgres_cube(): config = { "execution": {"executable": "chgres_cube"}, - "namelist": {"base_file": "/path"}, + "namelist": {"base_file": "/path", "validate": True}, "run_dir": "/tmp", } errors = schema_validator("chgres-cube", "properties", "chgres_cube") @@ -160,7 +161,7 @@ def test_schema_chgres_cube_run_dir(chgres_cube_prop): def test_schema_esg_grid(): config = { "execution": {"executable": "esg_grid"}, - "namelist": {"base_file": "/path"}, + "namelist": {"base_file": "/path", "validate": True}, "run_dir": "/tmp", } errors = schema_validator("esg-grid", "properties", "esg_grid") @@ -198,16 +199,28 @@ def test_schema_esg_grid_namelist(esg_grid_prop, esg_namelist): @pytest.mark.parametrize("key", ["delx", "dely", "lx", "ly", "pazi", "plat", "plon"]) -def test_schema_esg_grid_regional_grid_nml_properties(key): - errors = partial(schema_validator("esg-grid", "$defs", "regional_grid_nml_properties")) - # An integer value is ok: - assert not errors({key: 88}) +def test_schema_esg_grid_namelist_content(key): + config: dict = { + "regional_grid_nml": { + "delx": 88, + "dely": 88, + "lx": 88, + "ly": 88, + "pazi": 88, + "plat": 88, + "plon": 88, + } + } + errors = partial(schema_validator("esg-grid", "$defs", "namelist_content")) + assert not errors(config) # A floating-point value is ok: - assert not errors({key: 3.14}) + config["regional_grid_nml"][key] = 3.14 + assert not errors(config) # It is an error for the value to be of type string: - assert "not of type 'number'" in errors({key: "foo"}) - # It is an error not to supply a value: - assert "not of type 'object'" in errors({key}) + config["regional_grid_nml"][key] = "foo" + assert "not of type 'number'" in errors(config) + # Each key is required: + assert "is a required property" in errors(with_del(config, "regional_grid_nml", key)) def test_schema_esg_grid_run_dir(esg_grid_prop): @@ -332,7 +345,7 @@ def test_schema_fv3(): "field_table": {"base_file": "/path"}, "lateral_boundary_conditions": {"interval_hours": 1, "offset": 0, "path": "/tmp/file"}, "length": 3, - "namelist": {"base_file": "/path"}, + "namelist": {"base_file": "/path", "validate": True}, "run_dir": "/tmp", } errors = schema_validator("fv3", "properties", "fv3") @@ -356,7 +369,7 @@ def test_schema_fv3(): "files_to_copy": {"fn": "/path"}, "files_to_link": {"fn": "/path"}, "model_configure": {"base_file": "/path"}, - "namelist": {"base_file": "/path"}, + "namelist": {"base_file": "/path", "validate": True}, } ) # Additional top-level keys are not allowed: @@ -661,7 +674,7 @@ def test_schema_make_solo_mosaic_run_dir(make_solo_mosaic_prop): def test_schema_mpas(): config = { "execution": {"executable": "atmosphere_model"}, - "namelist": {"base_file": "path/to/simple.nml"}, + "namelist": {"base_file": "path/to/simple.nml", "validate": True}, "run_dir": "path/to/rundir", "streams": {"path": "path/to/streams.atmosphere.in", "values": {"world": "user"}}, } @@ -770,7 +783,7 @@ def test_schema_mpas_streams(mpas_prop): def test_schema_mpas_init(): config = { "execution": {"executable": "mpas_init"}, - "namelist": {"base_file": "path/to/simple.nml"}, + "namelist": {"base_file": "path/to/simple.nml", "validate": True}, "run_dir": "path/to/rundir", "streams": {"path": "path/to/streams.atmosphere.in", "values": {"world": "user"}}, } @@ -1009,7 +1022,7 @@ def test_schema_rocoto_workflow_cycledef(): def test_schema_sfc_climo_gen(): config = { "execution": {"executable": "sfc_climo_gen"}, - "namelist": {"base_file": "/path"}, + "namelist": {"base_file": "/path", "validate": True}, "run_dir": "/tmp", } errors = schema_validator("sfc-climo-gen", "properties", "sfc_climo_gen") @@ -1161,6 +1174,7 @@ def test_schema_upp(): "kpo": 3, }, }, + "validate": True, }, "run_dir": "/path/to/run", }