Coverage for core / src / sensorkit / config / section.py: 100%
53 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
1# SPDX-License-Identifier: Apache-2.0
2import operator
3from collections.abc import Iterable, Mapping
4from typing import Any, Callable, Literal, NamedTuple
6from pydantic import BaseModel, TypeAdapter
8type UnifiedConfigIdMapper = Callable[[Any], Iterable[str]]
9type UnifiedConfigModelMapper[T] = Callable[[T], Iterable[BaseModel]]
10type IdSource = Literal["by_key", "by_subkey", "mapping_key", "default"]
12DEFAULT_ID_KEY = "id"
13KEYED_ID_SOURCES = ("by_key", "by_subkey")
14DEFAULTABLE_ID_SOURCES = ("by_key", "default")
17def _section_entries(value: Any) -> Iterable[Any]:
18 return value.values() if isinstance(value, Mapping) else value
21def _validate_id_naming(key: str, id_source: str, id_key: str | None, id_default: str | None):
22 """Reject a declaration whose ID arguments contradict the source it names.
24 Args:
25 key: Top-level YAML key the section claims.
26 id_source: Where the section takes its entity IDs from.
27 id_key: Key holding the entity ID, where one was given.
28 id_default: Entity ID to fall back on, where one was given.
30 Raises:
31 ValueError: The arguments cannot apply together.
32 """
33 if id_key is not None and id_source not in KEYED_ID_SOURCES:
34 raise ValueError(f"Config section {key!r} cannot take 'id_key' with {id_source!r}")
36 # One ID cannot stand in for entries that omit their key, which would leave several
37 # entries sharing an entity.
38 if id_default is not None and id_source not in DEFAULTABLE_ID_SOURCES:
39 raise ValueError(f"Config section {key!r} cannot take 'id_default' with {id_source!r}")
41 if id_source == "default" and id_default is None:
42 raise ValueError(f"Config section {key!r} must give an 'id_default' with {id_source!r}")
45class ConfigSection(NamedTuple):
46 """A config section handler.
48 Args:
49 key: Top-level YAML key this handler claims.
50 adapter: Validates the section's raw value.
51 id_source: Where the section takes its entity IDs from.
52 id_mapper: Takes the raw value and returns the entity ID to write each model under,
53 one per model the model mapper yields.
54 model_mapper: Takes the validated instance and yields the models to write to KV, one
55 per ID. None writes the validated instance itself.
56 service_path: Path to a service implementation, where the section launches one.
57 id_key: Key holding the entity ID, for the keyed ID sources.
58 id_key_required: Whether the file has to supply `id_key`. False where the section
59 falls back to an ID of its own.
60 """
62 key: str
63 adapter: TypeAdapter
64 id_source: IdSource
65 id_mapper: UnifiedConfigIdMapper
66 model_mapper: UnifiedConfigModelMapper | None = None
67 service_path: str | None = None
68 id_key: str | None = None
69 id_key_required: bool = False
72_registry: dict[str, ConfigSection] = {}
75def declare_config_section[T](
76 key: str,
77 parse_type: Any,
78 *,
79 id_source: IdSource,
80 id_key: str | None = None,
81 id_default: str | None = None,
82 id_mapper: UnifiedConfigIdMapper | None = None,
83 model_mapper: UnifiedConfigModelMapper[T] | None = None,
84 service_path: str | None = None,
85):
86 """Declare and register a config section handler.
88 The section's records are validated against `parse_type` and written to the KV namespace
89 of the entity each one configures. Where those IDs come from is declared rather than
90 computed, so the generated JSON Schema can describe the key carrying them alongside the
91 model's own fields.
93 Args:
94 key: Top-level YAML key this handler claims.
95 parse_type: Pydantic-compatible type used to validate the raw YAML value. Accepts any
96 form TypeAdapter understands: type[T], list[T], etc.
97 id_source: Where the section takes the entity ID for each of its records. `by_key`
98 reads it from a key of the value itself, for a section configuring one entity.
99 `by_subkey` reads it from a key of each entry, for a section configuring one
100 entity per entry. `mapping_key` takes the IDs from the value's own keys.
101 `default` names the entity at declaration, and the file supplies no ID at all.
102 id_key: Key holding the entity ID, for `by_key` and `by_subkey`. Defaults to `id`.
103 id_default: Entity ID to use where the file supplies none. Required with `default`,
104 which admits no other ID. Given with `by_key` it makes the key optional.
105 id_mapper: Optional callable that takes the raw value and returns an entity ID per
106 model, replacing the one `id_source` implies. Sections producing several models
107 from a single entry need one, since IDs are matched against the models rather
108 than against the entries.
109 model_mapper: Optional callable that takes the validated instance and yields the
110 models to write to KV, one per ID. Defaults to the section's entries.
111 service_path: Optional path to a service implementation.
113 Raises:
114 ValueError: The section is already registered, or its ID naming is inconsistent.
115 """
116 _validate_id_naming(key, id_source, id_key, id_default)
118 resolved_key = id_key or DEFAULT_ID_KEY
120 match id_source:
121 case "by_key":
122 derived = (
123 operator.itemgetter(resolved_key)
124 if id_default is None
125 else lambda value: value.get(resolved_key, id_default)
126 )
127 case "by_subkey":
128 derived = lambda value: (entry[resolved_key] for entry in _section_entries(value))
129 model_mapper = model_mapper or _section_entries
130 case "mapping_key":
131 derived = iter
132 model_mapper = model_mapper or _section_entries
133 case "default":
134 derived = lambda _: id_default
135 case _:
136 raise ValueError(f"Config section {key!r} has an unknown ID source {id_source!r}")
138 if key in _registry:
139 raise ValueError(f"Config section {key!r} already registered")
141 keyed = id_source in KEYED_ID_SOURCES
143 _registry[key] = ConfigSection(
144 key=key,
145 adapter=TypeAdapter(parse_type),
146 id_source=id_source,
147 id_mapper=id_mapper or derived,
148 model_mapper=model_mapper,
149 service_path=service_path,
150 id_key=resolved_key if keyed else None,
151 id_key_required=keyed and id_default is None,
152 )
155def get_config_section(key: str) -> ConfigSection | None:
156 """Retrieve a registered config section handler.
158 Args:
159 key: The top-level YAML key to look up.
161 Returns:
162 The registered section, or None if the key is not found in the registry.
163 """
164 return _registry.get(key)
167def config_sections() -> dict[str, ConfigSection]:
168 """Retrieve every registered config section handler.
170 Returns:
171 A snapshot of the registry, keyed by top-level YAML key.
172 """
173 return dict(_registry)