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+ """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