Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revamp config compare for compatibility with more configs #654

Merged
merged 26 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/sections/user_guide/cli/drivers/upp/help.out
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Optional arguments:

Positional arguments:
TASK
control_file
The GRIB control file
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved
files_copied
Files copied for run
files_linked
Expand Down
Original file line number Diff line number Diff line change
@@ -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 only in - 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
12 changes: 8 additions & 4 deletions docs/sections/user_guide/cli/tools/config/compare-diff.out
Original file line number Diff line number Diff line change
@@ -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 only in - 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
5 changes: 2 additions & 3 deletions docs/sections/user_guide/cli/tools/config/compare-match.out
Original file line number Diff line number Diff line change
@@ -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
14 changes: 9 additions & 5 deletions docs/sections/user_guide/cli/tools/config/compare-verbose.out
Original file line number Diff line number Diff line change
@@ -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 only in - 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
72 changes: 43 additions & 29 deletions src/uwtools/config/formats/base.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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


Expand Down Expand Up @@ -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 only in - or + file, blank => matching line")
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved
log.info("-" * MSGWIDTH)
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved

@property
def _depth(self) -> int:
"""
Expand Down Expand Up @@ -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.
"""
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved

def compare_config(
self, dict1: dict, dict2: Optional[dict] = None, header: Optional[bool] = True
) -> bool:
"""
Compare two config dictionaries.

Expand All @@ -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
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved

def dereference(self, context: Optional[dict] = None) -> None:
"""
Expand Down
6 changes: 6 additions & 0 deletions src/uwtools/config/formats/fieldtable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/uwtools/config/formats/ini.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/uwtools/config/formats/nml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/uwtools/config/formats/sh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/uwtools/config/formats/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions src/uwtools/config/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
70 changes: 62 additions & 8 deletions src/uwtools/tests/config/formats/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import os
from datetime import datetime
from textwrap import dedent
from unittest.mock import patch

import yaml
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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 only in - or + file, blank => matching line
---------------------------------------------------------------------
salad:
base: kale
- dressing: balsamic
? ^ ^ ^^^
+ dressing: italian
? ^^ ^ ^
maddenp-noaa marked this conversation as resolved.
Show resolved Hide resolved
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 only in - or + file, blank => matching line
---------------------------------------------------------------------
"""
for line in dedent(anomalous).strip("\n").split("\n"):
assert not logged(caplog, line)


def test_dereference(tmp_path):
Expand Down
Loading
Loading