Skip to content

Commit 9820e71

Browse files
committed
Make the LoggingConfigUpdatingActor Reconfigurable
The `LoggingConfigUpdatingActor` now inherits also from `Reconfigurable` to ensure a consistent initialization. This also means the actor can now receive `None` as configuration (for example if the configuration is removed), in which case it will go back to the default configuration. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 27cfff8 commit 9820e71

File tree

3 files changed

+54
-86
lines changed

3 files changed

+54
-86
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
+ Renamed to `LoggingConfigUpdatingActor` to follow the actor naming convention.
1414
+ Make all arguments to the constructor keyword-only.
15+
+ If the configuration is removed, the actor will now load back the default configuration.
1516

1617
* `LoggingConfig`
1718

src/frequenz/sdk/config/_logging_actor.py

Lines changed: 29 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@
33

44
"""Read and update logging severity from config."""
55

6+
from __future__ import annotations
7+
68
import logging
7-
from collections.abc import Mapping
89
from dataclasses import dataclass, field
9-
from typing import Annotated, Any
10+
from typing import Annotated, Sequence
1011

1112
import marshmallow
1213
import marshmallow.validate
13-
from frequenz.channels import Receiver
1414

1515
from ..actor import Actor
16-
from ._util import load_config
16+
from ._manager import ConfigManager
17+
from ._reconfigurable import Reconfigurable
1718

1819
_logger = logging.getLogger(__name__)
1920

@@ -66,7 +67,7 @@ class LoggingConfig:
6667
"""The list of loggers configurations."""
6768

6869

69-
class LoggingConfigUpdatingActor(Actor):
70+
class LoggingConfigUpdatingActor(Actor, Reconfigurable[LoggingConfig]):
7071
"""Actor that listens for logging configuration changes and sets them.
7172
7273
Example:
@@ -84,26 +85,12 @@ class LoggingConfigUpdatingActor(Actor):
8485
8586
```python
8687
import asyncio
87-
from collections.abc import Mapping
88-
from typing import Any
8988
90-
from frequenz.channels import Broadcast
91-
from frequenz.sdk.config import LoggingConfigUpdatingActor, ConfigManagingActor
89+
from frequenz.sdk.config import LoggingConfigUpdatingActor
9290
from frequenz.sdk.actor import run as run_actors
9391
9492
async def run() -> None:
95-
config_channel = Broadcast[Mapping[str, Any]](name="config", resend_latest=True)
96-
actors = [
97-
ConfigManagingActor(
98-
config_paths=["config.toml"], output=config_channel.new_sender()
99-
),
100-
LoggingConfigUpdatingActor(
101-
config_recv=config_channel.new_receiver(limit=1)).map(
102-
lambda app_config: app_config.get("logging", {}
103-
)
104-
),
105-
]
106-
await run_actors(*actors)
93+
await run_actors(LoggingConfigUpdatingActor())
10794
10895
asyncio.run(run())
10996
```
@@ -112,18 +99,24 @@ async def run() -> None:
11299
will be updated as well.
113100
"""
114101

102+
# pylint: disable-next=too-many-arguments
115103
def __init__(
116104
self,
117105
*,
118-
config_recv: Receiver[Mapping[str, Any]],
106+
config_key: str | Sequence[str] = "logging",
107+
config_manager: ConfigManager | None = None,
119108
log_datefmt: str = "%Y-%m-%dT%H:%M:%S%z",
120109
log_format: str = "%(asctime)s %(levelname)-8s %(name)s:%(lineno)s: %(message)s",
121110
name: str | None = None,
122111
):
123112
"""Initialize this instance.
124113
125114
Args:
126-
config_recv: The receiver to listen for configuration changes.
115+
config_key: The key to use to retrieve the configuration from the
116+
configuration manager. If `None`, the whole configuration will be used.
117+
config_manager: The configuration manager to use. If `None`, the [global
118+
configuration manager][frequenz.sdk.config.get_config_manager] will be
119+
used.
127120
log_datefmt: Use the specified date/time format in logs.
128121
log_format: Use the specified format string in logs.
129122
name: The name of this actor. If `None`, `str(id(self))` will be used. This
@@ -135,15 +128,18 @@ def __init__(
135128
in the application (through a previous `basicConfig()` call), then the format
136129
settings specified here will be ignored.
137130
"""
138-
self._config_recv = config_recv
131+
self._current_config: LoggingConfig = LoggingConfig()
132+
133+
super().__init__(
134+
name=name,
135+
config_key=config_key,
136+
config_manager=config_manager,
137+
config_schema=LoggingConfig,
138+
)
139139

140140
# Setup default configuration.
141141
# This ensures logging is configured even if actor fails to start or
142142
# if the configuration cannot be loaded.
143-
self._current_config: LoggingConfig = LoggingConfig()
144-
145-
super().__init__(name=name)
146-
147143
logging.basicConfig(
148144
format=log_format,
149145
datefmt=log_datefmt,
@@ -152,17 +148,11 @@ def __init__(
152148

153149
async def _run(self) -> None:
154150
"""Listen for configuration changes and update logging."""
155-
async for message in self._config_recv:
156-
try:
157-
new_config = load_config(LoggingConfig, message)
158-
except marshmallow.ValidationError:
159-
_logger.exception(
160-
"Invalid logging configuration received. Skipping config update"
161-
)
162-
continue
163-
164-
if new_config != self._current_config:
165-
self._update_logging(new_config)
151+
config_receiver = await self.initialize_config(skip_none=False)
152+
async for new_config in config_receiver:
153+
# When we receive None, we want to reset the logging configuration to the
154+
# default
155+
self._update_logging(new_config or LoggingConfig())
166156

167157
def _update_logging(self, config: LoggingConfig) -> None:
168158
"""Configure the logging level."""

tests/config/test_logging_actor.py

Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import asyncio
77
import logging
8-
from collections.abc import Mapping
98
from typing import Any
109

1110
import pytest
@@ -77,78 +76,56 @@ async def test_logging_config_updating_actor(
7776
# is not working anyway - python ignores it.
7877
mocker.patch("frequenz.sdk.config._logging_actor.logging.basicConfig")
7978

80-
config_channel = Broadcast[Mapping[str, Any]](name="config")
81-
config_sender = config_channel.new_sender()
79+
# Mock ConfigManager
80+
mock_config_manager = mocker.Mock()
81+
mock_config_manager.config_channel = Broadcast[LoggingConfig | None](name="config")
82+
mock_config_manager.new_receiver = mocker.AsyncMock(
83+
return_value=mock_config_manager.config_channel.new_receiver()
84+
)
85+
8286
async with LoggingConfigUpdatingActor(
83-
config_recv=config_channel.new_receiver().map(
84-
lambda app_config: app_config.get("logging", {})
85-
)
87+
config_key="logging",
88+
config_manager=mock_config_manager,
8689
) as actor:
8790
assert logging.getLogger("frequenz.sdk.actor").level == logging.NOTSET
8891
assert logging.getLogger("frequenz.sdk.timeseries").level == logging.NOTSET
8992

9093
update_logging_spy = mocker.spy(actor, "_update_logging")
9194

9295
# Send first config
93-
await config_sender.send(
94-
{
95-
"logging": {
96-
"root_logger": {"level": "ERROR"},
97-
"loggers": {
98-
"frequenz.sdk.actor": {"level": "DEBUG"},
99-
"frequenz.sdk.timeseries": {"level": "ERROR"},
100-
},
101-
}
102-
}
96+
expected_config = LoggingConfig(
97+
root_logger=LoggerConfig(level="ERROR"),
98+
loggers={
99+
"frequenz.sdk.actor": LoggerConfig(level="DEBUG"),
100+
"frequenz.sdk.timeseries": LoggerConfig(level="ERROR"),
101+
},
103102
)
103+
await mock_config_manager.config_channel.new_sender().send(expected_config)
104104
await asyncio.sleep(0.01)
105-
update_logging_spy.assert_called_once_with(
106-
LoggingConfig(
107-
root_logger=LoggerConfig(level="ERROR"),
108-
loggers={
109-
"frequenz.sdk.actor": LoggerConfig(level="DEBUG"),
110-
"frequenz.sdk.timeseries": LoggerConfig(level="ERROR"),
111-
},
112-
)
113-
)
105+
update_logging_spy.assert_called_once_with(expected_config)
114106
assert logging.getLogger("frequenz.sdk.actor").level == logging.DEBUG
115107
assert logging.getLogger("frequenz.sdk.timeseries").level == logging.ERROR
116108
update_logging_spy.reset_mock()
117109

118110
# Update config
119-
await config_sender.send(
120-
{
121-
"logging": {
122-
"root_logger": {"level": "WARNING"},
123-
"loggers": {
124-
"frequenz.sdk.actor": {"level": "INFO"},
125-
},
126-
}
127-
}
128-
)
129-
await asyncio.sleep(0.01)
130111
expected_config = LoggingConfig(
131112
root_logger=LoggerConfig(level="WARNING"),
132113
loggers={
133114
"frequenz.sdk.actor": LoggerConfig(level="INFO"),
134115
},
135116
)
117+
await mock_config_manager.config_channel.new_sender().send(expected_config)
118+
await asyncio.sleep(0.01)
136119
update_logging_spy.assert_called_once_with(expected_config)
137120
assert logging.getLogger("frequenz.sdk.actor").level == logging.INFO
138121
assert logging.getLogger("frequenz.sdk.timeseries").level == logging.NOTSET
139122
update_logging_spy.reset_mock()
140123

141-
# Send invalid config to make sure actor doesn't crash and doesn't setup invalid config.
142-
await config_sender.send({"logging": {"root_logger": {"level": "UNKNOWN"}}})
143-
await asyncio.sleep(0.01)
144-
update_logging_spy.assert_not_called()
145-
assert actor._current_config == expected_config
146-
update_logging_spy.reset_mock()
147-
148-
# Send empty config to reset logging to default
149-
await config_sender.send({"other": {"var1": 1}})
124+
# Send a None config to make sure actor doesn't crash and configures a default logging
125+
await mock_config_manager.config_channel.new_sender().send(None)
150126
await asyncio.sleep(0.01)
151127
update_logging_spy.assert_called_once_with(LoggingConfig())
152-
assert logging.getLogger("frequenz.sdk.actor").level == logging.NOTSET
153-
assert logging.getLogger("frequenz.sdk.timeseries").level == logging.NOTSET
128+
assert (
129+
actor._current_config == LoggingConfig() # pylint: disable=protected-access
130+
)
154131
update_logging_spy.reset_mock()

0 commit comments

Comments
 (0)