diff --git a/README.md b/README.md index 34133d4..e9de497 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ str_name = ${?HOME} dash-to-underscore = true float_num = 2.2 iso_datetime = "2000-01-01T20:00:00" +iso_duration = "P123DT4H5M6S" # this is a comment list_data = [ a @@ -87,6 +88,7 @@ class Config: dash_to_underscore: bool float_num: float iso_datetime: datetime + iso_duration: timedelta list_data: List[Text] nested: Nested nested_list: List[Nested] diff --git a/confs/readme.hocon b/confs/readme.hocon index a6a03db..616b077 100644 --- a/confs/readme.hocon +++ b/confs/readme.hocon @@ -3,6 +3,7 @@ str_name = ${?HOME} dash-to-underscore = true float_num = 2.2 iso_datetime = "2000-01-01T20:00:00" +iso_duration = "P123DT4H5M6S" # this is a comment list_data = [ a diff --git a/dataconf/utils.py b/dataconf/utils.py index 89ee85d..833357a 100644 --- a/dataconf/utils.py +++ b/dataconf/utils.py @@ -3,6 +3,7 @@ from dataclasses import fields from dataclasses import is_dataclass from datetime import datetime +from datetime import timedelta from enum import Enum from enum import IntEnum from inspect import isclass @@ -28,6 +29,8 @@ from pyhocon import ConfigFactory from pyhocon.config_tree import ConfigList from pyhocon.config_tree import ConfigTree +from isodate import parse_duration +from isodate import Duration import pyparsing from dataconf.version import PY310up @@ -220,6 +223,20 @@ def __parse(value: any, clazz: Type, path: str, strict: bool, ignore_unexpected: f"expected type {clazz} at {path}, cannot parse due to {e}" ) + if clazz is timedelta: + dt = __parse_type(value, clazz, path, isinstance(value, str)) + try: + duration = parse_duration(dt) + if isinstance(duration, Duration): + raise ParseException( + "The ISO 8601 duration provided can not contain years or months" + ) + return duration + except ValueError as e: + raise ParseException( + f"expected type {clazz} at {path}, cannot parse due to {e}" + ) + if clazz is relativedelta: return __parse_type(value, clazz, path, isinstance(value, relativedelta)) diff --git a/poetry.lock b/poetry.lock index 1b3d1b4..8e94500 100644 --- a/poetry.lock +++ b/poetry.lock @@ -255,6 +255,20 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isodate" +version = "0.6.1" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = false +python-versions = "*" +files = [ + {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"}, + {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "jinja2" version = "3.1.3" diff --git a/pyproject.toml b/pyproject.toml index 72bbbcd..0217152 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ pyhocon = "0.3.59" python-dateutil = "^2.8.2" PyYAML = "^6.0.1" pyparsing = "2.4.7" +isodate = "^0.6.1" [tool.poetry.group.dev.dependencies] pytest = ">=7.4.2,<9.0.0" diff --git a/tests/test_parse.py b/tests/test_parse.py index c9cf37e..8d8e15c 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from dataclasses import field from datetime import datetime +from datetime import timedelta from datetime import timezone from enum import Enum from enum import IntEnum @@ -303,6 +304,49 @@ class A: with pytest.raises(ParseException): assert loads(conf, A) + def test_duration(self) -> None: + @dataclass + class A: + b: timedelta + + conf = """ + b = "P123DT4H5M6S" + """ + assert loads(conf, A) == A(b=timedelta(days=123, hours=4, minutes=5, seconds=6)) + + def test_bad_duration(self) -> None: + @dataclass + class A: + b: timedelta + + conf = """ + b = "P123D4H5M6S" + """ + with pytest.raises(ParseException): + assert loads(conf, A) + + def test_unsupported_duration_with_year(self) -> None: + @dataclass + class A: + b: timedelta + + conf = """ + b = "P1Y" + """ + with pytest.raises(ParseException): + assert loads(conf, A) + + def test_unsupported_duration_with_month(self) -> None: + @dataclass + class A: + b: timedelta + + conf = """ + b = "P1M" + """ + with pytest.raises(ParseException): + assert loads(conf, A) + def test_optional_with_default(self) -> None: @dataclass class A: diff --git a/tests/test_regression.py b/tests/test_regression.py index b4b79a3..c801068 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from dataclasses import field from datetime import datetime +from datetime import timedelta import os import tempfile from typing import Dict @@ -45,6 +46,7 @@ class Config: dash_to_underscore: bool float_num: float iso_datetime: datetime + iso_duration: timedelta list_data: List[Text] nested: Nested nested_list: List[Nested] @@ -63,6 +65,7 @@ class Config: dash_to_underscore=True, float_num=2.2, iso_datetime=datetime(2000, 1, 1, 20), + iso_duration=timedelta(days=123, hours=4, minutes=5, seconds=6), list_data=["a", "b"], nested=Nested(a="test", b=1), nested_list=[Nested(a="test1", b=2.5)],