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

Add !dict and !list UW YAML tags #682

Merged
merged 13 commits into from
Jan 7, 2025
38 changes: 38 additions & 0 deletions docs/sections/user_guide/yaml/tags.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ Converts the tagged node to a Python ``bool`` object. For example, given ``input

flag1: True
flag2: !bool "{{ flag1 }}"
flag3: !bool 0

.. code-block:: text

$ uw config realize -i ../input.yaml --output-format yaml
flag1: True
flag2: True
flag3: False
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved


``!datetime``
Expand All @@ -58,6 +60,24 @@ Converts the tagged node to a Python ``datetime`` object. For example, given ``i

The value provided to the tag must be in :python:`ISO 8601 format<datetime.html#datetime.datetime.fromisoformat>` to be interpreted correctly by the ``!datetime`` tag.

``!dict``
^^^^^^^^^

Converts the tagged node to a Python ``dict`` value. For example, given ``input.yaml``:

.. code-block:: yaml

d1: {'a0': 0, 'b1': 1, 'c2': 2}
d2: !dict "{{ '{' }}{% for n in range(3) %} b{{ n }}: {{ n }},{% endfor %}{{ '}' }}"
d3: !dict "{ b0: 0, b1: 1, b2: 2,}"

.. code-block:: text

$ uw config realize --input-file input.yaml --output-format yaml
d1: {'a0': 0, 'b1': 1, 'c2': 2}
d2: {'b0': 0, 'b1': 1, 'b2': 2}
d3: {'c0': 0, 'c1': 1, 'c2': 2}
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved

``!float``
^^^^^^^^^^

Expand Down Expand Up @@ -140,6 +160,24 @@ Converts the tagged node to a Python ``int`` value. For example, given ``input.y
f2: 11
f2: 140

``!list``
^^^^^^^^^

Converts the tagged node to a Python ``list`` value. For example, given ``input.yaml``:

.. code-block:: yaml

l1: [1, 2, 3]
l2: !list "[{% for n in range(3) %} a{{ n }},{% endfor %} ]"
l3: !list "[ a, b, c, ]"

.. code-block:: text

$ uw config realize --input-file input.yaml --output-format yaml
l1: [1, 2, 3]
l2: ['a0', 'a1', 'a2']
l3: ['a', 'b', 'c']
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved

``!remove``
^^^^^^^^^^^

Expand Down
23 changes: 19 additions & 4 deletions src/uwtools/config/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,35 @@ class UWYAMLConvert(UWYAMLTag):
method. See the pyyaml documentation for details.
"""

TAGS = ("!bool", "!datetime", "!float", "!int")
TAGS = ("!bool", "!datetime", "!dict", "!float", "!int", "!list")

def convert(self) -> Union[datetime, float, int]:
def convert(self) -> Union[datetime, dict, float, int, list]:
"""
Return the original YAML value converted to the specified type.

Will raise an exception if the value cannot be represented as the specified type.
"""
converters: dict[
str, Union[Callable[[str], bool], Callable[[str], datetime], type[float], type[int]]
str,
Union[
Callable[[str], bool],
Callable[[str], datetime],
Callable[[str], dict],
type[float],
type[int],
Callable[[str], list],
],
] = dict(
zip(
self.TAGS,
[lambda x: {"True": True, "False": False}[x], datetime.fromisoformat, float, int],
[
lambda x: bool(yaml.safe_load(x)),
datetime.fromisoformat,
lambda x: dict(yaml.safe_load(x)),
float,
int,
lambda x: list(yaml.safe_load(x)),
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved
],
)
)
return converters[self.tag](self.value)
Expand Down
41 changes: 34 additions & 7 deletions src/uwtools/tests/config/formats/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,25 @@ def test_dereference(tmp_path):
c: !int '{{ N | int + 11 }}'
d: '{{ X }}'
e:
- !int '42'
- !float '3.14'
- !datetime '{{ D }}'
- !bool "False"
- !datetime '{{ D }}'
- !float '3.14'
- !int '42'
- !dict "{ b0: 0, b1: 1, b2: 2,}"
- !dict "[ ['c0',0], ['c1',1], ['c2',2], ]"
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved
- !list "[ a0, a1, a2, ]"
f:
f1: !int '42'
f1: True
f2: !float '3.14'
f3: True
f3: !dict "{ b0: 0, b1: 1, b2: 2,}"
f4: !dict "[ ['c0',0], ['c1',1], ['c2',2], ]"
f5: !int '42'
f6: !list "[ 0, 1, 2, ]"
g: !bool '{{ f.f3 }}'
h: !bool 0
D: 2024-10-10 00:19:00
I: !dict "{ b0: 0, b1: 1, b2: 2,}"
L: !list "[ a0, a1, a2, ]"
N: "22"

""".strip()
Expand All @@ -237,10 +246,28 @@ def test_dereference(tmp_path):
"a": 44,
"b": {"c": 33},
"d": "{{ X }}",
"e": [42, 3.14, datetime.fromisoformat("2024-10-10 00:19:00"), False],
"f": {"f1": 42, "f2": 3.14, "f3": True},
"e": [
False,
datetime.fromisoformat("2024-10-10 00:19:00"),
3.14,
42,
{"b0": 0, "b1": 1, "b2": 2},
{"c0": 0, "c1": 1, "c2": 2},
["a0", "a1", "a2"],
],
"f": {
"f1": True,
"f2": 3.14,
"f3": {"b0": 0, "b1": 1, "b2": 2},
"f4": {"c0": 0, "c1": 1, "c2": 2},
"f5": 42,
"f6": [0, 1, 2],
},
"g": True,
"h": False,
"D": datetime.fromisoformat("2024-10-10 00:19:00"),
"I": {"b0": 0, "b1": 1, "b2": 2},
"L": ["a0", "a1", "a2"],
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved
"N": "22",
}

Expand Down
21 changes: 18 additions & 3 deletions src/uwtools/tests/config/test_jinja2.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,20 @@ def test_unrendered(s, status):
assert jinja2.unrendered(s) is status


@mark.parametrize("tag", ["!bool", "!datetime", "!float", "!int"])
def test__deref_convert_no(caplog, tag):
@mark.parametrize(
"tag,value",
[
("!datetime", "foo"),
("!dict", "foo"),
("!float", "foo"),
("!int", "foo"),
("!list", "null"),
],
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved
)
def test__deref_convert_no(caplog, tag, value):
log.setLevel(logging.DEBUG)
loader = yaml.SafeLoader(os.devnull)
val = UWYAMLConvert(loader, yaml.ScalarNode(tag=tag, value="foo"))
val = UWYAMLConvert(loader, yaml.ScalarNode(tag=tag, value=value))
assert jinja2._deref_convert(val=val) == val
assert not regex_logged(caplog, "Converted")
assert regex_logged(caplog, "Conversion failed")
Expand All @@ -301,9 +310,15 @@ def test__deref_convert_no(caplog, tag):
"converted,tag,value",
[
(True, "!bool", "True"),
(False, "!bool", "0"),
(datetime(2024, 9, 9, 0, 0), "!datetime", "2024-09-09 00:00:00"),
({"a": 0, "b": 1}, "!dict", "{a: 0, b: 1}"),
({"a": 0, "b": 1}, "!dict", "[[a, 0], [b, 1]]"),
(3.14, "!float", "3.14"),
(42, "!int", "42"),
([0, 1, 2], "!list", "[0, 1, 2]"),
(["f", "o", "o"], "!list", "foo"),
([0, 1, 2], "!list", "{0: a, 1: b, 2: c}"),
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved
],
)
def test__deref_convert_ok(caplog, converted, tag, value):
Expand Down
27 changes: 21 additions & 6 deletions src/uwtools/tests/config/test_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,9 @@ def loader(self):
return yaml.SafeLoader("")

# These tests bypass YAML parsing, constructing nodes with explicit string values. They then
# demonstrate that those nodes' convert() methods return representations in type type specified
# demonstrate that those nodes' convert() methods return representations in the type specified
# by the tag.

def test_bool_bad(self, loader):
ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!bool", value="foo"))
with raises(KeyError):
ts.convert()

elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved
@mark.parametrize("value, expected", [("False", False), ("True", True)])
def test_bool_values(self, expected, loader, value):
ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!bool", value=value))
Expand All @@ -110,6 +105,16 @@ def test_datetime_ok(self, loader):
assert ts.convert() == datetime(2024, 8, 9, 12, 22, 42)
self.comp(ts, "!datetime '2024-08-09 12:22:42'")

def test_dict_no(self, loader):
ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!dict", value="42"))
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved
with raises(TypeError):
ts.convert()

def test_dict_ok(self, loader):
ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!dict", value="{a0: 0, a1: 1}"))
assert ts.convert() == {"a0": 0, "a1": 1}
self.comp(ts, "!dict '{a0: 0, a1: 1}'")

def test_float_no(self, loader):
ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!float", value="foo"))
with raises(ValueError):
Expand All @@ -130,6 +135,16 @@ def test_int_ok(self, loader):
assert ts.convert() == 42
self.comp(ts, "!int '42'")

def test_list_no(self, loader):
ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!list", value="null"))
with raises(TypeError):
ts.convert()

def test_list_ok(self, loader):
ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!list", value="[1, 2, 3,]"))
assert ts.convert() == [1, 2, 3]
self.comp(ts, "!list '[1, 2, 3,]'")
elcarpenterNOAA marked this conversation as resolved.
Show resolved Hide resolved

def test___repr__(self, loader):
ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!int", value="42"))
assert str(ts) == "!int 42"
Expand Down
Loading