Skip to content

Commit

Permalink
feat: add timedelta support (#134)
Browse files Browse the repository at this point in the history
As requested in #133, I have added support for parsing and converting an
ISO 8601 duration to a `timedelta`. Additionally, I have added automated
tests to verify the behavior and a usage example in the `README.md`.
Please feel free to let me know if I failed to add tests somewhere that
should really include some tests.
  • Loading branch information
lannuttia authored Feb 1, 2024
1 parent 4017db2 commit fec2d16
Show file tree
Hide file tree
Showing 7 changed files with 82 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions confs/readme.hocon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions dataconf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))

Expand Down
14 changes: 14 additions & 0 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 44 additions & 0 deletions tests/test_parse.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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)],
Expand Down

0 comments on commit fec2d16

Please sign in to comment.