Skip to content

Commit 1f839e4

Browse files
authored
Improve logging and API of the config module (frequenz-floss#1133)
- **Log the full actor name when restarting** - **Improve logging for configuration file reading** - **Make `load_config` keyword arguments keyword-only** - **Make arguments passed to `marshmallow` explicit** - **Document and fix how to implement custom stopping logic** - **Enabled INFO logging by default** - **Change debug logs to info**
2 parents f6f345f + d0fff79 commit 1f839e4

File tree

8 files changed

+78
-16
lines changed

8 files changed

+78
-16
lines changed

RELEASE_NOTES.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
+ The class is now immutable.
2727
+ The constructor now accepts only keyword arguments.
2828

29+
* `load_config()`:
30+
31+
+ The `base_schema` argument is now keyword-only.
32+
+ The arguments forwarded to `marshmallow.Schema.load()` now must be passed explicitly via the `marshmallow_load_kwargs` argument, as a `dict`, to improve the type-checking.
33+
2934
## New Features
3035

3136
- `LoggingConfigUpdatingActor`
@@ -34,4 +39,4 @@
3439

3540
## Bug Fixes
3641

37-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
42+
- Fix a bug in `BackgroundService` where it won't try to `self.cancel()` and `await self.wait()` if there are no internal tasks. This prevented to properly implement custom stop logic without having to redefine the `stop()` method too.

src/frequenz/sdk/actor/_actor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ async def _run_loop(self) -> None:
9999
limit_str = f"({n_restarts}/{limit_str})"
100100
if self._restart_limit is None or n_restarts < self._restart_limit:
101101
n_restarts += 1
102-
_logger.info("Actor %s: Restarting %s...", self._name, limit_str)
102+
_logger.info("Actor %s: Restarting %s...", self, limit_str)
103103
continue
104104
_logger.info(
105105
"Actor %s: Maximum restarts attempted %s, bailing out...",

src/frequenz/sdk/actor/_background_service.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ class BackgroundService(abc.ABC):
3636
information, please refer to the [Python `asyncio`
3737
documentation](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task).
3838
39-
Example:
39+
Example: Simple background service example
4040
```python
41+
from typing_extensions import override
4142
import datetime
4243
import asyncio
4344
@@ -46,6 +47,7 @@ def __init__(self, resolution_s: float, *, name: str | None = None) -> None:
4647
super().__init__(name=name)
4748
self._resolution_s = resolution_s
4849
50+
@override
4951
def start(self) -> None:
5052
self._tasks.add(asyncio.create_task(self._tick()))
5153
@@ -67,6 +69,45 @@ async def main() -> None:
6769
6870
asyncio.run(main())
6971
```
72+
73+
Example: Background service example using custom stopping logic
74+
If you need to implement custom stopping logic, you can override the
75+
[`cancel()`][frequenz.sdk.actor.BackgroundService.cancel] and
76+
[`wait()`][frequenz.sdk.actor.BackgroundService.wait] methods, and the
77+
[`is_running`][frequenz.sdk.actor.BackgroundService.is_running] property.
78+
79+
For example, if you are using an external library that uses tasks internally and
80+
you don't have access to them.
81+
82+
```python
83+
from typing_extensions import override
84+
import asyncio
85+
86+
class SomeService(BackgroundService):
87+
def __init__(self, somelib, *, name: str | None = None) -> None:
88+
self.somelib = somelib
89+
super().__init__(name=name)
90+
91+
@override
92+
def start(self) -> None:
93+
self.somelib.start()
94+
95+
@property
96+
@override
97+
def is_running(self) -> bool:
98+
return self.somelib.is_running()
99+
100+
@override
101+
def cancel(self, msg: str | None = None) -> None:
102+
self.somelib.cancel()
103+
104+
@override
105+
async def wait(self) -> None:
106+
try:
107+
await self.somelib.wait()
108+
except BaseExceptionGroup as exc:
109+
raise BaseExceptionGroup("Error while stopping SomeService", [exc]) from exc
110+
```
70111
"""
71112

72113
def __init__(self, *, name: str | None = None) -> None:
@@ -144,8 +185,6 @@ async def stop(self, msg: str | None = None) -> None: # noqa: DOC503
144185
BaseExceptionGroup: If any of the tasks spawned by this service raised an
145186
exception.
146187
"""
147-
if not self._tasks:
148-
return
149188
self.cancel(msg)
150189
try:
151190
await self.wait()

src/frequenz/sdk/config/_logging_actor.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class LoggingConfig:
4646
"""A configuration for the logging system."""
4747

4848
root_logger: LoggerConfig = field(
49-
default_factory=LoggerConfig,
49+
default_factory=lambda: LoggerConfig(level="INFO"),
5050
metadata={
5151
"metadata": {
5252
"description": "Default default configuration for all loggers.",
@@ -147,6 +147,7 @@ def __init__(
147147
logging.basicConfig(
148148
format=log_format,
149149
datefmt=log_datefmt,
150+
level=logging.INFO,
150151
)
151152
self._update_logging(self._current_config)
152153

@@ -173,18 +174,18 @@ def _update_logging(self, config: LoggingConfig) -> None:
173174
logging.getLogger(logger_id).setLevel(logging.NOTSET)
174175

175176
self._current_config = config
176-
_logger.debug(
177+
_logger.info(
177178
"Setting root logger level to '%s'", self._current_config.root_logger.level
178179
)
179180
logging.getLogger().setLevel(self._current_config.root_logger.level)
180181

181182
# For each logger in the new config, set the log level
182183
for logger_id, logger_config in self._current_config.loggers.items():
183-
_logger.debug(
184+
_logger.info(
184185
"Setting log level for logger '%s' to '%s'",
185186
logger_id,
186187
logger_config.level,
187188
)
188189
logging.getLogger(logger_id).setLevel(logger_config.level)
189190

190-
_logger.info("Logging config changed to: %s", self._current_config)
191+
_logger.info("Logging config update completed.")

src/frequenz/sdk/config/_managing_actor.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,26 @@ def _read_config(self) -> abc.Mapping[str, Any] | None:
117117
config: dict[str, Any] = {}
118118

119119
for config_path in self._config_paths:
120+
_logger.info("%s: Reading configuration file %s...", self, config_path)
120121
try:
121122
with config_path.open("rb") as toml_file:
122123
data = tomllib.load(toml_file)
124+
_logger.info(
125+
"%s: Configuration file %r read successfully.",
126+
self,
127+
str(config_path),
128+
)
123129
config = _recursive_update(config, data)
124130
except ValueError as err:
125131
_logger.error("%s: Can't read config file, err: %s", self, err)
126132
error_count += 1
127133
except OSError as err:
128134
# It is ok for config file to don't exist.
129135
_logger.error(
130-
"%s: Error reading config file %s (%s). Ignoring it.",
136+
"%s: Error reading config file %r (%s). Ignoring it.",
131137
self,
138+
str(config_path),
132139
err,
133-
config_path,
134140
)
135141
error_count += 1
136142

@@ -140,6 +146,12 @@ def _read_config(self) -> abc.Mapping[str, Any] | None:
140146
)
141147
return None
142148

149+
_logger.info(
150+
"%s: Read %s/%s configuration files successfully.",
151+
self,
152+
len(self._config_paths) - error_count,
153+
len(self._config_paths),
154+
)
143155
return config
144156

145157
async def send_config(self) -> None:

src/frequenz/sdk/config/_util.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ def load_config(
3232
cls: type[DataclassT],
3333
config: Mapping[str, Any],
3434
/,
35+
*,
3536
base_schema: type[Schema] | None = None,
36-
**marshmallow_load_kwargs: Any,
37+
marshmallow_load_kwargs: dict[str, Any] | None = None,
3738
) -> DataclassT:
3839
"""Load a configuration from a dictionary into an instance of a configuration class.
3940
@@ -62,13 +63,15 @@ def load_config(
6263
base_schema: An optional class to be used as a base schema for the configuration
6364
class. This allow using custom fields for example. Will be passed to
6465
[`marshmallow_dataclass.class_schema`][].
65-
**marshmallow_load_kwargs: Additional arguments to be passed to
66+
marshmallow_load_kwargs: Additional arguments to be passed to
6667
[`marshmallow.Schema.load`][].
6768
6869
Returns:
6970
The loaded configuration as an instance of the configuration class.
7071
"""
71-
instance = class_schema(cls, base_schema)().load(config, **marshmallow_load_kwargs)
72+
instance = class_schema(cls, base_schema)().load(
73+
config, **(marshmallow_load_kwargs or {})
74+
)
7275
# We need to cast because `.load()` comes from marshmallow and doesn't know which
7376
# type is returned.
7477
return cast(DataclassT, instance)

tests/actor/test_actor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ async def test_restart_on_unhandled_exception(
245245
),
246246
(
247247
*ACTOR_INFO,
248-
f"Actor test: Restarting ({i}/{restart_limit})...",
248+
f"Actor RaiseExceptionActor[test]: Restarting ({i}/{restart_limit})...",
249249
),
250250
(
251251
*ACTOR_INFO,

tests/config/test_util.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ def test_load_config_type_hints(mocker: MockerFixture) -> None:
100100
config: dict[str, Any] = {}
101101

102102
# We add the type hint to test that the return type (hint) is correct
103-
_: SimpleConfig = load_config(SimpleConfig, config, marshmallow_arg=1)
103+
_: SimpleConfig = load_config(
104+
SimpleConfig, config, marshmallow_load_kwargs={"marshmallow_arg": 1}
105+
)
104106
mock_class_schema.return_value.load.assert_called_once_with(
105107
config, marshmallow_arg=1
106108
)

0 commit comments

Comments
 (0)