Skip to content

Commit

Permalink
feat: support .ini files and fix convert_types for recursion
Browse files Browse the repository at this point in the history
  • Loading branch information
robinvandernoord committed Jul 3, 2023
1 parent cb65060 commit 9333029
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 21 deletions.
18 changes: 18 additions & 0 deletions pytest_examples/config.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# https://docs.python.org/3/library/configparser.html

[default]
string = "value"
number = 45
BOOLEAN = yes
other_boolean = true

[topsecret.server.example]
Port = 50022
ForwardX11 = no
nothing = null

[section with spaces]
# comment
key with spaces = value with spaces
empty =
with colon : as seperator
35 changes: 27 additions & 8 deletions src/configuraptor/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def __load_data(
if isinstance(data, Path):
with data.open("rb") as f:
loader = loaders.get(data.suffix or data.name)
data = loader(f)
data = loader(f, data.resolve())

if not data:
return {}
Expand Down Expand Up @@ -158,12 +158,25 @@ def str_to_bool(value: str) -> bool:
raise ValueError("Not booly.")


def str_to_none(value: str) -> typing.Optional[str]:
"""
Convert a string value of null/none to None, or keep the original string otherwise.
"""
if value.lower() in {"", "null", "none"}:
return None
else:
return value


def convert_between(from_value: F, from_type: typing.Type[F], to_type: type[T]) -> T:
"""
Convert a value between types.
"""
if from_type is str and to_type is bool:
return str_to_bool(from_value) # type: ignore
if from_type is str:
if to_type is bool:
return str_to_bool(from_value) # type: ignore
elif to_type is None or to_type is types.NoneType: # noqa: E721
return str_to_none(from_value) # type: ignore
# default: just convert type:
return to_type(from_value) # type: ignore

Expand Down Expand Up @@ -318,7 +331,9 @@ def dataclass_field(cls: Type, key: str) -> typing.Optional[dc.Field[typing.Any]
return fields.get(key)


def load_recursive(cls: Type, data: dict[str, T], annotations: dict[str, Type]) -> dict[str, T]:
def load_recursive(
cls: Type, data: dict[str, T], annotations: dict[str, Type], convert_types: bool = False
) -> dict[str, T]:
"""
For all annotations (recursively gathered from parents with `all_annotations`), \
try to resolve the tree of annotations.
Expand Down Expand Up @@ -359,19 +374,22 @@ class Second:
arguments = typing.get_args(_type)
if origin is list and arguments and is_custom_class(arguments[0]):
subtype = arguments[0]
value = [_load_into_recurse(subtype, subvalue) for subvalue in value]
value = [_load_into_recurse(subtype, subvalue, convert_types=convert_types) for subvalue in value]

elif origin is dict and arguments and is_custom_class(arguments[1]):
# e.g. dict[str, Point]
subkeytype, subvaluetype = arguments
# subkey(type) is not a custom class, so don't try to convert it:
value = {subkey: _load_into_recurse(subvaluetype, subvalue) for subkey, subvalue in value.items()}
value = {
subkey: _load_into_recurse(subvaluetype, subvalue, convert_types=convert_types)
for subkey, subvalue in value.items()
}
# elif origin is dict:
# keep data the same
elif origin is typing.Union and arguments:
for arg in arguments:
if is_custom_class(arg):
value = _load_into_recurse(arg, value)
value = _load_into_recurse(arg, value, convert_types=convert_types)
else:
# print(_type, arg, value)
...
Expand All @@ -385,6 +403,7 @@ class Second:
# actually just passing _type as first arg!
typing.cast(Type_C[typing.Any], _type),
value,
convert_types=convert_types,
)

elif _key in cls.__dict__:
Expand Down Expand Up @@ -443,7 +462,7 @@ def check_and_convert_data(
annotations = all_annotations(cls, _except=_except)

to_load = convert_config(data)
to_load = load_recursive(cls, to_load, annotations)
to_load = load_recursive(cls, to_load, annotations, convert_types=convert_types)
if strict:
to_load = ensure_types(to_load, annotations, convert_types=convert_types)

Expand Down
8 changes: 5 additions & 3 deletions src/configuraptor/loaders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@
"""

import typing
from pathlib import Path

from ._types import T_config

# tomli used for every Python version now.
from .loaders_shared import dotenv, json, toml, yaml
from .loaders_shared import dotenv, ini, json, toml, yaml

__all__ = ["get", "toml", "json", "yaml", "dotenv"]
__all__ = ["get", "toml", "json", "yaml", "dotenv", "ini"]

T_loader = typing.Callable[[typing.BinaryIO], T_config]
T_loader = typing.Callable[[typing.BinaryIO, Path], T_config]

LOADERS: dict[str, T_loader] = {
"toml": toml,
"json": json,
"yml": yaml,
"yaml": yaml,
"env": dotenv,
"ini": ini,
}


Expand Down
56 changes: 48 additions & 8 deletions src/configuraptor/loaders/loaders_shared.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""
File loaders that work regardless of Python version.
"""
import io
import configparser
import json as json_lib
import typing
from collections import defaultdict
from pathlib import Path
from typing import BinaryIO

import tomli
Expand All @@ -12,35 +15,72 @@
from ._types import T_config, as_tconfig


def json(f: BinaryIO) -> T_config:
def json(f: BinaryIO, _: typing.Optional[Path]) -> T_config:
"""
Load a JSON file.
"""
data = json_lib.load(f)
return as_tconfig(data)


def yaml(f: BinaryIO) -> T_config:
def yaml(f: BinaryIO, _: typing.Optional[Path]) -> T_config:
"""
Load a YAML file.
"""
data = yaml_lib.load(f, yaml_lib.SafeLoader)
return as_tconfig(data)


def toml(f: BinaryIO) -> T_config:
def toml(f: BinaryIO, _: typing.Optional[Path]) -> T_config:
"""
Load a toml file.
"""
data = tomli.load(f)
return as_tconfig(data)


def dotenv(f: BinaryIO) -> T_config:
def dotenv(_: typing.Optional[BinaryIO], fullpath: Path) -> T_config:
"""
Load a toml file.
"""
_bytes = f.read()
text = _bytes.decode()
data = dotenv_values(stream=io.StringIO(text))
data = dotenv_values(fullpath)
return as_tconfig(data)


def _convert_key(key: str) -> str:
return key.replace(" ", "_").replace("-", "_")


def _convert_value(value: str) -> str:
if value.startswith('"') and value.endswith('"'):
value = value.removeprefix('"').removesuffix('"')
return value


RecursiveDict = dict[str, typing.Union[str, "RecursiveDict"]]


def ini(_: typing.Optional[BinaryIO], fullpath: Path) -> T_config:
"""
Load an ini file.
"""
config = configparser.ConfigParser()
config.read(fullpath)

final_data: defaultdict[str, RecursiveDict] = defaultdict(dict)
for section in config.sections():
data: RecursiveDict = {_convert_key(k): _convert_value(v) for k, v in dict(config[section]).items()}
section = _convert_key(section)
if "." in section:
_section = _current = {} # type: ignore
for part in section.split("."):
_current[part] = _current.get(part) or {}
_current = _current[part]

# nested structure is set up, now load the right data into it:
_current |= data
final_data |= _section
else:
final_data[section] = data

return as_tconfig(dict(final_data))
2 changes: 1 addition & 1 deletion tests/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@

def _load_toml():
with EXAMPLE_FILE.open("rb") as f:
return loaders.toml(f)
return loaders.toml(f, None)
8 changes: 7 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest

from src.configuraptor import all_annotations
from src.configuraptor.core import is_optional
from src.configuraptor.core import is_optional, str_to_none
from tests.constants import EXAMPLE_FILE


Expand Down Expand Up @@ -53,3 +53,9 @@ def test_no_data():
configuraptor.core._load_data({"-": 0, "+": None}, key="+", classname="-.+")
configuraptor.core._load_data({"-": 0, "+": None}, key="", classname="-.+")
configuraptor.core._load_data({"-": 0, "+": None}, key=None, classname="-.+")


def test_str_to_none():
assert str_to_none("null") == str_to_none("none") == str_to_none("None") == str_to_none("") == None

assert str_to_none("yeet") != None
58 changes: 58 additions & 0 deletions tests/test_inifile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import typing

from src import configuraptor

from .constants import PYTEST_EXAMPLES

INI = PYTEST_EXAMPLES / "config.ini"


class Default:
string: str
number: int
boolean: bool
other_boolean: bool


class Example:
port: int
forwardx11: bool
nothing: None


class Server:
example: Example


class TopSecret:
server: Server


class WithSpaces:
key_with_spaces: str
empty: str
with_colon: str


class MyConfig:
default: Default
topsecret: TopSecret
section_with_spaces: WithSpaces


def test_basic_ini():
my_config = configuraptor.load_into(MyConfig, INI, convert_types=True)

assert my_config
assert my_config.default.string == "value"
assert my_config.default.number == 45
assert my_config.default.boolean is True
assert my_config.default.other_boolean is True

assert my_config.topsecret.server.example.port == 50022
assert my_config.topsecret.server.example.forwardx11 is False
assert my_config.topsecret.server.example.nothing is None

assert isinstance(my_config.section_with_spaces.key_with_spaces, str)
assert my_config.section_with_spaces.empty == ""
assert my_config.section_with_spaces.with_colon == "as seperator"

0 comments on commit 9333029

Please sign in to comment.