diff --git a/docs/sections/user_guide/cli/tools/rocoto/realize-exec-stdout-verbose.out b/docs/sections/user_guide/cli/tools/rocoto/realize-exec-stdout-verbose.out index d04532f14..02a29dca1 100644 --- a/docs/sections/user_guide/cli/tools/rocoto/realize-exec-stdout-verbose.out +++ b/docs/sections/user_guide/cli/tools/rocoto/realize-exec-stdout-verbose.out @@ -1,21 +1,21 @@ -[2024-05-23T19:39:16] DEBUG Command: uw rocoto realize --config-file rocoto.yaml --verbose -[2024-05-23T19:39:16] DEBUG Dereferencing, current value: -[2024-05-23T19:39:16] DEBUG workflow: -[2024-05-23T19:39:16] DEBUG attrs: -[2024-05-23T19:39:16] DEBUG realtime: false -[2024-05-23T19:39:16] DEBUG scheduler: slurm -[2024-05-23T19:39:16] DEBUG cycledef: -[2024-05-23T19:39:16] DEBUG - attrs: -[2024-05-23T19:39:16] DEBUG group: howdy -[2024-05-23T19:39:16] DEBUG spec: 202209290000 202209300000 06:00:00 +[2024-07-09T00:31:39] DEBUG Command: uw rocoto realize --config-file rocoto.yaml --verbose +[2024-07-09T00:31:39] DEBUG Dereferencing, current value: +[2024-07-09T00:31:39] DEBUG workflow: +[2024-07-09T00:31:39] DEBUG attrs: +[2024-07-09T00:31:39] DEBUG realtime: false +[2024-07-09T00:31:39] DEBUG scheduler: slurm +[2024-07-09T00:31:39] DEBUG cycledef: +[2024-07-09T00:31:39] DEBUG - attrs: +[2024-07-09T00:31:39] DEBUG group: howdy +[2024-07-09T00:31:39] DEBUG spec: 202209290000 202209300000 06:00:00 ... -[2024-05-23T19:39:16] DEBUG account: '&ACCOUNT;' -[2024-05-23T19:39:16] DEBUG attrs: -[2024-05-23T19:39:16] DEBUG cycledefs: howdy -[2024-05-23T19:39:16] DEBUG command: echo hello $person -[2024-05-23T19:39:16] DEBUG envars: -[2024-05-23T19:39:16] DEBUG person: siri -[2024-05-23T19:39:16] DEBUG jobname: hello -[2024-05-23T19:39:16] DEBUG nodes: 1:ppn=1 -[2024-05-23T19:39:16] DEBUG walltime: 00:01:00 -[2024-05-23T19:39:16] INFO 0 Rocoto validation errors found +[2024-07-09T00:31:39] DEBUG attrs: +[2024-07-09T00:31:39] DEBUG cycledefs: howdy +[2024-07-09T00:31:39] DEBUG account: '&ACCOUNT;' +[2024-07-09T00:31:39] DEBUG command: echo hello $person +[2024-07-09T00:31:39] DEBUG jobname: hello +[2024-07-09T00:31:39] DEBUG nodes: 1:ppn=1 +[2024-07-09T00:31:39] DEBUG walltime: 00:01:00 +[2024-07-09T00:31:39] DEBUG envars: +[2024-07-09T00:31:39] DEBUG person: siri +[2024-07-09T00:31:39] INFO 0 Rocoto validation errors found diff --git a/src/uwtools/config/formats/base.py b/src/uwtools/config/formats/base.py index 3ca86d266..857384b79 100644 --- a/src/uwtools/config/formats/base.py +++ b/src/uwtools/config/formats/base.py @@ -5,14 +5,13 @@ from abc import ABC, abstractmethod from collections import UserDict from copy import deepcopy -from io import StringIO from pathlib import Path from typing import Optional, Union import yaml from uwtools.config import jinja2 -from uwtools.config.support import INCLUDE_TAG, depth, log_and_error +from uwtools.config.support import INCLUDE_TAG, depth, log_and_error, yaml_to_str from uwtools.exceptions import UWConfigError from uwtools.logging import INDENT, log @@ -44,14 +43,21 @@ def __init__(self, config: Optional[Union[dict, Path]] = None) -> None: def __repr__(self) -> str: """ - Returns the YAML string representation of a Config object. + Returns the string representation of a Config object. """ - s = StringIO() - yaml.dump(self.data, s) - return s.getvalue() + return self._dict_to_str(self.data) # Private methods + @classmethod + @abstractmethod + def _dict_to_str(cls, cfg: dict) -> str: + """ + Returns the string representation of the given dict. + + :param cfg: A dict object. + """ + @abstractmethod def _load(self, config_file: Optional[Path]) -> dict: """ @@ -165,7 +171,7 @@ def dereference(self, context: Optional[dict] = None) -> None: def logstate(state: str) -> None: log.debug("Dereferencing, %s value:", state) - for line in str(self).split("\n"): + for line in yaml_to_str(self.data).split("\n"): log.debug("%s%s", INDENT, line) while True: diff --git a/src/uwtools/config/formats/fieldtable.py b/src/uwtools/config/formats/fieldtable.py index ffb8132b5..45705e77f 100644 --- a/src/uwtools/config/formats/fieldtable.py +++ b/src/uwtools/config/formats/fieldtable.py @@ -12,6 +12,31 @@ class FieldTableConfig(YAMLConfig): an input YAML file. """ + # Private methods + + @classmethod + def _dict_to_str(cls, cfg: dict) -> str: + """ + Returns the field-table representation of the given dict. + + :param cfg: A dict object. + """ + lines = [] + for field, settings in cfg.items(): + lines.append(f' "TRACER", "atmos_mod", "{field}"') + for key, value in settings.items(): + if isinstance(value, dict): + method_string = f'{" ":7}"{key}", "{value.pop("name")}"' + # All control vars go into one set of quotes. + control_vars = [f"{method}={val}" for method, val in value.items()] + # Whitespace after the comma matters. + lines.append(f'{method_string}, "{", ".join(control_vars)}"') + else: + # Formatting of variable spacing dependent on key length. + lines.append(f'{" ":11}"{key}", "{value}"') + lines[-1] += " /" + return "\n".join(lines) + # Public methods def dump(self, path: Optional[Path] = None) -> None: @@ -22,8 +47,8 @@ def dump(self, path: Optional[Path] = None) -> None: """ self.dump_dict(self.data, path) - @staticmethod - def dump_dict(cfg: dict, path: Optional[Path] = None) -> None: + @classmethod + def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None: """ Dumps a provided config dictionary in Field Table format. @@ -45,22 +70,8 @@ def dump_dict(cfg: dict, path: Optional[Path] = None) -> None: :param cfg: The in-memory config object to dump. :param path: Path to dump config to. """ - lines = [] - for field, settings in cfg.items(): - lines.append(f' "TRACER", "atmos_mod", "{field}"') - for key, value in settings.items(): - if isinstance(value, dict): - method_string = f'{" ":7}"{key}", "{value.pop("name")}"' - # All control vars go into one set of quotes. - control_vars = [f"{method}={val}" for method, val in value.items()] - # Whitespace after the comma matters. - lines.append(f'{method_string}, "{", ".join(control_vars)}"') - else: - # Formatting of variable spacing dependent on key length. - lines.append(f'{" ":11}"{key}", "{value}"') - lines[-1] += " /" with writable(path) as f: - print("\n".join(lines), file=f) + print(cls._dict_to_str(cfg), file=f) @staticmethod def get_depth_threshold() -> Optional[int]: diff --git a/src/uwtools/config/formats/ini.py b/src/uwtools/config/formats/ini.py index 63758257a..ce9861bf4 100644 --- a/src/uwtools/config/formats/ini.py +++ b/src/uwtools/config/formats/ini.py @@ -25,6 +25,28 @@ def __init__(self, config: Union[dict, Optional[Path]] = None): # Private methods + @classmethod + def _dict_to_str(cls, cfg: dict) -> str: + """ + Returns the INI representation of the given dict. + + :param cfg: A dict object. + """ + + # Configparser adds a newline after each section, presumably to create nice-looking output + # when an INI contains multiple sections. Unfortunately, it also adds a newline after the + # _final_ section, resulting in an anomalous trailing newline. To avoid this, write first to + # memory, then strip the trailing newline. + + config_check_depths_dump(config_obj=cfg, target_format=FORMAT.ini) + parser = configparser.ConfigParser() + sio = StringIO() + parser.read_dict(cfg) + parser.write(sio) + s = sio.getvalue().strip() + sio.close() + return s + def _load(self, config_file: Optional[Path]) -> dict: """ Reads and parses an INI file. @@ -46,31 +68,18 @@ def dump(self, path: Optional[Path] = None) -> None: :param path: Path to dump config to. """ - config_check_depths_dump(config_obj=self, target_format=FORMAT.ini) self.dump_dict(self.data, path) - @staticmethod - def dump_dict(cfg: dict, path: Optional[Path] = None) -> None: + @classmethod + def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None: """ Dumps a provided config dictionary in INI format. :param cfg: The in-memory config object to dump. :param path: Path to dump config to. """ - - # Configparser adds a newline after each section, presumably to create nice-looking output - # when an INI contains multiple sections. Unfortunately, it also adds a newline after the - # _final_ section, resulting in an anomalous trailing newline. To avoid this, write first to - # memory, then strip the trailing newline. - - config_check_depths_dump(config_obj=cfg, target_format=FORMAT.ini) - parser = configparser.ConfigParser() - s = StringIO() - parser.read_dict(cfg) - parser.write(s) with writable(path) as f: - print(s.getvalue().strip(), file=f) - s.close() + print(cls._dict_to_str(cfg), file=f) @staticmethod def get_depth_threshold() -> Optional[int]: diff --git a/src/uwtools/config/formats/nml.py b/src/uwtools/config/formats/nml.py index 4371f33cf..e8fbbbef9 100644 --- a/src/uwtools/config/formats/nml.py +++ b/src/uwtools/config/formats/nml.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from io import StringIO from pathlib import Path from typing import Optional, Union @@ -27,6 +28,27 @@ def __init__(self, config: Union[dict, Optional[Path]] = None) -> None: # Private methods + @classmethod + def _dict_to_str(cls, cfg: dict) -> str: + """ + Returns the field-table representation of the given dict. + + :param cfg: A dict object. + """ + + def to_od(d): + return OrderedDict( + {key: to_od(val) if isinstance(val, dict) else val for key, val in d.items()} + ) + + config_check_depths_dump(config_obj=cfg, target_format=FORMAT.nml) + nml: Namelist = Namelist(to_od(cfg)) if not isinstance(cfg, Namelist) else cfg + sio = StringIO() + nml.write(sio, sort=False) + s = sio.getvalue() + sio.close() + return s.strip() + def _load(self, config_file: Optional[Path]) -> dict: """ Reads and parses a Fortran namelist file. @@ -49,24 +71,16 @@ def dump(self, path: Optional[Path]) -> None: """ self.dump_dict(cfg=self.data, path=path) - @staticmethod - def dump_dict(cfg: Union[dict, Namelist], path: Optional[Path] = None) -> None: + @classmethod + def dump_dict(cls, cfg: Union[dict, Namelist], path: Optional[Path] = None) -> None: """ Dumps a provided config dictionary in Fortran namelist format. :param cfg: The in-memory config object to dump. :param path: Path to dump config to. """ - - def to_od(d): - return OrderedDict( - {key: to_od(val) if isinstance(val, dict) else val for key, val in d.items()} - ) - - config_check_depths_dump(config_obj=cfg, target_format=FORMAT.nml) - nml: Namelist = Namelist(to_od(cfg)) if not isinstance(cfg, Namelist) else cfg with writable(path) as f: - nml.write(f, sort=False) + print(cls._dict_to_str(cfg), file=f) @staticmethod def get_depth_threshold() -> Optional[int]: diff --git a/src/uwtools/config/formats/sh.py b/src/uwtools/config/formats/sh.py index 29e88bed6..0107c6d4d 100644 --- a/src/uwtools/config/formats/sh.py +++ b/src/uwtools/config/formats/sh.py @@ -1,6 +1,5 @@ import re import shlex -from io import StringIO from pathlib import Path from typing import Optional, Union @@ -27,6 +26,19 @@ def __init__(self, config: Union[dict, Optional[Path]] = None): # Private methods + @classmethod + def _dict_to_str(cls, cfg: dict) -> str: + """ + Returns the field-table representation of the given dict. + + :param cfg: A dict object. + """ + config_check_depths_dump(config_obj=cfg, target_format=FORMAT.sh) + lines = [] + for key, value in cfg.items(): + lines.append("%s=%s" % (key, shlex.quote(str(value)))) + return "\n".join(lines) + def _load(self, config_file: Optional[Path]) -> dict: """ Reads and parses key=value lines from shell code. @@ -58,25 +70,16 @@ def dump(self, path: Optional[Path]) -> None: config_check_depths_dump(config_obj=self, target_format=FORMAT.sh) self.dump_dict(self.data, path) - @staticmethod - def dump_dict(cfg: dict, path: Optional[Path] = None) -> None: + @classmethod + def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None: """ Dumps a provided config dictionary in bash format. :param cfg: The in-memory config object to dump. :param path: Path to dump config to. """ - - # Write first to a StringIO object to avoid creating a partial file in case of problems - # rendering or quoting config values. - - config_check_depths_dump(config_obj=cfg, target_format=FORMAT.sh) - s = StringIO() - for key, value in cfg.items(): - print("%s=%s" % (key, shlex.quote(str(value))), file=s) with writable(path) as f: - print(s.getvalue().strip(), file=f) - s.close() + print(cls._dict_to_str(cfg), file=f) @staticmethod def get_depth_threshold() -> Optional[int]: diff --git a/src/uwtools/config/formats/yaml.py b/src/uwtools/config/formats/yaml.py index 807cc5fbd..faa0a67b9 100644 --- a/src/uwtools/config/formats/yaml.py +++ b/src/uwtools/config/formats/yaml.py @@ -7,7 +7,14 @@ from f90nml import Namelist # type: ignore from uwtools.config.formats.base import Config -from uwtools.config.support import INCLUDE_TAG, UWYAMLConvert, UWYAMLRemove, from_od, log_and_error +from uwtools.config.support import ( + INCLUDE_TAG, + UWYAMLConvert, + UWYAMLRemove, + from_od, + log_and_error, + yaml_to_str, +) from uwtools.exceptions import UWConfigError from uwtools.strings import FORMAT from uwtools.utils.file import readable, writable @@ -44,14 +51,17 @@ class YAMLConfig(Config): Concrete class to handle YAML config files. """ - def __repr__(self) -> str: - """ - The string representation of a YAMLConfig object. + # Private methods + + @classmethod + def _dict_to_str(cls, cfg: dict) -> str: """ - self._add_yaml_representers() - return yaml.dump(self.data, default_flow_style=False).strip() + Returns the YAML representation of the given dict. - # Private methods + :param cfg: The in-memory config object. + """ + cls._add_yaml_representers() + return yaml_to_str(cfg) def _load(self, config_file: Optional[Path]) -> dict: """ @@ -124,9 +134,8 @@ def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None: :param cfg: The in-memory config object to dump. :param path: Path to dump config to. """ - cls._add_yaml_representers() with writable(path) as f: - yaml.dump(cfg, f, sort_keys=False) + print(cls._dict_to_str(cfg), file=f) @staticmethod def get_depth_threshold() -> Optional[int]: diff --git a/src/uwtools/config/support.py b/src/uwtools/config/support.py index 90b43e2da..632405d89 100644 --- a/src/uwtools/config/support.py +++ b/src/uwtools/config/support.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from collections import OrderedDict from importlib import import_module from typing import Type, Union @@ -66,6 +67,15 @@ def log_and_error(msg: str) -> Exception: return UWConfigError(msg) +def yaml_to_str(cfg: dict) -> str: + """ + Returns a uwtools-conventional YAML representation of the given dict. + + :param cfg: A dict object. + """ + return yaml.dump(cfg, default_flow_style=False, sort_keys=False, width=math.inf).strip() + + class UWYAMLTag: """ A base class for custom UW YAML tags. diff --git a/src/uwtools/tests/config/formats/test_base.py b/src/uwtools/tests/config/formats/test_base.py index f2f367e8c..71950dac8 100644 --- a/src/uwtools/tests/config/formats/test_base.py +++ b/src/uwtools/tests/config/formats/test_base.py @@ -39,6 +39,10 @@ class ConcreteConfig(Config): Config subclass for testing purposes. """ + @classmethod + def _dict_to_str(cls, cfg): + pass + def _load(self, config_file): with readable(config_file) as f: return yaml.safe_load(f.read()) @@ -62,11 +66,6 @@ def get_format(): # Tests -def test___repr__(capsys, config): - print(config) - assert yaml.safe_load(capsys.readouterr().out)["foo"] == 88 - - def test__load_paths(config, tmp_path): paths = (tmp_path / fn for fn in ("f1", "f2")) for path in paths: diff --git a/src/uwtools/tests/config/formats/test_fieldtable.py b/src/uwtools/tests/config/formats/test_fieldtable.py index 5f2a37d99..edcfe9f5f 100644 --- a/src/uwtools/tests/config/formats/test_fieldtable.py +++ b/src/uwtools/tests/config/formats/test_fieldtable.py @@ -1,8 +1,10 @@ -# pylint: disable=missing-function-docstring +# pylint: disable=missing-function-docstring,redefined-outer-name """ Tests for uwtools.config.formats.fieldtable module. """ +from pytest import fixture, mark + from uwtools.config.formats.fieldtable import FieldTableConfig from uwtools.tests.support import fixture_path from uwtools.utils.file import FORMAT @@ -10,30 +12,37 @@ # Tests -def test_get_format(): +@fixture(scope="module") +def config(): + return fixture_path("FV3_GFS_v16.yaml") + + +@fixture(scope="module") +def ref(): + with open(fixture_path("field_table.FV3_GFS_v16"), "r", encoding="utf-8") as f: + return f.read().strip() + + +def test_fieldtable_get_format(): assert FieldTableConfig.get_format() == FORMAT.fieldtable -def test_get_depth_threshold(): +def test_fieldtable_get_depth_threshold(): assert FieldTableConfig.get_depth_threshold() is None -def test_instantiation_depth(): +def test_fieldtable_instantiation_depth(): # Any depth is fine. assert FieldTableConfig(config={1: {2: {3: 4}}}) -def test_simple(tmp_path): - """ - Test reading a YAML config object and generating a field table file. - """ - cfgfile = fixture_path("FV3_GFS_v16.yaml") +@mark.parametrize("func", [repr, str]) +def test_fieldtable_repr_str(config, func, ref): + assert func(FieldTableConfig(config=config)).strip() == ref + + +def test_fieldtable_simple(config, ref, tmp_path): outfile = tmp_path / "field_table_from_yaml.FV3_GFS" - reference = fixture_path("field_table.FV3_GFS_v16") - FieldTableConfig(cfgfile).dump(outfile) - with open(reference, "r", encoding="utf-8") as f1: - reflines = [line.strip().replace("'", "") for line in f1] - with open(outfile, "r", encoding="utf-8") as f2: - outlines = [line.strip().replace("'", "") for line in f2] - for line1, line2 in zip(outlines, reflines): - assert line1 == line2 + FieldTableConfig(config=config).dump(outfile) + with open(outfile, "r", encoding="utf-8") as out: + assert out.read().strip() == ref diff --git a/src/uwtools/tests/config/formats/test_ini.py b/src/uwtools/tests/config/formats/test_ini.py index 6566e4692..d12bdbd11 100644 --- a/src/uwtools/tests/config/formats/test_ini.py +++ b/src/uwtools/tests/config/formats/test_ini.py @@ -5,7 +5,7 @@ import filecmp -from pytest import raises +from pytest import mark, raises from uwtools.config.formats.ini import INIConfig from uwtools.exceptions import UWConfigError @@ -15,21 +15,21 @@ # Tests -def test_get_format(): +def test_ini_get_format(): assert INIConfig.get_format() == FORMAT.ini -def test_get_depth_threshold(): +def test_ini_get_depth_threshold(): assert INIConfig.get_depth_threshold() == 2 -def test_instantiation_depth(): +def test_ini_instantiation_depth(): with raises(UWConfigError) as e: INIConfig(config={1: {2: {3: 4}}}) assert str(e.value) == "Cannot instantiate depth-2 INIConfig with depth-3 config" -def test_parse_include(): +def test_ini_parse_include(): """ Test that an INI file handles include tags properly. """ @@ -40,7 +40,14 @@ def test_parse_include(): assert len(cfgobj["config"]) == 5 -def test_simple(salad_base, tmp_path): +@mark.parametrize("func", [repr, str]) +def test_ini_repr_str(func): + config = fixture_path("simple.ini") + with open(config, "r", encoding="utf-8") as f: + assert func(INIConfig(config)) == f.read().strip() + + +def test_ini_simple(salad_base, tmp_path): """ Test that INI config load and dump work with a basic INI file. diff --git a/src/uwtools/tests/config/formats/test_nml.py b/src/uwtools/tests/config/formats/test_nml.py index ee44fb718..1ae001e22 100644 --- a/src/uwtools/tests/config/formats/test_nml.py +++ b/src/uwtools/tests/config/formats/test_nml.py @@ -6,7 +6,7 @@ import filecmp import f90nml # type: ignore -from pytest import fixture, raises +from pytest import fixture, mark, raises from uwtools.config.formats.nml import NMLConfig from uwtools.exceptions import UWConfigError @@ -24,35 +24,35 @@ def data(): # Tests -def test_dump_dict_dict(data, tmp_path): +def test_nml_dump_dict_dict(data, tmp_path): path = tmp_path / "a.nml" NMLConfig.dump_dict(cfg=data, path=path) nml = f90nml.read(path) assert nml == data -def test_dump_dict_Namelist(data, tmp_path): +def test_nml_dump_dict_Namelist(data, tmp_path): path = tmp_path / "a.nml" NMLConfig.dump_dict(cfg=f90nml.Namelist(data), path=path) nml = f90nml.read(path) assert nml == data -def test_get_format(): +def test_nml_get_format(): assert NMLConfig.get_format() == FORMAT.nml -def test_get_depth_threshold(): +def test_nml_get_depth_threshold(): assert NMLConfig.get_depth_threshold() == 2 -def test_instantiation_depth(): +def test_nml_instantiation_depth(): with raises(UWConfigError) as e: NMLConfig(config={1: {2: {3: 4}}}) assert str(e.value) == "Cannot instantiate depth-2 NMLConfig with depth-3 config" -def test_parse_include(): +def test_nml_parse_include(): """ Test that non-YAML handles include tags properly. """ @@ -63,7 +63,7 @@ def test_parse_include(): assert len(cfgobj["config"]) == 5 -def test_parse_include_mult_sect(): +def test_nml_parse_include_mult_sect(): """ Test that non-YAML handles include tags with files that have multiple sections in separate file. """ @@ -77,7 +77,14 @@ def test_parse_include_mult_sect(): assert len(cfgobj["setting"]) == 3 -def test_simple(salad_base, tmp_path): +@mark.parametrize("func", [repr, str]) +def test_ini_repr_str(func): + config = fixture_path("simple.nml") + with open(config, "r", encoding="utf-8") as f: + assert func(NMLConfig(config)) == f.read().strip() + + +def test_nml_simple(salad_base, tmp_path): """ Test that namelist load, update, and dump work with a basic namelist file. """ diff --git a/src/uwtools/tests/config/formats/test_sh.py b/src/uwtools/tests/config/formats/test_sh.py index 6f9e7f901..5d51a043e 100644 --- a/src/uwtools/tests/config/formats/test_sh.py +++ b/src/uwtools/tests/config/formats/test_sh.py @@ -3,9 +3,10 @@ Tests for uwtools.config.formats.sh module. """ +from textwrap import dedent from typing import Any -from pytest import raises +from pytest import mark, raises from uwtools.config.formats.sh import SHConfig from uwtools.exceptions import UWConfigError @@ -15,21 +16,21 @@ # Tests -def test_get_format(): +def test_sh_get_format(): assert SHConfig.get_format() == FORMAT.sh -def test_get_depth_threshold(): +def test_sh_get_depth_threshold(): assert SHConfig.get_depth_threshold() == 1 -def test_instantiation_depth(): +def test_sh_instantiation_depth(): with raises(UWConfigError) as e: SHConfig(config={1: {2: {3: 4}}}) assert str(e.value) == "Cannot instantiate depth-1 SHConfig with depth-3 config" -def test_parse_include(): +def test_sh_parse_include(): """ Test that an sh file with no sections handles include tags properly. """ @@ -40,6 +41,19 @@ def test_parse_include(): assert len(cfgobj) == 5 +@mark.parametrize("func", [repr, str]) +def test_sh_repr_str(func): + config = fixture_path("simple.sh") + expected = """ + base=kale + fruit=banana + vegetable=tomato + how_many=12 + dressing=balsamic + """ + assert func(SHConfig(config)) == dedent(expected).strip() + + def test_sh(salad_base): """ Test that sh config load and dump work with a basic sh file. diff --git a/src/uwtools/tests/config/formats/test_yaml.py b/src/uwtools/tests/config/formats/test_yaml.py index ac13dea7d..93673128b 100644 --- a/src/uwtools/tests/config/formats/test_yaml.py +++ b/src/uwtools/tests/config/formats/test_yaml.py @@ -14,7 +14,7 @@ import f90nml # type: ignore import yaml -from pytest import raises +from pytest import mark, raises from uwtools import exceptions from uwtools.config import support @@ -27,20 +27,20 @@ # Tests -def test_get_format(): +def test_yaml_get_format(): assert YAMLConfig.get_format() == FORMAT.yaml -def test_get_depth_threshold(): +def test_yaml_get_depth_threshold(): assert YAMLConfig.get_depth_threshold() is None -def test_instantiation_depth(): +def test_yaml_instantiation_depth(): # Any depth is fine. assert YAMLConfig(config={1: {2: {3: 4}}}) -def test_composite_types(): +def test_yaml_composite_types(): """ Test that YAML load and dump work with a YAML file that has multiple data structures and levels. """ @@ -58,7 +58,7 @@ def test_composite_types(): assert models[0]["config"]["vertical_resolution"] == 64 -def test_include_files(): +def test_yaml_include_files(): """ Test that including files via the include constructor works as expected. """ @@ -82,7 +82,7 @@ def test_include_files(): assert cfgobj["reverse_files"]["vegetable"] == "eggplant" -def test_simple(tmp_path): +def test_yaml_simple(tmp_path): """ Test that YAML load, update, and dump work with a basic YAML file. """ @@ -107,7 +107,7 @@ def test_simple(tmp_path): assert cfgobj == expected -def test_constructor_error_no_quotes(tmp_path): +def test_yaml_constructor_error_no_quotes(tmp_path): # Test that Jinja2 template without quotes raises UWConfigError. tmpfile = tmp_path / "test.yaml" with tmpfile.open("w", encoding="utf-8") as f: @@ -121,7 +121,7 @@ def test_constructor_error_no_quotes(tmp_path): assert "value is enclosed in quotes" in str(e.value) -def test_constructor_error_not_dict_from_file(tmp_path): +def test_yaml_constructor_error_not_dict_from_file(tmp_path): # Test that a useful exception is raised if the YAML file input is a non-dict value. tmpfile = tmp_path / "test.yaml" with tmpfile.open("w", encoding="utf-8") as f: @@ -131,7 +131,7 @@ def test_constructor_error_not_dict_from_file(tmp_path): assert f"Parsed a str value from {tmpfile}, expected a dict" in str(e.value) -def test_constructor_error_not_dict_from_stdin(): +def test_yaml_constructor_error_not_dict_from_stdin(): # Test that a useful exception is raised if the YAML stdin input is a non-dict value. with patch.object(sys, "stdin", new=StringIO("a string")): with raises(exceptions.UWConfigError) as e: @@ -139,7 +139,7 @@ def test_constructor_error_not_dict_from_stdin(): assert "Parsed a str value from stdin, expected a dict" in str(e.value) -def test_constructor_error_unregistered_constructor(tmp_path): +def test_yaml_constructor_error_unregistered_constructor(tmp_path): # Test that unregistered constructor raises UWConfigError. tmpfile = tmp_path / "test.yaml" @@ -151,7 +151,15 @@ def test_constructor_error_unregistered_constructor(tmp_path): assert "Define the constructor before proceeding" in str(e.value) -def test_stdin_plus_relpath_failure(caplog): +@mark.parametrize("func", [repr, str]) +def test_yaml_repr_str(func): + config = fixture_path("simple.yaml") + with open(config, "r", encoding="utf-8") as f: + for actual, expected in zip(func(YAMLConfig(config)).split("\n"), f.readlines()): + assert actual.strip() == expected.strip() + + +def test_yaml_stdin_plus_relpath_failure(caplog): # Instantiate a YAMLConfig with no input file, triggering a read from stdin. Patch stdin to # provide YAML with an include directive specifying a relative path. Since a relative path # is meaningless relative to stdin, assert that an appropriate error is logged and exception @@ -167,7 +175,7 @@ def test_stdin_plus_relpath_failure(caplog): assert logged(caplog, msg) -def test_unexpected_error(tmp_path): +def test_yaml_unexpected_error(tmp_path): cfgfile = tmp_path / "cfg.yaml" with open(cfgfile, "w", encoding="utf-8") as f: print("{n: 88}", file=f) @@ -179,7 +187,7 @@ def test_unexpected_error(tmp_path): assert msg in str(e.value) -def test__add_yaml_representers(): +def test_yaml__add_yaml_representers(): YAMLConfig._add_yaml_representers() representers = yaml.Dumper.yaml_representers assert support.UWYAMLConvert in representers @@ -187,14 +195,14 @@ def test__add_yaml_representers(): assert f90nml.Namelist in representers -def test__represent_namelist(): +def test_yaml__represent_namelist(): YAMLConfig._add_yaml_representers() namelist = f90nml.reads("&namelist\n key = value\n/\n") expected = "{namelist: {key: value}}" assert yaml.dump(namelist, default_flow_style=True).strip() == expected -def test__represent_ordereddict(): +def test_yaml__represent_ordereddict(): YAMLConfig._add_yaml_representers() ordereddict_values = OrderedDict([("example", OrderedDict([("key", "value")]))]) expected = "{example: {key: value}}" diff --git a/src/uwtools/tests/config/test_support.py b/src/uwtools/tests/config/test_support.py index f36ed3520..bec854c44 100644 --- a/src/uwtools/tests/config/test_support.py +++ b/src/uwtools/tests/config/test_support.py @@ -60,6 +60,16 @@ def test_log_and_error(caplog): assert logged(caplog, msg) +def test_yaml_to_str(capsys): + xs = " ".join("x" * 999) + expected = f"xs: {xs}" + cfgobj = YAMLConfig({"xs": xs}) + assert repr(cfgobj) == expected + assert str(cfgobj) == expected + cfgobj.dump() + assert capsys.readouterr().out.strip() == expected + + class Test_UWYAMLConvert: """ Tests for class uwtools.config.support.UWYAMLConvert.