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.
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