Skip to content

Commit c0f9fd5

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 c0f9fd5

File tree

3 files changed

+365
-37
lines changed

3 files changed

+365
-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.toml")
14+
# New
15+
actor = ConfigManagingActor(config_files=["config.toml"])
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: 113 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,59 @@
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. If any
27+
file is updated, all the configuration files will be re-read and sent to the output
28+
sender.
29+
30+
If no configuration file could be read, the actor will raise an exception.
31+
32+
The configuration files are read in the order of the paths, so the last path will
33+
override the configuration set by the previous paths. Dict keys will be merged
34+
recursively, but other objects (like lists) will be replaced by the value in the
35+
last path.
36+
37+
Example:
38+
If `config1.toml` contains:
39+
40+
```toml
41+
var1 = [1, 2]
42+
var2 = 2
43+
[section]
44+
var3 = [1, 3]
45+
```
46+
47+
And `config2.toml` contains:
48+
49+
```toml
50+
var2 = "hello" # Can override with a different type too
51+
var3 = 4
52+
[section]
53+
var3 = 5
54+
var4 = 5
55+
```
56+
57+
Then the final configuration will be:
58+
59+
```py
60+
{
61+
"var1": [1, 2],
62+
"var2": "hello",
63+
"var3": 4,
64+
"section": {
65+
"var3": 5,
66+
"var4": 5,
67+
},
68+
}
69+
```
3170
"""
3271

3372
# pylint: disable-next=too-many-arguments
3473
def __init__(
3574
self,
36-
config_path: pathlib.Path | str,
75+
config_paths: abc.Sequence[pathlib.Path | str],
3776
output: Sender[abc.Mapping[str, Any]],
3877
event_types: abc.Set[EventType] = frozenset(EventType),
3978
*,
@@ -44,7 +83,11 @@ def __init__(
4483
"""Initialize this instance.
4584
4685
Args:
47-
config_path: The path to the TOML file with the configuration.
86+
config_paths: The paths to the TOML files with the configuration. Order
87+
matters, as the configuration will be read and updated in the order
88+
of the paths, so the last path will override the configuration set by
89+
the previous paths. Dict keys will be merged recursively, but other
90+
objects (like lists) will be replaced by the value in the last path.
4891
output: The sender to send the configuration to.
4992
event_types: The set of event types to monitor.
5093
name: The name of the actor. If `None`, `str(id(self))` will
@@ -54,11 +97,14 @@ def __init__(
5497
polling is enabled.
5598
"""
5699
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-
)
100+
self._config_paths: list[pathlib.Path] = [
101+
(
102+
config_path
103+
if isinstance(config_path, pathlib.Path)
104+
else pathlib.Path(config_path)
105+
)
106+
for config_path in config_paths
107+
]
62108
self._output: Sender[abc.Mapping[str, Any]] = output
63109
self._event_types: abc.Set[EventType] = event_types
64110
self._force_polling: bool = force_polling
@@ -73,12 +119,22 @@ def _read_config(self) -> abc.Mapping[str, Any]:
73119
Raises:
74120
ValueError: If config file cannot be read.
75121
"""
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
122+
error_count = 0
123+
config: dict[str, Any] = {}
124+
125+
for config_path in self._config_paths:
126+
try:
127+
with config_path.open("rb") as toml_file:
128+
data = tomllib.load(toml_file)
129+
config = _recursive_update(config, data)
130+
except ValueError as err:
131+
_logger.error("%s: Can't read config file, err: %s", self, err)
132+
error_count += 1
133+
134+
if error_count == len(self._config_paths):
135+
raise ValueError(f"{self}: Can't read any of the config files")
136+
137+
return config
82138

83139
async def send_config(self) -> None:
84140
"""Send the configuration to the output sender."""
@@ -94,45 +150,72 @@ async def _run(self) -> None:
94150
"""
95151
await self.send_config()
96152

153+
parent_paths = {p.parent for p in self._config_paths}
154+
97155
# 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
156+
# parent directories instead just in case a configuration file doesn't exist yet
99157
# or it is deleted and recreated again.
100158
file_watcher = FileWatcher(
101-
paths=[self._config_path.parent],
159+
paths=list(parent_paths),
102160
event_types=self._event_types,
103161
force_polling=self._force_polling,
104162
polling_interval=self._polling_interval,
105163
)
106164

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

114173
match event.type:
115174
case EventType.CREATE:
116175
_logger.info(
117176
"%s: The configuration file %s was created, sending new config...",
118177
self,
119-
self._config_path,
178+
event.path,
120179
)
121180
await self.send_config()
122181
case EventType.MODIFY:
123182
_logger.info(
124183
"%s: The configuration file %s was modified, sending update...",
125184
self,
126-
self._config_path,
185+
event.path,
127186
)
128187
await self.send_config()
129188
case EventType.DELETE:
130189
_logger.info(
131190
"%s: The configuration file %s was deleted, ignoring...",
132191
self,
133-
self._config_path,
192+
event.path,
134193
)
135194
case _:
136195
assert_never(event.type)
137196
finally:
138197
del file_watcher
198+
199+
200+
def _recursive_update(
201+
target: dict[str, Any], overrides: Mapping[str, Any]
202+
) -> dict[str, Any]:
203+
"""Recursively updates dictionary d1 with values from dictionary d2.
204+
205+
Args:
206+
target: The original dictionary to be updated.
207+
overrides: The dictionary with updates.
208+
209+
Returns:
210+
The updated dictionary.
211+
"""
212+
for key, value in overrides.items():
213+
if (
214+
key in target
215+
and isinstance(target[key], MutableMapping)
216+
and isinstance(value, MutableMapping)
217+
):
218+
_recursive_update(target[key], value)
219+
else:
220+
target[key] = value
221+
return target

0 commit comments

Comments
 (0)