From 6bd018893c1254315d607aadc2e5fbb5d305c85e Mon Sep 17 00:00:00 2001 From: "William W. Kimball, Jr., MBA, MSIS" <30981667+wwkimball@users.noreply.github.com> Date: Mon, 26 Sep 2022 21:06:22 -0500 Subject: [PATCH] Prep v3.6.6 (#183) --- CHANGES | 20 +++ README.md | 43 ++--- mypy.ini | 6 + setup.py | 3 +- tests/test_commands_yaml_get.py | 4 +- tests/test_common_nodes.py | 52 +++++- tests/test_common_parsers.py | 20 ++- tests/test_enums_yamlvalueformats.py | 16 ++ tests/test_func.py | 3 +- tests/test_processor.py | 135 ++++++++++++++- tests/test_wrappers_consoleprinter.py | 19 +++ yamlpath/__init__.py | 2 +- yamlpath/commands/yaml_get.py | 20 ++- yamlpath/common/nodes.py | 227 +++++++++++++++++++++++++- yamlpath/common/parsers.py | 35 +++- yamlpath/enums/yamlvalueformats.py | 28 ++++ yamlpath/patches/timestamp.py | 134 +++++++++++++++ yamlpath/wrappers/consoleprinter.py | 27 ++- 18 files changed, 740 insertions(+), 54 deletions(-) create mode 100644 yamlpath/patches/timestamp.py diff --git a/CHANGES b/CHANGES index 6cce6a75..6650cf09 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,23 @@ +3.6.6 +Enhancements: +* Support ruamel.yaml up to version 0.17.21. + +Bug Fixes: +* YAML timestamp values could not be created via yamlpath tools or its library, + per http://yaml.org/type/timestamp.html. + * CAUTION 1: ruamel.yaml seems to force all timestamps to UTC while it loads + YAML/JSON/Compatible data. So, when the timestamp value contains time-zone + data, it will be stripped off after it is applied to the timestamp value. + The yamlpath library reverses this when emitting the affected values but if + you attempt to load the timestamp values directly in the DOM, you'll end up + with the UTC-converted value, stripped of any time-zone specification. If + you need the original, unmodified data as a time-zone aware + datetime.datetime value, pass it through the helper method, + Nodes.get_timestamp_with_tzinfo(data: AnchoredTimeStamp). + * CAUTION 2: In order to support timestamp parsing, this project now depends + on the python-dateutil library. Be sure to install it! + Thanks again go to https://github.com/AndydeCleyre! + 3.6.5 Bug Fixes: * When using EYAML with block formatted values on Windows, the block formatting diff --git a/README.md b/README.md index 06a89f69..994ffe76 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ expressions: 5. `aliases[.%string]` (search for any elements containing "string") 6. `aliases[.$value]` (search for any elements ending with "value") 7. `aliases[.=~/^(\b[Ss][a-z]+\s){2}[a-z]+$/]` (search for any elements matching - a complex Regular Expression, which happens to match the example) + a complex Python Regular Expression, which happens to match the example) 8. `/aliases[0]` (same as 1 but in forward-slash notation) 9. `/aliases/0` (same as 2 but in forward-slash notation) 10. `/aliases[&first_anchor]` (same as 3 but in forward-slash notation) @@ -188,7 +188,8 @@ YAML Path understands these segment types: * Greater Than match: `hash[access_level>0]` * Less Than or Equal match: `hash[access_level<=100]` * Greater Than or Equal match: `hash[access_level>=0]` - * Regular Expression matches: `hash[access_level=~/^\D+$/]` (the `/` Regular + * [Python Regular Expression](https://docs.python.org/3/library/re.html) + matches: `hash[access_level=~/^\D+$/]` (the `/` Regular Expression delimiter can be substituted for any character you need, except white-space; note that `/` does not interfere with forward-slash notation *and it does not need to be escaped* because the entire search expression is @@ -272,10 +273,11 @@ all -- came pre-installed. It is generally safe to have more than one version of Python on your system at the same time, especially when using [virtual Python environments](https://docs.python.org/3/library/venv.html). -*yamlpath* depends on *ruamel.yaml* (derived from and greatly extending PyYAML). -When using OS-native packages or `pip`, you do not need to pre-install -*ruamel.yaml* except under extraordinary circumstances like using very old -versions of `pip` or its own dependency, *setuptools*. +*yamlpath* depends on *ruamel.yaml* (derived from and greatly extending PyYAML) +and *python-dateutil*. When using OS-native packages or `pip`, you do not need +to pre-install these libraries yourself except under extraordinary +circumstances like using very old versions of `pip` or its own dependency, +*setuptools*. ### Using pip @@ -292,8 +294,8 @@ with Python. It is your responsibility to keep `pip` and *setuptools* up-to-date. When `pip` or *setuptools* become outdated, _you will experience errors_ when trying to install newer Python packages like *yamlpath* **unless you preinstall such packages' dependencies**. In the case of *yamlpath*, this -means you'd need to preinstall *ruamel.yaml* if you cannot or choose not to -upgrade `pip` and/or *setuptools*. +means you'd need to preinstall *ruamel.yaml* and *python-dateutil* if you +cannot or choose not to upgrade `pip` and/or *setuptools*. As long as your `pip` and *setuptools* are up-to-date, installing *yamlpath* is as simple as a single command (the "3.7" suffix to the `pip` command is @@ -309,9 +311,9 @@ Very old versions of Python 3 ship with seriously outdated versions of `pip` and its *setuptools* dependency. When using versions of `pip` older than **18.1** or *setuptools* older than version **46.4.0**, you will not be able to install *yamlpath* with a single command. In this case, you have two options: either -pre-install *ruamel.yaml* before installing *yamlpath* or update `pip` and/or -*setuptools* to at least the minimum required versions so `pip` can -auto-determine and install dependencies. This issue is not unique to +pre-install *ruamel.yaml* and *python-dateutil* before installing *yamlpath* or +update `pip` and/or *setuptools* to at least the minimum required versions so +`pip` can auto-determine and install dependencies. This issue is not unique to *yamlpath*. Upgrading `pip` and *setuptools* is trivially simple as long as you have @@ -328,21 +330,22 @@ pip3.7 install --upgrade setuptools ``` When you cannot or will not update `pip` or *setuptools*, just pre-install -*ruamel.yaml* before yamlpath. Each must be installed seperately and in order, -like this (you **cannot** combine these installations into a single command): +*ruamel.yaml* and *python-dateutil* before yamlpath. Each must be installed +seperately and in order, like this (you **cannot** combine these installations +into a single command): ```shell -pip3.7 install ruamel.yaml +pip3.7 install ruamel.yaml python-dateutil pip3.7 install yamlpath ``` The downside to choosing this manual installation path is that you may end up -with an incompatible version of *ruamel.yaml*. This will manifest either as an -inability to install *yamlpath* at all, or only certain versions of *yamlpath*, -or *yamlpath* may experience unexpected errors caused by the incompatible code. -For the best experience, you are strongly encouraged to just keep `pip` and -*setuptools* up-to-date, particularly as a routine part of installing any new -Python packages. +with an incompatible version of *ruamel.yaml* or *python-dateutil*. This will +manifest either as an inability to install *yamlpath* at all, or only certain +versions of *yamlpath*, or *yamlpath* may experience unexpected errors caused +by the incompatible code. For the best experience, you are strongly encouraged +to just keep `pip` and *setuptools* up-to-date, particularly as a routine part +of installing any new Python packages. ### Installing EYAML (Optional) diff --git a/mypy.ini b/mypy.ini index 79ec191c..2f070e5c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4,3 +4,9 @@ warn_unused_configs = True [mypy-ruamel.*] ignore_missing_imports = True + +[mypy-dateutil.*] +ignore_missing_imports = True + +[mypy-yamlpath.patches.*] +ignore_missing_imports = True diff --git a/setup.py b/setup.py index e74e66c6..7ad9b5d1 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,8 @@ }, python_requires=">3.6.0", install_requires=[ - "ruamel.yaml>=0.15.96,!=0.17.0,!=0.17.1,!=0.17.2,!=0.17.5,<=0.17.17", + "ruamel.yaml>=0.15.96,!=0.17.0,!=0.17.1,!=0.17.2,!=0.17.5,!=0.17.18,<=0.17.21", + "python-dateutil<=3" ], tests_require=[ "pytest", diff --git a/tests/test_commands_yaml_get.py b/tests/test_commands_yaml_get.py index 567a89ec..aaedb66d 100644 --- a/tests/test_commands_yaml_get.py +++ b/tests/test_commands_yaml_get.py @@ -160,11 +160,13 @@ def test_get_every_data_type(self, script_runner, tmp_path_factory): nothingthing: emptystring: "" nullstring: "null" +datething: 2022-09-23 +timestampthing: 2022-09-24T14:13:12-7:30 """ # Note that true nulls are translated as "\x00" (hexadecimal NULL # control-characters). - results = ["6", "6.8", "yes", "no", "True", "False", "\x00", "\x00", "", "null"] + results = ["6", "6.8", "yes", "no", "True", "False", "\x00", "\x00", "", "null", "2022-09-23", "2022-09-24T14:13:12-07:30"] yaml_file = create_temp_yaml_file(tmp_path_factory, content) result = script_runner.run(self.command, "--query=*", yaml_file) diff --git a/tests/test_common_nodes.py b/tests/test_common_nodes.py index 9e532ed2..53b8c13d 100644 --- a/tests/test_common_nodes.py +++ b/tests/test_common_nodes.py @@ -1,6 +1,24 @@ import pytest - -import ruamel.yaml as ry +from datetime import date, datetime +from types import SimpleNamespace + +from ruamel.yaml.comments import CommentedSeq, CommentedMap, TaggedScalar +from ruamel.yaml.scalarstring import PlainScalarString +from ruamel.yaml.scalarbool import ScalarBoolean +from ruamel.yaml.scalarfloat import ScalarFloat +from ruamel.yaml.scalarint import ScalarInt +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: # pragma: no cover + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + AnchoredDate = AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence shall come AnchoredDate? from yamlpath.enums import YAMLValueFormats from yamlpath.common import Nodes @@ -18,7 +36,7 @@ def test_list_to_str(self): assert "[]" == Nodes.make_new_node("", "[]", YAMLValueFormats.DEFAULT) def test_anchored_string(self): - node = ry.scalarstring.PlainScalarString("value") + node = PlainScalarString("value") node.yaml_set_anchor("anchored") new_node = Nodes.make_new_node(node, "new", YAMLValueFormats.DEFAULT) assert new_node.anchor.value == node.anchor.value @@ -29,15 +47,15 @@ def test_anchored_string(self): ### def test_tag_map(self): new_tag = "!something" - old_node = ry.comments.CommentedMap({"key": "value"}) + old_node = CommentedMap({"key": "value"}) new_node = Nodes.apply_yaml_tag(old_node, new_tag) assert new_node.tag.value == new_tag def test_update_tag(self): old_tag = "!tagged" new_tag = "!changed" - old_node = ry.scalarstring.PlainScalarString("tagged value") - tagged_node = ry.comments.TaggedScalar(old_node, tag=old_tag) + old_node = PlainScalarString("tagged value") + tagged_node = TaggedScalar(old_node, tag=old_tag) new_node = Nodes.apply_yaml_tag(tagged_node, new_tag) assert new_node.tag.value == new_tag assert new_node.value == old_node @@ -45,8 +63,8 @@ def test_update_tag(self): def test_delete_tag(self): old_tag = "!tagged" new_tag = "" - old_node = ry.scalarstring.PlainScalarString("tagged value") - tagged_node = ry.comments.TaggedScalar(old_node, tag=old_tag) + old_node = PlainScalarString("tagged value") + tagged_node = TaggedScalar(old_node, tag=old_tag) new_node = Nodes.apply_yaml_tag(tagged_node, new_tag) assert not hasattr(new_node, "tag") assert new_node == old_node @@ -73,3 +91,21 @@ def test_aoh_is_inconsistent(self): {"key": "value"}, None ]) + + + ### + # wrap_type + ### + @pytest.mark.parametrize("value,checktype", [ + ([], CommentedSeq), + ({}, CommentedMap), + ("", PlainScalarString), + (1, ScalarInt), + (1.1, ScalarFloat), + (True, ScalarBoolean), + (date(2022, 8, 2), AnchoredDate), + (datetime(2022, 8, 2, 13, 22, 31), AnchoredTimeStamp), + (SimpleNamespace(), SimpleNamespace), + ]) + def test_wrap_type(self, value, checktype): + assert isinstance(Nodes.wrap_type(value), checktype) diff --git a/tests/test_common_parsers.py b/tests/test_common_parsers.py index 14483791..06647176 100644 --- a/tests/test_common_parsers.py +++ b/tests/test_common_parsers.py @@ -3,6 +3,18 @@ import datetime as dt import ruamel.yaml as ry +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: # pragma: no cover + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + AnchoredDate = AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence shall come AnchoredDate? from yamlpath.enums import YAMLValueFormats from yamlpath.common import Parsers @@ -94,7 +106,9 @@ def test_jsonify_complex_ruamel_data(self): "null": null_node, "dates": ry.comments.CommentedSeq([ dt.date(2020, 10, 31), - dt.date(2020, 11, 3) + dt.date(2020, 11, 3), + AnchoredDate(2020, 12, 1), + AnchoredTimeStamp(2021, 1, 13, 1, 2, 3) ]), "t_bool": ry.scalarbool.ScalarBoolean(1), "f_bool": ry.scalarbool.ScalarBoolean(0) @@ -104,11 +118,13 @@ def test_jsonify_complex_ruamel_data(self): assert jdata["null"] == null_value assert jdata["dates"][0] == "2020-10-31" assert jdata["dates"][1] == "2020-11-03" + assert jdata["dates"][2] == "2020-12-01" + assert jdata["dates"][3] == "2021-01-13T01:02:03" assert jdata["t_bool"] == 1 assert jdata["f_bool"] == 0 jstr = json.dumps(jdata) - assert jstr == """{"tagged": "tagged value", "null": null, "dates": ["2020-10-31", "2020-11-03"], "t_bool": true, "f_bool": false}""" + assert jstr == """{"tagged": "tagged value", "null": null, "dates": ["2020-10-31", "2020-11-03", "2020-12-01", "2021-01-13T01:02:03"], "t_bool": true, "f_bool": false}""" def test_jsonify_complex_python_data(self): cdata = { diff --git a/tests/test_enums_yamlvalueformats.py b/tests/test_enums_yamlvalueformats.py index 74fad310..1c898cd7 100644 --- a/tests/test_enums_yamlvalueformats.py +++ b/tests/test_enums_yamlvalueformats.py @@ -1,4 +1,5 @@ import pytest +from datetime import datetime, date from ruamel.yaml.scalarstring import ( PlainScalarString, @@ -10,6 +11,15 @@ from ruamel.yaml.scalarbool import ScalarBoolean from ruamel.yaml.scalarfloat import ScalarFloat from ruamel.yaml.scalarint import ScalarInt +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + ) # type: ignore +else: + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp from yamlpath.enums import YAMLValueFormats @@ -20,6 +30,7 @@ def test_get_names(self): assert YAMLValueFormats.get_names() == [ "BARE", "BOOLEAN", + "DATE", "DEFAULT", "DQUOTE", "FLOAT", @@ -27,11 +38,13 @@ def test_get_names(self): "INT", "LITERAL", "SQUOTE", + "TIMESTAMP", ] @pytest.mark.parametrize("input,output", [ ("BARE", YAMLValueFormats.BARE), ("BOOLEAN", YAMLValueFormats.BOOLEAN), + ("DATE", YAMLValueFormats.DATE), ("DEFAULT", YAMLValueFormats.DEFAULT), ("DQUOTE", YAMLValueFormats.DQUOTE), ("FLOAT", YAMLValueFormats.FLOAT), @@ -39,6 +52,7 @@ def test_get_names(self): ("INT", YAMLValueFormats.INT), ("LITERAL", YAMLValueFormats.LITERAL), ("SQUOTE", YAMLValueFormats.SQUOTE), + ("TIMESTAMP", YAMLValueFormats.TIMESTAMP), ]) def test_from_str(self, input, output): assert output == YAMLValueFormats.from_str(input) @@ -50,12 +64,14 @@ def test_from_str_nameerror(self): @pytest.mark.parametrize("input,output", [ (FoldedScalarString(""), YAMLValueFormats.FOLDED), (LiteralScalarString(""), YAMLValueFormats.LITERAL), + (date(2022, 9, 24), YAMLValueFormats.DATE), (DoubleQuotedScalarString(''), YAMLValueFormats.DQUOTE), (SingleQuotedScalarString(""), YAMLValueFormats.SQUOTE), (PlainScalarString(""), YAMLValueFormats.BARE), (ScalarBoolean(False), YAMLValueFormats.BOOLEAN), (ScalarFloat(1.01), YAMLValueFormats.FLOAT), (ScalarInt(10), YAMLValueFormats.INT), + (AnchoredTimeStamp(2022, 9, 24, 7, 42, 38), YAMLValueFormats.TIMESTAMP), (None, YAMLValueFormats.DEFAULT), ]) def test_from_node(self, input, output): diff --git a/tests/test_func.py b/tests/test_func.py index 2d9e5b55..1fb1c3c7 100644 --- a/tests/test_func.py +++ b/tests/test_func.py @@ -9,7 +9,6 @@ from ruamel.yaml.scalarint import ScalarInt from yamlpath.enums import AnchorMatches, PathSearchMethods, YAMLValueFormats -from yamlpath.types import PathAttributes from yamlpath.path import SearchTerms from yamlpath import YAMLPath from yamlpath.func import ( @@ -31,7 +30,7 @@ wrap_type, ) -from tests.conftest import create_temp_yaml_file, quiet_logger +from tests.conftest import create_temp_yaml_file @pytest.fixture diff --git a/tests/test_processor.py b/tests/test_processor.py index 34e6ebde..dc834528 100644 --- a/tests/test_processor.py +++ b/tests/test_processor.py @@ -1,22 +1,29 @@ import pytest -from datetime import date +from datetime import date, datetime from types import SimpleNamespace from ruamel.yaml import YAML from ruamel.yaml.comments import TaggedScalar +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: + from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence comes AnchoredDate? from yamlpath.func import unwrap_node_coords from yamlpath.exceptions import YAMLPathException from yamlpath.enums import ( PathSeperators, PathSegmentTypes, - PathSearchMethods, YAMLValueFormats, ) from yamlpath.path import SearchTerms from yamlpath.wrappers import ConsolePrinter from yamlpath import YAMLPath, Processor -from tests.conftest import quiet_logger class Test_Processor(): @@ -80,7 +87,7 @@ def test_get_none_data_nodes(self, quiet_logger): ("/**/Hey*", ["Hey, Number Two!"], True, None), ("lots_of_names.**.name", ["Name 1-1", "Name 2-1", "Name 3-1", "Name 4-1", "Name 4-2", "Name 4-3", "Name 4-4"], True, None), ("/array_of_hashes/**", [1, "one", 2, "two"], True, None), - ("products_hash.*[dimensions.weight==4].(availability.start.date)+(availability.stop.date)", [[date(2020, 8, 1), date(2020, 9, 25)], [date(2020, 1, 1), date(2020, 1, 1)]], True, None), + ("products_hash.*[dimensions.weight==4].(availability.start.date)+(availability.stop.date)", [[AnchoredDate(2020, 8, 1), AnchoredDate(2020, 9, 25)], [AnchoredDate(2020, 1, 1), AnchoredDate(2020, 1, 1)]], True, None), ("products_array[dimensions.weight==4].product", ["doohickey", "widget"], True, None), ("(products_hash.*.dimensions.weight)[max()][parent(2)].dimensions.weight", [10], True, None), ("/Locations/*/*", ["ny", "bstn"], True, None), @@ -492,6 +499,122 @@ def test_set_value(self, quiet_logger, yamlpath, value, tally, mustexist, vforma matchtally += 1 assert matchtally == tally + @pytest.mark.parametrize("yamlpath,value,compare,tally,mustexist,vformat,pathsep", [ + ("/datetimes/date", + date(2022, 8, 2), + AnchoredDate(2022, 8, 2), + 1, + True, + YAMLValueFormats.DATE, + PathSeperators.FSLASH, + ), + ("datetimes.date", + '2022-08-02', + AnchoredDate(2022, 8, 2), + 1, + True, + YAMLValueFormats.DATE, + PathSeperators.DOT, + ), + ("datetimes.timestamp", + datetime(2022, 8, 2, 13, 22, 31), + AnchoredTimeStamp(2022, 8, 2, 13, 22, 31), + 1, + True, + YAMLValueFormats.TIMESTAMP, + PathSeperators.DOT, + ), + ("/datetimes/timestamp", + '2022-08-02T13:22:31', + AnchoredTimeStamp(2022, 8, 2, 13, 22, 31), + 1, + True, + YAMLValueFormats.TIMESTAMP, + PathSeperators.FSLASH, + ), + ("aliases[&date]", + '2022-08-02', + AnchoredDate(2022, 8, 2), + 1, + True, + YAMLValueFormats.DATE, + PathSeperators.DOT, + ), + ("aliases[×tamp]", + datetime(2022, 8, 2, 13, 22, 31), + AnchoredTimeStamp(2022, 8, 2, 13, 22, 31), + 1, + True, + YAMLValueFormats.TIMESTAMP, + PathSeperators.DOT, + ), + ]) + def test_set_datetimes(self, quiet_logger, yamlpath, value, compare, tally, mustexist, vformat, pathsep): + yamldata = """--- +aliases: + - &date 2022-02-21 + - ×tamp 2022-11-20T15:14:13 +datetimes: + date: 2022-09-23 + timestamp: 2022-09-24T01:02:03.04000 +reused: + date: *date + timestamp: *timestamp +""" + yaml = YAML() + data = yaml.load(yamldata) + processor = Processor(quiet_logger, data) + processor.set_value(yamlpath, value, mustexist=mustexist, value_format=vformat, pathsep=pathsep) + matchtally = 0 + for node in processor.get_nodes(yamlpath, mustexist=mustexist): + changed_value = unwrap_node_coords(node) + if isinstance(changed_value, list): + compare_idx = 0 + for result in changed_value: + assert result == compare[compare_idx] + compare_idx += 1 + matchtally += 1 + continue + assert changed_value == compare + matchtally += 1 + assert matchtally == tally + + @pytest.mark.parametrize("yamlpath,value,vformat,exmsg", [ + ("/datetimes/date", + "2022.9.24", + YAMLValueFormats.DATE, + "not a YAML-compatible ISO8601 date" + ), + ("/datetimes/date", + "2022-90-24", + YAMLValueFormats.DATE, + "cannot be cast to an ISO8601 date" + ), + ("/datetimes/timestamp", + "2022-9-24 @ 7:41am", + YAMLValueFormats.TIMESTAMP, + "not a YAML-compatible ISO8601 timestamp" + ), + ("/datetimes/timestamp", + "2022-90-24T07:41:00", + YAMLValueFormats.TIMESTAMP, + "cannot be cast to an ISO8601 timestamp" + ), + ]) + def test_cannot_set_impossible_datetimes(self, quiet_logger, yamlpath, value, vformat, exmsg): + yamldata = """--- +datetimes: + date: 2022-09-23 + timestamp: 2022-09-24T01:02:03.04000 +""" + yaml = YAML() + data = yaml.load(yamldata) + processor = Processor(quiet_logger, data) + + with pytest.raises(YAMLPathException) as ex: + processor.set_value(yamlpath, value, value_format=vformat) + assert -1 < str(ex.value).find(exmsg) + def test_cannot_set_nonexistent_required_node_error(self, quiet_logger): yamldata = """--- key: value @@ -973,9 +1096,11 @@ def test_get_every_data_type(self, quiet_logger): nothingthing: emptystring: "" nullstring: "null" +datething: 2022-09-24 +timestampthing: 2022-09-24T15:24:32 """ - results = [6, 6.8, "yes", "no", True, False, None, None, "", "null"] + results = [6, 6.8, "yes", "no", True, False, None, None, "", "null", AnchoredDate(2022, 9, 24), AnchoredTimeStamp(2022, 9, 24, 15, 24, 32)] yaml = YAML() data = yaml.load(yamldata) diff --git a/tests/test_wrappers_consoleprinter.py b/tests/test_wrappers_consoleprinter.py index dbb921fd..59ebe852 100644 --- a/tests/test_wrappers_consoleprinter.py +++ b/tests/test_wrappers_consoleprinter.py @@ -4,6 +4,18 @@ from ruamel.yaml.comments import CommentedMap, CommentedSeq, CommentedSet, TaggedScalar from ruamel.yaml.scalarstring import PlainScalarString, FoldedScalarString +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: # pragma: no cover + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + AnchoredDate = AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence shall come AnchoredDate? from yamlpath.enums import PathSegmentTypes from yamlpath.wrappers import NodeCoords, ConsolePrinter @@ -75,6 +87,13 @@ def test_debug_noisy(self, capsys): "DEBUG: [1](&Anchor)TestVal", ]) + "\n" == console.out + logger.debug({"date": AnchoredDate(2022, 9, 23), "timestamp": AnchoredTimeStamp(2022, 9, 25, 1, 2, 3, 40000)}) + console = capsys.readouterr() + assert "\n".join([ + "DEBUG: [date]2022-09-23", + "DEBUG: [timestamp]2022-09-25T01:02:03.040000", + ]) + "\n" == console.out + logger.debug({"ichi": 1, anchoredkey: anchoredval}) console = capsys.readouterr() assert "\n".join([ diff --git a/yamlpath/__init__.py b/yamlpath/__init__.py index f72dea3a..4609b1c6 100644 --- a/yamlpath/__init__.py +++ b/yamlpath/__init__.py @@ -1,6 +1,6 @@ """Core YAML Path classes.""" # Establish the version number common to all components -__version__ = "3.6.5" +__version__ = "3.6.6" from yamlpath.yamlpath import YAMLPath from yamlpath.processor import Processor diff --git a/yamlpath/commands/yaml_get.py b/yamlpath/commands/yaml_get.py index e2899ff9..e5105591 100644 --- a/yamlpath/commands/yaml_get.py +++ b/yamlpath/commands/yaml_get.py @@ -15,9 +15,22 @@ from os.path import isfile from ruamel.yaml.comments import CommentedSet +# pylint: disable=wrong-import-position,ungrouped-imports +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: # pragma: no cover + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + AnchoredDate = AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence shall come AnchoredDate? from yamlpath import __version__ as YAMLPATH_VERSION -from yamlpath.common import Parsers +from yamlpath.common import Parsers, Nodes from yamlpath import YAMLPath from yamlpath.exceptions import YAMLPathException from yamlpath.eyaml.exceptions import EYAMLCommandException @@ -26,6 +39,7 @@ from yamlpath.eyaml import EYAMLProcessor from yamlpath.wrappers import ConsolePrinter +# pylint: enable=wrong-import-position,ungrouped-imports def processcli(): """Process command-line arguments.""" @@ -193,6 +207,10 @@ def main(): else: if node is None: node = "\x00" + elif isinstance(node, AnchoredDate): + node = node.date().isoformat() + elif isinstance(node, AnchoredTimeStamp): + node = Nodes.get_timestamp_with_tzinfo(node).isoformat() print("{}".format(str(node).replace("\n", r"\n"))) except RecursionError: log.critical( diff --git a/yamlpath/common/nodes.py b/yamlpath/common/nodes.py index a6b99285..89fbdaa3 100644 --- a/yamlpath/common/nodes.py +++ b/yamlpath/common/nodes.py @@ -4,9 +4,12 @@ Copyright 2020 William W. Kimball, Jr. MBA MSIS """ import re +from datetime import datetime, date, timedelta, timezone from ast import literal_eval from typing import Any +from dateutil import parser + from ruamel.yaml.comments import CommentedSeq, CommentedMap, TaggedScalar from ruamel.yaml.scalarbool import ScalarBoolean from ruamel.yaml.scalarfloat import ScalarFloat @@ -18,6 +21,19 @@ FoldedScalarString, LiteralScalarString, ) +# pylint: disable=wrong-import-position,ungrouped-imports +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: # pragma: no cover + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + AnchoredDate = AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence shall come AnchoredDate? from yamlpath.enums import ( PathSegmentTypes, @@ -25,6 +41,7 @@ ) from yamlpath.wrappers import NodeCoords from yamlpath import YAMLPath +# pylint: enable=wrong-import-position,ungrouped-imports class Nodes: @@ -57,10 +74,10 @@ def make_new_node( - `ValueError' when the new value is not numeric and value_format requires it to be so """ - new_node = None - new_type = type(source_node) - new_value = value - valform = YAMLValueFormats.DEFAULT + new_node: Any = None + new_type: Any = type(source_node) + new_value: Any = value + valform: YAMLValueFormats = YAMLValueFormats.DEFAULT if isinstance(value_format, YAMLValueFormats): valform = value_format @@ -139,6 +156,79 @@ def make_new_node( + " cast to an integer number.") .format(valform, value) ) from wrap_ex + elif valform == YAMLValueFormats.DATE: + new_type = AnchoredDate + + if isinstance(value, (AnchoredDate, date, datetime)): + new_value = value + else: + # Enforce matches against http://yaml.org/type/timestamp.html + yaml_spec_re = re.compile(r"""(?x) + ^ + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] # (ymd) + $""") + dt_matches = yaml_spec_re.match(value) + if not dt_matches: + raise ValueError( + f"The requested value format is {valform}, but" + + f" '{value}' is not a YAML-compatible ISO8601 date" + + " per http://yaml.org/type/timestamp.html") + + try: + new_value = parser.parse(value) + except ValueError as wrap_ex: + raise ValueError( + f"The requested value format is {valform}, but " + + f" {value}' cannot be cast to an ISO8601 date." + ) from wrap_ex + + anchor_val = None + if hasattr(source_node, "anchor"): + anchor_val = source_node.anchor.value + + new_node = Nodes.make_date_node(new_value, anchor_val) + elif valform == YAMLValueFormats.TIMESTAMP: + new_type = AnchoredTimeStamp + t_sep = ' ' + + if isinstance(value, (datetime, AnchoredTimeStamp)): + new_value = value + else: + # Enforce matches against http://yaml.org/type/timestamp.html + yaml_spec_re = re.compile(r"""(?x) + ^ + [0-9][0-9][0-9][0-9] # (year) + -[0-9][0-9]? # (month) + -[0-9][0-9]? # (day) + ([Tt]|[ \t]+)[0-9][0-9]? # (hour) + :[0-9][0-9] # (minute) + :[0-9][0-9] # (second) + (\.[0-9]*)? # (fraction) + (([ \t]*)Z|[-+][0-9][0-9]?(:[0-9][0-9])?)? # (time zone) + $""") + dt_matches = yaml_spec_re.match(value) + if not dt_matches: + raise ValueError( + f"The requested value format is {valform}, but" + + f" '{value}' is not a YAML-compatible ISO8601" + + " timestamp per http://yaml.org/type/timestamp.html") + + t_sep = dt_matches.group(1) + + try: + new_value = parser.parse(value) + except ValueError as wrap_ex: + raise ValueError( + f"The requested value format is {valform}, but" + + f" '{value}' cannot be cast to an ISO8601 timestamp." + ) from wrap_ex + + anchor_val = None + if hasattr(source_node, "anchor"): + anchor_val = source_node.anchor.value + + new_node = Nodes.make_timestamp_node( + new_value, t_sep, anchor_val) else: # Punt to whatever the best Scalar type may be try: @@ -172,6 +262,85 @@ def make_new_node( return new_node + @staticmethod + def make_date_node(value: date, anchor: str = None) -> AnchoredDate: + r""" + Create a new AnchoredDate data node from a bare date. + + An optional anchor may be attached. + + Parameters: + 1. value (date) The bare date to wrap. + 2. anchor (str) OPTIONAL anchor to add. + + Returns: (AnchoredDate) The new node + """ + if anchor is None: + new_node = AnchoredDate( + value.year + , value.month + , value.day + ) + else: + new_node = AnchoredDate( + value.year + , value.month + , value.day + , anchor=anchor + ) + + return new_node + + @staticmethod + def make_timestamp_node( + value: datetime, t_separator: str, anchor: str = None + ) -> AnchoredTimeStamp: + r""" + Create a new AnchoredTimeStamp data node from a bare datetime. + + An optional anchor may be attached. + + Parameters: + 1. value (datetime) The bare datetime to wrap. + 2. t_separator (str) One of [Tt\s] to separate date from time + 3. anchor (str) OPTIONAL anchor to add. + + Returns: (AnchoredTimeStamp) The new node + """ + if anchor is None: + new_node = AnchoredTimeStamp( + value.year + , value.month + , value.day + , value.hour + , value.minute + , value.second + , value.microsecond + , value.tzinfo + ) + else: + new_node = AnchoredTimeStamp( + value.year + , value.month + , value.day + , value.hour + , value.minute + , value.second + , value.microsecond + , value.tzinfo + , anchor=anchor + ) + + # Add a T separator only when set + if t_separator in ['T', 't']: + # Ignore W0212 here because there is literally no other way to tell + # ruamel.yaml to preserve the T separator for this timestamp at the + # time of this writing. This code is indeed therefore fragile. + # pylint: disable=protected-access + new_node._yaml['t'] = t_separator + + return new_node + @staticmethod def make_float_node(value: float, anchor: str = None): """ @@ -268,6 +437,14 @@ def wrap_type(value: Any) -> Any: wrapped_value = Nodes.make_float_node(ast_value) elif typ is bool: wrapped_value = ScalarBoolean(bool(value)) + elif typ is date: + wrapped_value = AnchoredDate( + value.year, value.month, value.day) + elif typ is datetime: + wrapped_value = AnchoredTimeStamp( + value.year, value.month, value.day, + value.hour, value.minute, value.second, value.microsecond, + value.tzinfo) return wrapped_value @@ -481,3 +658,45 @@ def typed_value(value: str) -> Any: except SyntaxError: typed_value = value return typed_value + + @staticmethod + def get_timestamp_with_tzinfo(data: AnchoredTimeStamp) -> datetime: + """ + Get an AnchoredTimeStamp with time-zone info correctly applied. + + For whatever reason, ruamel.yaml hides time-zone data in a private + dict rather than as a manifest property of the wrapped datetime value. + Doing so causes the datetime value to be pre-calculated when emitted, + with the time-zone delta applied to the original value. The net effect + is users get a different value out than they put in. This method + rewinds the pre-calculation and combines the time-zone with the + original data as befits a complete datetime value. + + Parameters: + 1. value (AnchoredTimeStamp) the value to correct + + Returns: (datetime) time-zone aware non-pre-calculated value + """ + # As stated in the method comments, ruamel.yaml hides the time-zone + # details in a private dict after forcibly normalizing the datetime; + # there is no public accessor for this. Also ignoring the mypy type + # check on the various returns because ruamel.yaml defines TimeStamp + # as an 'Any' type rather than a 'TimeStamp' or even its superclass of + # 'datetime'. It is perfectly accurate to assert that this method is + # correctly returning a 'datetime' despite the ruamel.yaml type + # annotation error. + # pylint: disable=protected-access + tzinfo_raw = (data._yaml['tz'] + if hasattr(data, "_yaml") and 'tz' in data._yaml + else None) + if tzinfo_raw: + tzre = re.compile(r'([+\-]?)(\d{1,2}):?(\d{2})') + tzmatches = tzre.match(tzinfo_raw) + if tzmatches: + sign_mark, hours, minutes = tzmatches.groups() + sign = -1 if sign_mark == '-' else 1 + tdelta = timedelta(hours=int(hours), minutes=int(minutes)) + tzinfo = timezone(sign * tdelta) + data = (data + tdelta * sign).replace( + tzinfo=tzinfo) + return data # type: ignore diff --git a/yamlpath/common/parsers.py b/yamlpath/common/parsers.py index a5b665bc..c61ca9a8 100644 --- a/yamlpath/common/parsers.py +++ b/yamlpath/common/parsers.py @@ -5,7 +5,7 @@ """ import warnings from sys import maxsize, stdin -from datetime import date +from datetime import date, datetime from typing import Any, Dict, Generator, Tuple import ruamel.yaml # type: ignore @@ -19,12 +19,27 @@ from ruamel.yaml.comments import ( CommentedMap, CommentedSet, CommentedSeq, TaggedScalar ) +# pylint: disable=wrong-import-position,ungrouped-imports +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: # pragma: no cover + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + AnchoredDate = AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence shall come AnchoredDate? from yamlpath.wrappers import ConsolePrinter +from yamlpath.common import Nodes if ruamel.yaml.version_info < (0, 17, 5): # pragma: no cover from yamlpath.patches.aliasstyle import MySerializer # type: ignore from yamlpath.patches.aliasstyle import MyEmitter # type: ignore +# pylint: enable=wrong-import-position,ungrouped-imports class Parsers: @@ -308,8 +323,8 @@ def stringify_dates(data: Any) -> Any: elif isinstance(data, CommentedSeq): for idx, ele in enumerate(data): data[idx] = Parsers.stringify_dates(ele) - elif isinstance(data, date): - return str(data) + elif isinstance(data, (datetime, date)): + return data.isoformat() return data @staticmethod @@ -350,13 +365,17 @@ def jsonify_yaml_data(data: Any) -> Any: elif isinstance(data, TaggedScalar): if data.tag.value == "!null": return None - return Parsers.jsonify_yaml_data(data.value) - elif isinstance(data, date): - return str(data) + data = Parsers.jsonify_yaml_data(data.value) + elif isinstance(data, AnchoredDate): + data = data.date().isoformat() + elif isinstance(data, AnchoredTimeStamp): + data = Nodes.get_timestamp_with_tzinfo(data).isoformat() + elif isinstance(data, (datetime, date)): + data = data.isoformat() elif isinstance(data, bytes): - return str(data) + data = str(data) elif isinstance(data, (ScalarBoolean, bool)): - return bool(data) + data = bool(data) return data @staticmethod diff --git a/yamlpath/enums/yamlvalueformats.py b/yamlpath/enums/yamlvalueformats.py index 996532ce..ef4be5a6 100644 --- a/yamlpath/enums/yamlvalueformats.py +++ b/yamlpath/enums/yamlvalueformats.py @@ -3,6 +3,7 @@ Copyright 2019, 2020 William W. Kimball, Jr. MBA MSIS """ +import datetime from enum import Enum, auto from typing import Any, List @@ -16,6 +17,20 @@ from ruamel.yaml.scalarbool import ScalarBoolean from ruamel.yaml.scalarfloat import ScalarFloat from ruamel.yaml.scalarint import ScalarInt +# pylint: disable=wrong-import-position,ungrouped-imports +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: # pragma: no cover + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + AnchoredDate = AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence shall come AnchoredDate? +# pylint: enable=wrong-import-position,ungrouped-imports class YAMLValueFormats(Enum): @@ -32,6 +47,9 @@ class YAMLValueFormats(Enum): `BOOLEAN` The value is written as a bare True or False. + `DATE` + The value is written as a bare ISO8601 date without a time component. + `DEFAULT` The value is written in whatever format is deemed most appropriate. @@ -56,10 +74,15 @@ class YAMLValueFormats(Enum): `SQUOTE` The value is demarcated via apostrophes ('). + + `TIMESTAMP` + The value is a timestamp per the supported syntax of ISO8601 by + http://yaml.org/type/timestamp.html. """ BARE = auto() BOOLEAN = auto() + DATE = auto() DEFAULT = auto() DQUOTE = auto() FLOAT = auto() @@ -67,6 +90,7 @@ class YAMLValueFormats(Enum): INT = auto() LITERAL = auto() SQUOTE = auto() + TIMESTAMP = auto() @staticmethod def get_names() -> List[str]: @@ -137,5 +161,9 @@ def from_node(node: Any) -> "YAMLValueFormats": best_type = YAMLValueFormats.FLOAT elif node_type is ScalarInt: best_type = YAMLValueFormats.INT + elif node_type is AnchoredDate or node_type is datetime.date: + best_type = YAMLValueFormats.DATE + elif node_type is AnchoredTimeStamp or node_type is datetime.datetime: + best_type = YAMLValueFormats.TIMESTAMP return best_type diff --git a/yamlpath/patches/timestamp.py b/yamlpath/patches/timestamp.py new file mode 100644 index 00000000..fda9c4a4 --- /dev/null +++ b/yamlpath/patches/timestamp.py @@ -0,0 +1,134 @@ +# pylint: skip-file +# type: ignore +""" +Fix missing anchors from timestamp and date nodes. + +Source: https://sourceforge.net/p/ruamel-yaml/tickets/440/ +Copyright 2022 Anthon van der Neut +""" +import ruamel.yaml +Anchor = ruamel.yaml.anchor.Anchor +TimeStamp = ruamel.yaml.timestamp.TimeStamp + +from typing import Any, Dict, Union # NOQA +import datetime +import copy + + +if not hasattr(TimeStamp, 'anchor'): + + class AnchoredTimeStamp(TimeStamp): + """Extend TimeStamp to track YAML Anchors.""" + + def __init__(self, *args: Any, **kw: Any) -> None: + """Initialize a new instance.""" + self._yaml: Dict[Any, Any] = dict(t=False, tz=None, delta=0) + + def __new__(cls, *args: Any, **kw: Any) -> Any: # datetime is immutable + """Create a new, immutable instance.""" + anchor = kw.pop('anchor', None) + ts = TimeStamp.__new__(cls, *args, **kw) + if anchor is not None: + ts.yaml_set_anchor(anchor, always_dump=True) + return ts + + def __deepcopy__(self, memo: Any) -> Any: + """Deeply copy this instance to another.""" + ts = AnchoredTimeStamp(self.year, self.month, self.day, self.hour, self.minute, self.second) + ts._yaml = copy.deepcopy(self._yaml) + return ts + + @property + def anchor(self) -> Any: + """Access the YAML Anchor.""" + if not hasattr(self, Anchor.attrib): + setattr(self, Anchor.attrib, Anchor()) + return getattr(self, Anchor.attrib) + + def yaml_anchor(self, any: bool = False) -> Any: + """Get the YAML Anchor.""" + if not hasattr(self, Anchor.attrib): + return None + if any or self.anchor.always_dump: + return self.anchor + return None + + def yaml_set_anchor(self, value: Any, always_dump: bool = False) -> None: + """Set the YAML Anchor.""" + self.anchor.value = value + self.anchor.always_dump = always_dump + + + class AnchoredDate(AnchoredTimeStamp): + """Define AnchoredDate.""" + + pass + + + def construct_anchored_timestamp( + self, node: Any, values: Any = None + ) -> Union[AnchoredTimeStamp, AnchoredDate]: + """Construct an AnchoredTimeStamp.""" + try: + match = self.timestamp_regexp.match(node.value) + except TypeError: + match = None + if match is None: + raise ConstructorError( + None, + None, + f'failed to construct timestamp from "{node.value}"', + node.start_mark, + ) + values = match.groupdict() + dd = ruamel.yaml.util.create_timestamp(**values) # this has delta applied + delta = None + if values['tz_sign']: + tz_hour = int(values['tz_hour']) + minutes = values['tz_minute'] + tz_minute = int(minutes) if minutes else 0 + delta = datetime.timedelta(hours=tz_hour, minutes=tz_minute) + if values['tz_sign'] == '-': + delta = -delta + if isinstance(dd, datetime.datetime): + data = AnchoredTimeStamp( + dd.year, dd.month, dd.day, dd.hour, dd.minute, dd.second, dd.microsecond, anchor=node.anchor + ) + else: + data = AnchoredDate(dd.year, dd.month, dd.day, 0, 0, 0, 0, anchor=node.anchor) + return data + if delta: + data._yaml['delta'] = delta + tz = values['tz_sign'] + values['tz_hour'] + if values['tz_minute']: + tz += ':' + values['tz_minute'] + data._yaml['tz'] = tz + else: + if values['tz']: # no delta + data._yaml['tz'] = values['tz'] + if values['t']: + data._yaml['t'] = True + return data + + ruamel.yaml.constructor.RoundTripConstructor.add_constructor('tag:yaml.org,2002:timestamp', construct_anchored_timestamp) + + def represent_anchored_timestamp(self, data: Any): + """Render an AnchoredTimeStamp.""" + try: + anchor = data.yaml_anchor() + except AttributeError: + anchor = None + inter = 'T' if data._yaml['t'] else ' ' + _yaml = data._yaml + if _yaml['delta']: + data += _yaml['delta'] + if isinstance(data, AnchoredDate): + value = data.date().isoformat() + else: + value = data.isoformat(inter) + if _yaml['tz']: + value += _yaml['tz'] + return self.represent_scalar('tag:yaml.org,2002:timestamp', value, anchor=anchor) + + ruamel.yaml.representer.RoundTripRepresenter.add_representer(AnchoredTimeStamp, represent_anchored_timestamp) + ruamel.yaml.representer.RoundTripRepresenter.add_representer(AnchoredDate, represent_anchored_timestamp) diff --git a/yamlpath/wrappers/consoleprinter.py b/yamlpath/wrappers/consoleprinter.py index a1d89d2f..31cd1794 100644 --- a/yamlpath/wrappers/consoleprinter.py +++ b/yamlpath/wrappers/consoleprinter.py @@ -23,8 +23,22 @@ CommentedSet, TaggedScalar ) +# pylint: disable=wrong-import-position,ungrouped-imports +from ruamel.yaml import version_info as ryversion +if ryversion < (0, 17, 22): # pragma: no cover + from yamlpath.patches.timestamp import ( + AnchoredTimeStamp, + AnchoredDate, + ) # type: ignore +else: # pragma: no cover + # Temporarily fool MYPY into resolving the future-case imports + from ruamel.yaml.timestamp import TimeStamp as AnchoredTimeStamp + AnchoredDate = AnchoredTimeStamp + #from ruamel.yaml.timestamp import AnchoredTimeStamp + # From whence shall come AnchoredDate? from yamlpath.wrappers.nodecoords import NodeCoords +# pylint: enable=wrong-import-position,ungrouped-imports class ConsolePrinter: @@ -287,7 +301,18 @@ def _debug_scalar(data: Any, **kwargs) -> str: dtype += ",folded@{}".format(data.fold_pos) print_prefix += anchor_prefix - print_line = str(data).replace("\n", "\n{}".format(print_prefix)) + + if isinstance(data, AnchoredDate): + print_line = data.date().isoformat() + elif isinstance(data, AnchoredTimeStamp): + # Import loop occurs when this import is moved to the top because + # NodeCoords uses Nodes which uses NodeCoords + #pylint: disable=import-outside-toplevel + from yamlpath.common.nodes import Nodes + print_line = Nodes.get_timestamp_with_tzinfo(data).isoformat() + else: + print_line = str(data).replace("\n", "\n{}".format(print_prefix)) + return ConsolePrinter._debug_prefix_lines( "{}{}{}".format(print_prefix, print_line, dtype))