Skip to content

Commit

Permalink
Better support for realizing scalar YAML values (ufs-community#512)
Browse files Browse the repository at this point in the history
  • Loading branch information
maddenp-noaa authored Jun 19, 2024
1 parent 91b5dad commit fd5bdae
Show file tree
Hide file tree
Showing 5 changed files with 91 additions and 71 deletions.
67 changes: 56 additions & 11 deletions src/uwtools/config/formats/yaml.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
from collections import OrderedDict
from pathlib import Path
from types import SimpleNamespace as ns
from typing import Optional

import yaml
from f90nml import Namelist # type: ignore

from uwtools.config.formats.base import Config
from uwtools.config.support import (
INCLUDE_TAG,
UWYAMLConvert,
UWYAMLRemove,
add_yaml_representers,
log_and_error,
)
from uwtools.config.support import INCLUDE_TAG, UWYAMLConvert, UWYAMLRemove, from_od, log_and_error
from uwtools.exceptions import UWConfigError
from uwtools.strings import FORMAT
from uwtools.utils.file import readable, writable
Expand Down Expand Up @@ -52,7 +48,7 @@ def __repr__(self) -> str:
"""
The string representation of a YAMLConfig object.
"""
add_yaml_representers()
self._add_yaml_representers()
return yaml.dump(self.data, default_flow_style=False).strip()

# Private methods
Expand Down Expand Up @@ -120,15 +116,15 @@ 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 YAML format.
:param cfg: The in-memory config object to dump.
:param path: Path to dump config to.
"""
add_yaml_representers()
cls._add_yaml_representers()
with writable(path) as f:
yaml.dump(cfg, f, sort_keys=False)

Expand All @@ -145,3 +141,52 @@ def get_format() -> str:
Returns the config's format name.
"""
return FORMAT.yaml

# Private methods

@classmethod
def _add_yaml_representers(cls) -> None:
"""
Add representers to the YAML dumper for custom types.
"""
yaml.add_representer(UWYAMLConvert, UWYAMLConvert.represent)
yaml.add_representer(Namelist, cls._represent_namelist)
yaml.add_representer(OrderedDict, cls._represent_ordereddict)

@classmethod
def _represent_namelist(cls, dumper: yaml.Dumper, data: Namelist) -> yaml.nodes.MappingNode:
"""
Convert an f90nml Namelist to an OrderedDict, then represent as a YAML mapping.
:param dumper: The YAML dumper.
:param data: The f90nml Namelist to serialize.
"""
namelist_dict = data.todict()
return dumper.represent_mapping("tag:yaml.org,2002:map", namelist_dict)

@classmethod
def _represent_ordereddict(
cls, dumper: yaml.Dumper, data: OrderedDict
) -> yaml.nodes.MappingNode:
"""
Recursrively convert an OrderedDict to a dict, then represent as a YAML mapping.
:param dumper: The YAML dumper.
:param data: The OrderedDict to serialize.
"""

return dumper.represent_mapping("tag:yaml.org,2002:map", from_od(data))


def _write_plain_open_ended(self, *args, **kwargs) -> None:
"""
Write YAML without ...
end-of-stream marker.
"""
self.write_plain_base(*args, **kwargs)
self.open_ended = False


setattr(yaml.emitter.Emitter, "write_plain_base", yaml.emitter.Emitter.write_plain)
setattr(yaml.emitter.Emitter, "write_plain", _write_plain_open_ended)
35 changes: 0 additions & 35 deletions src/uwtools/config/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from typing import Dict, Type, Union

import yaml
from f90nml import Namelist # type: ignore

from uwtools.exceptions import UWConfigError
from uwtools.logging import log
Expand All @@ -17,15 +16,6 @@
# Public functions


def add_yaml_representers() -> None:
"""
Add representers to the YAML dumper for custom types.
"""
yaml.add_representer(UWYAMLConvert, UWYAMLConvert.represent)
yaml.add_representer(Namelist, _represent_namelist)
yaml.add_representer(OrderedDict, _represent_ordereddict)


def depth(d: dict) -> int:
"""
The depth of a dictionary.
Expand Down Expand Up @@ -76,31 +66,6 @@ def log_and_error(msg: str) -> Exception:
return UWConfigError(msg)


# Private functions


def _represent_namelist(dumper: yaml.Dumper, data: Namelist) -> yaml.nodes.MappingNode:
"""
Convert an f90nml Namelist to an OrderedDict, then represent as a YAML mapping.
:param dumper: The YAML dumper.
:param data: The f90nml Namelist to serialize.
"""
namelist_dict = data.todict()
return dumper.represent_mapping("tag:yaml.org,2002:map", namelist_dict)


def _represent_ordereddict(dumper: yaml.Dumper, data: OrderedDict) -> yaml.nodes.MappingNode:
"""
Recursrively convert an OrderedDict to a dict, then represent as a YAML mapping.
:param dumper: The YAML dumper.
:param data: The OrderedDict to serialize.
"""

return dumper.represent_mapping("tag:yaml.org,2002:map", from_od(data))


class UWYAMLTag:
"""
A base class for custom UW YAML tags.
Expand Down
26 changes: 25 additions & 1 deletion src/uwtools/tests/config/formats/test_yaml.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# pylint: disable=missing-function-docstring
# pylint: disable=missing-function-docstring,protected-access
"""
Tests for uwtools.config.formats.yaml module.
"""
Expand All @@ -7,10 +7,12 @@
import filecmp
import logging
import sys
from collections import OrderedDict
from io import StringIO
from textwrap import dedent
from unittest.mock import patch

import f90nml # type: ignore
import yaml
from pytest import raises

Expand Down Expand Up @@ -175,3 +177,25 @@ def test_unexpected_error(tmp_path):
with raises(UWConfigError) as e:
YAMLConfig(config=cfgfile)
assert msg in str(e.value)


def test__add_yaml_representers():
YAMLConfig._add_yaml_representers()
representers = yaml.Dumper.yaml_representers
assert support.UWYAMLConvert in representers
assert OrderedDict in representers
assert f90nml.Namelist in representers


def test__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():
YAMLConfig._add_yaml_representers()
ordereddict_values = OrderedDict([("example", OrderedDict([("key", "value")]))])
expected = "{example: {key: value}}"
assert yaml.dump(ordereddict_values, default_flow_style=True).strip() == expected
24 changes: 0 additions & 24 deletions src/uwtools/tests/config/test_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@
import logging
from collections import OrderedDict

import f90nml # type: ignore
import pytest
import yaml
from f90nml import Namelist
from pytest import fixture, raises

from uwtools.config import support
Expand All @@ -24,14 +22,6 @@
from uwtools.utils.file import FORMAT


def test_add_yaml_representers():
support.add_yaml_representers()
representers = yaml.Dumper.yaml_representers
assert support.UWYAMLConvert in representers
assert OrderedDict in representers
assert Namelist in representers


@pytest.mark.parametrize(
"d,n", [({1: 88}, 1), ({1: {2: 88}}, 2), ({1: {2: {3: 88}}}, 3), ({1: {}}, 2)]
)
Expand Down Expand Up @@ -73,20 +63,6 @@ def test_log_and_error(caplog):
assert logged(caplog, msg)


def test_represent_namelist():
support.add_yaml_representers()
namelist = f90nml.reads("&namelist\n key = value\n/\n")
assert yaml.dump(namelist, default_flow_style=True).strip() == "{namelist: {key: value}}"


def test_represent_ordereddict():
support.add_yaml_representers()
ordereddict_values = OrderedDict([("example", OrderedDict([("key", "value")]))])
assert (
yaml.dump(ordereddict_values, default_flow_style=True).strip() == "{example: {key: value}}"
)


class Test_UWYAMLConvert:
"""
Tests for class uwtools.config.support.UWYAMLConvert.
Expand Down
10 changes: 10 additions & 0 deletions src/uwtools/tests/config/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,16 @@ def test_realize_config_remove_yaml_to_yaml_subtree(tmp_path):
)


def test_realize_config_scalar_value(capsys):
stdinproxy.cache_clear()
tools.realize_config(
input_config=YAMLConfig(config={"foo": {"bar": "baz"}}),
output_format="yaml",
key_path=["foo", "bar"],
)
assert capsys.readouterr().out.strip() == "baz"


def test_realize_config_simple_ini(tmp_path):
"""
Test that providing an INI file with necessary settings will create an INI config file.
Expand Down

0 comments on commit fd5bdae

Please sign in to comment.