diff --git a/docs/conf.py b/docs/conf.py index 23a829e62..e0d47ffc6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,6 +54,7 @@ "noaa": ("https://www.noaa.gov/%s", "%s"), "pylint": ("https://pylint.readthedocs.io/en/stable/%s", "%s"), "pytest": ("https://docs.pytest.org/en/7.4.x/%s", "%s"), + "python": ("https://docs.python.org/3/library/%s", "%s"), "rocoto": ("https://christopherwharrop.github.io/rocoto/%s", "%s"), "rst": ("https://www.sphinx-doc.org/en/master/usage/restructuredtext/%s", "%s"), "rtd": ("https://readthedocs.org/projects/uwtools/%s", "%s"), diff --git a/docs/sections/user_guide/yaml/tags.rst b/docs/sections/user_guide/yaml/tags.rst index 3a603a0e0..a3a03c5a2 100644 --- a/docs/sections/user_guide/yaml/tags.rst +++ b/docs/sections/user_guide/yaml/tags.rst @@ -23,6 +23,24 @@ Or explicit: Additionally, UW defines the following tags to support use cases not covered by standard tags: +``!datetime`` +^^^^^^^^^^^^^ + +Converts the tagged node to a Python ``datetime`` object. For example, given ``input.yaml``: + +.. code-block:: yaml + + date1: 2024-09-01 + date2: !datetime "{{ date1 }}" + +.. code-block:: text + + % uw config realize -i ../input.yaml --output-format yaml + date1: 2024-09-01 + date2: 2024-09-01 00:00:00 + +The value provided to the tag must be in :python:`ISO 8601 format` to be interpreted correctly by the ``!datetime`` tag. + ``!float`` ^^^^^^^^^^ @@ -62,7 +80,7 @@ Parse the tagged file and include its tags. For example, given ``input.yaml``: .. code-block:: yaml - values: !INCLUDE [./supplemental.yaml] + values: !include [./supplemental.yaml] and ``supplemental.yaml``: diff --git a/src/uwtools/config/jinja2.py b/src/uwtools/config/jinja2.py index 58683a68a..1323cbff9 100644 --- a/src/uwtools/config/jinja2.py +++ b/src/uwtools/config/jinja2.py @@ -3,6 +3,7 @@ """ import os +from datetime import datetime from functools import cached_property from pathlib import Path from typing import Optional, Union @@ -14,7 +15,7 @@ from uwtools.logging import INDENT, MSGWIDTH, log from uwtools.utils.file import get_file_format, readable, writable -_ConfigVal = Union[bool, dict, float, int, list, str, UWYAMLConvert, UWYAMLRemove] +_ConfigVal = Union[bool, datetime, dict, float, int, list, str, UWYAMLConvert, UWYAMLRemove] class J2Template: diff --git a/src/uwtools/config/support.py b/src/uwtools/config/support.py index 96b433862..c11737a4f 100644 --- a/src/uwtools/config/support.py +++ b/src/uwtools/config/support.py @@ -2,8 +2,9 @@ import math from collections import OrderedDict +from datetime import datetime from importlib import import_module -from typing import Type, Union +from typing import Callable, Type, Union import yaml @@ -11,7 +12,7 @@ from uwtools.logging import log from uwtools.strings import FORMAT -INCLUDE_TAG = "!INCLUDE" +INCLUDE_TAG = "!include" # Public functions @@ -107,15 +108,17 @@ class UWYAMLConvert(UWYAMLTag): method. See the pyyaml documentation for details. """ - TAGS = ("!float", "!int") + TAGS = ("!datetime", "!float", "!int") - def convert(self) -> Union[float, int]: + def convert(self) -> Union[datetime, float, int]: """ 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[type[float], type[int]]] = dict(zip(self.TAGS, [float, int])) + converters: dict[str, Union[Callable[[str], datetime], type[float], type[int]]] = dict( + zip(self.TAGS, [datetime.fromisoformat, float, int]) + ) return converters[self.tag](self.value) diff --git a/src/uwtools/tests/config/formats/test_base.py b/src/uwtools/tests/config/formats/test_base.py index e3995e751..663f4ef03 100644 --- a/src/uwtools/tests/config/formats/test_base.py +++ b/src/uwtools/tests/config/formats/test_base.py @@ -5,6 +5,7 @@ import datetime as dt import logging import os +from datetime import datetime from unittest.mock import patch import yaml @@ -97,7 +98,7 @@ def test__parse_include(config): config.data.update( { "config": { - "salad_include": f"!INCLUDE [{include_path}]", + "salad_include": f"!include [{include_path}]", "meat": "beef", "dressing": "poppyseed", } @@ -155,10 +156,13 @@ def test_dereference(tmp_path): e: - !int '42' - !float '3.14' + - !datetime '{{ D }}' f: f1: !int '42' f2: !float '3.14' +D: 2024-10-10 00:19:00 N: "22" + """.strip() path = tmp_path / "config.yaml" with open(path, "w", encoding="utf-8") as f: @@ -166,12 +170,14 @@ def test_dereference(tmp_path): config = YAMLConfig(path) with patch.dict(os.environ, {"N": "999"}, clear=True): config.dereference() + print(config["e"]) assert config == { "a": 44, "b": {"c": 33}, "d": "{{ X }}", - "e": [42, 3.14], + "e": [42, 3.14, datetime.fromisoformat("2024-10-10 00:19:00")], "f": {"f1": 42, "f2": 3.14}, + "D": datetime.fromisoformat("2024-10-10 00:19:00"), "N": "22", } diff --git a/src/uwtools/tests/config/test_jinja2.py b/src/uwtools/tests/config/test_jinja2.py index 6b10bdf91..6d430bf8b 100644 --- a/src/uwtools/tests/config/test_jinja2.py +++ b/src/uwtools/tests/config/test_jinja2.py @@ -5,6 +5,7 @@ import logging import os +from datetime import datetime from io import StringIO from textwrap import dedent from types import SimpleNamespace as ns @@ -280,7 +281,7 @@ def test_unrendered(s, status): assert jinja2.unrendered(s) is status -@mark.parametrize("tag", ["!float", "!int"]) +@mark.parametrize("tag", ["!datetime", "!float", "!int"]) def test__deref_convert_no(caplog, tag): log.setLevel(logging.DEBUG) loader = yaml.SafeLoader(os.devnull) @@ -290,7 +291,14 @@ def test__deref_convert_no(caplog, tag): assert regex_logged(caplog, "Conversion failed") -@mark.parametrize("converted,tag,value", [(3.14, "!float", "3.14"), (42, "!int", "42")]) +@mark.parametrize( + "converted,tag,value", + [ + (datetime(2024, 9, 9, 0, 0), "!datetime", "2024-09-09 00:00:00"), + (3.14, "!float", "3.14"), + (42, "!int", "42"), + ], +) def test__deref_convert_ok(caplog, converted, tag, value): log.setLevel(logging.DEBUG) loader = yaml.SafeLoader(os.devnull) diff --git a/src/uwtools/tests/config/test_support.py b/src/uwtools/tests/config/test_support.py index 409f6edad..f17754697 100644 --- a/src/uwtools/tests/config/test_support.py +++ b/src/uwtools/tests/config/test_support.py @@ -5,6 +5,7 @@ import logging from collections import OrderedDict +from datetime import datetime import yaml from pytest import fixture, mark, raises @@ -87,6 +88,18 @@ def loader(self): # demonstrate that those nodes' convert() methods return representations in type type specified # by the tag. + def test_datetime_no(self, loader): + ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!datetime", value="foo")) + with raises(ValueError): + ts.convert() + + def test_datetime_ok(self, loader): + ts = support.UWYAMLConvert( + loader, yaml.ScalarNode(tag="!datetime", value="2024-08-09 12:22:42") + ) + assert ts.convert() == datetime(2024, 8, 9, 12, 22, 42) + self.comp(ts, "!datetime '2024-08-09 12:22:42'") + def test_float_no(self, loader): ts = support.UWYAMLConvert(loader, yaml.ScalarNode(tag="!float", value="foo")) with raises(ValueError): diff --git a/src/uwtools/tests/fixtures/include_files.ini b/src/uwtools/tests/fixtures/include_files.ini index a096c1ac7..5d337b1d7 100644 --- a/src/uwtools/tests/fixtures/include_files.ini +++ b/src/uwtools/tests/fixtures/include_files.ini @@ -1,4 +1,4 @@ [config] -salad_include = !INCLUDE [./fruit_config.ini] +salad_include = !include [./fruit_config.ini] meat = beef dressing = poppyseed diff --git a/src/uwtools/tests/fixtures/include_files.nml b/src/uwtools/tests/fixtures/include_files.nml index 6df874c6b..fa90c6a5e 100644 --- a/src/uwtools/tests/fixtures/include_files.nml +++ b/src/uwtools/tests/fixtures/include_files.nml @@ -1,5 +1,5 @@ &config - salad_include = '!INCLUDE [./fruit_config.nml]' + salad_include = '!include [./fruit_config.nml]' meat = beef dressing = poppyseed / diff --git a/src/uwtools/tests/fixtures/include_files.sh b/src/uwtools/tests/fixtures/include_files.sh index 9b8ab8e08..d11786be6 100644 --- a/src/uwtools/tests/fixtures/include_files.sh +++ b/src/uwtools/tests/fixtures/include_files.sh @@ -1,3 +1,3 @@ -salad_include="!INCLUDE [./fruit_config.sh]" +salad_include="!include [./fruit_config.sh]" meat=beef dressing=poppyseed diff --git a/src/uwtools/tests/fixtures/include_files.yaml b/src/uwtools/tests/fixtures/include_files.yaml index 537f1b0d9..6fbc80bdc 100644 --- a/src/uwtools/tests/fixtures/include_files.yaml +++ b/src/uwtools/tests/fixtures/include_files.yaml @@ -1,3 +1,3 @@ -salad: !INCLUDE [./fruit_config.yaml] -two_files: !INCLUDE [./fruit_config.yaml, ./fruit_config_similar.yaml] -reverse_files: !INCLUDE [./fruit_config_similar.yaml, ./fruit_config.yaml] +salad: !include [./fruit_config.yaml] +two_files: !include [./fruit_config.yaml, ./fruit_config_similar.yaml] +reverse_files: !include [./fruit_config_similar.yaml, ./fruit_config.yaml] diff --git a/src/uwtools/tests/fixtures/include_files_with_sect.nml b/src/uwtools/tests/fixtures/include_files_with_sect.nml index 6bac4f1dc..d80ad8af1 100644 --- a/src/uwtools/tests/fixtures/include_files_with_sect.nml +++ b/src/uwtools/tests/fixtures/include_files_with_sect.nml @@ -1,5 +1,5 @@ &config - salad_include = '!INCLUDE [./fruit_config_mult_sect.nml]' + salad_include = '!include [./fruit_config_mult_sect.nml]' meat = beef dressing = poppyseed /