@@ -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+
416469class _InvalidConfig :
417470 """A sentinel to represent an invalid configuration."""
418471
0 commit comments