diff --git a/src/uwtools/resources/FV3Forecast.jsonschema b/src/uwtools/resources/FV3Forecast.jsonschema index fac796dec..f472fb15a 100644 --- a/src/uwtools/resources/FV3Forecast.jsonschema +++ b/src/uwtools/resources/FV3Forecast.jsonschema @@ -1,38 +1,33 @@ { "$defs": { - "updatable_config": { - "additionalProperties": false, - "properties": { - "properties": { - "base_file": { - "format": "uri", - "type": "string" - }, - "update_values": { - "type": "object" - } - }, - "required": [ - "base_file" - ], - "type": "object" - } + "filesToStage": { + "minProperties": 1, + "patternProperties": { + "^.*$": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + } + }, + "type": "object" } }, - "description": "This document is to validate user-defined FV3 forecast config files", "properties": { "forecast": { "additionalProperties": false, - "description": "parameters of the forecast", "properties": { - "cycle_dependent|static": { - "propertyNames": { - "type": "string" - }, - "type": "object" + "cycle_dependent": { + "$ref": "#/$defs/filesToStage" }, "diag_table": { - "format": "uri", "type": "string" }, "domain": { @@ -43,88 +38,237 @@ "type": "string" }, "executable": { - "format": "uri", "type": "string" }, - "fd_ufs": { - "$ref": "#/$defs/updatable_config" - }, "field_table": { - "$ref": "#/$defs/updatable_config" + "additionalProperties": false, + "properties": { + "base_file": { + "type": "string" + }, + "update_values": { + "minProperties": 1, + "patternProperties": { + "^.*$": { + "additionalProperties": false, + "properties": { + "longname": { + "type": "string" + }, + "profile_type": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "name": { + "const": "fixed" + }, + "surface_value": { + "type": "number" + } + }, + "required": [ + "name", + "surface_value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "const": "profile" + }, + "surface_value": { + "type": "number" + }, + "top_value": { + "type": "number" + } + }, + "required": [ + "name", + "surface_value", + "top_value" + ], + "type": "object" + } + ] + }, + "units": { + "type": "string" + } + }, + "required": [ + "longname", + "profile_type", + "units" + ], + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "base_file" + ], + "type": "object" }, "length": { "minimum": 1, "type": "integer" }, "model_configure": { - "$ref": "#/$defs/updatable_config" - }, - "mpicmd": { - "type": "string" + "additionalProperties": false, + "properties": { + "base_file": { + "type": "string" + }, + "update_values": { + "minProperties": 1, + "patternProperties": { + "^.*$": { + "type": [ + "boolean", + "number", + "string" + ] + } + }, + "type": "object" + } + }, + "required": [ + "base_file" + ], + "type": "object" }, "namelist": { - "$ref": "#/$defs/updatable_config" + "additionalProperties": false, + "properties": { + "base_file": { + "type": "string" + }, + "update_values": { + "minProperties": 1, + "patternProperties": { + "^.*$": { + "minProperties": 1, + "patternProperties": { + "^.*$": { + "minProperties": 1, + "type": [ + "array", + "boolean", + "number", + "string" + ] + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "base_file" + ], + "type": "object" }, "run_dir": { - "format": "uri", "type": "string" }, - "ufs_configure": { - "format": "uri", - "type": "string" + "runtime_info": { + "additionalProperties": false, + "properties": { + "mpi_args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threads": { + "type": "integer" + } + }, + "type": "object" + }, + "static": { + "$ref": "#/$defs/filesToStage" } }, + "required": [ + "run_dir", + "runtime_info" + ], "type": "object" }, "platform": { + "additionalProperties": false, "properties": { - "required": [ - "mpicmd" - ], + "mpicmd": { + "type": "string" + }, "scheduler": { "enum": [ - "lsf", + "lfs", "pbs", "slurm" ], "type": "string" } }, + "required": [ + "scheduler" + ], "type": "object" }, "preprocessing": { + "additionalProperties": false, "properties": { "lateral_boundary_conditions": { + "additionalProperties": false, "properties": { "interval_hours": { - "default": 3, "minimum": 1, - "type": "number" + "type": "integer" }, "offset": { - "default": 0, "minimum": 0, - "type": "number" + "type": "integer" }, "output_file_path": { - "format": "uri", "type": "string" } }, + "required": [ + "interval_hours", + "offset", + "output_file_path" + ], "type": "object" } }, + "required": [ + "lateral_boundary_conditions" + ], "type": "object" }, "user": { + "additionalProperties": false, "properties": { "account": { "type": "string" } }, + "required": [ + "account" + ], "type": "object" } }, - "title": "FV3 Forecast config", "type": "object" } diff --git a/src/uwtools/tests/drivers/test_forecast.py b/src/uwtools/tests/drivers/test_forecast.py index 4c9b8659b..6c87728f1 100644 --- a/src/uwtools/tests/drivers/test_forecast.py +++ b/src/uwtools/tests/drivers/test_forecast.py @@ -17,7 +17,7 @@ from uwtools.drivers.driver import Driver from uwtools.drivers.forecast import FV3Forecast from uwtools.logging import log -from uwtools.tests.support import compare_files, fixture_path, logged +from uwtools.tests.support import compare_files, fixture_path, logged, validator from uwtools.types import ExistAct @@ -404,3 +404,22 @@ def test_FV3Forecast__run_via_local_execution(fv3_run_assets): assert success is True assert lines[0] == "Command:" execute.assert_called_once_with(cmd=ANY, cwd=ANY, log_output=True) + + +# Schema tests + + +def test_FV3Forecast_schema_filesToStage(): + errors = validator("FV3Forecast.jsonschema", "$defs", "filesToStage") + # The input must be an dict: + assert "is not of type 'object'" in errors([]) + # A str -> str dict is ok: + assert not errors({"file1": "/path/to/file1", "file2": "/path/to/file2"}) + # A str -> List[str] dict is ok: + assert not errors({"dir": ["/path/to/file1", "/path/to/file2"]}) + # An empty dict is not allowed: + assert "does not have enough properties" in errors({}) + # Non-string values are not allowed: + assert "not valid" in errors({"file1": True}) + # Non-string list elements are not allowed: + assert "not valid" in errors({"dir": [88]}) diff --git a/src/uwtools/tests/support.py b/src/uwtools/tests/support.py index 8c7ada7a4..904c88d58 100644 --- a/src/uwtools/tests/support.py +++ b/src/uwtools/tests/support.py @@ -3,9 +3,14 @@ import re from importlib import resources from pathlib import Path +from typing import Callable +import yaml from _pytest.logging import LogCaptureFixture +from uwtools.config.validator import _validation_errors +from uwtools.utils.file import resource_pathobj + def compare_files(path1: str, path2: str) -> bool: """ @@ -86,3 +91,19 @@ def regex_logged(caplog: LogCaptureFixture, msg: str) -> bool: """ pattern = re.compile(re.escape(msg)) return any(pattern.search(record.message) for record in caplog.records) + + +def validator(schema_fn: str, *args) -> Callable: + """ + Create a lambda that returns errors from validating a config input. + + :param schema_fn: The schema filename, relative to package resources. + :param args: Keys leading to sub-schema to be used to validate eventual input. + :returns: A lambda that, when called with an input to test, returns a string (possibly empty) + containing the validation errors. + """ + with open(resource_pathobj(schema_fn), "r", encoding="utf-8") as f: + schema = yaml.safe_load(f) + for arg in args: + schema = {"$defs": schema["$defs"], **schema[arg]} + return lambda config: "\n".join(str(x) for x in _validation_errors(config, schema)) diff --git a/src/uwtools/tests/test_rocoto.py b/src/uwtools/tests/test_rocoto.py index 7cd6ecb87..9dacad4db 100644 --- a/src/uwtools/tests/test_rocoto.py +++ b/src/uwtools/tests/test_rocoto.py @@ -3,20 +3,17 @@ Tests for uwtools.rocoto module. """ -from typing import Callable, List +from typing import List from unittest.mock import DEFAULT as D from unittest.mock import PropertyMock, patch import pytest -import yaml from pytest import fixture, raises from uwtools import rocoto from uwtools.config.formats.yaml import YAMLConfig -from uwtools.config.validator import _validation_errors from uwtools.exceptions import UWConfigError, UWError -from uwtools.tests.support import fixture_path -from uwtools.utils.file import resource_pathobj +from uwtools.tests.support import fixture_path, validator # Fixtures @@ -38,20 +35,6 @@ def validation_assets(tmp_path): return xml_file_bad, xml_file_good, xml_string_bad, xml_string_good -# Helpers - - -def validator(*args) -> Callable: - # Supply, as args, zero or more keys leading through the schema dict to the sub-schema to be - # used to validate some input. This function returns a lambda that, when called with the input - # to test, returns a string (possibly empty) containing the validation errors. - with open(resource_pathobj("rocoto.jsonschema"), "r", encoding="utf-8") as f: - schema = yaml.safe_load(f) - for arg in args: - schema = {"$defs": schema["$defs"], **schema[arg]} - return lambda config: "\n".join(str(x) for x in _validation_errors(config, schema)) - - # Tests @@ -455,8 +438,8 @@ def test_dump(self, instance, tmp_path): # Schema tests -def test_schema_compoundTimeString(): - errors = validator("$defs", "compoundTimeString") +def test_rocoto_schema_compoundTimeString(): + errors = validator("rocoto.jsonschema", "$defs", "compoundTimeString") # Just a string is ok: assert not errors("foo") # An int value is ok: @@ -473,8 +456,8 @@ def test_schema_compoundTimeString(): assert "is not valid" in errors({"cyclestr": {"value": "@Y@m@d@H", "attrs": {"offset": "x"}}}) -def test_schema_dependency_sh(): - errors = validator("$defs", "dependency") +def test_rocoto_schema_dependency_sh(): + errors = validator("rocoto.jsonschema", "$defs", "dependency") # Basic spec: assert not errors({"sh": {"command": "foo"}}) # The "command" property is mandatory: @@ -493,8 +476,8 @@ def test_schema_dependency_sh(): assert not errors({"sh": {"command": {"cyclestr": {"value": "foo-@Y@m@d@H"}}}}) -def test_schema_metatask_attrs(): - errors = validator("$defs", "metatask", "properties", "attrs") +def test_rocoto_schema_metatask_attrs(): + errors = validator("rocoto.jsonschema", "$defs", "metatask", "properties", "attrs") # Valid modes are "parallel" and "serial": assert not errors({"mode": "parallel"}) assert not errors({"mode": "serial"}) @@ -506,8 +489,8 @@ def test_schema_metatask_attrs(): assert "'foo' is not of type 'integer'" in errors({"throttle": "foo"}) -def test_schema_workflow_cycledef(): - errors = validator("properties", "workflow", "properties", "cycledef") +def test_rocoto_schema_workflow_cycledef(): + errors = validator("rocoto.jsonschema", "properties", "workflow", "properties", "cycledef") # Basic spec: spec = "202311291200 202312011200 06:00:00" assert not errors([{"spec": spec}])