diff --git a/docs/conf.py b/docs/conf.py index 8e0e67f94..c1ec605c0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,7 +21,10 @@ html_static_path = ["static"] html_theme = "sphinx_rtd_theme" intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} -nitpick_ignore_regex = [("py:class", r"^uwtools\..*"), ("py:class", "f90nml.Namelist")] +nitpick_ignore = [ + ("py:class", "Path"), + ("py:class", "f90nml.Namelist"), +] numfig = True numfig_format = {"figure": "Figure %s"} project = "Unified Workflow Tools" diff --git a/docs/sections/user_guide/api/config.rst b/docs/sections/user_guide/api/config.rst index 7b9811bb3..da9415bac 100644 --- a/docs/sections/user_guide/api/config.rst +++ b/docs/sections/user_guide/api/config.rst @@ -5,5 +5,5 @@ :target: https://mybinder.org/v2/gh/ufs-community/uwtools/main?labpath=notebooks%2Fconfig.ipynb .. automodule:: uwtools.api.config - :inherited-members: UserDict :members: + :show-inheritance: diff --git a/notebooks/config.ipynb b/notebooks/config.ipynb index e49a44ae1..1cbd80e7b 100644 --- a/notebooks/config.ipynb +++ b/notebooks/config.ipynb @@ -1228,15 +1228,17 @@ "text": [ "Help on function validate in module uwtools.api.config:\n", "\n", - "validate(schema_file: Union[pathlib.Path, str], config: Union[dict, str, uwtools.config.formats.yaml.YAMLConfig, pathlib.Path, NoneType] = None, stdin_ok: bool = False) -> bool\n", + "validate(schema_file: Union[pathlib.Path, str], config_data: Union[bool, dict, float, int, list, str, uwtools.config.formats.yaml.YAMLConfig, NoneType] = None, config_path: Union[str, pathlib.Path, NoneType] = None, stdin_ok: bool = False) -> bool\n", " Check whether the specified config conforms to the specified JSON Schema spec.\n", "\n", - " If no config is specified, ``stdin`` is read and will be parsed as YAML and then validated. A\n", - " ``dict`` or a YAMLConfig instance may also be provided for validation.\n", + " Specify at most one of config_data or config_path. If no config is specified, ``stdin`` is read\n", + " and will be parsed as YAML and then validated.\n", "\n", " :param schema_file: The JSON Schema file to use for validation.\n", - " :param config: The config to validate.\n", + " :param config_data: A config to validate.\n", + " :param config_path: A path to a file containing a config to validate.\n", " :param stdin_ok: OK to read from ``stdin``?\n", + " :raises: TypeError if both config_* arguments specified.\n", " :return: ``True`` if the YAML file conforms to the schema, ``False`` otherwise.\n", "\n" ] @@ -1322,7 +1324,7 @@ "id": "8c61a2d2-473c-45c6-9c6c-6c07fc5bf940", "metadata": {}, "source": [ - "The schema file and config from above are passed to the respective `schema_file` and `config` parameters. Config file paths should be passed as a string or Path object. Files should be of YAML format, or parseable as YAML. Alternatively, a `YAMLConfig` object or a Python `dict` can be provided. `validate()` returns `True` if the config conforms to the JSON schema, and `False` otherwise. With a logger initialized, details about any validation errors are reported.\n", + "The schema file and config from above are passed to the respective `schema_file` and `config_path` parameters. Config file paths should be passed as a string or Path object. Files should be of YAML format, or parseable as YAML. Alternatively, a `YAMLConfig` object or a Python `dict` can be provided. `validate()` returns `True` if the config conforms to the JSON schema, and `False` otherwise. With a logger initialized, details about any validation errors are reported.\n", "" ] }, @@ -1353,7 +1355,7 @@ "source": [ "config.validate(\n", " schema_file='fixtures/config/validate.jsonschema',\n", - " config='fixtures/config/get-config.yaml'\n", + " config_path='fixtures/config/get-config.yaml'\n", ")" ] }, @@ -1362,7 +1364,7 @@ "id": "8d151205-4a95-4b46-aa1d-30ec30d96e88", "metadata": {}, "source": [ - "The `config` argument also accepts a dictionary. In the next example, validation errors exist, and the logger reports the number of errors found along with their locations and details.\n", + "A mutually-exclusive alternative to the `config_path` argument, the `config_data` argument commonly accepts a `dict` object, but can also validate configs based on `bool`, `float`, `int`, `list`, or `str` values. In the next example, validation errors exist, and the logger reports the number of errors found along with their locations and details.\n", "" ] }, @@ -1395,7 +1397,7 @@ "source": [ "config.validate(\n", " schema_file='fixtures/config/validate.jsonschema',\n", - " config={'greeting':'Hello', 'recipient':47}\n", + " config_data={'greeting':'Hello', 'recipient':47}\n", ")" ] }, @@ -1470,10 +1472,10 @@ " | ----------------------------------------------------------------------\n", " | Methods inherited from uwtools.config.formats.base.Config:\n", " |\n", - " | __repr__(self) -> str\n", + " | __repr__(self) -> 'str'\n", " | Return the string representation of a Config object.\n", " |\n", - " | compare_config(self, dict1: dict, dict2: Optional[dict] = None, header: Optional[bool] = True) -> bool\n", + " | compare_config(self, dict1: 'dict', dict2: 'Optional[dict]' = None, header: 'Optional[bool]' = True) -> 'bool'\n", " | Compare two config dictionaries.\n", " |\n", " | Assumes a section/key/value structure.\n", @@ -1482,10 +1484,10 @@ " | :param dict2: The second dictionary (default: this config).\n", " | :return: True if the configs are identical, False otherwise.\n", " |\n", - " | dereference(self, context: Optional[dict] = None) -> None\n", + " | dereference(self, context: 'Optional[dict]' = None) -> 'Config'\n", " | Render as much Jinja2 syntax as possible.\n", " |\n", - " | update_from(self, src: Union[dict, collections.UserDict]) -> None\n", + " | update_from(self, src: 'Union[dict, UserDict]') -> 'None'\n", " | Update a config.\n", " |\n", " | :param src: The dictionary with new data to use.\n", diff --git a/notebooks/template.ipynb b/notebooks/template.ipynb index 26890bc7e..2d30db496 100644 --- a/notebooks/template.ipynb +++ b/notebooks/template.ipynb @@ -52,7 +52,7 @@ "text": [ "Help on function render in module uwtools.api.template:\n", "\n", - "render(values_src: Union[dict, str, pathlib.Path, NoneType] = None, values_format: Optional[str] = None, input_file: Union[str, pathlib.Path, NoneType] = None, output_file: Union[str, pathlib.Path, NoneType] = None, overrides: Optional[dict[str, str]] = None, env: bool = False, searchpath: Optional[list[str]] = None, values_needed: bool = False, dry_run: bool = False, stdin_ok: bool = False) -> str\n", + "render(values_src: Union[dict, pathlib.Path, str, NoneType] = None, values_format: Optional[str] = None, input_file: Union[str, pathlib.Path, NoneType] = None, output_file: Union[str, pathlib.Path, NoneType] = None, overrides: Optional[dict[str, str]] = None, env: bool = False, searchpath: Optional[list[str]] = None, values_needed: bool = False, dry_run: bool = False, stdin_ok: bool = False) -> str\n", " Render a Jinja2 template to a file, based on specified values.\n", "\n", " Primary values used to render the template are taken from the specified file. The format of the\n", @@ -314,7 +314,7 @@ "text": [ "Help on function render_to_str in module uwtools.api.template:\n", "\n", - "render_to_str(values_src: Union[dict, str, pathlib.Path, NoneType] = None, values_format: Optional[str] = None, input_file: Union[str, pathlib.Path, NoneType] = None, overrides: Optional[dict[str, str]] = None, env: bool = False, searchpath: Optional[list[str]] = None, values_needed: bool = False, dry_run: bool = False) -> str\n", + "render_to_str(values_src: Union[dict, pathlib.Path, str, NoneType] = None, values_format: Optional[str] = None, input_file: Union[str, pathlib.Path, NoneType] = None, overrides: Optional[dict[str, str]] = None, env: bool = False, searchpath: Optional[list[str]] = None, values_needed: bool = False, dry_run: bool = False) -> str\n", " Render a Jinja2 template to a string, based on specified values.\n", "\n", " See ``render()`` for details on arguments, etc.\n", diff --git a/recipe/meta.json b/recipe/meta.json index b56cc1342..c6c597cb0 100644 --- a/recipe/meta.json +++ b/recipe/meta.json @@ -36,5 +36,5 @@ "requests =2.32.*" ] }, - "version": "2.5.0" + "version": "2.6.0" } diff --git a/src/uwtools/api/config.py b/src/uwtools/api/config.py index 5df802939..bf327ce2a 100644 --- a/src/uwtools/api/config.py +++ b/src/uwtools/api/config.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Optional, Union -from uwtools.config.formats.base import Config as _Config +from uwtools.config.formats.base import Config from uwtools.config.formats.fieldtable import FieldTableConfig from uwtools.config.formats.ini import INIConfig from uwtools.config.formats.nml import NMLConfig @@ -15,6 +15,8 @@ from uwtools.config.support import YAMLKey from uwtools.config.tools import compare_configs as _compare from uwtools.config.tools import realize_config as _realize +from uwtools.config.validator import ConfigDataT, ConfigPathT +from uwtools.config.validator import validate_check_config as _validate_check_config from uwtools.config.validator import validate_external as _validate_external from uwtools.exceptions import UWConfigError from uwtools.utils.api import ensure_data_source as _ensure_data_source @@ -111,9 +113,9 @@ def get_yaml_config( def realize( - input_config: Optional[Union[_Config, Path, dict, str]] = None, + input_config: Optional[Union[Config, Path, dict, str]] = None, input_format: Optional[str] = None, - update_config: Optional[Union[_Config, Path, dict, str]] = None, + update_config: Optional[Union[Config, Path, dict, str]] = None, update_format: Optional[str] = None, output_file: Optional[Union[Path, str]] = None, output_format: Optional[str] = None, @@ -143,9 +145,9 @@ def realize( def realize_to_dict( # pylint: disable=unused-argument - input_config: Optional[Union[dict, _Config, Path, str]] = None, + input_config: Optional[Union[dict, Config, Path, str]] = None, input_format: Optional[str] = None, - update_config: Optional[Union[dict, _Config, Path, str]] = None, + update_config: Optional[Union[dict, Config, Path, str]] = None, update_format: Optional[str] = None, key_path: Optional[list[YAMLKey]] = None, values_needed: bool = False, @@ -163,25 +165,32 @@ def realize_to_dict( # pylint: disable=unused-argument def validate( schema_file: Union[Path, str], - config: Optional[Union[dict, YAMLConfig, Path, str]] = None, + config_data: Optional[ConfigDataT] = None, + config_path: Optional[ConfigPathT] = None, stdin_ok: bool = False, ) -> bool: """ Check whether the specified config conforms to the specified JSON Schema spec. - If no config is specified, ``stdin`` is read and will be parsed as YAML and then validated. A - ``dict`` or a YAMLConfig instance may also be provided for validation. + Specify at most one of config_data or config_path. If no config is specified, ``stdin`` is read + and will be parsed as YAML and then validated. :param schema_file: The JSON Schema file to use for validation. - :param config: The config to validate. + :param config_data: A config to validate. + :param config_path: A path to a file containing a config to validate. :param stdin_ok: OK to read from ``stdin``? + :raises: TypeError if both config_* arguments specified. :return: ``True`` if the YAML file conforms to the schema, ``False`` otherwise. """ + _validate_check_config(config_data, config_path) + if config_data is None: + config_path = _ensure_data_source(_str2path(config_path), stdin_ok) try: _validate_external( schema_file=_str2path(schema_file), desc="config", - config=_ensure_data_source(_str2path(config), stdin_ok), + config_data=config_data, + config_path=config_path, ) except UWConfigError: return False @@ -252,6 +261,7 @@ def validate( ).strip() __all__ = [ + "Config", "FieldTableConfig", "INIConfig", "NMLConfig", diff --git a/src/uwtools/cli.py b/src/uwtools/cli.py index 84c162f1c..cec57a408 100644 --- a/src/uwtools/cli.py +++ b/src/uwtools/cli.py @@ -252,7 +252,7 @@ def _dispatch_config_validate(args: Args) -> bool: """ return uwtools.api.config.validate( schema_file=args[STR.schemafile], - config=args[STR.infile], + config_path=args[STR.infile], stdin_ok=True, ) diff --git a/src/uwtools/config/formats/base.py b/src/uwtools/config/formats/base.py index ff3f18671..5f91a28bf 100644 --- a/src/uwtools/config/formats/base.py +++ b/src/uwtools/config/formats/base.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import difflib import os import re @@ -23,7 +25,7 @@ class Config(ABC, UserDict): several configuration-file formats. """ - def __init__(self, config: Optional[Union[dict, str, Path]] = None) -> None: + def __init__(self, config: Optional[Union[dict, str, Config, Path]] = None) -> None: """ :param config: Config file to load (None => read from stdin), or initial dict. """ @@ -31,6 +33,9 @@ def __init__(self, config: Optional[Union[dict, str, Path]] = None) -> None: if isinstance(config, dict): self._config_file = None self.update(config) + elif isinstance(config, Config): + self._config_file = config._config_file + self.update(config.data) else: self._config_file = str2path(config) if config else None self.data = self._load(self._config_file) @@ -216,7 +221,7 @@ def config_file(self) -> Optional[Path]: """ return self._config_file - def dereference(self, context: Optional[dict] = None) -> None: + def dereference(self, context: Optional[dict] = None) -> Config: """ Render as much Jinja2 syntax as possible. """ @@ -234,6 +239,7 @@ def logstate(state: str) -> None: break self.data = new logstate("final") + return self @abstractmethod def dump(self, path: Optional[Path]) -> None: diff --git a/src/uwtools/config/validator.py b/src/uwtools/config/validator.py index 201197aae..0e8910ded 100644 --- a/src/uwtools/config/validator.py +++ b/src/uwtools/config/validator.py @@ -19,6 +19,10 @@ # Public functions +JSONValueT = Union[bool, dict, float, int, list, str] +ConfigDataT = Union[JSONValueT, YAMLConfig] +ConfigPathT = Union[str, Path] + def bundle(schema: dict, keys: Optional[list] = None) -> dict: """ @@ -57,7 +61,7 @@ def internal_schema_file(schema_name: str) -> Path: return resource_path("jsonschema") / f"{schema_name}.jsonschema" -def validate(schema: dict, desc: str, config: dict) -> bool: +def validate(schema: dict, desc: str, config: JSONValueT) -> bool: """ Report any errors arising from validation of the given config against the given JSON Schema. @@ -77,56 +81,85 @@ def validate(schema: dict, desc: str, config: dict) -> bool: return not bool(errors) +def validate_check_config( + config_data: Optional[ConfigDataT] = None, config_path: Optional[ConfigPathT] = None +) -> None: + """ + Enforce mutual exclusivity of config_* arguments. + + :param config_data: A config to validate. + :param config_path: A path to a file containing a config to validate. + :raises: TypeError if both config_* arguments specified. + """ + if config_data is not None and config_path is not None: + raise TypeError("Specify at most one of config_data, config_path") + + def validate_internal( - schema_name: str, desc: str, config: Optional[Union[dict, YAMLConfig, Path]] = None + schema_name: str, + desc: str, + config_data: Optional[ConfigDataT] = None, + config_path: Optional[ConfigPathT] = None, ) -> None: """ Validate a config against a uwtools-internal schema. + Specify at most one of config_data or config_path. If no config is specified, ``stdin`` is read + and will be parsed as YAML and then validated. + :param schema_name: Name of uwtools schema to validate the config against. :param desc: A description of the config being validated, for logging. - :param config: The config to validate. - :raises: UWConfigError if config fails validation. + :param config_data: A config to validate. + :param config_path: A path to a file containing a config to validate. + :raises: TypeError if both config_* arguments specified. """ + validate_check_config(config_data, config_path) log.info("Validating config against internal schema: %s", schema_name) - validate_external(config=config, schema_file=internal_schema_file(schema_name), desc=desc) + validate_external( + schema_file=internal_schema_file(schema_name), + desc=desc, + config_data=config_data, + config_path=config_path, + ) def validate_external( - schema_file: Path, desc: str, config: Optional[Union[dict, YAMLConfig, Path]] = None + schema_file: Path, + desc: str, + config_data: Optional[ConfigDataT] = None, + config_path: Optional[ConfigPathT] = None, ) -> None: """ Validate a YAML config against the JSON Schema in the given schema file. + Specify at most one of config_data or config_path. If no config is specified, ``stdin`` is read + and will be parsed as YAML and then validated. + :param schema_file: The JSON Schema file to use for validation. :param desc: A description of the config being validated, for logging. - :param config: The config to validate. - :raises: UWConfigError if config fails validation. - """ + :param config_data: A config to validate. + :param config_path: A path to a file containing a config to validate. + :raises: TypeError if both config_* arguments specified. + """ + validate_check_config(config_data, config_path) + config: JSONValueT + if config_data is None: + config = YAMLConfig(config_path).dereference().data + elif isinstance(config_data, YAMLConfig): + config = config_data.data + else: + config = config_data if not str(schema_file).startswith(str(resource_path())): log.debug("Using schema file: %s", schema_file) with open(schema_file, "r", encoding="utf-8") as f: schema = json.load(f) - cfgobj = _prep_config(config) - if not validate(schema=schema, desc=desc, config=cfgobj.data): + if not validate(schema=schema, desc=desc, config=config): raise UWConfigError("YAML validation errors") # Private functions -def _prep_config(config: Union[dict, YAMLConfig, Optional[Path]]) -> YAMLConfig: - """ - Ensure a dereferenced YAMLConfig object for various input types. - - :param config: The config to validate. - :return: A dereferenced YAMLConfig object based on the input config. - """ - cfgobj = config if isinstance(config, YAMLConfig) else YAMLConfig(config) - cfgobj.dereference() - return cfgobj - - @cache def _registry() -> Registry: """ @@ -143,7 +176,7 @@ def retrieve(uri: str) -> Resource: return Registry(retrieve=retrieve) # type: ignore -def _validation_errors(config: Union[dict, list], schema: dict) -> list[ValidationError]: +def _validation_errors(config: JSONValueT, schema: dict) -> list[ValidationError]: """ Identify schema-validation errors. diff --git a/src/uwtools/drivers/driver.py b/src/uwtools/drivers/driver.py index 28bc8badd..35fd3c7d3 100644 --- a/src/uwtools/drivers/driver.py +++ b/src/uwtools/drivers/driver.py @@ -46,7 +46,7 @@ def __init__( self, cycle: Optional[datetime] = None, leadtime: Optional[timedelta] = None, - config: Optional[Union[dict, str, YAMLConfig, Path]] = None, + config: Optional[Union[dict, str, Path, YAMLConfig]] = None, dry_run: bool = False, key_path: Optional[list[YAMLKey]] = None, schema_file: Optional[Path] = None, @@ -233,7 +233,7 @@ def _validate(self) -> None: :raises: UWConfigError if config fails validation. """ kwargs: dict = { - "config": self._config_intermediate, + "config_data": self._config_intermediate, "desc": "%s config" % self.driver_name(), } if self.schema_file: @@ -536,7 +536,9 @@ def _validate(self) -> None: """ Assets._validate(self) validate_internal( - schema_name=STR.platform, desc="platform config", config=self._config_intermediate + schema_name=STR.platform, + desc="platform config", + config_data=self._config_intermediate, ) def _write_runscript(self, path: Path, envvars: Optional[dict[str, str]] = None) -> None: diff --git a/src/uwtools/fs.py b/src/uwtools/fs.py index f7d214a49..41809117a 100644 --- a/src/uwtools/fs.py +++ b/src/uwtools/fs.py @@ -116,7 +116,15 @@ def _validate(self) -> None: :raises: UWConfigError if config fails validation. """ - validate_internal(schema_name=self._schema, desc="fs config", config=self._config) + config_data, config_path = ( + (self._config, None) if isinstance(self._config, dict) else (None, self._config) + ) + validate_internal( + schema_name=self._schema, + desc="fs config", + config_data=config_data, + config_path=config_path, + ) class FileStager(Stager): diff --git a/src/uwtools/resources/info.json b/src/uwtools/resources/info.json index bc21a6c20..ffa9be4b9 100644 --- a/src/uwtools/resources/info.json +++ b/src/uwtools/resources/info.json @@ -1,4 +1,4 @@ { "buildnum": "0", - "version": "2.5.0" + "version": "2.6.0" } diff --git a/src/uwtools/rocoto.py b/src/uwtools/rocoto.py index 180d66aaf..e98c94589 100644 --- a/src/uwtools/rocoto.py +++ b/src/uwtools/rocoto.py @@ -350,15 +350,20 @@ def _add_workflow_tasks(self, e: _Element, config: dict) -> None: tag, name = self._tag_name(key) {STR.metatask: self._add_metatask, STR.task: self._add_task}[tag](e, subconfig, name) - def _config_validate(self, config: Union[dict, YAMLConfig, Optional[Path]]) -> None: + def _config_validate(self, config: Optional[Union[dict, Path, YAMLConfig]] = None) -> None: """ Validate the given YAML config. :param config: YAMLConfig object or path to YAML file (None => read stdin). :raises: UWConfigError if config fails validation. """ - schema_file = resource_path("jsonschema/rocoto.jsonschema") - validate_yaml(schema_file=schema_file, desc="Rocoto config", config=config) + config_data, config_path = (None, config) if isinstance(config, Path) else (config, None) + validate_yaml( + schema_file=resource_path("jsonschema/rocoto.jsonschema"), + desc="Rocoto config", + config_data=config_data, + config_path=config_path, + ) @property def _doctype(self) -> Optional[str]: diff --git a/src/uwtools/tests/api/test_config.py b/src/uwtools/tests/api/test_config.py index 71796c814..101024dda 100644 --- a/src/uwtools/tests/api/test_config.py +++ b/src/uwtools/tests/api/test_config.py @@ -117,9 +117,9 @@ def test_realize_update_config_none(): ) -@mark.parametrize("cfg", [{"foo": "bar"}, YAMLConfig(config={})]) -def test_validate(cfg): - kwargs: dict = {"schema_file": "schema-file", "config": cfg} +@mark.parametrize("cfg", [{"foo": "bar"}, YAMLConfig(config={"foo": "bar"})]) +def test_validate_config_data(cfg): + kwargs: dict = {"schema_file": "schema-file", "config_data": cfg} with patch.object(config, "_validate_external") as _validate_external: assert config.validate(**kwargs) is True _validate_external.side_effect = UWConfigError() @@ -127,18 +127,19 @@ def test_validate(cfg): _validate_external.assert_called_with( schema_file=Path(kwargs["schema_file"]), desc="config", - config=kwargs["config"], + config_data=kwargs["config_data"], + config_path=None, ) @mark.parametrize("cast", (str, Path)) -def test_validate_config_file(cast, tmp_path): +def test_validate_config_path(cast, tmp_path): cfg = tmp_path / "config.yaml" with open(cfg, "w", encoding="utf-8") as f: yaml.dump({}, f) - kwargs: dict = {"schema_file": "schema-file", "config": cast(cfg)} + kwargs: dict = {"schema_file": "schema-file", "config_path": cast(cfg)} with patch.object(config, "_validate_external", return_value=True) as _validate_external: assert config.validate(**kwargs) _validate_external.assert_called_once_with( - schema_file=Path(kwargs["schema_file"]), desc="config", config=cfg + schema_file=Path(kwargs["schema_file"]), desc="config", config_data=None, config_path=cfg ) diff --git a/src/uwtools/tests/config/formats/test_base.py b/src/uwtools/tests/config/formats/test_base.py index 4bb3baaad..f71311ab2 100644 --- a/src/uwtools/tests/config/formats/test_base.py +++ b/src/uwtools/tests/config/formats/test_base.py @@ -194,7 +194,12 @@ def test_compare_config_ini(caplog, salad_base): assert not logged(caplog, line) -def test_config_file(config): +def test_config_from_config(config): + assert config.config_file.name == "config.yaml" + assert ConcreteConfig(config).data == config.data + + +def test_config_from_file(config): assert config.config_file.name == "config.yaml" assert config.config_file.is_file() @@ -240,8 +245,8 @@ def test_dereference(tmp_path): print(yaml, file=f) config = YAMLConfig(path) with patch.dict(os.environ, {"N": "999"}, clear=True): - config.dereference() - print(config["e"]) + retval = config.dereference() + assert retval is config assert config == { "a": 44, "b": {"c": 33}, diff --git a/src/uwtools/tests/config/test_validator.py b/src/uwtools/tests/config/test_validator.py index 7eb035749..aec53d62c 100644 --- a/src/uwtools/tests/config/test_validator.py +++ b/src/uwtools/tests/config/test_validator.py @@ -4,13 +4,13 @@ """ import json import logging +from functools import partial from pathlib import Path from textwrap import dedent from typing import Any from unittest.mock import Mock, patch -import yaml -from pytest import fixture, raises +from pytest import fixture, mark, raises from uwtools.config import validator from uwtools.config.formats.yaml import YAMLConfig @@ -125,7 +125,7 @@ def write_as_json(data: dict[str, Any], path: Path) -> Path: # Test functions -def test_bundle(caplog): +def test_config_validator_bundle(caplog): log.setLevel(logging.DEBUG) schema = {"fruit": {"$ref": "urn:uwtools:a"}, "flowers": None} with patch.object(validator, "_registry") as _registry: @@ -150,16 +150,30 @@ def test_bundle(caplog): assert logged(caplog, msg) -def test_internal_schema_file(): +def test_config_validator_internal_schema_file(): with patch.object(validator, "resource_path", return_value=Path("/foo/bar")): assert validator.internal_schema_file("baz") == Path("/foo/bar/baz.jsonschema") -def test_validate(config, schema): +@mark.parametrize( + "schema,config", + [ + ({"type": "boolean"}, True), # bool + ({"type": "number"}, 3.14), # float + ({"type": "integer"}, 42), # int + ({"type": "array"}, [1, 2, 3]), # list + ({"type": "string"}, "foo"), # str + ], +) +def test_config_validator_validate_alt_types(schema, config): + assert validator.validate(schema=schema, desc="test", config=config) is True + + +def test_config_validator_validate_dict(config, schema): assert validator.validate(schema=schema, desc="test", config=config) -def test_validate_fail_bad_enum_val(caplog, config, schema): +def test_config_validator_validate_fail_bad_enum_val(caplog, config, schema): log.setLevel(logging.INFO) config["color"] = "yellow" # invalid enum value assert not validator.validate(schema=schema, desc="test", config=config) @@ -167,7 +181,7 @@ def test_validate_fail_bad_enum_val(caplog, config, schema): 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): +def test_config_validator_validate_fail_bad_number_val(caplog, config, schema): log.setLevel(logging.INFO) config["number"] = "string" # invalid number value assert not validator.validate(schema=schema, desc="test", config=config) @@ -175,7 +189,7 @@ def test_validate_fail_bad_number_val(caplog, config, schema): assert any(x for x in caplog.records if "'string' is not of type 'number'" in x.message) -def test_validate_fail_top_level(caplog): +def test_config_validator_validate_fail_top_level(caplog): schema = { "additionalProperties": False, "properties": {"n": {"type": "integer"}}, @@ -194,49 +208,43 @@ def test_validate_fail_top_level(caplog): assert all(line in caplog.messages for line in dedent(expected).strip().split("\n")) -def test_validate_internal_no(caplog, schema_file): +@mark.parametrize( + "config_data,config_path", [(True, None), (None, True), (None, None), (True, True)] +) +def test_config_validator_validate_check_config(config_data, config_path): + f = partial(validator.validate_check_config, config_data, config_path) + if config_data is None or config_path is None: + assert f() is None + else: + with raises(TypeError) as e: + f() + assert str(e.value) == "Specify at most one of config_data, config_path" + + +def test_config_validator_validate_internal_no(caplog, schema_file): with patch.object(validator, "resource_path", return_value=schema_file.parent): with raises(UWConfigError) as e: - validator.validate_internal(schema_name="a", desc="test", config={"color": "orange"}) + validator.validate_internal( + schema_name="a", desc="test", config_data={"color": "orange"} + ) assert logged(caplog, "Error at color:") assert logged(caplog, " 'orange' is not one of ['blue', 'red']") assert str(e.value) == "YAML validation errors" -def test_validate_internal_ok(schema_file): +def test_config_validator_validate_internal_ok(schema_file): with patch.object(validator, "resource_path", return_value=schema_file.parent): - validator.validate_internal(schema_name="a", desc="test", config={"color": "blue"}) + validator.validate_internal(schema_name="a", desc="test", config_data={"color": "blue"}) -def test_validate_external(assets, config, schema): +def test_config_validator_validate_external(assets, config, schema): schema_file, _, cfgobj = assets with patch.object(validator, "validate") as validate: - validator.validate_external(schema_file=schema_file, desc="test", config=cfgobj) + validator.validate_external(schema_file=schema_file, desc="test", config_data=cfgobj) validate.assert_called_once_with(schema=schema, desc="test", config=config) -def test_prep_config_cfgobj(prep_config_dict): - cfgobj = validator._prep_config(config=YAMLConfig(config=prep_config_dict)) - assert isinstance(cfgobj, YAMLConfig) - assert cfgobj == {"roses": "red", "color": "red"} - - -def test__prep_config_dict(prep_config_dict): - cfgobj = validator._prep_config(config=prep_config_dict) - assert isinstance(cfgobj, YAMLConfig) - assert cfgobj == {"roses": "red", "color": "red"} - - -def test__prep_config_file(prep_config_dict, tmp_path): - path = tmp_path / "config.yaml" - with open(path, "w", encoding="utf-8") as f: - yaml.dump(prep_config_dict, f) - cfgobj = validator._prep_config(config=path) - assert isinstance(cfgobj, YAMLConfig) - assert cfgobj == {"roses": "red", "color": "red"} - - -def test__registry(tmp_path): +def test_config_validator__registry(tmp_path): validator._registry.cache_clear() d = {"foo": "bar"} path = tmp_path / "foo-bar.jsonschema" @@ -248,15 +256,15 @@ def test__registry(tmp_path): resource_path.assert_called_once_with("jsonschema/foo-bar.jsonschema") -def test__validation_errors_bad_enum_value(config, schema): +def test_config_validator__validation_errors_bad_enum_value(config, schema): config["color"] = "yellow" assert len(validator._validation_errors(config, schema)) == 1 -def test__validation_errors_bad_number_value(config, schema): +def test_config_validator__validation_errors_bad_number_value(config, schema): config["number"] = "string" assert len(validator._validation_errors(config, schema)) == 1 -def test__validation_errors_pass(config, schema): +def test_config_validator__validation_errors_pass(config, schema): assert not validator._validation_errors(config, schema) diff --git a/src/uwtools/tests/drivers/test_driver.py b/src/uwtools/tests/drivers/test_driver.py index 9f33c2961..1553d699d 100644 --- a/src/uwtools/tests/drivers/test_driver.py +++ b/src/uwtools/tests/drivers/test_driver.py @@ -312,7 +312,7 @@ def test_Assets__validate_internal(assetsobj): assert validate_internal.call_args_list[0].kwargs == { "schema_name": "concrete", "desc": "concrete config", - "config": assetsobj.config_full, + "config_data": assetsobj.config_full, } @@ -324,7 +324,7 @@ def test_Assets__validate_external(config): assert validate_external.call_args_list[0].kwargs == { "schema_file": schema_file, "desc": "concrete config", - "config": assetsobj.config_full, + "config_data": assetsobj.config_full, } @@ -607,7 +607,7 @@ def test_Driver__validate_external(config): assert validate_external.call_args_list[0].kwargs == { "schema_file": schema_file, "desc": "concrete config", - "config": assetsobj.config_full, + "config_data": assetsobj.config_full, } @@ -618,12 +618,12 @@ def test_Driver__validate_internal(assetsobj): assert validate_internal.call_args_list[0].kwargs == { "schema_name": "concrete", "desc": "concrete config", - "config": assetsobj.config_full, + "config_data": assetsobj.config_full, } assert validate_internal.call_args_list[1].kwargs == { "schema_name": "platform", "desc": "platform config", - "config": assetsobj.config_full, + "config_data": assetsobj.config_full, } diff --git a/src/uwtools/tests/test_cli.py b/src/uwtools/tests/test_cli.py index 6a5248ff5..6fa40ac9a 100644 --- a/src/uwtools/tests/test_cli.py +++ b/src/uwtools/tests/test_cli.py @@ -371,7 +371,8 @@ def test__dispatch_config_validate_config_obj(): cli._dispatch_config_validate(_dispatch_config_validate_args) _validate_external_args = { STR.schemafile: _dispatch_config_validate_args[STR.schemafile], - STR.config: _dispatch_config_validate_args[STR.infile], + "config_data": None, + "config_path": _dispatch_config_validate_args[STR.infile], } _validate_external.assert_called_once_with(**_validate_external_args, desc="config")