Skip to content

Commit

Permalink
feat(mapping): add configuraptor.Mapping and .MutableMapping to suppo…
Browse files Browse the repository at this point in the history
…rt **unpacking (but not on default TypedConfig)
robinvandernoord committed Jun 22, 2023
1 parent cd28f8c commit b4c6b9d
Showing 8 changed files with 131 additions and 18 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -63,7 +63,7 @@ build_command = "hatch build"
directory = "src"
include = []
exclude = []
stop-after-first-failure = false
stop-after-first-failure = true
coverage = 100
badge = true

4 changes: 3 additions & 1 deletion src/configuraptor/__init__.py
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@
# SPDX-FileCopyrightText: 2023-present Robin van der Noord <robinvandernoord@gmail.com>
#
# SPDX-License-Identifier: MIT
from .cls import TypedConfig, update
from .cls import TypedConfig, TypedMapping, TypedMutableMapping, update
from .core import (
all_annotations,
check_and_convert_data,
@@ -22,6 +22,8 @@
__all__ = [
# cls
"TypedConfig",
"TypedMapping",
"TypedMutableMapping",
"update",
# singleton
"Singleton",
81 changes: 72 additions & 9 deletions src/configuraptor/cls.py
Original file line number Diff line number Diff line change
@@ -3,11 +3,13 @@
"""

import typing
from collections.abc import Mapping, MutableMapping
from typing import Any, Iterator

from .core import T_data, all_annotations, check_type, load_into
from .errors import ConfigErrorExtraKey, ConfigErrorInvalidType

C = typing.TypeVar("C", bound=typing.Any)
C = typing.TypeVar("C", bound=Any)


class TypedConfig:
@@ -16,17 +18,15 @@ class TypedConfig:
"""

@classmethod
def load(
cls: typing.Type[C], data: T_data, key: str = None, init: dict[str, typing.Any] = None, strict: bool = True
) -> C:
def load(cls: typing.Type[C], data: T_data, key: str = None, init: dict[str, Any] = None, strict: bool = True) -> C:
"""
Load a class' config values from the config file.
SomeClass.load(data, ...) = load_into(SomeClass, data, ...).
"""
return load_into(cls, data, key=key, init=init, strict=strict)

def _update(self, _strict: bool = True, _allow_none: bool = False, **values: typing.Any) -> None:
def _update(self, _strict: bool = True, _allow_none: bool = False, **values: Any) -> None:
"""
Can be used if .update is overwritten with another value in the config.
"""
@@ -44,7 +44,7 @@ def _update(self, _strict: bool = True, _allow_none: bool = False, **values: typ

setattr(self, key, value)

def update(self, _strict: bool = True, _allow_none: bool = False, **values: typing.Any) -> None:
def update(self, _strict: bool = True, _allow_none: bool = False, **values: Any) -> None:
"""
Update values on this config.
@@ -65,15 +65,78 @@ def _format(self, string: str) -> str:
return string.format(**self.__dict__)


K = typing.TypeVar("K", bound=str)
V = typing.TypeVar("V", bound=Any)


class TypedMapping(TypedConfig, Mapping[K, V]):
"""
Note: this can't be used as a singleton!
"""

def __getitem__(self, key: K) -> V:
"""
Dict-notation to get attribute.
Example:
my_config[key]
"""
return typing.cast(V, self.__dict__[key])

def __len__(self) -> int:
"""
Required for Mapping.
"""
return len(self.__dict__)

def __iter__(self) -> Iterator[K]:
"""
Required for Mapping.
"""
# keys is actually a `dict_keys` but mypy doesn't need to know that
keys = typing.cast(list[K], self.__dict__.keys())
return iter(keys)


class TypedMutableMapping(TypedMapping[K, V], MutableMapping[K, V]):
"""
Note: this can't be used as a singleton!
"""

def __setitem__(self, key: str, value: V) -> None:
"""
Dict notation to set attribute.
Example:
my_config[key] = value
"""
self.update(**{key: value})

def __delitem__(self, key: K) -> None:
"""
Dict notation to delete attribute.
Example:
del my_config[key]
"""
del self.__dict__[key]

def update(self, *args: Any, **kwargs: V) -> None: # type: ignore
"""
Ensure TypedConfig.update is used en not MutableMapping.update.
"""
return TypedConfig._update(self, *args, **kwargs)


# also expose as separate function:
def update(instance: typing.Any, _strict: bool = True, _allow_none: bool = False, **values: typing.Any) -> None:
def update(self: Any, _strict: bool = True, _allow_none: bool = False, **values: Any) -> None:
"""
Update values on a config.
Args:
instance: config instance to update
self: config instance to update
_strict: allow wrong types?
_allow_none: allow None or skip those entries?
**values: key: value pairs in the right types to update.
"""
return TypedConfig._update(instance, _strict, _allow_none, **values)
return TypedConfig._update(self, _strict, _allow_none, **values)
2 changes: 2 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@
from src.configuraptor.core import is_optional
from tests.constants import EXAMPLE_FILE


def test_invalid_extension():
from src.configuraptor.loaders import get

@@ -20,6 +21,7 @@ def test_is_optional_with_weird_inputs():
assert is_optional(math.nan) is False
assert is_optional(typing.Optional[dict[str, typing.Optional[str]]]) is True


class Base:
has: int

2 changes: 1 addition & 1 deletion tests/test_dumps.py
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@
import yaml

from src.configuraptor import TypedConfig
from src.configuraptor.dump import asdict, asyaml, asjson, astoml
from src.configuraptor.dump import asdict, asjson, astoml, asyaml


class Simple(TypedConfig):
3 changes: 2 additions & 1 deletion tests/test_mypy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest
import typing

import pytest

from src.configuraptor import load_into


2 changes: 1 addition & 1 deletion tests/test_postponed.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

from src.configuraptor import TypedConfig, Singleton, postpone
from src.configuraptor import Singleton, TypedConfig, postpone
from src.configuraptor.errors import IsPostponedError


53 changes: 49 additions & 4 deletions tests/test_toml_typedconfig_class.py
Original file line number Diff line number Diff line change
@@ -5,7 +5,13 @@
import pytest

from src import configuraptor
from src.configuraptor.errors import ConfigError, ConfigErrorInvalidType, ConfigErrorExtraKey
from src.configuraptor import asdict
from src.configuraptor.errors import (
ConfigError,
ConfigErrorExtraKey,
ConfigErrorInvalidType,
)

from .constants import EMPTY_FILE, EXAMPLE_FILE, _load_toml


@@ -77,13 +83,14 @@ class SecondExtra:
allowed: bool


class Tool(configuraptor.TypedConfig):
class Tool(configuraptor.TypedMutableMapping):
first: First
fruits: list[Fruit]
second_extra: SecondExtra
third: str = "-"


class Empty(configuraptor.TypedConfig):
class Empty(configuraptor.TypedMapping):
default: str = "allowed"


@@ -114,7 +121,7 @@ def test_typedconfig_update():
first.update(string="updated")
assert first.string == "updated"

first.update(string=None)
configuraptor.update(first, string=None)
assert first.string == "updated"

first.update(string=None, _allow_none=True)
@@ -136,9 +143,11 @@ def test_typedconfig_update():
first.update(new_key="some value", _strict=False)
assert first.new_key == "some value"


class MyConfig(configuraptor.TypedConfig):
update: bool = False


def test_typedconfig_update_name_collision():
config = MyConfig.load({"update": True}, key="")

@@ -149,7 +158,43 @@ def test_typedconfig_update_name_collision():
configuraptor.update(config, update=True)
assert config.update == True


def test_mapping():
tool = Tool.load(EXAMPLE_FILE, key="tool")

assert tool._format("{first.string}") == "src"

first = tool.first

# tool is a MutableMapping so this should work:
assert tool["first"] == first
with pytest.raises(TypeError):
# first is not a mutable mapping, so this should not work:
assert first["string"]

tool_d = dict(**tool)

assert tool_d["third"] == asdict(tool)["tool"]["third"] == tool.third
assert tool_d["first"].string == asdict(tool)["tool"]["first"]["string"]

tool.third = "!"
tool.first.string = "!"
with pytest.raises(TypeError):
tool.first["string"] = "123"
tool["third"] = "123"

with pytest.raises(ConfigErrorInvalidType):
tool["third"] = 123

del tool['third']

with pytest.raises(KeyError):
assert not tool['third']

non_mut = Empty.load({})

assert non_mut.default == non_mut["default"] == "allowed"
assert "{default}".format(**non_mut) == "allowed"

with pytest.raises(TypeError):
non_mut["default"] = "overwrite"

0 comments on commit b4c6b9d

Please sign in to comment.