|
4 | 4 | """Read and update config variables.""" |
5 | 5 |
|
6 | 6 | import logging |
7 | | -import os |
| 7 | +import pathlib |
8 | 8 | import tomllib |
9 | 9 | from collections import abc |
10 | | -from typing import Any, Dict |
| 10 | +from typing import Any, assert_never |
11 | 11 |
|
12 | 12 | from frequenz.channels import Sender |
13 | 13 | from frequenz.channels.util import FileWatcher |
|
20 | 20 |
|
21 | 21 | @actor |
22 | 22 | class ConfigManagingActor: |
23 | | - """ |
24 | | - Manages config variables. |
| 23 | + """An actor that monitors a TOML configuration file for updates. |
| 24 | +
|
| 25 | + When the file is updated, the new configuration is sent, as a [`dict`][], to the |
| 26 | + `output` sender. |
25 | 27 |
|
26 | | - Config variables are read from file. |
27 | | - Only single file can be read. |
28 | | - If new file is read, then previous configs will be forgotten. |
| 28 | + When the actor is started, if a configuration file already exists, then it will be |
| 29 | + read and sent to the `output` sender before the actor starts monitoring the file |
| 30 | + for updates. This way users can rely on the actor to do the initial configuration |
| 31 | + reading too. |
29 | 32 | """ |
30 | 33 |
|
31 | 34 | def __init__( |
32 | 35 | self, |
33 | | - conf_file: str, |
| 36 | + config_path: pathlib.Path | str, |
34 | 37 | output: Sender[Config], |
35 | 38 | event_types: abc.Set[FileWatcher.EventType] = frozenset(FileWatcher.EventType), |
36 | 39 | ) -> None: |
37 | | - """Read config variables from the file. |
| 40 | + """Initialize this instance. |
38 | 41 |
|
39 | 42 | Args: |
40 | | - conf_file: Path to file with config variables. |
41 | | - output: Channel to publish updates to. |
42 | | - event_types: Which types of events should update the config and |
43 | | - trigger a notification. |
| 43 | + config_path: The path to the TOML file with the configuration. |
| 44 | + output: The sender to send the config to. |
| 45 | + event_types: The set of event types to monitor. |
44 | 46 | """ |
45 | | - self._conf_file: str = conf_file |
46 | | - self._conf_dir: str = os.path.dirname(conf_file) |
47 | | - self._file_watcher = FileWatcher( |
48 | | - paths=[self._conf_dir], event_types=event_types |
| 47 | + self._config_path: pathlib.Path = ( |
| 48 | + config_path |
| 49 | + if isinstance(config_path, pathlib.Path) |
| 50 | + else pathlib.Path(config_path) |
49 | 51 | ) |
50 | | - self._output = output |
51 | | - |
52 | | - def _read_config(self) -> Dict[str, Any]: |
53 | | - """Read the contents of the config file. |
| 52 | + # FileWatcher can't watch for non-existing files, so we need to watch for the |
| 53 | + # parent directory instead just in case a configuration file doesn't exist yet |
| 54 | + # or it is deleted and recreated again. |
| 55 | + self._file_watcher: FileWatcher = FileWatcher( |
| 56 | + paths=[self._config_path.parent], event_types=event_types |
| 57 | + ) |
| 58 | + self._output: Sender[Config] = output |
54 | 59 |
|
55 | | - Raises: |
56 | | - ValueError: if config file cannot be read. |
| 60 | + def _read_config(self) -> dict[str, Any]: |
| 61 | + """Read the contents of the configuration file. |
57 | 62 |
|
58 | 63 | Returns: |
59 | 64 | A dictionary containing configuration variables. |
| 65 | +
|
| 66 | + Raises: |
| 67 | + ValueError: If config file cannot be read. |
60 | 68 | """ |
61 | 69 | try: |
62 | | - with open(self._conf_file, "rb") as toml_file: |
| 70 | + with self._config_path.open("rb") as toml_file: |
63 | 71 | return tomllib.load(toml_file) |
64 | 72 | except ValueError as err: |
65 | | - logging.error("Can't read config file, err: %s", err) |
| 73 | + logging.error("%s: Can't read config file, err: %s", self, err) |
66 | 74 | raise |
67 | 75 |
|
68 | 76 | async def send_config(self) -> None: |
69 | | - """Send config file using a broadcast channel.""" |
| 77 | + """Send the configuration to the output sender.""" |
70 | 78 | conf_vars = self._read_config() |
71 | 79 | config = Config(conf_vars) |
72 | 80 | await self._output.send(config) |
73 | 81 |
|
74 | 82 | async def run(self) -> None: |
75 | | - """Watch config file and update when modified. |
76 | | -
|
77 | | - At startup, the Config Manager sends the current config so that it |
78 | | - can be cache in the Broadcast channel and served to receivers even if |
79 | | - there hasn't been any change to the config file itself. |
80 | | - """ |
| 83 | + """Monitor for and send configuration file updates.""" |
81 | 84 | await self.send_config() |
82 | 85 |
|
83 | 86 | async for event in self._file_watcher: |
84 | | - if event.type != FileWatcher.EventType.DELETE: |
85 | | - if str(event.path) == self._conf_file: |
| 87 | + # Since we are watching the whole parent directory, we need to make sure |
| 88 | + # we only react to events related to the configuration file. |
| 89 | + if event.path != self._config_path: |
| 90 | + continue |
| 91 | + |
| 92 | + match event.type: |
| 93 | + case FileWatcher.EventType.CREATE: |
86 | 94 | _logger.info( |
87 | | - "Update configs, because file %s was modified.", |
88 | | - self._conf_file, |
| 95 | + "%s: The configuration file %s was created, sending new config...", |
| 96 | + self, |
| 97 | + self._config_path, |
89 | 98 | ) |
90 | 99 | await self.send_config() |
91 | | - |
92 | | - _logger.debug("ConfigManager stopped.") |
| 100 | + case FileWatcher.EventType.MODIFY: |
| 101 | + _logger.info( |
| 102 | + "%s: The configuration file %s was modified, sending update...", |
| 103 | + self, |
| 104 | + self._config_path, |
| 105 | + ) |
| 106 | + await self.send_config() |
| 107 | + case FileWatcher.EventType.DELETE: |
| 108 | + _logger.info( |
| 109 | + "%s: The configuration file %s was deleted, ignoring...", |
| 110 | + self, |
| 111 | + self._config_path, |
| 112 | + ) |
| 113 | + case _: |
| 114 | + assert_never(event.type) |
0 commit comments