Skip to content

Commit c3f4aca

Browse files
Add support to configure log level via config file.
Add marshmallow LoggerConfig and LoggingConfig classes to load logging configuration. Add LoggingConfigUpdater actor to listen for config updates and configure the loggers. Signed-off-by: Elzbieta Kotulska <[email protected]>
1 parent ff786cd commit c3f4aca

File tree

4 files changed

+308
-1
lines changed

4 files changed

+308
-1
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ dependencies = [
3535
"networkx >= 2.8, < 4",
3636
"numpy >= 1.26.4, < 2",
3737
"typing_extensions >= 4.6.1, < 5",
38+
"marshmallow >= 3.19.0, < 4",
39+
"marshmallow_dataclass >= 8.7.1, < 9",
3840
]
3941
dynamic = ["version"]
4042

src/frequenz/sdk/config/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,11 @@
44
"""Read and update config variables."""
55

66
from ._config_managing import ConfigManagingActor
7+
from ._logging_config_updater import LoggerConfig, LoggingConfig, LoggingConfigUpdater
78

8-
__all__ = ["ConfigManagingActor"]
9+
__all__ = [
10+
"ConfigManagingActor",
11+
"LoggingConfig",
12+
"LoggerConfig",
13+
"LoggingConfigUpdater",
14+
]
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Read and update logging severity from config."""
5+
6+
import logging
7+
from collections.abc import Mapping
8+
from dataclasses import field
9+
from typing import Annotated, Any, Self, cast
10+
11+
import marshmallow
12+
import marshmallow.validate
13+
from frequenz.channels import Receiver
14+
from marshmallow import RAISE
15+
from marshmallow_dataclass import class_schema, dataclass
16+
17+
from frequenz.sdk.actor import Actor
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
LogLevel = Annotated[
22+
str,
23+
marshmallow.fields.String(
24+
validate=marshmallow.validate.OneOf(choices=logging.getLevelNamesMapping())
25+
),
26+
]
27+
28+
29+
@dataclass
30+
class LoggerConfig:
31+
"""A configuration for a logger."""
32+
33+
level: LogLevel = field(
34+
default="INFO",
35+
metadata={
36+
"metadata": {
37+
"description": "Log level for the logger. Uses standard logging levels."
38+
},
39+
"required": False,
40+
},
41+
)
42+
"""The log level for the logger."""
43+
44+
45+
@dataclass
46+
class LoggingConfig:
47+
"""A configuration for the logging system."""
48+
49+
default: LoggerConfig = field(
50+
default_factory=LoggerConfig,
51+
metadata={
52+
"metadata": {
53+
"description": "Default default configuration for all loggers.",
54+
},
55+
"required": False,
56+
},
57+
)
58+
"""The default log level."""
59+
60+
loggers: dict[str, LoggerConfig] = field(
61+
default_factory=dict,
62+
metadata={
63+
"metadata": {
64+
"description": "Configuration for a logger (the key is the logger name)."
65+
},
66+
"required": False,
67+
},
68+
)
69+
"""The list of loggers configurations."""
70+
71+
@classmethod
72+
def load(cls, configs: Mapping[str, Any]) -> Self: # noqa: DOC502
73+
"""Load and validate configs from a dictionary.
74+
75+
Args:
76+
configs: The configuration to validate.
77+
78+
Returns:
79+
The configuration if they are valid.
80+
81+
Raises:
82+
ValidationError: if the configuration are invalid.
83+
"""
84+
schema = class_schema(cls)()
85+
return cast(Self, schema.load(configs, unknown=RAISE))
86+
87+
88+
class LoggingConfigUpdater(Actor):
89+
"""Actor that listens for logging configuration changes and sets them.
90+
91+
Example:
92+
`config.toml` file:
93+
```toml
94+
[logging.default]
95+
level = "INFO"
96+
97+
[logging.loggers."frequenz.actor.pv_self_consumption"]
98+
level = "DEBUG"
99+
100+
[logging.loggers."frequenz.actor.forecast"]
101+
level = "DEBUG"
102+
```
103+
104+
```python
105+
import asyncio
106+
from collections.abc import Mapping
107+
from typing import Any
108+
109+
from frequenz.channels import Broadcast
110+
from frequenz.sdk.config import LoggingConfigUpdater, ConfigManager
111+
from frequenz.sdk.actor import run as run_actors
112+
113+
async def run() -> None:
114+
config_channel = Broadcast[Mapping[str, Any]](name="config", resend_latest=True)
115+
actors = [
116+
ConfigManager(config_paths=["config.toml"], output=config_channel.new_sender()),
117+
LoggingConfigUpdater(
118+
config_recv=config_channel.new_receiver(limit=1)).map(
119+
lambda app_config: app_config.get("logging", {}
120+
)
121+
),
122+
]
123+
await run_actors(*actors)
124+
125+
asyncio.run(run())
126+
```
127+
128+
Now whenever the `config.toml` file is updated, the logging configuration
129+
will be updated as well.
130+
"""
131+
132+
def __init__(
133+
self,
134+
config_recv: Receiver[Mapping[str, Any]],
135+
log_format: str = "%(asctime)s %(levelname)-8s %(name)s:%(lineno)s: %(message)s",
136+
log_datefmt: str = "%Y-%m-%dT%H:%M:%S%z",
137+
):
138+
"""Initialize this instance.
139+
140+
Args:
141+
config_recv: The receiver to listen for configuration changes.
142+
log_format: Use the specified format string in logs.
143+
log_datefmt: Use the specified date/time format in logs.
144+
"""
145+
super().__init__()
146+
self._config_recv = config_recv
147+
self._logging_config: LoggingConfig
148+
self._format = log_format
149+
self._datefmt = log_datefmt
150+
151+
# Setup default configuration.
152+
# This ensures logging is configured even if actor fails to start or
153+
# if the configuration cannot be loaded.
154+
self._update_logging(LoggingConfig())
155+
156+
async def _run(self) -> None:
157+
"""Listen for configuration changes and update logging."""
158+
async for message in self._config_recv:
159+
try:
160+
new_config = LoggingConfig.load(message)
161+
except marshmallow.ValidationError:
162+
_logger.exception(
163+
"Invalid logging configuration received. Skipping config update"
164+
)
165+
continue
166+
167+
if new_config != self._logging_config:
168+
self._update_logging(new_config)
169+
170+
def _update_logging(self, config: LoggingConfig) -> None:
171+
"""Configure the logging level."""
172+
self._logging_config = config
173+
174+
logging.basicConfig(
175+
format=self._format,
176+
level=self._logging_config.default.level,
177+
datefmt=self._datefmt,
178+
)
179+
180+
for logger_id, logger_config in self._logging_config.loggers.items():
181+
_logger.debug(
182+
"Setting log level for logger '%s' to '%s'",
183+
logger_id,
184+
logger_config.level,
185+
)
186+
logging.getLogger(logger_id).setLevel(logger_config.level)
187+
188+
_logger.info("Logging config changed to: %s", self._logging_config)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for logging config updater."""
5+
6+
import asyncio
7+
from collections.abc import Mapping
8+
from typing import Any
9+
10+
import pytest
11+
from frequenz.channels import Broadcast
12+
from marshmallow import ValidationError
13+
from pytest_mock import MockerFixture
14+
15+
from frequenz.sdk.config import LoggerConfig, LoggingConfig, LoggingConfigUpdater
16+
17+
18+
def test_logging_config() -> None:
19+
"""Test if logging config is correctly loaded."""
20+
config_raw = {
21+
"default": {"level": "DEBUG"},
22+
"loggers": {
23+
"frequenz.sdk.actor": {"level": "INFO"},
24+
"frequenz.sdk.timeseries": {"level": "ERROR"},
25+
},
26+
}
27+
config = LoggingConfig(
28+
default=LoggerConfig(level="DEBUG"),
29+
loggers={
30+
"frequenz.sdk.actor": LoggerConfig(level="INFO"),
31+
"frequenz.sdk.timeseries": LoggerConfig(level="ERROR"),
32+
},
33+
)
34+
35+
assert LoggingConfig.load(config_raw) == config
36+
37+
config_raw = {}
38+
config = LoggingConfig()
39+
assert LoggingConfig.load(config_raw) == config
40+
41+
config_raw = {"default": {"level": "UNKNOWN"}}
42+
with pytest.raises(ValidationError):
43+
LoggingConfig.load(config_raw)
44+
45+
config_raw = {"unknown": {"frequenz.sdk.actor": {"level": "DEBUG"}}}
46+
with pytest.raises(ValidationError):
47+
LoggingConfig.load(config_raw)
48+
49+
50+
async def test_logging_config_updater_actor(
51+
mocker: MockerFixture,
52+
) -> None:
53+
"""Test if logging is configured and updated correctly."""
54+
# Mock logging module in this file. Other logging won't be affected.
55+
logging_mock = mocker.patch("frequenz.sdk.config._logging_config_updater.logging")
56+
57+
config_channel = Broadcast[Mapping[str, Any]](name="config")
58+
config_sender = config_channel.new_sender()
59+
async with LoggingConfigUpdater(
60+
config_recv=config_channel.new_receiver().map(
61+
lambda app_config: app_config.get("logging", {})
62+
)
63+
) as actor:
64+
# Check if default config has been set
65+
logging_mock.basicConfig.assert_called_once()
66+
67+
update_logging_spy = mocker.spy(actor, "_update_logging")
68+
69+
# Send first config
70+
await config_sender.send({"logging": {"default": {"level": "ERROR"}}})
71+
await asyncio.sleep(0.01)
72+
update_logging_spy.assert_called_once_with(
73+
LoggingConfig(default=LoggerConfig(level="ERROR"))
74+
)
75+
update_logging_spy.reset_mock()
76+
77+
# Update config
78+
await config_sender.send(
79+
{
80+
"logging": {
81+
"default": {"level": "WARNING"},
82+
"loggers": {
83+
"frequenz.sdk.actor": {"level": "INFO"},
84+
"frequenz.sdk.timeseries": {"level": "ERROR"},
85+
},
86+
}
87+
}
88+
)
89+
await asyncio.sleep(0.01)
90+
expected_config = LoggingConfig(
91+
default=LoggerConfig(level="WARNING"),
92+
loggers={
93+
"frequenz.sdk.actor": LoggerConfig(level="INFO"),
94+
"frequenz.sdk.timeseries": LoggerConfig(level="ERROR"),
95+
},
96+
)
97+
update_logging_spy.assert_called_once_with(expected_config)
98+
update_logging_spy.reset_mock()
99+
100+
# Send invalid config to make sure actor doesn't crash and doesn't setup invalid config.
101+
await config_sender.send({"logging": {"default": {"level": "UNKNOWN"}}})
102+
await asyncio.sleep(0.01)
103+
update_logging_spy.assert_not_called()
104+
assert actor._logging_config == expected_config
105+
update_logging_spy.reset_mock()
106+
107+
# Send empty config
108+
await config_sender.send({"other": {"var1": 1}})
109+
await asyncio.sleep(0.01)
110+
update_logging_spy.assert_called_once_with(LoggingConfig())
111+
update_logging_spy.reset_mock()

0 commit comments

Comments
 (0)