Coverage for core / src / sensorkit / config / parser.py: 80%
96 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
2from __future__ import annotations
4import collections
5import importlib
6import itertools
7from collections.abc import Iterable, Mapping
8from dataclasses import dataclass
9from typing import Any
11from loguru import logger
12from pydantic import BaseModel, Field, model_validator
14from sensorkit.common.keyword import KeywordDict
15from sensorkit.config.section import ConfigSection, get_config_section
17PARSER_VERSION = 1
20class GeneralConfig(BaseModel, extra="forbid"):
21 backend: str | None = None
22 modules: list[str] = Field(default_factory=list)
23 imports: list[str] = Field(default_factory=list)
26class ServiceConfig(BaseModel, extra="forbid"):
27 id: str
28 python_path: str | None = None
29 python_module: str | None = None
30 python_file: str | None = None
32 @model_validator(mode="after")
33 def _validator(self):
34 if bool(self.python_module) == bool(self.python_path):
35 raise ValueError("Exactly one of 'python_module' or 'python_path' must be set")
37 if self.python_file and self.python_path:
38 raise ValueError("Cannot set both 'python_file' and 'python_path'")
40 return self
43class SensorKitBaseConfig(BaseModel, extra="allow"):
44 version: int = 0
45 sensorkit: GeneralConfig = Field(default_factory=GeneralConfig)
46 globals: KeywordDict = Field(default_factory=KeywordDict)
47 services: list[ServiceConfig] = Field(default_factory=list)
49 @model_validator(mode="after")
50 def _validate(self):
51 if self.version != PARSER_VERSION:
52 raise ConfigVersionUnsupported(f"Unsupported config version: {self.version}")
54 return self
56 def resolve_dynamic_sections(self):
57 # Make sure core sections are imported.
58 importlib.import_module("sensorkit.config.core")
60 ekv, services = _parse_extra_sections(self.model_extra)
61 return SensorKitConfig(
62 base=self,
63 services=self.services + services,
64 entity_kv=ekv,
65 )
67 def configured_imports(self) -> Iterable[str]:
68 # Modules must come first, in case any imported user code uses them.
69 return itertools.chain(
70 (f"sensorkit.{module}" for module in self.sensorkit.modules),
71 self.sensorkit.imports,
72 )
75@dataclass
76class SensorKitConfig:
77 base: SensorKitBaseConfig
78 services: list[ServiceConfig]
79 entity_kv: dict[str, list[BaseModel]]
81 @property
82 def backend(self):
83 return self.base.sensorkit.backend
85 @property
86 def global_kv(self):
87 return self.base.globals
90def _validate_mapper_return_value[T](value: Any, expected_type: type[T]) -> tuple[T] | None:
91 if isinstance(value, expected_type):
92 return (value,)
93 elif isinstance(value, Iterable):
94 value = tuple(value)
96 if not isinstance(value, tuple) or not all(isinstance(elem, expected_type) for elem in value):
97 logger.debug(f"Invalid mapper return value for type {expected_type.__name__}: {value}")
98 return None
100 return value
103def _parse_section(section: ConfigSection, value: Any) -> Iterable[tuple[str, BaseModel]]:
104 try:
105 ids = _validate_mapper_return_value(section.id_mapper(value), str)
106 except Exception as e:
107 raise ConfigError(f"Error in config section {section.key!r} (id mapper error)") from e
109 if ids is None:
110 raise ConfigError(f"Error in config section {section.key!r} (bad id mapper?)")
112 instance = section.adapter.validate_python(value)
114 try:
115 models = _validate_mapper_return_value(
116 section.model_mapper(instance) if section.model_mapper is not None else instance,
117 BaseModel,
118 )
119 except Exception as e:
120 raise ConfigError(f"Error in config section {section.key!r} (model mapper error)") from e
122 if models is None:
123 raise ConfigError(f"Error in config section {section.key!r} (bad model mapper?)")
125 return zip(ids, models, strict=True)
128def _parse_extra_sections(config: Mapping[str, Any]):
129 ekv: dict[str, list[BaseModel]] = collections.defaultdict(list)
130 services: list[ServiceConfig] = []
132 for key, value in config.items():
133 section = get_config_section(key)
135 if not section:
136 raise ConfigSectionUnknown(f"Unknown config section {key!r}")
138 for entity_id, model in _parse_section(section, value):
139 ekv[entity_id].append(model)
141 if section.service_path:
142 services.append(ServiceConfig(id=entity_id, python_module=section.service_path))
144 return ekv, services
147def parse_config(config: Mapping[str, Any]) -> SensorKitBaseConfig:
148 """Parse the given unified configuration dict.
150 Args:
151 config: A mapping containing the unified configuration data.
153 Returns:
154 A SensorKitBaseConfig instance.
156 Raises:
157 ConfigVersionUnsupported: The config version is mismatched
158 ConfigSectionUnknown: A config section cannot be found in the registry
159 ConfigError: An internal error with config processing
160 ValidationError: A config section fails Pydantic validation during parsing
161 """
162 return SensorKitBaseConfig.model_validate(config)
165class ConfigError(Exception):
166 """Raised when an error occurs during config loading."""
169class ConfigVersionUnsupported(ConfigError):
170 """Raised when the config version is unsupported."""
173class ConfigSectionUnknown(ConfigError):
174 """Raised when the config section is unknown."""