Skip to content

Commit ba0ce39

Browse files
committed
config: Allow reading from multiple files
The `ConfigManagingActor` can now take more than one config file, and will read multiple files one after the other, overriding the previous values. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 7d57b8b commit ba0ce39

File tree

3 files changed

+352
-35
lines changed

3 files changed

+352
-35
lines changed

RELEASE_NOTES.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,18 @@
66

77
## Upgrading
88

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
9+
- The `ConfigManagingActor` now takes multiple configuration files as input, and the argument was renamed from `config_file` to `config_files`. If you are using this actor, please update your code. For example:
10+
11+
```python
12+
# Old
13+
actor = ConfigManagingActor(config_file="config.yaml")
14+
# New
15+
actor = ConfigManagingActor(config_files=["config.yaml"])
16+
```
1017

1118
## New Features
1219

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
20+
- The `ConfigManagingActor` can now take multiple configuration files as input, allowing to override default configurations with custom configurations.
1421

1522
## Bug Fixes
1623

src/frequenz/sdk/config/_config_managing.py

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pathlib
88
import tomllib
99
from collections import abc
10+
from collections.abc import Mapping, MutableMapping
1011
from datetime import timedelta
1112
from typing import Any, assert_never
1213

@@ -19,21 +20,48 @@
1920

2021

2122
class ConfigManagingActor(Actor):
22-
"""An actor that monitors a TOML configuration file for updates.
23+
"""An actor that monitors a TOML configuration files for updates.
2324
24-
When the file is updated, the new configuration is sent, as a [`dict`][], to the
25-
`output` sender.
25+
When the actor is started the configuration files will be read and sent to the
26+
output sender. Then the actor will start monitoring the files for updates.
2627
27-
When the actor is started, if a configuration file already exists, then it will be
28-
read and sent to the `output` sender before the actor starts monitoring the file
29-
for updates. This way users can rely on the actor to do the initial configuration
30-
reading too.
28+
If no configuration file could be read, the actor will raise a exception.
29+
30+
The configuration files are read in the order of the paths, so the last path will
31+
override the configuration set by the previous paths. Dict keys will be merged
32+
recursively, but other objects (like lists) will be replaced by the value in the
33+
last path.
34+
35+
Example:
36+
If `config1.toml` contains:
37+
38+
```toml
39+
var1 = 1
40+
var2 = 2
41+
```
42+
43+
And `config2.toml` contains:
44+
45+
```toml
46+
var2 = 3
47+
var3 = 4
48+
```
49+
50+
Then the final configuration will be:
51+
52+
```py
53+
{
54+
"var1": 1,
55+
"var2": 3,
56+
"var3": 4,
57+
}
58+
```
3159
"""
3260

3361
# pylint: disable-next=too-many-arguments
3462
def __init__(
3563
self,
36-
config_path: pathlib.Path | str,
64+
config_paths: abc.Iterable[pathlib.Path | str],
3765
output: Sender[abc.Mapping[str, Any]],
3866
event_types: abc.Set[EventType] = frozenset(EventType),
3967
*,
@@ -44,7 +72,11 @@ def __init__(
4472
"""Initialize this instance.
4573
4674
Args:
47-
config_path: The path to the TOML file with the configuration.
75+
config_paths: The paths to the TOML files with the configuration. Order is
76+
important, as the configuration will be read and updated in the order
77+
of the paths, so the last path will override the configuration set by
78+
the previous paths. Dict keys will be merged recursively, but other
79+
objects (like lists) will be replaced by the value in the last path.
4880
output: The sender to send the configuration to.
4981
event_types: The set of event types to monitor.
5082
name: The name of the actor. If `None`, `str(id(self))` will
@@ -54,11 +86,14 @@ def __init__(
5486
polling is enabled.
5587
"""
5688
super().__init__(name=name)
57-
self._config_path: pathlib.Path = (
58-
config_path
59-
if isinstance(config_path, pathlib.Path)
60-
else pathlib.Path(config_path)
61-
)
89+
self._config_paths: list[pathlib.Path] = [
90+
(
91+
config_path
92+
if isinstance(config_path, pathlib.Path)
93+
else pathlib.Path(config_path)
94+
)
95+
for config_path in config_paths
96+
]
6297
self._output: Sender[abc.Mapping[str, Any]] = output
6398
self._event_types: abc.Set[EventType] = event_types
6499
self._force_polling: bool = force_polling
@@ -73,12 +108,22 @@ def _read_config(self) -> abc.Mapping[str, Any]:
73108
Raises:
74109
ValueError: If config file cannot be read.
75110
"""
76-
try:
77-
with self._config_path.open("rb") as toml_file:
78-
return tomllib.load(toml_file)
79-
except ValueError as err:
80-
_logger.error("%s: Can't read config file, err: %s", self, err)
81-
raise
111+
error_count = 0
112+
config: dict[str, Any] = {}
113+
114+
for config_path in self._config_paths:
115+
try:
116+
with config_path.open("rb") as toml_file:
117+
data = tomllib.load(toml_file)
118+
config = _recursive_update(config, data)
119+
except ValueError as err:
120+
_logger.error("%s: Can't read config file, err: %s", self, err)
121+
error_count += 1
122+
123+
if error_count == len(self._config_paths):
124+
raise ValueError(f"{self}: Can't read any of the config files")
125+
126+
return config
82127

83128
async def send_config(self) -> None:
84129
"""Send the configuration to the output sender."""
@@ -94,45 +139,72 @@ async def _run(self) -> None:
94139
"""
95140
await self.send_config()
96141

142+
parent_paths = {p.parent for p in self._config_paths}
143+
97144
# FileWatcher can't watch for non-existing files, so we need to watch for the
98-
# parent directory instead just in case a configuration file doesn't exist yet
145+
# parent directories instead just in case a configuration file doesn't exist yet
99146
# or it is deleted and recreated again.
100147
file_watcher = FileWatcher(
101-
paths=[self._config_path.parent],
148+
paths=list(parent_paths),
102149
event_types=self._event_types,
103150
force_polling=self._force_polling,
104151
polling_interval=self._polling_interval,
105152
)
106153

107154
try:
108155
async for event in file_watcher:
109-
# Since we are watching the whole parent directory, we need to make sure
110-
# we only react to events related to the configuration file.
111-
if not event.path.samefile(self._config_path):
156+
# Since we are watching the whole parent directories, we need to make
157+
# sure we only react to events related to the configuration files we
158+
# are interested in.
159+
if not any(event.path.samefile(p) for p in self._config_paths):
112160
continue
113161

114162
match event.type:
115163
case EventType.CREATE:
116164
_logger.info(
117165
"%s: The configuration file %s was created, sending new config...",
118166
self,
119-
self._config_path,
167+
event.path,
120168
)
121169
await self.send_config()
122170
case EventType.MODIFY:
123171
_logger.info(
124172
"%s: The configuration file %s was modified, sending update...",
125173
self,
126-
self._config_path,
174+
event.path,
127175
)
128176
await self.send_config()
129177
case EventType.DELETE:
130178
_logger.info(
131179
"%s: The configuration file %s was deleted, ignoring...",
132180
self,
133-
self._config_path,
181+
event.path,
134182
)
135183
case _:
136184
assert_never(event.type)
137185
finally:
138186
del file_watcher
187+
188+
189+
def _recursive_update(
190+
target: dict[str, Any], overrides: Mapping[str, Any]
191+
) -> dict[str, Any]:
192+
"""Recursively updates dictionary d1 with values from dictionary d2.
193+
194+
Args:
195+
target: The original dictionary to be updated.
196+
overrides: The dictionary with updates.
197+
198+
Returns:
199+
The updated dictionary.
200+
"""
201+
for key, value in overrides.items():
202+
if (
203+
key in target
204+
and isinstance(target[key], MutableMapping)
205+
and isinstance(value, MutableMapping)
206+
):
207+
_recursive_update(target[key], value)
208+
else:
209+
target[key] = value
210+
return target

0 commit comments

Comments
 (0)