Skip to content

Commit

Permalink
Merge pull request #105 from uclahs-cds/aholmes-fix-config-bug
Browse files Browse the repository at this point in the history
Fix confusing issue when config class names don't end with `Config`.
  • Loading branch information
aholmes authored Sep 9, 2024
2 parents 8d8768f + 40c64c9 commit 1fe3128
Show file tree
Hide file tree
Showing 3 changed files with 154 additions and 10 deletions.
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"

0 comments on commit 1fe3128

Please sign in to comment.