Skip to content

Commit 27c4886

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 7d086e3 commit 27c4886

File tree

1 file changed

+63
-18
lines changed

1 file changed

+63
-18
lines changed

src/frequenz/sdk/config/_manager.py

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
class InvalidValueForKeyError(ValueError):
2323
"""An error indicating that the value under the specified key is invalid."""
2424

25-
def __init__(self, msg: str, *, key: str, value: Any) -> None:
25+
def __init__(self, msg: str, *, key: Sequence[str], value: Any) -> None:
2626
"""Initialize this error.
2727
2828
Args:
@@ -126,7 +126,12 @@ def __repr__(self) -> str:
126126

127127
def new_receiver(
128128
self,
129-
key: str,
129+
# This is tricky, because a str is also a Sequence[str], if we would use only
130+
# Sequence[str], then a regular string would also be accepted and taken as
131+
# a sequence, like "key" -> ["k", "e", "y"]. We should never remove the str from
132+
# the allowed types without changing Sequence[str] to something more specific,
133+
# like list[str] or tuple[str] (but both have their own problems).
134+
key: str | Sequence[str],
130135
/,
131136
*,
132137
skip_unchanged: bool = True,
@@ -144,6 +149,10 @@ def new_receiver(
144149
Only the configuration under the `key` will be received by the receiver. If the
145150
`key` is not found in the configuration, the receiver will receive `None`.
146151
152+
If the key is a sequence of strings, it will be treated as a nested key and the
153+
receiver will receive the configuration under the nested key. For example
154+
`["key", "subkey"]` will get only `config["key"]["subkey"]`.
155+
147156
The value under `key` must be another mapping, otherwise an error
148157
will be logged and a [`frequenz.sdk.config.InvalidValueForKeyError`][] instance
149158
will be sent to the receiver.
@@ -164,14 +173,16 @@ def new_receiver(
164173
```
165174
166175
Args:
167-
key: The configuration key to be read by the receiver.
176+
key: The configuration key to be read by the receiver. If a sequence of
177+
strings is used, it is used as a sub-key.
168178
skip_unchanged: Whether to skip sending the configuration if it hasn't
169179
changed compared to the last one received.
170180
171181
Returns:
172182
The receiver for the configuration.
173183
"""
174-
receiver = self.config_channel.new_receiver(name=f"{self}:{key}", limit=1)
184+
recv_name = key if isinstance(key, str) else ":".join(key)
185+
receiver = self.config_channel.new_receiver(name=recv_name, limit=1)
175186

176187
def _get_key_or_error(
177188
config: Mapping[str, Any]
@@ -184,32 +195,55 @@ def _get_key_or_error(
184195
key_receiver = receiver.map(_get_key_or_error)
185196

186197
if skip_unchanged:
187-
return key_receiver.filter(WithPrevious(_not_equal_with_logging))
198+
# For some reason the type argument for WithPrevious is not inferred
199+
# correctly, so we need to specify it explicitly.
200+
return key_receiver.filter(
201+
WithPrevious[Mapping[str, Any] | InvalidValueForKeyError | None](
202+
lambda old, new: _not_equal_with_logging(
203+
key=key, old_value=old, new_value=new
204+
)
205+
)
206+
)
188207

189208
return key_receiver
190209

191210

192211
def _not_equal_with_logging(
212+
*,
213+
key: str | Sequence[str],
193214
old_value: Mapping[str, Any] | InvalidValueForKeyError | None,
194215
new_value: Mapping[str, Any] | InvalidValueForKeyError | None,
195216
) -> bool:
196217
"""Return whether the two mappings are not equal, logging if they are the same."""
197218
if old_value == new_value:
198-
_logger.info("Configuration has not changed, skipping update")
219+
_logger.info("Configuration has not changed for key %r, skipping update.", key)
199220
return False
200221

201222
if isinstance(new_value, InvalidValueForKeyError) and not isinstance(
202223
old_value, InvalidValueForKeyError
203224
):
225+
subkey_str = ""
226+
if key != new_value.key:
227+
subkey_str = f"When looking for sub-key {key!r}: "
204228
_logger.error(
205-
"Configuration for key %r has an invalid value: %r",
229+
"%sConfiguration for key %r has an invalid value: %r",
230+
subkey_str,
206231
new_value.key,
207232
new_value.value,
208233
)
209234
return True
210235

211236

212-
def _get_key(config: Mapping[str, Any], key: str) -> Mapping[str, Any] | None:
237+
def _get_key(
238+
config: Mapping[str, Any],
239+
# This is tricky, because a str is also a Sequence[str], if we would use only
240+
# Sequence[str], then a regular string would also be accepted and taken as
241+
# a sequence, like "key" -> ["k", "e", "y"]. We should never remove the str from
242+
# the allowed types without changing Sequence[str] to something more specific,
243+
# like list[str] or tuple[str].
244+
# TODO: write tests to validate this works correctly.
245+
key: str | Sequence[str],
246+
) -> Mapping[str, Any] | None:
213247
"""Get the value from the configuration under the specified key.
214248
215249
Args:
@@ -222,14 +256,25 @@ def _get_key(config: Mapping[str, Any], key: str) -> Mapping[str, Any] | None:
222256
Raises:
223257
InvalidValueForKeyError: If the value under the key is not a mapping.
224258
"""
225-
match config.get(key):
226-
case None:
259+
# We first normalize to a Sequence[str] to make it easier to work with.
260+
if isinstance(key, str):
261+
key = (key,)
262+
value = config
263+
current_path = []
264+
for subkey in key:
265+
current_path.append(subkey)
266+
if value is None:
227267
return None
228-
case Mapping() as value:
229-
return value
230-
case invalid_value:
231-
raise InvalidValueForKeyError(
232-
f"Value for key {key!r} is not a mapping: {invalid_value!r}",
233-
key=key,
234-
value=invalid_value,
235-
)
268+
match value.get(subkey):
269+
case None:
270+
return None
271+
case Mapping() as new_value:
272+
value = new_value
273+
case invalid_value:
274+
raise InvalidValueForKeyError(
275+
f"Value for key {current_path!r} is not a mapping: {invalid_value!r}",
276+
key=current_path,
277+
value=invalid_value,
278+
)
279+
value = new_value
280+
return value

0 commit comments

Comments
 (0)