Skip to content

Commit 5cd4b40

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 5cd4b40

File tree

3 files changed

+363
-37
lines changed

3 files changed

+363
-37
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: 111 additions & 30 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,57 @@
1920

2021

2122
class ConfigManagingActor(Actor):
22-
"""An actor that monitors a TOML configuration file for updates.
23-
24-
When the file is updated, the new configuration is sent, as a [`dict`][], to the
25-
`output` sender.
26-
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.
23+
"""An actor that monitors a TOML configuration files for updates.
24+
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.
27+
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, 2]
40+
var2 = 2
41+
[section]
42+
var3 = [1, 3]
43+
```
44+
45+
And `config2.toml` contains:
46+
47+
```toml
48+
var2 = "hello" # Can override with a different type too
49+
var3 = 4
50+
[section]
51+
var3 = 5
52+
var4 = 5
53+
```
54+
55+
Then the final configuration will be:
56+
57+
```py
58+
{
59+
"var1": [1, 2],
60+
"var2": "hello",
61+
"var3": 4,
62+
"section": {
63+
"var3": 5,
64+
"var4": 5,
65+
},
66+
}
67+
```
3168
"""
3269

3370
# pylint: disable-next=too-many-arguments
3471
def __init__(
3572
self,
36-
config_path: pathlib.Path | str,
73+
config_paths: abc.Sequence[pathlib.Path | str],
3774
output: Sender[abc.Mapping[str, Any]],
3875
event_types: abc.Set[EventType] = frozenset(EventType),
3976
*,
@@ -44,7 +81,11 @@ def __init__(
4481
"""Initialize this instance.
4582
4683
Args:
47-
config_path: The path to the TOML file with the configuration.
84+
config_paths: The paths to the TOML files with the configuration. Order
85+
matters, as the configuration will be read and updated in the order
86+
of the paths, so the last path will override the configuration set by
87+
the previous paths. Dict keys will be merged recursively, but other
88+
objects (like lists) will be replaced by the value in the last path.
4889
output: The sender to send the configuration to.
4990
event_types: The set of event types to monitor.
5091
name: The name of the actor. If `None`, `str(id(self))` will
@@ -54,11 +95,14 @@ def __init__(
5495
polling is enabled.
5596
"""
5697
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-
)
98+
self._config_paths: list[pathlib.Path] = [
99+
(
100+
config_path
101+
if isinstance(config_path, pathlib.Path)
102+
else pathlib.Path(config_path)
103+
)
104+
for config_path in config_paths
105+
]
62106
self._output: Sender[abc.Mapping[str, Any]] = output
63107
self._event_types: abc.Set[EventType] = event_types
64108
self._force_polling: bool = force_polling
@@ -73,12 +117,22 @@ def _read_config(self) -> abc.Mapping[str, Any]:
73117
Raises:
74118
ValueError: If config file cannot be read.
75119
"""
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
120+
error_count = 0
121+
config: dict[str, Any] = {}
122+
123+
for config_path in self._config_paths:
124+
try:
125+
with config_path.open("rb") as toml_file:
126+
data = tomllib.load(toml_file)
127+
config = _recursive_update(config, data)
128+
except ValueError as err:
129+
_logger.error("%s: Can't read config file, err: %s", self, err)
130+
error_count += 1
131+
132+
if error_count == len(self._config_paths):
133+
raise ValueError(f"{self}: Can't read any of the config files")
134+
135+
return config
82136

83137
async def send_config(self) -> None:
84138
"""Send the configuration to the output sender."""
@@ -94,45 +148,72 @@ async def _run(self) -> None:
94148
"""
95149
await self.send_config()
96150

151+
parent_paths = {p.parent for p in self._config_paths}
152+
97153
# 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
154+
# parent directories instead just in case a configuration file doesn't exist yet
99155
# or it is deleted and recreated again.
100156
file_watcher = FileWatcher(
101-
paths=[self._config_path.parent],
157+
paths=list(parent_paths),
102158
event_types=self._event_types,
103159
force_polling=self._force_polling,
104160
polling_interval=self._polling_interval,
105161
)
106162

107163
try:
108164
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):
165+
# Since we are watching the whole parent directories, we need to make
166+
# sure we only react to events related to the configuration files we
167+
# are interested in.
168+
if not any(event.path.samefile(p) for p in self._config_paths):
112169
continue
113170

114171
match event.type:
115172
case EventType.CREATE:
116173
_logger.info(
117174
"%s: The configuration file %s was created, sending new config...",
118175
self,
119-
self._config_path,
176+
event.path,
120177
)
121178
await self.send_config()
122179
case EventType.MODIFY:
123180
_logger.info(
124181
"%s: The configuration file %s was modified, sending update...",
125182
self,
126-
self._config_path,
183+
event.path,
127184
)
128185
await self.send_config()
129186
case EventType.DELETE:
130187
_logger.info(
131188
"%s: The configuration file %s was deleted, ignoring...",
132189
self,
133-
self._config_path,
190+
event.path,
134191
)
135192
case _:
136193
assert_never(event.type)
137194
finally:
138195
del file_watcher
196+
197+
198+
def _recursive_update(
199+
target: dict[str, Any], overrides: Mapping[str, Any]
200+
) -> dict[str, Any]:
201+
"""Recursively updates dictionary d1 with values from dictionary d2.
202+
203+
Args:
204+
target: The original dictionary to be updated.
205+
overrides: The dictionary with updates.
206+
207+
Returns:
208+
The updated dictionary.
209+
"""
210+
for key, value in overrides.items():
211+
if (
212+
key in target
213+
and isinstance(target[key], MutableMapping)
214+
and isinstance(value, MutableMapping)
215+
):
216+
_recursive_update(target[key], value)
217+
else:
218+
target[key] = value
219+
return target

0 commit comments

Comments
 (0)