diff --git a/src/programming/BL_Python/programming/config/__init__.py b/src/programming/BL_Python/programming/config/__init__.py index fb2e5f5f..4d724e6f 100644 --- a/src/programming/BL_Python/programming/config/__init__.py +++ b/src/programming/BL_Python/programming/config/__init__.py @@ -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") @@ -29,7 +33,9 @@ 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 @@ -37,16 +43,14 @@ def build(self) -> type[TConfig]: 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 diff --git a/src/programming/BL_Python/programming/config/exceptions.py b/src/programming/BL_Python/programming/config/exceptions.py new file mode 100644 index 00000000..be8f4ea6 --- /dev/null +++ b/src/programming/BL_Python/programming/config/exceptions.py @@ -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.""" diff --git a/src/programming/test/unit/test_config.py b/src/programming/test/unit/test_config.py new file mode 100644 index 00000000..aa5953b4 --- /dev/null +++ b/src/programming/test/unit/test_config.py @@ -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"