Skip to content

Commit

Permalink
Scala sealed trait ability (mostly) in dataconf using dataclass (#10)
Browse files Browse the repository at this point in the history
* added the ability to read nested dataclass configs replicating Scala sealed trait case class behavior with pureconfig

* removed print statment

* updated README to demostrate sealed traits usage with dataconf and dataclasses

* created version.py for __version__ which will populate from the library toml; incremented to 0.1.6

* called InputType and added return to sealed_trait

* added a second method to test for nested dataclass

* cleaned up fixed todo for the sealed trait mock testing

* pre-commit doesnt __version__ is there a way to override this?

* I see pre-commit looks at __all__; not really familiar with this library

* fixed relative to abs import in test, removed old comment, removed list comp from the for loop arg break down the for loop resulting in 0.4-0.35 test times to 0.25

* updated the readme example to parallel the test

* need to grab string name __name__ for getattr to use the new for loop

* abstract input class with dataclass override

* added @zifeo suggestion

* updated README

* removed conf assignment
  • Loading branch information
dwsmith1983 committed Sep 20, 2021
1 parent 18e350a commit 1a5257a
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 2 deletions.
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,50 @@ class Config:

print(dataconf.load(conf, Config))
# TestConf(test='pc.home', float=2.1, default='hello', list=['a', 'b'], nested=Nested(a='test'), nested_list=[Nested(a='test1')], duration=relativedelta(seconds=+2), default_factory={}, union=1)

# Replicating pureconfig Scala sealed trait case class behavior
# https://pureconfig.github.io/docs/overriding-behavior-for-sealed-families.html
class InputType:
"""
Abstract base class
"""
pass


@dataclass(init=True, repr=True)
class StringImpl(InputType):
name: Text
age: Text

def test_method(self):
print(f"{self.name} is {self.age} years old.")


@dataclass(init=True, repr=True)
class IntImpl(InputType):
area_code: int
phone_num: Text

def test_method(self):
print(f"The area code for {self.phone_num} is {str(self.area_code)}")


@dataclass
class Base:
location: Text
input_source: InputType

str_conf = """
{
location: Europe
input_source {
name: Thailand
age: "12"
}
}
"""

conf = dataconf.loads(str_conf, Base)
```

```python
Expand Down
3 changes: 2 additions & 1 deletion dataconf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
from dataconf.utils import dumps
from dataconf.utils import load
from dataconf.utils import loads
from dataconf.version import __version__

__all__ = ["load", "loads", "dump", "dumps"]
__all__ = ["load", "loads", "dump", "dumps", "__version__"]
20 changes: 20 additions & 0 deletions dataconf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ def __parse(value: any, clazz, path):
if clazz is ConfigTree:
return __parse_type(value, clazz, path, isinstance(value, ConfigTree))

# Todo: this should be cleaner
# iterates through class dict to check for subclasses
# if subclasses are a dataclass parse values
# the idea here is to replicate parsing of a sealed trait in Scala
# when using pureconfig
child_failures = []
for child_clazz in sorted(clazz.__subclasses__(), key=lambda c: c.__name__):
if is_dataclass(child_clazz):
try:
return __parse(value, child_clazz, path)
except TypeConfigException as f:
child_failures.append(str(f))

# no need to check length; false if empty
if child_failures:
fails = "\n- ".join(child_failures)
raise TypeConfigException(
f"expected type {clazz} at {path}, failed subclasses:{fails}"
)

raise TypeConfigException(f"expected type {clazz} at {path}, got {type(value)}")


Expand Down
3 changes: 3 additions & 0 deletions dataconf/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import importlib.metadata

__version__ = importlib.metadata.version("dataconf")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "dataconf"
version = "0.1.5"
version = "0.1.6"
description = "Lightweight configuration with automatic dataclasses parsing (HOCON/JSON/YAML/PROPERTIES)"
authors = []
license = "Apache2"
Expand Down
30 changes: 30 additions & 0 deletions tests/scala_sealed_trait.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from dataclasses import dataclass
from typing import Text


class InputType:
pass


@dataclass(init=True, repr=True)
class StringImpl(InputType):
name: Text
age: Text

def test_method(self):
return f"{self.name} is {self.age} years old."

def test_complex(self):
return int(self.age) * 3


@dataclass(init=True, repr=True)
class IntImpl(InputType):
area_code: int
phone_num: Text

def test_method(self):
return f"The area code for {self.phone_num} is {str(self.area_code)}"

def test_complex(self):
return self.area_code - 10
78 changes: 78 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@

from dataconf import load
from dataconf import loads
import dataconf.exceptions
from dataconf.exceptions import MissingTypeException
from dataconf.exceptions import UnexpectedKeysException
from dateutil.relativedelta import relativedelta
import pytest
from tests.scala_sealed_trait import InputType
from tests.scala_sealed_trait import IntImpl
from tests.scala_sealed_trait import StringImpl


PARENT_DIR = os.path.normpath(
Expand Down Expand Up @@ -241,3 +245,77 @@ class Base:
production=True,
conn=Conn(host="test.server.io", port=443),
)

def test_traits_string_impl(self) -> None:
@dataclass
class Base:
location: Text
input_source: InputType

str_conf = """
{
location: Europe
input_source {
name: Thailand
age: "12"
}
}
"""

conf = loads(str_conf, Base)
assert conf == Base(
location="Europe",
input_source=StringImpl(name="Thailand", age="12"),
)
assert conf.input_source.test_method() == "Thailand is 12 years old."
assert conf.input_source.test_complex() == 36

def test_traits_int_impl(self) -> None:
@dataclass
class Base:
location: Text
input_source: InputType

str_conf = """
{
location: Europe
input_source {
area_code: 94
phone_num: "1234567"
}
}
"""

conf = loads(str_conf, Base)
assert conf == Base(
location="Europe",
input_source=IntImpl(area_code=94, phone_num="1234567"),
)
assert conf.input_source.test_method() == "The area code for 1234567 is 94"
assert conf.input_source.test_complex() == 84

def test_traits_failure(self) -> None:
@dataclass
class Base:
location: Text
input_source: InputType

str_conf = """
{
location: Europe
input_source {
name: Thailand
age: "12"
city: Paris
}
}
"""

with pytest.raises(Exception) as e:
loads(str_conf, Base)

assert e.type == dataconf.exceptions.UnexpectedKeysException
assert (
e.value.args[0] == "unexpected keys city detected for type <class "
"'tests.scala_sealed_trait.StringImpl'> at .input_source"
)

0 comments on commit 1a5257a

Please sign in to comment.