|
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