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 confusing issue when config class names don't end with Config. #105

Merged
merged 4 commits into from
Sep 9, 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
24 changes: 14 additions & 10 deletions src/programming/BL_Python/programming/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

import toml
from BL_Python.programming.collections.dict import AnyDict, merge
from BL_Python.programming.config.exceptions import (
ConfigBuilderStateError,
NotEndsWithConfigError,
)

TConfig = TypeVar("TConfig")

Expand All @@ -29,24 +33,24 @@ def build(self) -> type[TConfig]:
return self._root_config

if not self._configs:
raise Exception("Cannot build a config without any configs.")
raise ConfigBuilderStateError(
"Cannot build a config without any base config types specified."
)

_new_type_base = self._root_config if self._root_config else object

attrs: dict[Any, Any] = {}
annotations: dict[str, Any] = {}

for config in self._configs:
try:
config_name = config.__name__[
: config.__name__.rindex("Config")
].lower()
annotations[config_name] = config
attrs[config_name] = None
except ValueError as e:
raise ValueError(
if not config.__name__.endswith("Config"):
raise NotEndsWithConfigError(
f"Class name '{config.__name__}' is not a valid config class. The name must end with 'Config'"
) from e
)

config_name = config.__name__[: config.__name__.rindex("Config")].lower()
annotations[config_name] = config
attrs[config_name] = None

attrs["__annotations__"] = annotations
# make one type that has the names of the config objects
Expand Down
10 changes: 10 additions & 0 deletions src/programming/BL_Python/programming/config/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class InvalidConfigNameError(Exception):
"""The class name used as a configuration type is invalid."""


class NotEndsWithConfigError(InvalidConfigNameError):
"""The name must end with `Config`."""


class ConfigBuilderStateError(Exception):
"""The config builder has not been configured correctly."""
130 changes: 130 additions & 0 deletions src/programming/test/unit/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import pytest
from BL_Python.programming.config import AbstractConfig, ConfigBuilder, load_config
from BL_Python.programming.config.exceptions import (
ConfigBuilderStateError,
NotEndsWithConfigError,
)
from pydantic import BaseModel
from pytest_mock import MockerFixture


class FooConfig(BaseModel):
value: str
other_value: bool = False


class BarConfig(BaseModel):
value: str


class BazConfig(BaseModel, AbstractConfig):
value: str


class TestConfig(BaseModel, AbstractConfig):
foo: FooConfig = FooConfig(value="xyz")
bar: BarConfig | None = None


class InvalidConfigClass(BaseModel, AbstractConfig):
pass


def test__Config__load_config__reads_toml_file(mocker: MockerFixture):
fake_config_dict = {}
toml_mock = mocker.patch("toml.load", return_value=fake_config_dict)
_ = load_config(TestConfig, "foo.toml")
assert toml_mock.called


def test__Config__load_config__initializes_section_config_value(mocker: MockerFixture):
fake_config_dict = {"foo": {"value": "abc123"}}
_ = mocker.patch("io.open")
_ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict)
config = load_config(TestConfig, "foo.toml")
assert config.foo.value == "abc123"


def test__Config__load_config__initializes_section_config(mocker: MockerFixture):
fake_config_dict = {"bar": {"value": "abc123"}}
_ = mocker.patch("io.open")
_ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict)
config = load_config(TestConfig, "foo.toml")
assert config.bar is not None
assert config.bar.value == "abc123"


def test__Config__load_config__applies_overrides(mocker: MockerFixture):
fake_config_dict = {"foo": {"value": "abc123"}}
override_config_dict = {"foo": {"value": "XYZ"}}
_ = mocker.patch("io.open")
_ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict)
config = load_config(TestConfig, "foo.toml", override_config_dict)
assert config.foo.value == override_config_dict["foo"]["value"]


def test__ConfigBuilder__build__raises_error_when_no_root_config_and_no_section_configs_specified():
config_builder = ConfigBuilder[TestConfig]()
with pytest.raises(ConfigBuilderStateError):
_ = config_builder.build()


def test__ConfigBuilder__build__raises_error_when_section_class_name_is_invalid():
config_builder = ConfigBuilder[TestConfig]()
_ = config_builder.with_configs([InvalidConfigClass])
with pytest.raises(NotEndsWithConfigError):
_ = config_builder.build()


def test__ConfigBuilder__build__uses_object_as_root_config_when_no_root_config_specified():
config_builder = ConfigBuilder[TestConfig]()
_ = config_builder.with_configs([BazConfig])
config_type = config_builder.build()
assert TestConfig not in config_type.__mro__
assert BazConfig not in config_type.__mro__
assert hasattr(config_type, "baz")
assert hasattr(config_type(), "baz")


def test__ConfigBuilder__build__uses_root_config_when_no_section_configs_specified():
config_builder = ConfigBuilder[TestConfig]()
_ = config_builder.with_root_config(TestConfig)
config_type = config_builder.build()
assert config_type is TestConfig
assert isinstance(config_type(), TestConfig)


def test__ConfigBuilder__build__creates_config_type_when_multiple_configs_specified(
mocker: MockerFixture,
):
fake_config_dict = {"baz": {"value": "ABC"}}
_ = mocker.patch("io.open")
_ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict)

config_builder = ConfigBuilder[TestConfig]()
_ = config_builder.with_root_config(TestConfig)
_ = config_builder.with_configs([BazConfig])
config_type = config_builder.build()
config = load_config(config_type, "foo.toml")

assert TestConfig in config_type.__mro__
assert hasattr(config, "baz")


def test__ConfigBuilder__build__sets_dynamic_config_values_when_multiple_configs_specified(
mocker: MockerFixture,
):
fake_config_dict = {"baz": {"value": "ABC"}}
_ = mocker.patch("io.open")
_ = mocker.patch("toml.decoder.loads", return_value=fake_config_dict)

config_builder = ConfigBuilder[TestConfig]()
_ = config_builder.with_root_config(TestConfig)
_ = config_builder.with_configs([BazConfig])
config_type = config_builder.build()
config = load_config(config_type, "foo.toml")

assert hasattr(config, "baz")
assert getattr(config, "baz")
assert getattr(getattr(config, "baz"), "value")
assert getattr(getattr(config, "baz"), "value") == "ABC"
Loading