From 71be4122eac6b67047a0ad5b71d640a5e73d2a31 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:36:26 +0200 Subject: [PATCH] Load YAML values like 10:30 as strings instead of base 60 numbers --- CHANGELOG.rst | 4 ++++ DOCUMENTATION.rst | 5 +++-- jsonargparse/_loaders_dumpers.py | 15 ++++++++++++++- jsonargparse_tests/test_loaders_dumpers.py | 16 +++++++++++++++- jsonargparse_tests/test_typing.py | 12 +++++++++++- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fa29ab44..6f01f7fc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -261,6 +261,10 @@ Changed describes the accepted values, a list given for ``Hashable`` becomes a tuple, and ``Sized`` rejects subclass specs (`#1007 `__). +- YAML values that look like base 60 numbers, e.g. ``10:30``, are now loaded as + strings, as in YAML 1.2, instead of as numbers, which prevented parsing them + as ``datetime.time`` (`#1008 + `__). Removed ^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index f21bb34e..99a754f0 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -1360,8 +1360,9 @@ jsonargparse can parse configuration files (config files). The dot notation hierarchy of the arguments (see :ref:`nested-namespaces`) defines the structure expected in these files. The default ``parser_mode`` is ``json_or_yaml``, which parses as JSON and, if that fails, as YAML. YAML requires the ``yaml`` extra, -see :ref:`installation`, so without it only JSON is accepted. To change the -mode, use the ``parser_mode`` parameter of the parser, e.g. +see :ref:`installation`, so without it only JSON is accepted. As in YAML 1.2, +timestamps and base 60 numbers, e.g. ``10:30``, are loaded as strings. To change +the mode, use the ``parser_mode`` parameter of the parser, e.g. ``ArgumentParser(parser_mode="json")``. The :py:attr:`.ArgumentParser.default_config_files` property holds patterns of diff --git a/jsonargparse/_loaders_dumpers.py b/jsonargparse/_loaders_dumpers.py index 68630d11..da19039e 100644 --- a/jsonargparse/_loaders_dumpers.py +++ b/jsonargparse/_loaders_dumpers.py @@ -79,8 +79,22 @@ def remove_implicit_resolver(cls, tag_to_remove): ] remove_implicit_resolver(DefaultLoader, "tag:yaml.org,2002:timestamp") + remove_implicit_resolver(DefaultLoader, "tag:yaml.org,2002:int") remove_implicit_resolver(DefaultLoader, "tag:yaml.org,2002:float") + # Without YAML 1.1 sexagesimal (base 60) numbers, e.g. 10:30, which YAML 1.2 removed + DefaultLoader.add_implicit_resolver( + "tag:yaml.org,2002:int", + re.compile( + """^(?: + [-+]?0b[0-1_]+ + |[-+]?0[0-7_]+ + |[-+]?(?:0|[1-9][0-9_]*) + |[-+]?0x[0-9a-fA-F_]+)$""", + re.X, + ), + list("-+0123456789"), + ) DefaultLoader.add_implicit_resolver( "tag:yaml.org,2002:float", re.compile( @@ -88,7 +102,6 @@ def remove_implicit_resolver(cls, tag_to_remove): [-+]?(?:[0-9][0-9_]*)\\.[0-9_]*(?:[eE][-+]?[0-9]+)? |[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+) |\\.[0-9_]+(?:[eE][-+][0-9]+)? - |[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]* |[-+]?\\.(?:inf|Inf|INF) |\\.(?:nan|NaN|NAN))$""", re.X, diff --git a/jsonargparse_tests/test_loaders_dumpers.py b/jsonargparse_tests/test_loaders_dumpers.py index 08a718a9..76d1e54e 100644 --- a/jsonargparse_tests/test_loaders_dumpers.py +++ b/jsonargparse_tests/test_loaders_dumpers.py @@ -5,7 +5,7 @@ import sys from dataclasses import dataclass from pathlib import Path -from typing import List +from typing import List, Union from unittest.mock import patch import pytest @@ -56,6 +56,20 @@ def test_yaml_implicit_null_disabled(parser): assert "Foo:" == cfg.bar.init_args.x +@skip_if_no_pyyaml +@pytest.mark.parametrize("value", ["10:30", "-1:30:00", "10:30:00.5"]) +def test_yaml_implicit_sexagesimal_disabled(parser, value): + parser.add_argument("--val", type=Union[int, float, str]) + assert value == parser.parse_args([f"--val={value}"]).val + + +@skip_if_no_pyyaml +@pytest.mark.parametrize(["value", "expected"], [("-1_000", -1000), ("0x1f", 31), ("1.5e3", 1500.0), (".5", 0.5)]) +def test_yaml_implicit_numbers(parser, value, expected): + parser.add_argument("--val", type=Union[int, float, str]) + assert expected == parser.parse_args([f"--val={value}"]).val + + def test_invalid_parser_mode(): pytest.raises(ValueError, lambda: ArgumentParser(parser_mode="invalid")) diff --git a/jsonargparse_tests/test_typing.py b/jsonargparse_tests/test_typing.py index 3171f063..1d53ed22 100644 --- a/jsonargparse_tests/test_typing.py +++ b/jsonargparse_tests/test_typing.py @@ -43,7 +43,7 @@ restricted_number_type, restricted_string_type, ) -from jsonargparse_tests.conftest import capture_logs, get_parser_help, json_or_yaml_load +from jsonargparse_tests.conftest import capture_logs, get_parser_help, json_or_yaml_load, skip_if_no_pyyaml if sys.version_info >= (3, 12): from typing import TypeAliasType @@ -580,6 +580,16 @@ def test_datetime_types(parser, type_, value, expected): parser.parse_args(["--value=not a date"]) +@skip_if_no_pyyaml +def test_time_in_list_and_config(parser): + parser.add_argument("--times", type=List[time]) + parser.add_argument("--opt", type=Optional[time]) + cfg = parser.parse_args(["--times=[10:30, 11:00:30]"]) + assert cfg.times == [time(10, 30), time(11, 0, 30)] + cfg = parser.parse_string("opt: 10:30") + assert cfg.opt == time(10, 30) + + def test_datetime_rejects_date(parser): parser.add_argument("--date", type=date) with pytest.raises(ArgumentError, match="--date"):