Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix/update cattrs #17

Merged
merged 4 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jobs:
matrix:
platform:
- manylinux2014_x86_64
- manylinux2014_aarch64
version:
- cp312-cp312
- cp311-cp311
Expand Down Expand Up @@ -111,6 +112,7 @@ jobs:
matrix:
platform:
- manylinux2014_x86_64
- manylinux2014_aarch64
version:
- cp312-cp312
- cp311-cp311
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def finalize_options(self):

install_requires = [
"attrs",
"cattrs<=22.1.0",
"cattrs",
"structlog>=20.1.0",
"toml>=0.10",
"typing_inspect>=0.4.0",
Expand Down
119 changes: 21 additions & 98 deletions src/buvar/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,75 +6,19 @@
import attr
import cattr
import structlog
import typing_inspect

from . import di, util

CNF_KEY = "buvar_config"


logger = structlog.get_logger()


# since we only need a single instance, we just hide the class magically
@functools.partial(lambda x: x())
class missing:
def __repr__(self):
return self.__class__.__name__


@attr.s(auto_attribs=True)
class ConfigValue:
name: typing.Optional[str] = None
help: typing.Optional[str] = None


def var(
default=missing,
converter=None,
factory=missing,
name=None,
validator=None,
help=None, # noqa: W0622,
):
return attr.ib(
metadata={CNF_KEY: ConfigValue(name, help)},
converter=converter,
validator=validator,
**({"default": default} if factory is missing else {"factory": factory}),
)


def _env_to_bool(val):
"""
Convert *val* to a bool if it's not a bool in the first place.
"""
if isinstance(val, bool):
return val
val = val.strip().lower()
if val in ("1", "true", "yes", "on"):
return True

return False


def _env_to_list(val):
"""Take a comma separated string and split it."""
if isinstance(val, str):
val = map(lambda x: x.strip(), val.split(","))
return val


def bool_var(default=missing, name=None, help=None): # noqa: W0622
return var(default=default, name=name, converter=_env_to_bool, help=help)


def list_var(default=missing, name=None, help=None): # noqa: W0622
return var(default=default, name=name, converter=_env_to_list, help=help)


class ConfigSource(dict):

"""Config dict, with loadable sections.

>>> @attr.s(auto_attribs=True)
Expand Down Expand Up @@ -117,8 +61,7 @@ def load(self, config_cls, name=None):
return config


class ConfigError(Exception):
...
class ConfigError(Exception): ...


# to get typing_inspect.is_generic_type()
Expand All @@ -133,7 +76,7 @@ class Config:
__buvar_config_section__: typing.Optional[str] = skip_section
__buvar_config_sections__: typing.Dict[str, type] = {}

def __init_subclass__(cls, *, section: str = skip_section, **kwargs):
def __init_subclass__(cls, *, section: str = skip_section, **_):
if section is skip_section:
return
if section in cls.__buvar_config_sections__:
Expand Down Expand Up @@ -201,45 +144,25 @@ def create_env_config(cls, *env_prefix):
return env_config


class RelaxedConverter(cattr.Converter):
"""This py:obj:`RelaxedConverter` is ignoring undefined and defaulting to
None on optional attributes."""

def structure_attrs_fromdict(
self, obj: typing.Mapping, cl: typing.Type
) -> typing.Any:
"""Instantiate an attrs class from a mapping (dict)."""
conv_obj = {}
dispatch = self._structure_func.dispatch
for a in attr.fields(cl):
# We detect the type by metadata.
type_ = a.type
if type_ is None:
# No type.
continue
name = a.name
try:
val = obj[name]
except KeyError:
if typing_inspect.is_optional_type(type_):
if a.default in (missing, attr.NOTHING):
val = None
else:
val = a.default
elif a.default in (missing, attr.NOTHING):
raise ValueError("Attribute is missing", a.name)
else:
continue

if a.converter is None:
val = dispatch(type_)(val, type_)

conv_obj[name] = val

return cl(**conv_obj)


relaxed_converter = RelaxedConverter()
# FIXME: deprecate relaxed_converter
converter = relaxed_converter = cattr.Converter()


def _env_to_bool(val, type):
"""
Convert *val* to a bool if it's not a bool in the first place.
"""
if isinstance(val, type):
return val
elif isinstance(val, str):
val = val.strip().lower()
if val in ("1", "true", "yes", "on"):
return True

return False


relaxed_converter.register_structure_hook(bool, _env_to_bool)


def generate_env_help(cls, env_prefix=""):
Expand Down
43 changes: 21 additions & 22 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async def test_config_source_schematize(mocker):
class FooConfig:
bar: str = "default"
foobar: float = 9.87
baz: bool = config.bool_var(default=False)
baz: bool = False

@attr.s(auto_attribs=True)
class BarConfig:
Expand All @@ -25,9 +25,9 @@ class BarConfig:
@attr.s(auto_attribs=True, kw_only=True)
class BimConfig:
bar: BarConfig
bam: bool = config.bool_var()
bum: int = config.var(123)
lst: typing.List = config.var(list)
bam: bool
bum: int = 123
lst: typing.List

sources = [
{"bar": {"bim": "123.4", "foo": {"bar": "1.23", "baz": "true"}}},
Expand All @@ -53,6 +53,7 @@ class BimConfig:
bim = cfg.load(BimConfig, "bim")
bar = cfg.load(BarConfig, "bar")
foo = cfg.load(FooConfig, "foo")
# (FooConfig(bar="value", foobar=123.5, baz=True),)

assert (bar, foo, bim) == (
BarConfig(bim=0.0, foo=FooConfig(bar="1.23", foobar=7.77, baz=False)),
Expand All @@ -74,7 +75,7 @@ async def test_config_generic_adapter(mocker):
class FooConfig(config.Config, section="foo"):
bar: str = "default"
foobar: float = 9.87
baz: bool = config.bool_var(default=False)
baz: bool = False

@attr.s(auto_attribs=True)
class BarConfig(config.Config, section="bar"):
Expand All @@ -84,9 +85,9 @@ class BarConfig(config.Config, section="bar"):
@attr.s(auto_attribs=True, kw_only=True)
class BimConfig(config.Config, section="bim"):
bar: BarConfig
bam: bool = config.bool_var()
bum: int = config.var(123)
lst: typing.List = config.var(list)
bam: bool
bum: int = 123
lst: typing.List

sources = [
{"bar": {"bim": "123.4", "foo": {"bar": "1.23", "baz": "true"}}},
Expand Down Expand Up @@ -130,16 +131,17 @@ class GeneralVars:

def test_config_missing():
import attr
from cattrs.errors import ClassValidationError
from buvar import config

source: dict = {"foo": {}}

@attr.s(auto_attribs=True)
class FooConfig:
bar: str = config.var()
bar: str

cfg = config.ConfigSource(source)
with pytest.raises(ValueError):
with pytest.raises(ClassValidationError):
cfg.load(FooConfig, "foo")


Expand All @@ -156,11 +158,11 @@ class FooConfig:
bim bam
"""

string_val: str = config.var(help="string")
float_val: float = config.var(9.87, help="float")
bool_val: bool = config.bool_var(help="bool")
int_val: int = config.var(help="int")
list_val: typing.List = config.var(list, help="list")
string_val: str
float_val: float = 9.87
bool_val: bool
int_val: int
list_val: typing.List

@attr.s(auto_attribs=True)
class BarConfig:
Expand All @@ -171,7 +173,7 @@ class BarConfig:
"""

bim: float
foo: FooConfig = config.var(help="foo")
foo: FooConfig

env_vars = {}
config_fields = list(config.traverse_attrs(BarConfig, target=env_vars))
Expand Down Expand Up @@ -279,20 +281,17 @@ async def test_config_subclass_abc(mocker):

mocker.patch.dict(config.Config.__buvar_config_sections__, clear=True)

class GeneralConfig(config.Config, section=None):
...
class GeneralConfig(config.Config, section=None): ...

class FooBase(metaclass=abc.ABCMeta):
@abc.abstractmethod
def foo(self):
...
def foo(self): ...

@attr.s(auto_attribs=True)
class FooConfig(config.Config, FooBase, section="foo"):
bar: str

def foo(self):
...
def foo(self): ...

assert config.skip_section not in config.Config.__buvar_config_sections__
assert FooBase not in config.Config.__buvar_config_sections__.values()
Expand Down