From ce2707a4df7abea3b8adc18ad27801156c574594 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:30:59 +0200 Subject: [PATCH 1/2] Support datetime, date, time, re.Pattern and Fraction types --- CHANGELOG.rst | 3 ++ DOCUMENTATION.rst | 11 ++--- jsonargparse/typing.py | 29 +++++++++++++ jsonargparse_tests/test_typehints.py | 15 ++----- jsonargparse_tests/test_typing.py | 61 ++++++++++++++++++++++++---- 5 files changed, 96 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 81288824..d0187fbf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -60,6 +60,9 @@ Added ``JSONARGPARSE_PARSER_MODE`` environment variable, to change the mode of parsers not given ``parser_mode`` (`#997 `__). +- Support for ``datetime``, ``date`` and ``time`` (ISO 8601), ``re.Pattern`` + and ``Fraction`` types (`#1002 + `__). Fixed ^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 6e6db979..0b8b8ebe 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -456,7 +456,8 @@ Types can be nested with any complexity. Notes about the support: :ref:`list-append`), ``Deque``, ``Iterable``, ``Sequence``, ``MutableSequence``, ``Collection``, ``Container``, ``Reversible``, ``Any``/``object``, ``Union``/``Optional`` (see :ref:`union-types`), - ``Literal``, ``Type``, ``Enum``, ``PathLike``, ``UUID``, ``timedelta``, the + ``Literal``, ``Type``, ``Enum``, ``PathLike``, ``UUID``, ``Fraction``, + ``re.Pattern``, ``datetime``/``date``/``time`` (ISO 8601), ``timedelta``, the restricted types of :ref:`restricted-numbers` and :ref:`restricted-strings`, and the path and URL types of :ref:`parsing-paths` and :ref:`parsing-urls`. @@ -1204,7 +1205,7 @@ representation back, only the class is needed. This is how ``jsonargparse.typing`` registers complex numbers, ``register_type(complex)``, which is the same as ``register_type(complex, serializer=str, deserializer=complex)``. Other classes need a serializer and/or a deserializer, -for example ``datetime``: +for example ``datetime`` with a format other than the default ISO 8601: .. testcode:: @@ -1214,18 +1215,18 @@ for example ``datetime``: def serializer(v): - return v.isoformat() + return v.strftime("%d/%m/%Y %H:%M") def deserializer(v): - return datetime.strptime(v, "%Y-%m-%dT%H:%M:%S") + return datetime.strptime(v, "%d/%m/%Y %H:%M") register_type(datetime, serializer, deserializer) parser = ArgumentParser() parser.add_argument("--datetime", type=datetime) - parser.parse_args(["--datetime=2008-09-03T20:56:35"]) + parser.parse_args(["--datetime=03/09/2008 20:56"]) Registering an already registered type replaces the previous one, jsonargparse's own registrations included. A debug log names the module of each, useful when diff --git a/jsonargparse/typing.py b/jsonargparse/typing.py index 1be58fbb..f3fe2592 100644 --- a/jsonargparse/typing.py +++ b/jsonargparse/typing.py @@ -612,6 +612,35 @@ def raise_error(): register_type_on_first_use("datetime.timedelta", deserializer=timedelta_deserializer) +def isoformat_serializer(value) -> str: + return value.isoformat() + + +def fromisoformat_deserializer(class_name: str) -> Callable: + def deserializer(value): + import datetime + + return getattr(datetime, class_name).fromisoformat(value) + + return deserializer + + +for _name in ["datetime", "date", "time"]: + register_type_on_first_use( + f"datetime.{_name}", serializer=isoformat_serializer, deserializer=fromisoformat_deserializer(_name) + ) + + +def pattern_serializer(value: re.Pattern) -> str: + return value.pattern + + +register_type_on_first_use( + "re.Pattern", serializer=pattern_serializer, deserializer=re.compile, deserializer_exceptions=(re.error, TypeError) +) +register_type_on_first_use("fractions.Fraction") + + def bytes_serializer(value: bytes | bytearray) -> str: from base64 import b64encode diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index 40000b67..6aab9e3b 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -402,20 +402,13 @@ def test_type_any_dump_registered_type(parser): # registered types serialize the same as when they are the type of the argument parser.add_argument("--path", type=Any, default=Path_fr(__file__)) parser.add_argument("--bytes", type=Any, default=b"ab") - cfg = parser.parse_args([]) - with assert_dump_warnings(serialized_as("Path_fr", "str"), serialized_as("bytes", "str")): - dump = json_or_yaml_load(parser.dump(cfg)) - assert dump == {"path": __file__, "bytes": "YWI="} - - -def test_type_any_dump_date_not_serializable(parser): - # the loaders don't parse timestamps, so a date is not a type that the config - # formats represent, even though yaml is able to write one parser.add_argument("--date", type=Any, default=date(2020, 1, 2)) cfg = parser.parse_args([]) - with assert_dump_warnings("Unable to serialize instance 2020-01-02"): + with assert_dump_warnings( + serialized_as("Path_fr", "str"), serialized_as("bytes", "str"), serialized_as("date", "str") + ): dump = json_or_yaml_load(parser.dump(cfg)) - assert dump == {"date": "Unable to serialize instance 2020-01-02"} + assert dump == {"path": __file__, "bytes": "YWI=", "date": "2020-01-02"} def test_type_any_dump_not_round_trippable_warns(parser): diff --git a/jsonargparse_tests/test_typing.py b/jsonargparse_tests/test_typing.py index f20dc15b..6898e974 100644 --- a/jsonargparse_tests/test_typing.py +++ b/jsonargparse_tests/test_typing.py @@ -4,10 +4,12 @@ import os import pickle import random +import re import sys import uuid -from datetime import datetime, timedelta +from datetime import date, datetime, time, timedelta from decimal import Decimal +from fractions import Fraction from random import Random from typing import List, Mapping, Optional, TypeVar, Union from unittest.mock import patch @@ -382,19 +384,19 @@ def __bool__(self): assert parser.dump(cfg, format="json_compact") == '{"elems":[1,2,3]}' -def test_register_type_datetime(parser): +def test_register_type_datetime(parser, restore_registrations): def serializer(v): - return v.isoformat() + return v.strftime("%d/%m/%Y %H:%M") def deserializer(v): - return datetime.strptime(v, "%Y-%m-%dT%H:%M:%S") + return datetime.strptime(v, "%d/%m/%Y %H:%M") register_type(datetime, serializer, deserializer) parser.add_argument("--datetime", type=datetime) - cfg = parser.parse_args(["--datetime=2008-09-03T20:56:35"]) - assert cfg.datetime == datetime(2008, 9, 3, 20, 56, 35) - assert json_or_yaml_load(parser.dump(cfg)) == {"datetime": "2008-09-03T20:56:35"} + cfg = parser.parse_args(["--datetime=03/09/2008 20:56"]) + assert cfg.datetime == datetime(2008, 9, 3, 20, 56) + assert json_or_yaml_load(parser.dump(cfg)) == {"datetime": "03/09/2008 20:56"} register_type(datetime, serializer, deserializer) # identical re-registering is okay @@ -560,6 +562,51 @@ def test_uuid(parser): assert {"uuid": str(id1), "uuids": [str(id1), str(id2)]} == json_or_yaml_load(parser.dump(cfg)) +@pytest.mark.parametrize( + ["type_", "value", "expected"], + [ + (datetime, "2008-09-03T20:56:35", datetime(2008, 9, 3, 20, 56, 35)), + (date, "2008-09-03", date(2008, 9, 3)), + (time, "20:56:35", time(20, 56, 35)), + ], +) +def test_datetime_types(parser, type_, value, expected): + parser.add_argument("--value", type=type_) + cfg = parser.parse_args([f"--value={value}"]) + assert cfg.value == expected + assert type(cfg.value) is type_ + assert json_or_yaml_load(parser.dump(cfg)) == {"value": value} + with pytest.raises(ArgumentError, match="--value"): + parser.parse_args(["--value=not a date"]) + + +def test_datetime_rejects_date(parser): + parser.add_argument("--date", type=date) + with pytest.raises(ArgumentError, match="--date"): + parser.parse_args(["--date=2008-09-03T20:56:35"]) + + +def test_re_pattern(parser): + parser.add_argument("--pattern", type=re.Pattern) + parser.add_argument("--patterns", type=List[re.Pattern[str]]) + cfg = parser.parse_args([r"--pattern=^\d+$", r'--patterns=["a.c", "x*"]']) + assert cfg.pattern == re.compile(r"^\d+$") + assert cfg.patterns == [re.compile("a.c"), re.compile("x*")] + assert json_or_yaml_load(parser.dump(cfg)) == {"pattern": r"^\d+$", "patterns": ["a.c", "x*"]} + with pytest.raises(ArgumentError, match="--pattern"): + parser.parse_args(["--pattern=("]) + + +def test_fraction(parser): + parser.add_argument("--fraction", type=Fraction) + cfg = parser.parse_args(["--fraction=1/3"]) + assert cfg.fraction == Fraction(1, 3) + assert json_or_yaml_load(parser.dump(cfg)) == {"fraction": "1/3"} + assert parser.parse_args(["--fraction=0.1"]).fraction == Fraction(1, 10) + with pytest.raises(ArgumentError, match="--fraction"): + parser.parse_args(["--fraction=one third"]) + + def test_secret_str_methods(): value = SecretStr("secret") assert len(value) == 6 From aa347b28b99e814ab20c7f5173d4a41c8e5004ac Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:10:25 +0200 Subject: [PATCH 2/2] Address comments --- CHANGELOG.rst | 2 +- DOCUMENTATION.rst | 7 ++++--- jsonargparse/typing.py | 24 ++++++++++++++++++++++-- jsonargparse_tests/test_typing.py | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d0187fbf..2d208e0c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -61,7 +61,7 @@ Added parsers not given ``parser_mode`` (`#997 `__). - Support for ``datetime``, ``date`` and ``time`` (ISO 8601), ``re.Pattern`` - and ``Fraction`` types (`#1002 + (str only) and ``Fraction`` types (`#1002 `__). Fixed diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 0b8b8ebe..113d32cc 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -457,9 +457,10 @@ Types can be nested with any complexity. Notes about the support: ``MutableSequence``, ``Collection``, ``Container``, ``Reversible``, ``Any``/``object``, ``Union``/``Optional`` (see :ref:`union-types`), ``Literal``, ``Type``, ``Enum``, ``PathLike``, ``UUID``, ``Fraction``, - ``re.Pattern``, ``datetime``/``date``/``time`` (ISO 8601), ``timedelta``, the - restricted types of :ref:`restricted-numbers` and :ref:`restricted-strings`, - and the path and URL types of :ref:`parsing-paths` and :ref:`parsing-urls`. + ``re.Pattern`` (str only), ``datetime``/``date``/``time`` (ISO 8601), + ``timedelta``, the restricted types of :ref:`restricted-numbers` and + :ref:`restricted-strings`, and the path and URL types of :ref:`parsing-paths` + and :ref:`parsing-urls`. - ``dict``, ``Mapping``, ``MutableMapping``, ``MappingProxyType``, ``OrderedDict`` and ``TypedDict`` are supported, but only with ``str`` or diff --git a/jsonargparse/typing.py b/jsonargparse/typing.py index f3fe2592..5989c13b 100644 --- a/jsonargparse/typing.py +++ b/jsonargparse/typing.py @@ -631,12 +631,32 @@ def deserializer(value): ) +pattern_inline_flags = {re.ASCII: "a", re.IGNORECASE: "i", re.MULTILINE: "m", re.DOTALL: "s", re.VERBOSE: "x"} + + def pattern_serializer(value: re.Pattern) -> str: - return value.pattern + """Pattern text, with flags given at compile time prepended as inline flags.""" + missing_flags = value.flags & ~re.compile(value.pattern).flags + inline_flags = "".join(c for f, c in pattern_inline_flags.items() if missing_flags & f) + return f"(?{inline_flags}){value.pattern}" if inline_flags else value.pattern + + +def pattern_deserializer(value: str) -> re.Pattern: + if not isinstance(value, str): + raise TypeError(f"Expected a str pattern, got {value!r}") + return re.compile(value) + + +def is_str_pattern(value, class_type) -> bool: + return isinstance(value, class_type) and isinstance(value.pattern, str) register_type_on_first_use( - "re.Pattern", serializer=pattern_serializer, deserializer=re.compile, deserializer_exceptions=(re.error, TypeError) + "re.Pattern", + serializer=pattern_serializer, + deserializer=pattern_deserializer, + deserializer_exceptions=(re.error, TypeError), + type_check=is_str_pattern, ) register_type_on_first_use("fractions.Fraction") diff --git a/jsonargparse_tests/test_typing.py b/jsonargparse_tests/test_typing.py index 6898e974..3171f063 100644 --- a/jsonargparse_tests/test_typing.py +++ b/jsonargparse_tests/test_typing.py @@ -597,6 +597,21 @@ def test_re_pattern(parser): parser.parse_args(["--pattern=("]) +def test_re_pattern_flags(parser): + parser.add_argument("--pattern", type=re.Pattern, default=re.compile("(?x) a b", re.IGNORECASE | re.MULTILINE)) + cfg = parser.parse_args([]) + dump = json_or_yaml_load(parser.dump(cfg)) + assert dump == {"pattern": "(?im)(?x) a b"} + cfg = parser.parse_args([f"--pattern={dump['pattern']}"]) + assert cfg.pattern.flags == re.compile("(?x) a b", re.IGNORECASE | re.MULTILINE).flags + + +def test_re_pattern_bytes_not_supported(parser): + parser.add_argument("--pattern", type=re.Pattern) + with pytest.raises(ArgumentError, match="Expected a str pattern"): + parser.parse_object({"pattern": re.compile(b"a")}) + + def test_fraction(parser): parser.add_argument("--fraction", type=Fraction) cfg = parser.parse_args(["--fraction=1/3"])