diff --git a/docs/sections/user_guide/cli/drivers/upp/help.out b/docs/sections/user_guide/cli/drivers/upp/help.out index eb34386b6..155940673 100644 --- a/docs/sections/user_guide/cli/drivers/upp/help.out +++ b/docs/sections/user_guide/cli/drivers/upp/help.out @@ -12,6 +12,8 @@ Optional arguments: Positional arguments: TASK + control_file + The GRIB control file files_copied Files copied for run files_linked diff --git a/docs/sections/user_guide/cli/tools/config/compare-bad-extension-fix.out b/docs/sections/user_guide/cli/tools/config/compare-bad-extension-fix.out index 867c6f0dd..bbc7505d1 100644 --- a/docs/sections/user_guide/cli/tools/config/compare-bad-extension-fix.out +++ b/docs/sections/user_guide/cli/tools/config/compare-bad-extension-fix.out @@ -1,4 +1,8 @@ -[2024-05-23T19:39:15] INFO - a.txt -[2024-05-23T19:39:15] INFO + c.nml -[2024-05-23T19:39:15] INFO --------------------------------------------------------------------- -[2024-05-23T19:39:15] INFO values: recipient: - World + None +[2024-11-14T23:27:44] INFO - a.txt +[2024-11-14T23:27:44] INFO + c.nml +[2024-11-14T23:27:44] INFO --------------------------------------------------------------------- +[2024-11-14T23:27:44] INFO ↓ ? = info | -/+ = line unique to - or + file | blank = matching line +[2024-11-14T23:27:44] INFO --------------------------------------------------------------------- +[2024-11-14T23:27:44] INFO values: +[2024-11-14T23:27:44] INFO greeting: Hello +[2024-11-14T23:27:44] INFO - recipient: World diff --git a/docs/sections/user_guide/cli/tools/config/compare-diff.out b/docs/sections/user_guide/cli/tools/config/compare-diff.out index 9c33b4c5b..bd62f4793 100644 --- a/docs/sections/user_guide/cli/tools/config/compare-diff.out +++ b/docs/sections/user_guide/cli/tools/config/compare-diff.out @@ -1,4 +1,8 @@ -[2024-05-23T19:39:16] INFO - a.nml -[2024-05-23T19:39:16] INFO + c.nml -[2024-05-23T19:39:16] INFO --------------------------------------------------------------------- -[2024-05-23T19:39:16] INFO values: recipient: - World + None +[2024-11-14T23:27:44] INFO - a.nml +[2024-11-14T23:27:44] INFO + c.nml +[2024-11-14T23:27:44] INFO --------------------------------------------------------------------- +[2024-11-14T23:27:44] INFO ↓ ? = info | -/+ = line unique to - or + file | blank = matching line +[2024-11-14T23:27:44] INFO --------------------------------------------------------------------- +[2024-11-14T23:27:44] INFO values: +[2024-11-14T23:27:44] INFO greeting: Hello +[2024-11-14T23:27:44] INFO - recipient: World diff --git a/docs/sections/user_guide/cli/tools/config/compare-match.out b/docs/sections/user_guide/cli/tools/config/compare-match.out index c10927611..546e36399 100644 --- a/docs/sections/user_guide/cli/tools/config/compare-match.out +++ b/docs/sections/user_guide/cli/tools/config/compare-match.out @@ -1,3 +1,2 @@ -[2024-05-23T19:39:15] INFO - a.nml -[2024-05-23T19:39:15] INFO + b.nml -[2024-05-23T19:39:15] INFO --------------------------------------------------------------------- +[2024-11-14T23:27:45] INFO - a.nml +[2024-11-14T23:27:45] INFO + b.nml diff --git a/docs/sections/user_guide/cli/tools/config/compare-verbose.out b/docs/sections/user_guide/cli/tools/config/compare-verbose.out index 1eab26de4..ae1bb4d4e 100644 --- a/docs/sections/user_guide/cli/tools/config/compare-verbose.out +++ b/docs/sections/user_guide/cli/tools/config/compare-verbose.out @@ -1,5 +1,9 @@ -[2024-05-23T19:39:15] DEBUG Command: uw config compare --file-1-path a.nml --file-2-path c.nml --verbose -[2024-05-23T19:39:15] INFO - a.nml -[2024-05-23T19:39:15] INFO + c.nml -[2024-05-23T19:39:15] INFO --------------------------------------------------------------------- -[2024-05-23T19:39:15] INFO values: recipient: - World + None +[2024-11-14T23:27:45] DEBUG Command: uw config compare --file-1-path a.nml --file-2-path c.nml --verbose +[2024-11-14T23:27:45] INFO - a.nml +[2024-11-14T23:27:45] INFO + c.nml +[2024-11-14T23:27:45] INFO --------------------------------------------------------------------- +[2024-11-14T23:27:45] INFO ↓ ? = info | -/+ = line unique to - or + file | blank = matching line +[2024-11-14T23:27:45] INFO --------------------------------------------------------------------- +[2024-11-14T23:27:45] INFO values: +[2024-11-14T23:27:45] INFO greeting: Hello +[2024-11-14T23:27:45] INFO - recipient: World diff --git a/src/uwtools/config/formats/base.py b/src/uwtools/config/formats/base.py index c93209f07..436a3ea98 100644 --- a/src/uwtools/config/formats/base.py +++ b/src/uwtools/config/formats/base.py @@ -1,8 +1,11 @@ +import difflib import os import re from abc import ABC, abstractmethod from collections import UserDict from copy import deepcopy +from io import StringIO +from math import inf from pathlib import Path from typing import Optional, Union @@ -11,7 +14,7 @@ from uwtools.config import jinja2 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 +from uwtools.logging import INDENT, MSGWIDTH, log from uwtools.utils.file import str2path @@ -76,6 +79,26 @@ def _characterize_values(self, values: dict, parent: str) -> tuple[list, list]: complete.append(f"{INDENT}{parent}{key}") return complete, template + @staticmethod + def _compare_config_get_lines(d: dict) -> list[str]: + """ + Returns a line-by-line YAML representation of the given dict. + + :param d: A dict object. + """ + sio = StringIO() + yaml.safe_dump(d, stream=sio, default_flow_style=False, indent=2, width=inf) + return sio.getvalue().splitlines(keepends=True) + + @staticmethod + def _compare_config_log_header() -> None: + """ + Log a visual header and description of diff markers. + """ + log.info("-" * MSGWIDTH) + log.info("↓ ? = info | -/+ = line unique to - or + file | blank = matching line") + log.info("-" * MSGWIDTH) + @property def _depth(self) -> int: """ @@ -158,7 +181,15 @@ def _parse_include(self, ref_dict: Optional[dict] = None) -> None: # Public methods - def compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool: + @abstractmethod + def as_dict(self) -> dict: + """ + Returns a pure dict version of the config. + """ + + def compare_config( + self, dict1: dict, dict2: Optional[dict] = None, header: Optional[bool] = True + ) -> bool: """ Compare two config dictionaries. @@ -168,33 +199,16 @@ def compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool: :param dict2: The second dictionary (default: this config). :return: True if the configs are identical, False otherwise. """ - dict2 = self.data if dict2 is None else dict2 - diffs: dict = {} - - for sect, items in dict2.items(): - for key, val in items.items(): - if val != dict1.get(sect, {}).get(key, ""): - try: - diffs[sect][key] = f" - {val} + {dict1.get(sect, {}).get(key)}" - except KeyError: - diffs[sect] = {} - diffs[sect][key] = f" - {val} + {dict1.get(sect, {}).get(key)}" - - for sect, items in dict1.items(): - for key, val in items.items(): - if val != dict2.get(sect, {}).get(key, "") and diffs.get(sect, {}).get(key) is None: - try: - diffs[sect][key] = f" - {dict2.get(sect, {}).get(key)} + {val}" - except KeyError: - diffs[sect] = {} - diffs[sect][key] = f" - {dict2.get(sect, {}).get(key)} + {val}" - - for sect, keys in diffs.items(): - for key in keys: - msg = f"{sect}: {key:>15}: {keys[key]}" - log.info(msg) - - return not diffs + dict2 = self.as_dict() if dict2 is None else dict2 + lines1, lines2 = map(self._compare_config_get_lines, [dict1, dict2]) + difflines = list(difflib.ndiff(lines2, lines1)) + if all(line[0] == " " for line in difflines): # i.e. no +/-/? lines + return True + if header: + self._compare_config_log_header() + for diffline in difflines: + log.info(diffline.rstrip()) + return False def dereference(self, context: Optional[dict] = None) -> None: """ diff --git a/src/uwtools/config/formats/fieldtable.py b/src/uwtools/config/formats/fieldtable.py index 3e1ffd184..10686dc2c 100644 --- a/src/uwtools/config/formats/fieldtable.py +++ b/src/uwtools/config/formats/fieldtable.py @@ -52,6 +52,12 @@ def _get_format() -> str: # Public methods + def as_dict(self) -> dict: + """ + Returns a pure dict version of the config. + """ + return self.data + def dump(self, path: Optional[Path] = None) -> None: """ Dump the config in Field Table format. diff --git a/src/uwtools/config/formats/ini.py b/src/uwtools/config/formats/ini.py index 01ac4ef54..a4577c21a 100644 --- a/src/uwtools/config/formats/ini.py +++ b/src/uwtools/config/formats/ini.py @@ -72,6 +72,12 @@ def _load(self, config_file: Optional[Path]) -> dict: # Public methods + def as_dict(self) -> dict: + """ + Returns a pure dict version of the config. + """ + return self.data + def dump(self, path: Optional[Path] = None) -> None: """ Dump the config in INI format. diff --git a/src/uwtools/config/formats/nml.py b/src/uwtools/config/formats/nml.py index 93995d136..e7903b9ab 100644 --- a/src/uwtools/config/formats/nml.py +++ b/src/uwtools/config/formats/nml.py @@ -7,6 +7,7 @@ from f90nml import Namelist from uwtools.config.formats.base import Config +from uwtools.config.support import from_od from uwtools.config.tools import config_check_depths_dump from uwtools.strings import FORMAT from uwtools.utils.file import readable, writable @@ -76,6 +77,13 @@ def _load(self, config_file: Optional[Path]) -> dict: # Public methods + def as_dict(self) -> dict: + """ + Returns a pure dict version of the config. + """ + d = self.data + return from_od(d.todict()) if isinstance(d, Namelist) else d + def dump(self, path: Optional[Path]) -> None: """ Dump the config in Fortran namelist format. diff --git a/src/uwtools/config/formats/sh.py b/src/uwtools/config/formats/sh.py index 290b8f4e2..a25daef96 100644 --- a/src/uwtools/config/formats/sh.py +++ b/src/uwtools/config/formats/sh.py @@ -73,6 +73,12 @@ def _load(self, config_file: Optional[Path]) -> dict: # Public methods + def as_dict(self) -> dict: + """ + Returns a pure dict version of the config. + """ + return self.data + def dump(self, path: Optional[Path]) -> None: """ Dump the config as key=value lines. diff --git a/src/uwtools/config/formats/yaml.py b/src/uwtools/config/formats/yaml.py index c23ef6200..99c5b32a6 100644 --- a/src/uwtools/config/formats/yaml.py +++ b/src/uwtools/config/formats/yaml.py @@ -168,6 +168,12 @@ def _yaml_loader(self) -> type[yaml.SafeLoader]: # Public methods + def as_dict(self) -> dict: + """ + Returns a pure dict version of the config. + """ + return self.data + def dump(self, path: Optional[Path] = None) -> None: """ Dump the config in YAML format. diff --git a/src/uwtools/config/tools.py b/src/uwtools/config/tools.py index 2d99e651d..e92e84c6d 100644 --- a/src/uwtools/config/tools.py +++ b/src/uwtools/config/tools.py @@ -9,7 +9,7 @@ from uwtools.config.jinja2 import unrendered from uwtools.config.support import depth, format_to_config, log_and_error from uwtools.exceptions import UWConfigError, UWConfigRealizeError, UWError -from uwtools.logging import MSGWIDTH, log +from uwtools.logging import log from uwtools.strings import FORMAT from uwtools.utils.file import get_file_format @@ -34,8 +34,7 @@ def compare_configs( cfg_2: Config = format_to_config(config_2_format)(config_2_path) log.info("- %s", config_1_path) log.info("+ %s", config_2_path) - log.info("-" * MSGWIDTH) - return cfg_1.compare_config(cfg_2.data) + return cfg_1.compare_config(cfg_2.as_dict()) def config_check_depths_dump(config_obj: Union[Config, dict], target_format: str) -> None: diff --git a/src/uwtools/tests/config/formats/test_base.py b/src/uwtools/tests/config/formats/test_base.py index 663f4ef03..3e926eb13 100644 --- a/src/uwtools/tests/config/formats/test_base.py +++ b/src/uwtools/tests/config/formats/test_base.py @@ -6,6 +6,7 @@ import logging import os from datetime import datetime +from textwrap import dedent from unittest.mock import patch import yaml @@ -56,6 +57,9 @@ def _load(self, config_file): with readable(config_file) as f: return yaml.safe_load(f.read()) + def as_dict(self): + return self.data + def dump(self, path=None): pass @@ -112,7 +116,7 @@ def test__parse_include(config): assert len(config["config"]) == 2 -@mark.parametrize("fmt", [FORMAT.ini, FORMAT.nml, FORMAT.yaml]) +@mark.parametrize("fmt", [FORMAT.nml, FORMAT.yaml]) def test_compare_config(caplog, fmt, salad_base): """ Compare two config objects. @@ -129,15 +133,65 @@ def test_compare_config(caplog, fmt, salad_base): salad_base["salad"]["dressing"] = "italian" salad_base["salad"]["size"] = "large" del salad_base["salad"]["how_many"] - assert not cfgobj.compare_config(cfgobj, salad_base) assert not cfgobj.compare_config(salad_base) # Expect to see the following differences logged: - for msg in [ - "salad: how_many: - 12 + None", - "salad: dressing: - balsamic + italian", - "salad: size: - None + large", - ]: - assert logged(caplog, msg) + expected = """ + --------------------------------------------------------------------- + ↓ ? = info | -/+ = line unique to - or + file | blank = matching line + --------------------------------------------------------------------- + salad: + base: kale + - dressing: balsamic + ? ^ ^ ^^^ + + dressing: italian + ? ^^ ^ ^ + fruit: banana + - how_many: 12 + + size: large + vegetable: tomato + """ + for line in dedent(expected).strip("\n").split("\n"): + assert logged(caplog, line) + + +def test_compare_config_ini(caplog, salad_base): + """ + Compare two config objects. + """ + log.setLevel(logging.INFO) + cfgobj = tools.format_to_config("ini")(fixture_path("simple.ini")) + salad_base["salad"]["how_many"] = "12" # str "12" (not int 12) for ini + assert cfgobj.compare_config(salad_base) is True + # Expect no differences: + assert not caplog.records + caplog.clear() + # Create differences in base dict: + salad_base["salad"]["dressing"] = "italian" + salad_base["salad"]["size"] = "large" + del salad_base["salad"]["how_many"] + assert not cfgobj.compare_config(cfgobj.as_dict(), salad_base, header=False) + # Expect to see the following differences logged: + expected = """ + salad: + base: kale + - dressing: italian + ? ^^ ^^ + + dressing: balsamic + ? ^ +++ ^ + fruit: banana + - size: large + + how_many: '12' + vegetable: tomato + """ + for line in dedent(expected).strip("\n").split("\n"): + assert logged(caplog, line) + anomalous = """ + --------------------------------------------------------------------- + ↓ ? = info | -/+ = line unique to - or + file | blank = matching line + --------------------------------------------------------------------- + """ + for line in dedent(anomalous).strip("\n").split("\n"): + assert not logged(caplog, line) def test_dereference(tmp_path): diff --git a/src/uwtools/tests/config/formats/test_fieldtable.py b/src/uwtools/tests/config/formats/test_fieldtable.py index 8776f84b5..19de6d5ce 100644 --- a/src/uwtools/tests/config/formats/test_fieldtable.py +++ b/src/uwtools/tests/config/formats/test_fieldtable.py @@ -3,6 +3,8 @@ Tests for uwtools.config.formats.fieldtable module. """ +from textwrap import dedent + from pytest import fixture, mark from uwtools.config.formats.fieldtable import FieldTableConfig @@ -17,6 +19,24 @@ def config(): return fixture_path("FV3_GFS_v16.yaml") +@fixture +def dumpkit(tmp_path): + expected = """ + "TRACER", "atmos_mod", "sphum" + "longname", "specific humidity" + "units", "kg/kg" + "profile_type", "fixed", "surface_value=1e+30" / + """ + d = { + "sphum": { + "longname": "specific humidity", + "units": "kg/kg", + "profile_type": {"name": "fixed", "surface_value": 1.0e30}, + } + } + return d, dedent(expected).strip(), tmp_path / "config.fieldtable" + + @fixture(scope="module") def ref(): with open(fixture_path("field_table.FV3_GFS_v16"), "r", encoding="utf-8") as f: @@ -49,3 +69,25 @@ def test_fieldtable_simple(config, ref, tmp_path): FieldTableConfig(config=config).dump(outfile) with open(outfile, "r", encoding="utf-8") as out: assert out.read().strip() == ref + + +def test_fieldtable_as_dict(): + d1 = {"section": {"key": "value"}} + config = FieldTableConfig(d1) + d2 = config.as_dict() + assert d2 == d1 + assert isinstance(d2, dict) + + +def test_fieldtable_dump(dumpkit): + d, expected, path = dumpkit + FieldTableConfig(d).dump(path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected + + +def test_fieldtable_dump_dict(dumpkit): + d, expected, path = dumpkit + FieldTableConfig.dump_dict(d, path=path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected diff --git a/src/uwtools/tests/config/formats/test_ini.py b/src/uwtools/tests/config/formats/test_ini.py index c6a9f9f7f..7845e570b 100644 --- a/src/uwtools/tests/config/formats/test_ini.py +++ b/src/uwtools/tests/config/formats/test_ini.py @@ -1,17 +1,30 @@ -# pylint: disable=missing-function-docstring,protected-access +# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name """ Tests for uwtools.config.formats.ini module. """ import filecmp +from textwrap import dedent -from pytest import mark, raises +from pytest import fixture, mark, raises from uwtools.config.formats.ini import INIConfig from uwtools.exceptions import UWConfigError from uwtools.tests.support import fixture_path from uwtools.utils.file import FORMAT +# Fixtures + + +@fixture +def dumpkit(tmp_path): + expected = """ + [section] + key = value + """ + return {"section": {"key": "value"}}, dedent(expected).strip(), tmp_path / "config.ini" + + # Tests @@ -64,3 +77,25 @@ def test_ini_simple(salad_base, tmp_path): cfgobj.update({"dressing": ["ranch", "italian"]}) expected["dressing"] = ["ranch", "italian"] assert cfgobj == expected + + +def test_ini_as_dict(): + d1 = {"section": {"key": "value"}} + config = INIConfig(d1) + d2 = config.as_dict() + assert d2 == d1 + assert isinstance(d2, dict) + + +def test_ini_dump(dumpkit): + d, expected, path = dumpkit + INIConfig(d).dump(path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected + + +def test_ini_dump_dict(dumpkit): + d, expected, path = dumpkit + INIConfig.dump_dict(d, path=path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected diff --git a/src/uwtools/tests/config/formats/test_nml.py b/src/uwtools/tests/config/formats/test_nml.py index a0116556c..f5bf51a9e 100644 --- a/src/uwtools/tests/config/formats/test_nml.py +++ b/src/uwtools/tests/config/formats/test_nml.py @@ -21,6 +21,16 @@ def data(): return {"nml": {"key": "val"}} +@fixture +def dumpkit(tmp_path): + expected = """ + §ion + key = 'value' + / + """ + return {"section": {"key": "value"}}, dedent(expected).strip(), tmp_path / "config.nml" + + # Tests @@ -112,3 +122,25 @@ def test_nml_simple(salad_base, tmp_path): cfgobj.update({"dressing": ["ranch", "italian"]}) expected["dressing"] = ["ranch", "italian"] assert cfgobj == expected + + +def test_nml_as_dict(): + d1 = {"section": {"key": "value"}} + config = NMLConfig(d1) + d2 = config.as_dict() + assert d2 == d1 + assert isinstance(d2, dict) + + +def test_nml_dump(dumpkit): + d, expected, path = dumpkit + NMLConfig(d).dump(path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected + + +def test_nml_dump_dict(dumpkit): + d, expected, path = dumpkit + NMLConfig.dump_dict(d, path=path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected diff --git a/src/uwtools/tests/config/formats/test_sh.py b/src/uwtools/tests/config/formats/test_sh.py index ce6a0119e..bf4ac15bb 100644 --- a/src/uwtools/tests/config/formats/test_sh.py +++ b/src/uwtools/tests/config/formats/test_sh.py @@ -1,4 +1,4 @@ -# pylint: disable=missing-function-docstring,protected-access +# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name """ Tests for uwtools.config.formats.sh module. """ @@ -6,13 +6,24 @@ from textwrap import dedent from typing import Any -from pytest import mark, raises +from pytest import fixture, mark, raises from uwtools.config.formats.sh import SHConfig from uwtools.exceptions import UWConfigError from uwtools.tests.support import fixture_path from uwtools.utils.file import FORMAT +# Fixtures + + +@fixture +def dumpkit(tmp_path): + expected = """ + key=value + """ + return {"key": "value"}, dedent(expected).strip(), tmp_path / "config.yaml" + + # Tests @@ -68,3 +79,25 @@ def test_sh(salad_base): cfgobj.update({"dressing": ["ranch", "italian"]}) expected["dressing"] = ["ranch", "italian"] assert cfgobj == expected + + +def test_sh_as_dict(): + d1 = {"a": 1} + config = SHConfig(d1) + d2 = config.as_dict() + assert d2 == d1 + assert isinstance(d2, dict) + + +def test_sh_dump(dumpkit): + d, expected, path = dumpkit + SHConfig(d).dump(path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected + + +def test_sh_dump_dict(dumpkit): + d, expected, path = dumpkit + SHConfig.dump_dict(d, path=path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected diff --git a/src/uwtools/tests/config/formats/test_yaml.py b/src/uwtools/tests/config/formats/test_yaml.py index a7d84ece9..256edc8b1 100644 --- a/src/uwtools/tests/config/formats/test_yaml.py +++ b/src/uwtools/tests/config/formats/test_yaml.py @@ -1,4 +1,4 @@ -# pylint: disable=missing-function-docstring,protected-access +# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name """ Tests for uwtools.config.formats.yaml module. """ @@ -14,7 +14,7 @@ import f90nml # type: ignore import yaml -from pytest import mark, raises +from pytest import fixture, mark, raises from uwtools import exceptions from uwtools.config import support @@ -24,6 +24,18 @@ from uwtools.tests.support import fixture_path, logged from uwtools.utils.file import FORMAT, _stdinproxy +# Fixtures + + +@fixture +def dumpkit(tmp_path): + expected = """ + section: + key: value + """ + return {"section": {"key": "value"}}, dedent(expected).strip(), tmp_path / "config.yaml" + + # Tests @@ -208,3 +220,25 @@ def test_yaml_unexpected_error(tmp_path): with raises(UWConfigError) as e: YAMLConfig(config=cfgfile) assert msg in str(e.value) + + +def test_yaml_as_dict(): + d1 = {"section": {"key": "value"}} + config = YAMLConfig(d1) + d2 = config.as_dict() + assert d2 == d1 + assert isinstance(d2, dict) + + +def test_yaml_dump(dumpkit): + d, expected, path = dumpkit + YAMLConfig(d).dump(path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected + + +def test_yaml_dump_dict(dumpkit): + d, expected, path = dumpkit + YAMLConfig.dump_dict(d, path=path) + with open(path, "r", encoding="utf-8") as f: + assert f.read().strip() == expected diff --git a/src/uwtools/tests/config/test_tools.py b/src/uwtools/tests/config/test_tools.py index f42ffcdd3..69e5c77e7 100644 --- a/src/uwtools/tests/config/test_tools.py +++ b/src/uwtools/tests/config/test_tools.py @@ -117,7 +117,25 @@ def test_compare_configs_changed_value(compare_configs_assets, caplog): assert not tools.compare_configs( config_1_path=a, config_1_format=FORMAT.yaml, config_2_path=b, config_2_format=FORMAT.yaml ) - assert logged(caplog, "baz: qux: - 43 + 11") + expected = """ + - %s + + %s + --------------------------------------------------------------------- + ↓ ? = info | -/+ = line unique to - or + file | blank = matching line + --------------------------------------------------------------------- + baz: + - qux: 43 + ? ^^ + + qux: 11 + ? ^^ + foo: + bar: 42 + """ % ( + str(a), + str(b), + ) + for line in dedent(expected).strip("\n").split("\n"): + assert logged(caplog, line) def test_compare_configs_missing_key(compare_configs_assets, caplog): @@ -130,7 +148,22 @@ def test_compare_configs_missing_key(compare_configs_assets, caplog): assert not tools.compare_configs( config_1_path=b, config_1_format=FORMAT.yaml, config_2_path=a, config_2_format=FORMAT.yaml ) - assert logged(caplog, "baz: qux: - None + 43") + expected = """ + - %s + + %s + --------------------------------------------------------------------- + ↓ ? = info | -/+ = line unique to - or + file | blank = matching line + --------------------------------------------------------------------- + + baz: + + qux: 43 + foo: + bar: 42 + """ % ( + str(b), + str(a), + ) + for line in dedent(expected).strip("\n").split("\n"): + assert logged(caplog, line) def test_compare_configs_bad_format(caplog):