Skip to content

Commit 9df4beb

Browse files
authored
Add a function to load configurations with correct type hints (#1097)
When loading configurations from dictionaries, using `marshmallow_dataclass`, the type hints are not preserved by the `load()` method, leading to repetitive casting every time `load()` needs to be used. This commit adds a new `load_config()` function to the `frequenz.sdk.config` module that takes care of loading configurations from dictionaries into configuration classes with correct type hints.
2 parents 14840ca + 44a3390 commit 9df4beb

File tree

5 files changed

+121
-2
lines changed

5 files changed

+121
-2
lines changed

RELEASE_NOTES.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@
2121
## New Features
2222

2323
- The `ConfigManagingActor` can now take multiple configuration files as input, allowing to override default configurations with custom configurations.
24+
* A new `frequenz.sdk.config.load_config()` function is available to load configurations using `marshmallow_dataclass`es with correct type hints.
2425
- Implement and standardize logging configuration with the following changes:
25-
* Add LoggerConfig and LoggingConfig to standardize logging configuration.
26-
* Create LoggingConfigUpdater to handle runtime config updates.
26+
* Add `LoggerConfig` and `LoggingConfig` to standardize logging configuration.
27+
* Create `LoggingConfigUpdater` to handle runtime config updates.
2728
* Support individual log level settings for each module.
2829

2930
## Bug Fixes

mkdocs.yml

+2
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ plugins:
119119
- https://docs.python.org/3/objects.inv
120120
- https://frequenz-floss.github.io/frequenz-channels-python/v1.1/objects.inv
121121
- https://frequenz-floss.github.io/frequenz-client-microgrid-python/v0.5/objects.inv
122+
- https://lovasoa.github.io/marshmallow_dataclass/html/objects.inv
123+
- https://marshmallow.readthedocs.io/en/stable/objects.inv
122124
- https://networkx.org/documentation/stable/objects.inv
123125
- https://numpy.org/doc/stable/objects.inv
124126
- https://typing-extensions.readthedocs.io/en/stable/objects.inv

src/frequenz/sdk/config/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
from ._config_managing import ConfigManagingActor
77
from ._logging_config_updater import LoggerConfig, LoggingConfig, LoggingConfigUpdater
8+
from ._util import load_config
89

910
__all__ = [
1011
"ConfigManagingActor",
1112
"LoggingConfig",
1213
"LoggerConfig",
1314
"LoggingConfigUpdater",
15+
"load_config",
1416
]

src/frequenz/sdk/config/_util.py

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Utilities to deal with configuration."""
5+
6+
from collections.abc import Mapping
7+
from typing import Any, TypeVar, cast
8+
9+
from marshmallow_dataclass import class_schema
10+
11+
T = TypeVar("T")
12+
"""Type variable for configuration classes."""
13+
14+
15+
def load_config(
16+
cls: type[T],
17+
config: Mapping[str, Any],
18+
/,
19+
**marshmallow_load_kwargs: Any,
20+
) -> T:
21+
"""Load a configuration from a dictionary into an instance of a configuration class.
22+
23+
The configuration class is expected to be a [`dataclasses.dataclass`][], which is
24+
used to create a [`marshmallow.Schema`][] schema to validate the configuration
25+
dictionary.
26+
27+
To customize the schema derived from the configuration dataclass, you can use
28+
[`marshmallow_dataclass.dataclass`][] to specify extra metadata.
29+
30+
Additional arguments can be passed to [`marshmallow.Schema.load`][] using keyword
31+
arguments.
32+
33+
Args:
34+
cls: The configuration class.
35+
config: The configuration dictionary.
36+
**marshmallow_load_kwargs: Additional arguments to be passed to
37+
[`marshmallow.Schema.load`][].
38+
39+
Returns:
40+
The loaded configuration as an instance of the configuration class.
41+
"""
42+
instance = class_schema(cls)().load(config, **marshmallow_load_kwargs)
43+
# We need to cast because `.load()` comes from marshmallow and doesn't know which
44+
# type is returned.
45+
return cast(T, instance)

tests/config/test_util.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the config utilities."""
5+
6+
import dataclasses
7+
from typing import Any
8+
9+
import marshmallow
10+
import marshmallow_dataclass
11+
import pytest
12+
from pytest_mock import MockerFixture
13+
14+
from frequenz.sdk.config._util import load_config
15+
16+
17+
@dataclasses.dataclass
18+
class SimpleConfig:
19+
"""A simple configuration class for testing."""
20+
21+
name: str
22+
value: int
23+
24+
25+
@marshmallow_dataclass.dataclass
26+
class MmSimpleConfig:
27+
"""A simple configuration class for testing."""
28+
29+
name: str = dataclasses.field(metadata={"validate": lambda s: s.startswith("test")})
30+
value: int
31+
32+
33+
def test_load_config_dataclass() -> None:
34+
"""Test that load_config loads a configuration into a configuration class."""
35+
config: dict[str, Any] = {"name": "test", "value": 42}
36+
37+
loaded_config = load_config(SimpleConfig, config)
38+
assert loaded_config == SimpleConfig(name="test", value=42)
39+
40+
config["name"] = "not test"
41+
loaded_config = load_config(SimpleConfig, config)
42+
assert loaded_config == SimpleConfig(name="not test", value=42)
43+
44+
45+
def test_load_config_marshmallow_dataclass() -> None:
46+
"""Test that load_config loads a configuration into a configuration class."""
47+
config: dict[str, Any] = {"name": "test", "value": 42}
48+
loaded_config = load_config(MmSimpleConfig, config)
49+
assert loaded_config == MmSimpleConfig(name="test", value=42)
50+
51+
config["name"] = "not test"
52+
with pytest.raises(marshmallow.ValidationError):
53+
_ = load_config(MmSimpleConfig, config)
54+
55+
56+
def test_load_config_type_hints(mocker: MockerFixture) -> None:
57+
"""Test that load_config loads a configuration into a configuration class."""
58+
mock_class_schema = mocker.Mock()
59+
mock_class_schema.return_value.load.return_value = {"name": "test", "value": 42}
60+
mocker.patch(
61+
"frequenz.sdk.config._util.class_schema", return_value=mock_class_schema
62+
)
63+
config: dict[str, Any] = {}
64+
65+
# We add the type hint to test that the return type (hint) is correct
66+
_: MmSimpleConfig = load_config(MmSimpleConfig, config, marshmallow_arg=1)
67+
mock_class_schema.return_value.load.assert_called_once_with(
68+
config, marshmallow_arg=1
69+
)

0 commit comments

Comments
 (0)