Skip to content

Commit

Permalink
feat: add support for tuples (#152)
Browse files Browse the repository at this point in the history
This is intended to address #147. This will provide a way to create
hashable dataclasses even when they have a field that is needs to be an
iterable.
  • Loading branch information
lannuttia authored Apr 2, 2024
1 parent fd982f2 commit 1a5c567
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 22 deletions.
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ pre-commit install
```python
import os
from dataclasses import dataclass, field
from typing import List, Dict, Text, Union
from typing import List, Dict, Text, Union, Tuple
from dateutil.relativedelta import relativedelta
from datetime import datetime
from datetime import datetime, timedelta
import dataconf

conf = """
Expand All @@ -41,6 +41,15 @@ dash-to-underscore = true
float_num = 2.2
iso_datetime = "2000-01-01T20:00:00"
iso_duration = "P123DT4H5M6S"
variable_length_tuple_data = [
1
2
3
]
tuple_data = [
a
P1D
]
# this is a comment
list_data = [
a
Expand Down Expand Up @@ -89,6 +98,8 @@ class Config:
float_num: float
iso_datetime: datetime
iso_duration: timedelta
variable_length_tuple_data: Tuple[int, ...]
tuple_data: Tuple[Text, timedelta]
list_data: List[Text]
nested: Nested
nested_list: List[Nested]
Expand All @@ -101,17 +112,21 @@ class Config:

print(dataconf.string(conf, Config))
# Config(
# str_name='/users/root',
# str_name='/users/root/',
# dash_to_underscore=True,
# float_num=2.2,
# iso_datetime=datetime.datetime(2000, 1, 1, 20, 0),
# iso_duration=datetime.timedelta(days=123, seconds=14706),
# variable_length_tuple_data=(1,2,3),
# tuple_data=('a', datetime.timedelta(days=1)),
# list_data=['a', 'b'],
# nested=Nested(a='test'),
# nested=Nested(a='test', b=1),
# nested_list=[Nested(a='test1', b=2.5)],
# duration=relativedelta(seconds=+2),
# union=1,
# people=Person(name='Thailand'),
# duration=relativedelta(seconds=+2),
# union=1,
# people=Person(name='Thailand'),
# zone=Zone(area_code=42),
# default='hello',
# default='hello',
# default_factory={}
# )

Expand Down
39 changes: 32 additions & 7 deletions dataconf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,41 @@ def __parse(value: any, clazz: Type, path: str, strict: bool, ignore_unexpected:
args = get_args(clazz)

if origin is list:
if value is None:
raise MalformedConfigException(f"expected list at {path} but received None")

if len(args) != 1:
raise MissingTypeException("expected list with type information: List[?]")

if value is not None:
parse_candidate = args[0]
return [
__parse(v, parse_candidate, f"{path}[]", strict, ignore_unexpected)
for v in value
]
return None
parse_candidate = args[0]
return [
__parse(v, parse_candidate, f"{path}[]", strict, ignore_unexpected)
for v in value
]

if origin is tuple:
if value is None:
raise MalformedConfigException(
f"expected tuple at {path} but received None"
)

if len(args) < 1:
raise MissingTypeException("expected tuple with type information: Tuple[?]")

has_ellipsis = args[-1] == Ellipsis
if has_ellipsis and len(args) != 2:
raise MissingTypeException(
"expected one type since ellipsis is used: Tuple[?, ...]"
)
_args = args if not has_ellipsis else [args[0]] * len(value)
if len(value) > 0 and len(value) != len(_args):
raise MalformedConfigException(
"number of provided values does not match expected number of values for tuple."
)
return tuple(
__parse(v, arg, f"{path}[]", strict, ignore_unexpected)
for v, arg in zip(value, _args)
)

if origin is dict:
if len(args) != 2:
Expand Down
13 changes: 9 additions & 4 deletions tests/test_multi.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from dataclasses import dataclass
import os
from typing import Text
from typing import Text, Tuple
from datetime import timedelta
import urllib

from dataconf import multi
Expand All @@ -12,10 +13,14 @@ def test_simple(self) -> None:
@dataclass
class A:
a: Text
b: Tuple[Text, timedelta]
c: Tuple[int, ...]

expected = A(a="test")
assert multi.string("a = test").on(A) == expected
assert multi.dict({"a": "test"}).on(A) == expected
expected = A(a="test", b=("P1D", timedelta(days=1)), c=(1,))
assert multi.string("a = test\nb = [P1D\nP1D]\nc = [1]").on(A) == expected
assert (
multi.dict({"a": "test", "b": ("P1D", "P1D"), "c": (1,)}).on(A) == expected
)

def test_chain(self) -> None:
@dataclass
Expand Down
88 changes: 85 additions & 3 deletions tests/test_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import List
from typing import Optional
from typing import Text
from typing import Tuple
from typing import Union

import dataconf
Expand Down Expand Up @@ -102,14 +103,32 @@ class A:
def test_list(self) -> None:
@dataclass
class A:
a: List[Text]
b: List[Text]

conf = """
a = [
b = [
test
]
"""
assert loads(conf, A) == A(a=["test"])
assert loads(conf, A) == A(b=["test"])

with pytest.raises(MalformedConfigException):
loads("b = null", A)

def test_tuple(self) -> None:
@dataclass
class A:
b: Tuple[str, timedelta]

conf = """
b = [
test,
P1D
]
"""
assert loads(conf, A) == A(b=("test", timedelta(days=1)))
with pytest.raises(MalformedConfigException):
loads("b = null", A)

def test_boolean(self) -> None:
@dataclass
Expand Down Expand Up @@ -368,6 +387,39 @@ class A:
conf = ""
assert loads(conf, A) == A(b=[])

def test_empty_tuple(self) -> None:
@dataclass
class A:
b: Tuple[str, ...] = field(default_factory=tuple)

conf = ""
assert loads(conf, A) == A(b=())

def test_fixed_length_tuple(self) -> None:
@dataclass
class A:
b: Tuple[int, str, timedelta]

conf = """
{
"b": [1, "2", "P1D"]
}
"""
assert loads(conf, A) == A(b=(1, "2", timedelta(days=1)))

def test_fixed_length_mismatch(self) -> None:
@dataclass
class A:
b: Tuple[int, str, timedelta]

conf = """
{
"b": [1, "2"]
}
"""
with pytest.raises(MalformedConfigException):
loads(conf, A)

def test_json(self) -> None:
@dataclass
class A:
Expand Down Expand Up @@ -400,6 +452,9 @@ def test_missing_type(self) -> None:
with pytest.raises(MissingTypeException):
loads("", List)

with pytest.raises(MissingTypeException):
loads("", Tuple)

def test_missing_field(self) -> None:
@dataclass
class A:
Expand Down Expand Up @@ -681,6 +736,33 @@ class Base:
"""
assert loads(conf, Base).foo == [{"a": 1}, [2]]

def test_tuple_any(self) -> None:
@dataclass
class Base:
foo: Tuple[Any, ...]

conf = """
{
foo: [
1
"b"
]
}
"""
assert loads(conf, Base).foo == (1, "b")

conf = """
{
foo: [
{a: 1}
[
2
]
]
}
"""
assert loads(conf, Base).foo == ({"a": 1}, [2])

def test_yaml(self) -> None:
@dataclass
class B:
Expand Down

0 comments on commit 1a5c567

Please sign in to comment.