Skip to content

Commit

Permalink
UW-588 final validation for driver namelists (#499)
Browse files Browse the repository at this point in the history
  • Loading branch information
maddenp-noaa authored May 30, 2024
1 parent f0db1e0 commit 8303d82
Show file tree
Hide file tree
Showing 45 changed files with 513 additions and 342 deletions.
1 change: 1 addition & 0 deletions .github/scripts/format-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/sections/user_guide/yaml/components/chgres_cube.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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<global-chgres-cube-namelist-options>`.

.. include:: ../../../../shared/validate_namelist.rst

run_dir
^^^^^^^

Expand Down
2 changes: 2 additions & 0 deletions docs/sections/user_guide/yaml/components/esg_grid.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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<regional-esg-grid>`.

.. include:: ../../../../shared/validate_namelist.rst

run_dir:
^^^^^^^^

Expand Down
2 changes: 2 additions & 0 deletions docs/sections/user_guide/yaml/components/fv3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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<namelist-file-input-nml>`.

.. include:: ../../../../shared/validate_namelist.rst

run_dir:
^^^^^^^^

Expand Down
7 changes: 7 additions & 0 deletions docs/sections/user_guide/yaml/components/mpas.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
^^^^^^^^

Expand Down
7 changes: 7 additions & 0 deletions docs/sections/user_guide/yaml/components/mpas_init.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
^^^^^^^^

Expand Down
2 changes: 2 additions & 0 deletions docs/sections/user_guide/yaml/components/sfc_climo_gen.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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<id57>`.

.. include:: ../../../../shared/validate_namelist.rst

run_dir:
^^^^^^^^

Expand Down
6 changes: 4 additions & 2 deletions docs/sections/user_guide/yaml/components/upp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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 <https://upp.readthedocs.io/en/develop/BuildingRunningTesting/InputsOutputs.html#itag>`_.

.. include:: ../../../../shared/validate_namelist.rst

run_dir:
^^^^^^^^

Expand Down
1 change: 1 addition & 0 deletions docs/shared/chgres_cube.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/shared/esg_grid.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/shared/fv3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/shared/mpas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docs/shared/mpas_init.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docs/shared/sfc_climo_gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/shared/upp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ upp:
- 1000
- 100
- 1
validate: true
run_dir: /path/to/run
platform:
account: me
Expand Down
1 change: 1 addition & 0 deletions docs/shared/validate_namelist.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion recipe/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@
"pyyaml =6.0.*"
]
},
"version": "2.2.0"
"version": "2.3.0"
}
40 changes: 30 additions & 10 deletions src/uwtools/config/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand All @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/drivers/chgres_cube.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def namelist_file(self):
config_class=NMLConfig,
config_values=self._driver_config["namelist"],
path=path,
schema=self._namelist_schema(),
)

@tasks
Expand Down
59 changes: 48 additions & 11 deletions src/uwtools/drivers/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -115,25 +117,32 @@ 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.
: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]:
Expand All @@ -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]:
"""
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/drivers/esg_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/drivers/fv3.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def namelist_file(self):
config_class=NMLConfig,
config_values=self._driver_config["namelist"],
path=path,
schema=self._namelist_schema(),
)

@tasks
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/drivers/mpas.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def namelist_file(self):
config_class=NMLConfig,
config_values=namelist,
path=path,
schema=self._namelist_schema(),
)

# Private helper methods
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/drivers/mpas_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def namelist_file(self):
config_class=NMLConfig,
config_values=namelist,
path=path,
schema=self._namelist_schema(),
)

# Private helper methods
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/drivers/sfc_climo_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def namelist_file(self):
config_class=NMLConfig,
config_values=self._driver_config["namelist"],
path=path,
schema=self._namelist_schema(),
)

@tasks
Expand Down
1 change: 1 addition & 0 deletions src/uwtools/drivers/upp.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def namelist_file(self):
config_class=NMLConfig,
config_values=self._driver_config["namelist"],
path=path,
schema=self._namelist_schema(),
)

@tasks
Expand Down
Loading

0 comments on commit 8303d82

Please sign in to comment.