77import pathlib
88import tomllib
99from collections import abc
10+ from collections .abc import Mapping , MutableMapping
1011from datetime import timedelta
1112from typing import Any , assert_never
1213
1920
2021
2122class 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