Skip to content

Commit f7465bb

Browse files
committed
Add support to filter a sub-key
Configuration can be nested, and actors could have sub-actors that need their own configuration, so we need to support filtering the configuration by a sequence of keys. For example, if the configuration is `{"key": {"subkey": "value"}}`, and the key is `["key", "subkey"]`, then the receiver will get only `"value"`. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 36eca5b commit f7465bb

File tree

1 file changed

+66
-13
lines changed

1 file changed

+66
-13
lines changed

src/frequenz/sdk/config/_manager.py

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ async def new_receiver(
129129
*,
130130
wait_for_first: bool = True,
131131
skip_unchanged: bool = True,
132-
key: str,
132+
key: str | Sequence[str],
133133
) -> Receiver[Mapping[str, Any] | None]: ...
134134

135135
@overload
@@ -138,7 +138,7 @@ async def new_receiver( # pylint: disable=too-many-arguments
138138
*,
139139
wait_for_first: bool = True,
140140
skip_unchanged: bool = True,
141-
key: str,
141+
key: str | Sequence[str],
142142
schema: type[DataclassT],
143143
base_schema: type[Schema] | None,
144144
**marshmallow_load_kwargs: Any,
@@ -150,7 +150,12 @@ async def new_receiver( # pylint: disable=too-many-arguments # noqa: DOC502
150150
*,
151151
wait_for_first: bool = False,
152152
skip_unchanged: bool = True,
153-
key: str | None = None,
153+
# This is tricky, because a str is also a Sequence[str], if we would use only
154+
# Sequence[str], then a regular string would also be accepted and taken as
155+
# a sequence, like "key" -> ["k", "e", "y"]. We should never remove the str from
156+
# the allowed types without changing Sequence[str] to something more specific,
157+
# like list[str] or tuple[str].
158+
key: str | Sequence[str] | None = None,
154159
schema: type[DataclassT] | None = None,
155160
base_schema: type[Schema] | None = None,
156161
**marshmallow_load_kwargs: Any,
@@ -184,6 +189,10 @@ async def new_receiver( # pylint: disable=too-many-arguments # noqa: DOC502
184189
otherwise only the part of the configuration under the specified key is
185190
received, or `None` if the key is not found.
186191
192+
If the key is a sequence of strings, it will be treated as a nested key and the
193+
receiver will receive the configuration under the nested key. For example
194+
`["key", "subkey"]` will get only `config["key"]["subkey"]`.
195+
187196
### Schema validation
188197
189198
The configuration is received as a dictionary unless a `schema` is provided. In
@@ -267,7 +276,7 @@ def _load_config_with_logging(
267276
config: Mapping[str, Any],
268277
schema: type[DataclassT],
269278
*,
270-
key: str,
279+
key: str | Sequence[str],
271280
base_schema: type[Schema] | None = None,
272281
**marshmallow_load_kwargs: Any,
273282
) -> DataclassT | None | _InvalidConfig: ...
@@ -276,13 +285,13 @@ def _load_config_with_logging(
276285
config: Mapping[str, Any],
277286
schema: type[DataclassT],
278287
*,
279-
key: str | None = None,
288+
key: str | Sequence[str] | None = None,
280289
base_schema: type[Schema] | None = None,
281290
**marshmallow_load_kwargs: Any,
282291
) -> DataclassT | None | _InvalidConfig:
283292
"""Try to load a configuration and log any validation errors."""
284293
if key is not None:
285-
maybe_config = config.get(key, None)
294+
maybe_config = _get_key(config, key)
286295
if maybe_config is None:
287296
_logger.debug(
288297
"Configuration key %s not found, sending None: config=%r",
@@ -347,7 +356,7 @@ def _is_valid(
347356
)
348357
).filter(_is_valid)
349358
case (str(), None):
350-
return receiver.map(lambda config: config.get(key))
359+
return receiver.map(lambda config: _get_key(config, key))
351360
case (str(), type()):
352361
return receiver.map(
353362
lambda config: _load_config_with_logging(
@@ -372,7 +381,7 @@ class _NotEqualWithLogging:
372381
configuration has not changed for the specified key.
373382
"""
374383

375-
def __init__(self, key: str | None = None) -> None:
384+
def __init__(self, key: str | Sequence[str] | None) -> None:
376385
"""Initialize this instance.
377386
378387
Args:
@@ -384,18 +393,19 @@ def __call__(
384393
self, old_config: Mapping[str, Any] | None, new_config: Mapping[str, Any] | None
385394
) -> bool:
386395
"""Return whether the two mappings are not equal, logging if they are the same."""
387-
if self._key is None:
396+
key = self._key
397+
if key is None:
388398
has_changed = new_config != old_config
389399
else:
390400
match (new_config, old_config):
391401
case (None, None):
392402
has_changed = False
393403
case (None, Mapping()):
394-
has_changed = old_config.get(self._key) is not None
404+
has_changed = _get_key(old_config, key) is not None
395405
case (Mapping(), None):
396-
has_changed = new_config.get(self._key) is not None
406+
has_changed = _get_key(new_config, key) is not None
397407
case (Mapping(), Mapping()):
398-
has_changed = new_config.get(self._key) != old_config.get(self._key)
408+
has_changed = _get_key(new_config, key) != _get_key(old_config, key)
399409
case unexpected:
400410
# We can't use `assert_never` here because `mypy` is having trouble
401411
# narrowing the types of a tuple. See for example:
@@ -406,13 +416,56 @@ def __call__(
406416
assert False, f"Unexpected match: {unexpected}"
407417

408418
if not has_changed:
409-
key_str = f" for key '{self._key}'" if self._key else ""
419+
key_str = f" for key '{key}'" if key else ""
410420
_logger.info("Configuration%s has not changed, skipping update", key_str)
411421
_logger.debug("Old configuration%s being kept: %r", key_str, old_config)
412422

413423
return has_changed
414424

415425

426+
def _get_key(
427+
config: Mapping[str, Any],
428+
# This is tricky, because a str is also a Sequence[str], if we would use only
429+
# Sequence[str], then a regular string would also be accepted and taken as
430+
# a sequence, like "key" -> ["k", "e", "y"]. We should never remove the str from
431+
# the allowed types without changing Sequence[str] to something more specific,
432+
# like list[str] or tuple[str].
433+
key: str | Sequence[str] | None,
434+
) -> Mapping[str, Any] | None:
435+
"""Get the value from the configuration under the specified key."""
436+
if key is None:
437+
return config
438+
# Order here is very important too, str() needs to come first, otherwise a regular
439+
# will also match the Sequence[str] case.
440+
# TODO: write tests to validate this works correctly.
441+
if isinstance(key, str):
442+
key = (key,)
443+
value = config
444+
current_path = []
445+
for subkey in key:
446+
current_path.append(subkey)
447+
if value is None:
448+
return None
449+
match value.get(subkey):
450+
case None:
451+
return None
452+
case Mapping() as new_value:
453+
value = new_value
454+
case _:
455+
subkey_str = ""
456+
if len(key) > 1:
457+
subkey_str = f" when looking for sub-key {key!r}"
458+
_logger.error(
459+
"Found key %r%s but it's not a mapping, returning None: config=%r",
460+
current_path[0] if len(current_path) == 1 else current_path,
461+
subkey_str,
462+
config,
463+
)
464+
return None
465+
value = new_value
466+
return value
467+
468+
416469
class _InvalidConfig:
417470
"""A sentinel to represent an invalid configuration."""
418471

0 commit comments

Comments
 (0)