Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:13:30+02:00
Commit #1 ยท 63 snippets
README.md | 49 ++++ pyproject.toml | 1 + src/iolabs/common/__init__.py | 14 + src/iolabs/common/config_loader.py | 202 +++++++++++-- src/iolabs/common/config_model.py | 394 +++++++++++++++++++++++++ tests/test_config_loader.py | 30 +- tests/test_config_model.py | 584 +++++++++++++++++++++++++++++++++++++ uv.lock | 140 +++++++++ 8 files changed, 1387 insertions(+), 27 deletions(-)
Base model: extra=forbid, frozen, validate_default; the before-validator routes every field through the legacy coercion matrix.
| 1 | """Pydantic-v2 config layer for the pipeline packages. | ||
| 2 | |||
| 3 | `ConfigModel` is the base class every fleet package derives its config models | ||
| 4 | from. It keeps the accepted-input matrix of the hand-rolled coercion helpers in | ||
| 5 | :mod:`iolabs.common.config_loader` (see | ||
| 6 | :func:`iolabs.common.config_loader.coerce_to_field_type` for the spec) while | ||
| 7 | letting package authors write plain ``x: int`` / ``y: float`` / ``z: bool`` / | ||
| 8 | ``s: str`` fields; `Literal`, sequences, and nested models are validated by | ||
| 9 | pydantic itself. | ||
| 10 | |||
| 11 | Canonical package pattern:: | ||
| 12 | |||
| 13 | from collections.abc import Mapping | ||
| 14 | from pathlib import Path | ||
| 15 | from typing import Any, Literal | ||
| 16 | |||
| 17 | from iolabs.common import config_loader | ||
| 18 | |||
| 19 | |||
| 20 | class FooGroundConfig(config_loader.ConfigModel): | ||
| 21 | cell_m: float = 0.5 | ||
| 22 | enabled: bool = True | ||
| 23 | |||
| 24 | |||
| 25 | class FooConfig(config_loader.ConfigModel): | ||
| 26 | mode: Literal["fast", "exact"] = "fast" | ||
| 27 | ground: FooGroundConfig = FooGroundConfig() | ||
| 28 | |||
| 29 | |||
| 30 | class FooConfigError(config_loader.ConfigError): | ||
| 31 | \"\"\"Raised for an invalid foo config.\"\"\" | ||
| 32 | |||
| 33 | |||
| 34 | def build_foo_config( | ||
| 35 | overrides: Mapping[str, Any] | None = None, | ||
| 36 | config_path: str | Path | None = None, | ||
| 37 | ) -> dict[str, Any]: | ||
| 38 | return config_loader.load_config( | ||
| 39 | FooConfig, | ||
| 40 | package="iolabs_foo", | ||
| 41 | filename="default_config.json", | ||
| 42 | overrides=overrides, | ||
| 43 | config_path=config_path, | ||
| 44 | context="foo config", | ||
| 45 | error_cls=FooConfigError, | ||
| 46 | ).model_dump() | ||
| 47 | |||
| 48 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as | ||
| 49 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, | ||
| 50 | so no field can be rebound after construction. Freezing is shallow, as in | ||
| 51 | pydantic itself: a ``list``-valued field is still a mutable list, so prefer | ||
| 52 | ``tuple`` for sequence fields that must not change. | ||
| 53 | """ | ||
| 54 | |||
| 55 | from __future__ import annotations | ||
| 56 | |||
| 57 | import json | ||
| 58 | import logging | ||
| 59 | from collections.abc import Mapping | ||
| 60 | from pathlib import Path | ||
| 61 | from typing import Any, TypeVar, get_args | ||
| 62 | |||
| 63 | import pydantic | ||
| 64 | from pydantic import fields as pydantic_fields | ||
| 65 | |||
| 66 | from iolabs.common import config_loader | ||
| 67 | |||
| 68 | logger = logging.getLogger(__name__) | ||
| 69 | |||
| 70 | _M = TypeVar("_M", bound="ConfigModel") | ||
| 71 | |||
| 72 | _VALUE_ERROR_PREFIX = "Value error, " | ||
| 73 | |||
| 74 | _MODEL_REGISTRY: dict[str, type[ConfigModel] | None] = {} | ||
| 75 | |||
| 76 | |||
| 77 | class ConfigModel(pydantic.BaseModel): | ||
| 78 | """Frozen, extra-forbidding base model with fleet scalar coercion. | ||
| 79 | |||
| 80 | Every field is passed through the accepted-input matrix of | ||
| 81 | :func:`iolabs.common.config_loader.coerce_to_field_type` before pydantic | ||
| 82 | validates it, so ``"1e3"`` reaches an ``int`` field as ``1000``, ``"on"`` | ||
| 83 | reaches a ``bool`` field as ``True``, and ``True`` is rejected for an | ||
| 84 | ``int`` field instead of becoming ``1``. | ||
| 85 | """ | ||
| 86 | |||
| 87 | model_config = pydantic.ConfigDict( | ||
| 88 | extra="forbid", | ||
| 89 | frozen=True, | ||
| 90 | validate_default=True, | ||
| 91 | strict=False, | ||
| 92 | arbitrary_types_allowed=False, | ||
| 93 | ) | ||
| 94 | |||
| 95 | def __init_subclass__(cls, **kwargs: Any) -> None: | ||
| 96 | """Register the subclass so its error title can be resolved back to it.""" | ||
| 97 | super().__init_subclass__(**kwargs) | ||
| 98 | _register_model(cls) | ||
| 99 | |||
| 100 | @pydantic.field_validator("*", mode="before") | ||
| 101 | @classmethod | ||
| 102 | def _coerce_fleet_scalars(cls, value: Any, info: pydantic.ValidationInfo) -> Any: | ||
| 103 | """Coerce a raw JSON/CLI value to the field's declared annotation.""" | ||
| 104 | field = cls.model_fields.get(info.field_name or "") | ||
| 105 | if field is None or field.annotation is None: | ||
| 106 | return value | ||
| 107 | return config_loader.coerce_config_value( | ||
| 108 | info.field_name or "", value, field.annotation | ||
| 109 | ) | ||
| 110 | |||
| 111 | |||
| 112 | def _register_model(model_cls: type[ConfigModel]) -> None: | ||
| 113 | """Record *model_cls* under its name, flagging same-name classes ambiguous.""" | ||
| 114 | name = model_cls.__name__ | ||
| 115 | previous = _MODEL_REGISTRY.get(name, model_cls) | ||
| 116 | same_origin = previous is not None and (previous.__module__, previous.__qualname__) == ( | ||
| 117 | model_cls.__module__, | ||
| 118 | model_cls.__qualname__, | ||
| 119 | ) | ||
| 120 | _MODEL_REGISTRY[name] = model_cls if same_origin else None | ||
| 121 | |||
| 122 | |||
| 123 | def _model_from_title(title: str) -> type[ConfigModel] | None: | ||
| 124 | """Return the `ConfigModel` a validation error title names, when unambiguous.""" | ||
| 125 | return _MODEL_REGISTRY.get(title) | ||
| 126 | |||
| 127 | |||
| 128 | def validate_config( | ||
| 129 | model_cls: type[_M], | ||
| 130 | raw: Mapping[str, Any], | ||
| 131 | *, | ||
| 132 | context: str, | ||
| 133 | error_cls: type[ValueError] = config_loader.ConfigError, | ||
| 134 | ) -> _M: | ||
| 135 | """Validate a raw mapping into *model_cls*, wrapping pydantic errors. | ||
| 136 | |||
| 137 | Args: | ||
| 138 | model_cls: The `ConfigModel` subclass to build. | ||
| 139 | raw: The merged config mapping (packaged defaults plus overrides). | ||
| 140 | context: Human-readable config name used in error messages, e.g. | ||
| 141 | ``"foo config"``. | ||
| 142 | error_cls: Exception class raised for unknown keys and bad values. | ||
| 143 | |||
| 144 | Returns: | ||
| 145 | A validated, frozen instance of *model_cls*. | ||
| 146 | |||
| 147 | Raises: | ||
| 148 | error_cls: *raw* holds an unknown key, misses a required key, or holds | ||
| 149 | a value that is not valid for its declared field type. | ||
| 150 | """ | ||
| 151 | try: | ||
| 152 | return model_cls.model_validate(dict(raw)) | ||
| 153 | except pydantic.ValidationError as exc: | ||
| 154 | raise error_cls( | ||
| 155 | format_validation_error(exc, context=context, model_cls=model_cls) | ||
| 156 | ) from exc | ||
| 157 | |||
| 158 | |||
| 159 | def load_config( | ||
| 160 | model_cls: type[_M], | ||
| 161 | *, | ||
| 162 | package: str, | ||
| 163 | filename: str, | ||
| 164 | overrides: Mapping[str, Any] | None = None, | ||
| 165 | config_path: str | Path | None = None, | ||
| 166 | context: str, | ||
| 167 | error_cls: type[ValueError] = config_loader.ConfigError, | ||
| 168 | ) -> _M: | ||
| 169 | """Load, merge and validate a packaged JSON config into *model_cls*. | ||
| 170 | |||
| 171 | The packaged default JSON is read from *package*/*filename*, or from | ||
| 172 | *config_path* when that is given (the file then replaces the packaged | ||
| 173 | defaults rather than extending them). *overrides* is deep-merged on top by | ||
| 174 | :func:`iolabs.common.config_loader.deep_merge_dicts`, and the result is | ||
| 175 | validated by :func:`validate_config`. | ||
| 176 | |||
| 177 | Args: | ||
| 178 | model_cls: The `ConfigModel` subclass to build. | ||
| 179 | package: Import package holding the default JSON, e.g. ``"iolabs_foo"``. | ||
| 180 | filename: File name of the packaged JSON, e.g. ``"default_config.json"``. | ||
| 181 | overrides: Mapping merged onto the defaults, e.g. the result of | ||
| 182 | :func:`iolabs.common.config_loader.parse_set_overrides`. | ||
| 183 | config_path: Path to a JSON file used instead of the packaged defaults. | ||
| 184 | context: Human-readable config name used in error messages. | ||
| 185 | error_cls: Exception class raised for unreadable JSON, unknown keys and | ||
| 186 | bad values. | ||
| 187 | |||
| 188 | Returns: | ||
| 189 | A validated, frozen instance of *model_cls*. | ||
| 190 | |||
| 191 | Raises: | ||
| 192 | error_cls: The JSON is malformed, or the merged config is invalid. | ||
| 193 | OSError: The config file could not be read. | ||
| 194 | """ | ||
| 195 | if config_path is not None: | ||
| 196 | raw = _load_json_file(Path(config_path), error_cls=error_cls) | ||
| 197 | logger.debug("Loaded %s from %s", context, config_path) | ||
| 198 | else: | ||
| 199 | raw = _load_packaged(package, filename, error_cls=error_cls) | ||
| 200 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) | ||
| 201 | if overrides: | ||
| 202 | raw = config_loader.deep_merge_dicts(raw, dict(overrides)) | ||
| 203 | return validate_config(model_cls, raw, context=context, error_cls=error_cls) | ||
| 204 | |||
| 205 | |||
| 206 | def format_validation_error( | ||
| 207 | exc: pydantic.ValidationError, | ||
| 208 | *, | ||
| 209 | context: str, | ||
| 210 | model_cls: type[pydantic.BaseModel] | None = None, | ||
| 211 | ) -> str: | ||
| 212 | """Render a pydantic `ValidationError` as a fleet-style config message. | ||
| 213 | |||
| 214 | Unknown keys are grouped per section and reported as | ||
| 215 | ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is | ||
| 216 | dotted onto *context* (``"{context}.section"``). Value errors keep the | ||
| 217 | ``"Invalid <type> for '<dotted.field>': <value> ..."`` shape of the legacy | ||
| 218 | coercion helpers. | ||
| 219 | |||
| 220 | Args: | ||
| 221 | exc: The pydantic validation error. | ||
| 222 | context: Human-readable config name used as the message prefix. | ||
| 223 | model_cls: The validated model, used to list the allowed keys of the | ||
| 224 | offending section. Defaults to the `ConfigModel` named by | ||
| 225 | ``exc.title``; allowed-key lists are omitted when that name is | ||
| 226 | unknown or shared by several models. | ||
| 227 | |||
| 228 | Returns: | ||
| 229 | A newline-joined message covering every error in *exc*. | ||
| 230 | """ | ||
| 231 | if model_cls is None: | ||
| 232 | model_cls = _model_from_title(exc.title) | ||
| 233 | unknown: dict[tuple[Any, ...], list[str]] = {} | ||
| 234 | lines: list[str] = [] | ||
| 235 | for error in exc.errors(): | ||
| 236 | loc = tuple(error["loc"]) | ||
| 237 | if error["type"] == "extra_forbidden" and loc: | ||
| 238 | unknown.setdefault(loc[:-1], []).append(str(loc[-1])) | ||
| 239 | else: | ||
| 240 | lines.append(_format_single_error(error, context=context)) | ||
| 241 | unknown_lines = [ | ||
| 242 | _format_unknown_keys(prefix, keys, context=context, model_cls=model_cls) | ||
| 243 | for prefix, keys in sorted(unknown.items(), key=lambda item: [str(p) for p in item[0]]) | ||
| 244 | ] | ||
| 245 | return "\n".join(unknown_lines + lines) | ||
| 246 | |||
| 247 | |||
| 248 | def _format_unknown_keys( | ||
| 249 | prefix: tuple[Any, ...], | ||
| 250 | keys: list[str], | ||
| 251 | *, | ||
| 252 | context: str, | ||
| 253 | model_cls: type[pydantic.BaseModel] | None, | ||
| 254 | ) -> str: | ||
| 255 | """Return the ``Unknown ... key(s)`` line for one section.""" | ||
| 256 | node = ".".join([context, *(str(part) for part in prefix)]) | ||
| 257 | message = f"Unknown {node} key(s): {', '.join(sorted(keys))}." | ||
| 258 | allowed = _allowed_keys(model_cls, prefix) | ||
| 259 | if allowed: | ||
| 260 | message = f"{message} Allowed keys: {', '.join(sorted(allowed))}" | ||
| 261 | return message | ||
| 262 | |||
| 263 | |||
| 264 | def _format_single_error(error: Mapping[str, Any], *, context: str) -> str: | ||
| 265 | """Return one non-``extra_forbidden`` error as a readable line.""" | ||
| 266 | loc = tuple(error["loc"]) | ||
| 267 | dotted = ".".join(str(part) for part in loc) | ||
| 268 | message = str(error["msg"]) | ||
| 269 | if error["type"] == "missing": | ||
| 270 | return f"Missing required {context} key: '{dotted}'" | ||
| 271 | if message.startswith(_VALUE_ERROR_PREFIX): | ||
| 272 | message = message[len(_VALUE_ERROR_PREFIX):] | ||
| 273 | rewritten = _rewrite_field_name(message, loc) | ||
| 274 | if rewritten is not None: | ||
| 275 | return rewritten | ||
| 276 | return f"Invalid value for '{dotted}': {error.get('input')!r}. {message}." | ||
| 277 | |||
| 278 | |||
| 279 | def _rewrite_field_name(message: str, loc: tuple[Any, ...]) -> str | None: | ||
| 280 | """Replace the local field name of a coercion message with its dotted path. | ||
| 281 | |||
| 282 | Args: | ||
| 283 | message: A coercion message such as ``"Invalid int for 'x': 3.7 ..."``. | ||
| 284 | loc: The pydantic error location of the offending field. | ||
| 285 | |||
| 286 | Returns: | ||
| 287 | The message with the dotted path substituted, or ``None`` when | ||
| 288 | *message* does not have the coercion shape. | ||
| 289 | """ | ||
| 290 | head, _, tail = message.partition(" for '") | ||
| 291 | name, quote, rest = tail.partition("': ") | ||
| 292 | if not head.startswith("Invalid ") or not quote or "'" in name: | ||
| 293 | return None | ||
| 294 | dotted = ".".join([*(str(part) for part in loc[:-1]), name]) | ||
| 295 | return f"{head} for '{dotted}': {rest}" | ||
| 296 | |||
| 297 | |||
| 298 | def _allowed_keys( | ||
| 299 | model_cls: type[pydantic.BaseModel] | None, | ||
| 300 | loc: tuple[Any, ...], | ||
| 301 | ) -> list[str]: | ||
| 302 | """Return the keys accepted by the model reached by *loc*, if resolvable. | ||
| 303 | |||
| 304 | An aliased field is listed under its alias, i.e. under the key the config | ||
| 305 | file must actually use, plus its field name when the model also populates | ||
| 306 | by name. | ||
| 307 | """ | ||
| 308 | current = _model_at_loc(model_cls, loc) | ||
| 309 | if current is None: | ||
| 310 | return [] | ||
| 311 | by_name = bool( | ||
| 312 | current.model_config.get("populate_by_name") | ||
| 313 | or current.model_config.get("validate_by_name") | ||
| 314 | ) | ||
| 315 | keys: list[str] = [] | ||
| 316 | for name, field in current.model_fields.items(): | ||
| 317 | alias = _validation_alias(field) | ||
| 318 | if alias is None: | ||
| 319 | keys.append(name) | ||
| 320 | continue | ||
| 321 | keys.append(alias) | ||
| 322 | if by_name: | ||
| 323 | keys.append(name) | ||
| 324 | return keys | ||
| 325 | |||
| 326 | |||
| 327 | def _validation_alias(field: pydantic_fields.FieldInfo) -> str | None: | ||
| 328 | """Return the single string alias *field* is validated under, if any.""" | ||
| 329 | alias = field.validation_alias if field.validation_alias is not None else field.alias | ||
| 330 | return alias if isinstance(alias, str) else None | ||
| 331 | |||
| 332 | |||
| 333 | def _model_at_loc( | ||
| 334 | model_cls: type[pydantic.BaseModel] | None, | ||
| 335 | loc: tuple[Any, ...], | ||
| 336 | ) -> type[pydantic.BaseModel] | None: | ||
| 337 | """Walk *loc* from *model_cls* down to the model owning that location.""" | ||
| 338 | current = model_cls | ||
| 339 | for part in loc: | ||
| 340 | if current is None: | ||
| 341 | return None | ||
| 342 | if isinstance(part, int): | ||
| 343 | continue | ||
| 344 | field = _field_by_key(current, str(part)) | ||
| 345 | current = _unwrap_model(field.annotation) if field is not None else None | ||
| 346 | return current | ||
| 347 | |||
| 348 | |||
| 349 | def _field_by_key( | ||
| 350 | model_cls: type[pydantic.BaseModel], | ||
| 351 | key: str, | ||
| 352 | ) -> pydantic_fields.FieldInfo | None: | ||
| 353 | """Return the field of *model_cls* addressed by *key* (alias or name).""" | ||
| 354 | for name, field in model_cls.model_fields.items(): | ||
| 355 | if key in (_validation_alias(field), name): | ||
| 356 | return field | ||
| 357 | return None | ||
| 358 | |||
| 359 | |||
| 360 | def _unwrap_model(annotation: Any) -> type[pydantic.BaseModel] | None: | ||
| 361 | """Return the first `BaseModel` subclass inside *annotation*, if any.""" | ||
| 362 | if isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel): | ||
| 363 | return annotation | ||
| 364 | for arg in get_args(annotation): | ||
| 365 | found = _unwrap_model(arg) | ||
| 366 | if found is not None: | ||
| 367 | return found | ||
| 368 | return None | ||
| 369 | |||
| 370 | |||
| 371 | def _load_packaged(package: str, filename: str, *, error_cls: type[ValueError]) -> dict[str, Any]: | ||
| 372 | """Load the packaged default JSON, wrapping decode errors in *error_cls*.""" | ||
| 373 | try: | ||
| 374 | loaded = config_loader.load_packaged_json(package, filename) | ||
| 375 | except json.JSONDecodeError as exc: | ||
| 376 | raise error_cls(f"Invalid JSON in packaged config {package}:{filename}: {exc}") from exc | ||
| 377 | if not isinstance(loaded, dict): | ||
| 378 | raise error_cls( | ||
| 379 | f"Packaged config {package}:{filename} must hold a JSON object, " | ||
| 380 | f"got {type(loaded).__name__}" | ||
| 381 | ) | ||
| 382 | return loaded | ||
| 383 | |||
| 384 | |||
| 385 | def _load_json_file(path: Path, *, error_cls: type[ValueError]) -> dict[str, Any]: | ||
| 386 | """Load a JSON config file, wrapping decode errors in *error_cls*.""" | ||
| 387 | try: | ||
| 388 | with path.open("r", encoding="utf-8") as handle: | ||
| 389 | loaded = json.load(handle) | ||
| 390 | except json.JSONDecodeError as exc: | ||
| 391 | raise error_cls(f"Invalid JSON in config file {path}: {exc}") from exc | ||
| 392 | if not isinstance(loaded, dict): | ||
| 393 | raise error_cls(f"Config file {path} must hold a JSON object, got {type(loaded).__name__}") | ||
| 394 | return loaded | ||
| 0 |
| 227 | tokens ``1/true/yes/on`` and ``0/false/no/off``. Anything else raises, so | 292 | tokens ``1/true/yes/on`` and ``0/false/no/off``. Anything else raises, so |
| 228 | typos such as ``"flase"`` are rejected instead of read as ``False``. | 293 | typos such as ``"flase"`` are rejected instead of read as ``False``. |
| 229 | * ``int``: accepts ints, integral floats (``3.0``) and numeric strings | 294 | * ``int``: accepts ints, integral floats (``3.0``) and numeric strings |
| 230 | including exponent form (``"1e3"`` -> ``1000``). Non-integral values | 295 | including exponent form (``"1e3"`` -> ``1000``). Non-integral values |
| 231 | (``3.7``) and bools are rejected rather than truncated. | 296 | (``3.7``), bools and non-numeric types (``bytes``, ``None``, ...) are |
| 297 | rejected rather than truncated or re-parsed. | ||
| 232 | * ``float``: accepts ints, floats and numeric strings (including ``"nan"`` | 298 | * ``float``: accepts ints, floats and numeric strings (including ``"nan"`` |
| 233 | and ``"inf"``: finiteness is the caller's domain check, not this one). | 299 | and ``"inf"``: finiteness is the caller's domain check, not this one). |
| 234 | ``bool`` is rejected, as for ``int``; conversion errors are wrapped in | 300 | ``bool`` and non-numeric types (``bytes``, ``None``, ...) are rejected, |
| 235 | *error_cls*. | 301 | as for ``int``; conversion errors are wrapped in *error_cls*. |
| 236 | * ``str``: accepts ``str`` only. | 302 | * ``str``: accepts ``str`` only. |
| 237 | * ``X | None`` / ``Optional[X]``: ``None`` passes through, otherwise the | 303 | * ``X | None`` / ``Optional[X]``: ``None`` passes through, otherwise the |
| 238 | value is coerced to ``X``. Unions of two or more non-``None`` types pass | 304 | value is coerced to ``X``. Unions of two or more non-``None`` types pass |
| 239 | through unchanged. | 305 | through unchanged. |
| 240 | * ``tuple[...]`` / ``list[...]``: accepts a list or tuple (never a string or | 306 | * ``tuple[...]`` / ``list[...]``: accepts a list or tuple (never a string or |
| 241 | mapping), coercing each item to the declared item type. Fixed-length | 307 | mapping), coercing each item to the declared item type. Fixed-length |
| 242 | tuple annotations also check the item count. | 308 | tuple annotations also check the item count. |
| 243 | * ``Literal[...]``: the value must be one of the literal options. | 309 | * ``Literal[...]``: the value must be one of the literal options; an |
| 310 | ``enum`` option also matches its plain value (``"fast"`` for | ||
| 311 | ``Mode.FAST``) and is returned as the member. | ||
| 244 | * A nested dataclass type: a mapping value is built into that dataclass by | 312 | * A nested dataclass type: a mapping value is built into that dataclass by |
| 245 | :func:`dataclass_from_mapping` (unknown keys rejected, inner values | 313 | :func:`dataclass_from_mapping` (unknown keys rejected, inner values |
| 246 | coerced); a value that is already an instance passes through. | 314 | coerced); a value that is already an instance passes through. |
| 247 | 315 | ||
| 248 | Any other declared type (``dict``, ``Any``, an unresolved string | 316 | Any other declared type (``dict``, ``Any``, an unresolved string |
| 249 | annotation, a union of two or more non-``None`` types, ...) returns | 317 | annotation, a union of two or more non-``None`` types, ...) returns |
| 250 | *value* unchanged. | 318 | *value* unchanged. |
| 251 | 319 | ||
| 320 | Deprecated: declare a `ConfigModel` field and let it apply the same matrix. | ||
| 321 | |||
| 252 | Args: | 322 | Args: |
| 253 | name: Field name, used only in error messages. | 323 | name: Field name, used only in error messages. |
| 254 | value: The raw value from JSON or a parsed ``--set`` override. | 324 | value: The raw value from JSON or a parsed ``--set`` override. |
| 255 | declared: The field's declared type (``dataclasses.Field.type`` or a | 325 | declared: The field's declared type (``dataclasses.Field.type`` or a |
| 332 | raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc | 431 | raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc |
| 333 | if not as_float.is_integer(): | 432 | if not as_float.is_integer(): |
| 334 | raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.") | 433 | raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.") |
| 335 | return int(as_float) | 434 | return int(as_float) |
| 336 | try: | 435 | if isinstance(value, numbers.Real): |
| 337 | as_float = float(value) | 436 | as_float = float(value) |
| 338 | except (TypeError, ValueError) as exc: | 437 | if not as_float.is_integer(): |
| 339 | raise error_cls( | 438 | raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.") |
| 340 | f"Invalid int for '{name}': {value!r} ({type(value).__name__})." | 439 | return int(as_float) |
| 341 | ) from exc | 440 | raise error_cls(f"Invalid int for '{name}': {value!r} ({type(value).__name__}).") |
| 342 | if not as_float.is_integer(): | ||
| 343 | raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.") | ||
| 344 | return int(as_float) | ||
| 345 | 441 | ||
| 346 | 442 | ||
| 347 | def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float: | 443 | def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float: |
| 348 | """Parse a float; wrap conversion errors in *error_cls*.""" | 444 | """Parse a float; wrap conversion errors in *error_cls*.""" |
| 349 | if isinstance(value, bool): | 445 | if isinstance(value, bool): |
| 350 | raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).") | 446 | raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).") |
| 351 | try: | 447 | if isinstance(value, str): |
| 448 | try: | ||
| 449 | return float(value) | ||
| 450 | except ValueError as exc: | ||
| 451 | raise error_cls( | ||
| 452 | f"Invalid float for '{name}': {value!r} ({type(value).__name__})." | ||
| 453 | ) from exc | ||
| 454 | if isinstance(value, numbers.Real): | ||
| 352 | return float(value) | 455 | return float(value) |
| 353 | except (TypeError, ValueError) as exc: | 456 | raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).") |
| 354 | raise error_cls( | ||
| 355 | f"Invalid float for '{name}': {value!r} ({type(value).__name__})." | ||
| 356 | ) from exc | ||
| 357 | 457 | ||
| 358 | 458 | ||
| 359 | def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str: | 459 | def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str: |
| 360 | """Accept a string as-is; reject every other type.""" | 460 | """Accept a string as-is; reject every other type.""" |
Old helpers kept alive for published leaf wheels; warn once per call site.
| 1 | """Packaged-JSON config loading, deep-merge, key validation and value coercion. | 1 | """Packaged-JSON config loading, deep-merge, key validation and value coercion. |
| 2 | 2 | ||
| 3 | Covers the whole config path shared by the pipeline packages: load the | 3 | Covers the whole config path shared by the pipeline packages: load the |
| 4 | packaged default JSON, deep-merge CLI ``--set`` overrides onto it, reject | 4 | packaged default JSON, deep-merge CLI ``--set`` overrides onto it, reject |
| 5 | unknown keys (flat or against the defaults tree) and coerce raw JSON/CLI | 5 | unknown keys and coerce raw JSON/CLI values to the declared field types. |
| 6 | values to the declared dataclass field types. | 6 | |
| 7 | The current way to declare a config is a pydantic model derived from | ||
| 8 | `ConfigModel`, validated by :func:`load_config`; both are defined in | ||
| 9 | :mod:`iolabs.common.config_model` and re-exported here, together with | ||
| 10 | :func:`validate_config` and :func:`format_validation_error`. The canonical | ||
| 11 | package pattern is:: | ||
| 12 | |||
| 13 | from typing import Any, Literal | ||
| 14 | |||
| 15 | from iolabs.common import config_loader | ||
| 16 | |||
| 17 | |||
| 18 | class FooGroundConfig(config_loader.ConfigModel): | ||
| 19 | cell_m: float = 0.5 | ||
| 20 | |||
| 21 | |||
| 22 | class FooConfig(config_loader.ConfigModel): | ||
| 23 | mode: Literal["fast", "exact"] = "fast" | ||
| 24 | ground: FooGroundConfig = FooGroundConfig() | ||
| 25 | |||
| 26 | |||
| 27 | class FooConfigError(config_loader.ConfigError): | ||
| 28 | \"\"\"Raised for an invalid foo config.\"\"\" | ||
| 29 | |||
| 30 | |||
| 31 | def build_foo_config(overrides=None, config_path=None) -> dict[str, Any]: | ||
| 32 | return config_loader.load_config( | ||
| 33 | FooConfig, | ||
| 34 | package="iolabs_foo", | ||
| 35 | filename="default_config.json", | ||
| 36 | overrides=overrides, | ||
| 37 | config_path=config_path, | ||
| 38 | context="foo config", | ||
| 39 | error_cls=FooConfigError, | ||
| 40 | ).model_dump() | ||
| 41 | |||
| 42 | `ConfigError`, `default_config_path`, `load_packaged_json`, `deep_merge_dicts` | ||
| 43 | and `parse_set_overrides` stay first-class. The hand-rolled dataclass helpers | ||
| 44 | (`validate_allowed_keys`, `coerce_to_field_type`, `dataclass_from_mapping`, | ||
| 45 | `validate_against_defaults`) still work for published leaf wheels but emit a | ||
| 46 | `DeprecationWarning`. | ||
| 7 | """ | 47 | """ |
| 8 | 48 | ||
| 9 | from __future__ import annotations | 49 | from __future__ import annotations |
| 10 | 50 | ||
| 51 | import enum | ||
| 11 | import json | 52 | import json |
| 53 | import logging | ||
| 54 | import numbers | ||
| 12 | import sys | 55 | import sys |
| 56 | import warnings | ||
| 13 | from collections.abc import Collection, Mapping, Sequence | 57 | from collections.abc import Collection, Mapping, Sequence |
| 14 | from dataclasses import fields, is_dataclass | 58 | from dataclasses import fields, is_dataclass |
| 15 | from importlib import resources | 59 | from importlib import resources |
| 16 | from pathlib import Path | 60 | from pathlib import Path |
| 17 | from types import UnionType | 61 | from types import UnionType |
| 18 | from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints | 62 | from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints |
| 19 | 63 | ||
| 64 | logger = logging.getLogger(__name__) | ||
| 65 | |||
| 66 | _PYDANTIC_EXPORTS = frozenset( | ||
| 67 | {"ConfigModel", "load_config", "validate_config", "format_validation_error"} | ||
| 68 | ) | ||
| 69 | |||
| 20 | 70 | ||
| 21 | class ConfigError(ValueError): | 71 | class ConfigError(ValueError): |
| 22 | """Raised when a packaged-JSON config contains unsupported keys/values.""" | 72 | """Raised when a packaged-JSON config contains unsupported keys/values.""" |
| 23 | 73 |
The legacy coercion matrix as a non-deprecated public function; strictness (bool never int, str only str) is preserved on purpose.
| 258 | 328 | ||
| 259 | Returns: | 329 | Returns: |
| 260 | The coerced value, or *value* unchanged for unsupported declared types. | 330 | The coerced value, or *value* unchanged for unsupported declared types. |
| 261 | 331 | ||
| 332 | Raises: | ||
| 333 | error_cls: *value* is not valid for *declared*. | ||
| 334 | """ | ||
| 335 | _warn_deprecated("coerce_to_field_type") | ||
| 336 | return coerce_config_value(name, value, declared, error_cls=error_cls) | ||
| 337 | |||
| 338 | |||
| 339 | def coerce_config_value( | ||
| 340 | name: str, | ||
| 341 | value: Any, | ||
| 342 | declared: Any, | ||
| 343 | *, | ||
| 344 | error_cls: type[ValueError] = ConfigError, | ||
| 345 | ) -> Any: | ||
| 346 | """Coerce one raw config value to *declared*, the fleet accepted-input matrix. | ||
| 347 | |||
| 348 | Implementation shared by `ConfigModel` (per-field before-validator) and the | ||
| 349 | deprecated :func:`coerce_to_field_type`, whose docstring is the spec of the | ||
| 350 | accepted inputs. | ||
| 351 | |||
| 352 | Args: | ||
| 353 | name: Field name, used only in error messages. | ||
| 354 | value: The raw value from JSON or a parsed ``--set`` override. | ||
| 355 | declared: The field's declared type. | ||
| 356 | error_cls: Exception class raised for values that cannot be coerced. | ||
| 357 | |||
| 358 | Returns: | ||
| 359 | The coerced value, or *value* unchanged for unsupported declared types. | ||
| 360 | |||
| 262 | Raises: | 361 | Raises: |
| 263 | error_cls: *value* is not valid for *declared*. | 362 | error_cls: *value* is not valid for *declared*. |
| 264 | """ | 363 | """ |
| 265 | if isinstance(declared, str): | 364 | if isinstance(declared, str): |
| 4 | atomic_io, | 4 | atomic_io, |
| 5 | cli, | 5 | cli, |
| 6 | color_intensity_data, | 6 | color_intensity_data, |
| 7 | config_loader, | 7 | config_loader, |
| 8 | config_model, | ||
| 8 | crs, | 9 | crs, |
| 9 | diagnostic_data, | 10 | diagnostic_data, |
| 10 | ground_mask_io, | 11 | ground_mask_io, |
| 11 | indexed_ordered_dict, | 12 | indexed_ordered_dict, |
| 23 | configure_logging, | 24 | configure_logging, |
| 24 | ) | 25 | ) |
| 25 | from .config_loader import ( | 26 | from .config_loader import ( |
| 26 | ConfigError, | 27 | ConfigError, |
| 28 | coerce_config_value, | ||
| 27 | coerce_to_field_type, | 29 | coerce_to_field_type, |
| 28 | dataclass_from_mapping, | 30 | dataclass_from_mapping, |
| 29 | deep_merge_dicts, | 31 | deep_merge_dicts, |
| 30 | default_config_path, | 32 | default_config_path, |
| 32 | parse_set_overrides, | 34 | parse_set_overrides, |
| 33 | validate_against_defaults, | 35 | validate_against_defaults, |
| 34 | validate_allowed_keys, | 36 | validate_allowed_keys, |
| 35 | ) | 37 | ) |
| 38 | from .config_model import ( | ||
| 39 | ConfigModel, | ||
| 40 | format_validation_error, | ||
| 41 | load_config, | ||
| 42 | validate_config, | ||
| 43 | ) | ||
| 36 | 44 | ||
| 37 | __all__ = [ | 45 | __all__ = [ |
| 38 | "atomic_io", | 46 | "atomic_io", |
| 39 | "cli", | 47 | "cli", |
| 40 | "color_intensity_data", | 48 | "color_intensity_data", |
| 41 | "config_loader", | 49 | "config_loader", |
| 50 | "config_model", | ||
| 42 | "crs", | 51 | "crs", |
| 43 | "diagnostic_data", | 52 | "diagnostic_data", |
| 44 | "ground_mask_io", | 53 | "ground_mask_io", |
| 45 | "indexed_ordered_dict", | 54 | "indexed_ordered_dict", |
| 53 | "LOG_LEVEL_CHOICES", | 62 | "LOG_LEVEL_CHOICES", |
| 54 | "add_log_level_argument", | 63 | "add_log_level_argument", |
| 55 | "configure_logging", | 64 | "configure_logging", |
| 56 | "ConfigError", | 65 | "ConfigError", |
| 66 | "ConfigModel", | ||
| 67 | "coerce_config_value", | ||
| 57 | "coerce_to_field_type", | 68 | "coerce_to_field_type", |
| 58 | "dataclass_from_mapping", | 69 | "dataclass_from_mapping", |
| 59 | "default_config_path", | 70 | "default_config_path", |
| 60 | "deep_merge_dicts", | 71 | "deep_merge_dicts", |
| 72 | "format_validation_error", | ||
| 73 | "load_config", | ||
| 61 | "load_packaged_json", | 74 | "load_packaged_json", |
| 75 | "validate_config", | ||
| 62 | "parse_set_overrides", | 76 | "parse_set_overrides", |
| 63 | "validate_against_defaults", | 77 | "validate_against_defaults", |
| 64 | "validate_allowed_keys", | 78 | "validate_allowed_keys", |
| 65 | ] | 79 | ] |
| 81 | error_cls: type[ValueError] = ConfigError, | 131 | error_cls: type[ValueError] = ConfigError, |
| 82 | ) -> None: | 132 | ) -> None: |
| 83 | """Raise *error_cls* when *config* contains keys outside *allowed*. | 133 | """Raise *error_cls* when *config* contains keys outside *allowed*. |
| 84 | 134 | ||
| 135 | Deprecated: derive a `ConfigModel` (``extra="forbid"``) and validate it with | ||
| 136 | :func:`load_config` / :func:`validate_config` instead. | ||
| 137 | |||
| 85 | Args: | 138 | Args: |
| 86 | config: The config mapping whose keys are checked. | 139 | config: The config mapping whose keys are checked. |
| 87 | allowed: The keys *config* may hold. | 140 | allowed: The keys *config* may hold. |
| 88 | context: Human-readable config name used in the error message, e.g. | 141 | context: Human-readable config name used in the error message, e.g. |
| 96 | 149 | ||
| 97 | Raises: | 150 | Raises: |
| 98 | error_cls: *config* holds one or more keys outside *allowed*. | 151 | error_cls: *config* holds one or more keys outside *allowed*. |
| 99 | """ | 152 | """ |
| 153 | _warn_deprecated("validate_allowed_keys") | ||
| 154 | _validate_allowed_keys(config, allowed, context=context, error_cls=error_cls) | ||
| 155 | |||
| 156 | |||
| 157 | def _validate_allowed_keys( | ||
| 158 | config: Mapping[str, Any], | ||
| 159 | allowed: frozenset[str], | ||
| 160 | *, | ||
| 161 | context: str, | ||
| 162 | error_cls: type[ValueError], | ||
| 163 | ) -> None: | ||
| 164 | """Raise *error_cls* when *config* contains keys outside *allowed*.""" | ||
| 100 | unknown = sorted(set(config) - allowed) | 165 | unknown = sorted(set(config) - allowed) |
| 101 | if unknown: | 166 | if unknown: |
| 102 | raise error_cls( | 167 | raise error_cls( |
| 103 | f"Unknown {context} key(s): {', '.join(unknown)}. " | 168 | f"Unknown {context} key(s): {', '.join(unknown)}. " |
| 285 | return _coerce_sequence( | 384 | return _coerce_sequence( |
| 286 | name, value, get_args(declared), target=list, error_cls=error_cls | 385 | name, value, get_args(declared), target=list, error_cls=error_cls |
| 287 | ) | 386 | ) |
| 288 | if isinstance(declared, type) and is_dataclass(declared) and isinstance(value, Mapping): | 387 | if isinstance(declared, type) and is_dataclass(declared) and isinstance(value, Mapping): |
| 289 | return dataclass_from_mapping(declared, value, context=name, error_cls=error_cls) | 388 | return _dataclass_from_mapping(declared, value, context=name, error_cls=error_cls) |
| 290 | return value | 389 | return value |
| 291 | 390 | ||
| 292 | 391 | ||
| 293 | def _coerce_bool(name: str, value: Any, *, error_cls: type[ValueError]) -> bool: | 392 | def _coerce_bool(name: str, value: Any, *, error_cls: type[ValueError]) -> bool: |
| 369 | options: tuple[Any, ...], | 469 | options: tuple[Any, ...], |
| 370 | *, | 470 | *, |
| 371 | error_cls: type[ValueError], | 471 | error_cls: type[ValueError], |
| 372 | ) -> Any: | 472 | ) -> Any: |
| 373 | """Check *value* against the options of a ``Literal`` annotation.""" | 473 | """Check *value* against the options of a ``Literal`` annotation. |
| 474 | |||
| 475 | An option that is an `enum.Enum` member also matches its plain | ||
| 476 | JSON-representable value (``"fast"`` for ``Mode.FAST``), as pydantic does; | ||
| 477 | the member is returned. Types must match exactly otherwise, so ``True`` | ||
| 478 | never satisfies ``Literal[1]``. | ||
| 479 | """ | ||
| 374 | for option in options: | 480 | for option in options: |
| 375 | if type(option) is type(value) and option == value: | 481 | if type(option) is type(value) and option == value: |
| 376 | return option | 482 | return option |
| 483 | if isinstance(option, enum.Enum) and type(option.value) is type(value): | ||
| 484 | if option.value == value: | ||
| 485 | return option | ||
| 377 | allowed = ", ".join(repr(option) for option in options) | 486 | allowed = ", ".join(repr(option) for option in options) |
| 378 | raise error_cls(f"Invalid value for '{name}': {value!r}. Expected one of: {allowed}.") | 487 | raise error_cls(f"Invalid value for '{name}': {value!r}. Expected one of: {allowed}.") |
| 379 | 488 | ||
| 380 | 489 |
| 390 | return None | 499 | return None |
| 391 | candidates = [member for member in members if member is not type(None)] | 500 | candidates = [member for member in members if member is not type(None)] |
| 392 | if len(candidates) != 1: | 501 | if len(candidates) != 1: |
| 393 | return value | 502 | return value |
| 394 | return coerce_to_field_type(name, value, candidates[0], error_cls=error_cls) | 503 | return coerce_config_value(name, value, candidates[0], error_cls=error_cls) |
| 395 | 504 | ||
| 396 | 505 | ||
| 397 | def _coerce_sequence( | 506 | def _coerce_sequence( |
| 398 | name: str, | 507 | name: str, |
| 420 | item_types = [args[0]] * len(items) | 529 | item_types = [args[0]] * len(items) |
| 421 | if not item_types: | 530 | if not item_types: |
| 422 | return target(items) | 531 | return target(items) |
| 423 | coerced = [ | 532 | coerced = [ |
| 424 | coerce_to_field_type(f"{name}[{index}]", item, item_type, error_cls=error_cls) | 533 | coerce_config_value(f"{name}[{index}]", item, item_type, error_cls=error_cls) |
| 425 | for index, (item, item_type) in enumerate(zip(items, item_types, strict=True)) | 534 | for index, (item, item_type) in enumerate(zip(items, item_types, strict=True)) |
| 426 | ] | 535 | ] |
| 427 | return target(coerced) | 536 | return target(coerced) |
| 428 | 537 |
| 468 | TypeError: *cls* is not a dataclass type, a field annotation cannot be | 577 | TypeError: *cls* is not a dataclass type, a field annotation cannot be |
| 469 | resolved at runtime (unless ``coerce=False``), or a required field | 578 | resolved at runtime (unless ``coerce=False``), or a required field |
| 470 | is missing from *raw*. | 579 | is missing from *raw*. |
| 471 | """ | 580 | """ |
| 581 | _warn_deprecated("dataclass_from_mapping") | ||
| 582 | return _dataclass_from_mapping( | ||
| 583 | cls, raw, context=context, error_cls=error_cls, coerce=coerce | ||
| 584 | ) | ||
| 585 | |||
| 586 | |||
| 587 | def _dataclass_from_mapping( | ||
| 588 | cls: type[_T], | ||
| 589 | raw: Mapping[str, Any], | ||
| 590 | *, | ||
| 591 | context: str, | ||
| 592 | error_cls: type[ValueError], | ||
| 593 | coerce: bool = True, | ||
| 594 | ) -> _T: | ||
| 595 | """Build a dataclass instance from a raw mapping (implementation).""" | ||
| 472 | if not is_dataclass(cls) or not isinstance(cls, type): | 596 | if not is_dataclass(cls) or not isinstance(cls, type): |
| 473 | raise TypeError(f"dataclass_from_mapping requires a dataclass type, got {cls!r}") | 597 | raise TypeError(f"dataclass_from_mapping requires a dataclass type, got {cls!r}") |
| 474 | init_fields = [field for field in fields(cls) if field.init] | 598 | init_fields = [field for field in fields(cls) if field.init] |
| 475 | allowed = frozenset(field.name for field in init_fields) | 599 | allowed = frozenset(field.name for field in init_fields) |
| 476 | validate_allowed_keys(raw, allowed, context=context, error_cls=error_cls) | 600 | _validate_allowed_keys(raw, allowed, context=context, error_cls=error_cls) |
| 477 | if not coerce: | 601 | if not coerce: |
| 478 | return cls(**dict(raw)) | 602 | return cls(**dict(raw)) |
| 479 | declared_types = _resolve_field_types(cls, init_fields) | 603 | declared_types = _resolve_field_types(cls, init_fields) |
| 480 | values = { | 604 | values = { |
| 481 | name: coerce_to_field_type(name, value, declared_types[name], error_cls=error_cls) | 605 | name: coerce_config_value(name, value, declared_types[name], error_cls=error_cls) |
| 482 | for name, value in raw.items() | 606 | for name, value in raw.items() |
| 483 | } | 607 | } |
| 484 | return cls(**values) | 608 | return cls(**values) |
| 485 | 609 |
| 564 | node's keys. Recursion stops where *defaults* holds a non-mapping leaf, so | 688 | node's keys. Recursion stops where *defaults* holds a non-mapping leaf, so |
| 565 | leaf values are never inspected. The first offending node raises, naming | 689 | leaf values are never inspected. The first offending node raises, naming |
| 566 | the dotted context path (``"cluster-stepper config.lane_segment_width"``). | 690 | the dotted context path (``"cluster-stepper config.lane_segment_width"``). |
| 567 | 691 | ||
| 692 | Deprecated: a `ConfigModel` with ``extra="forbid"`` is the schema now; use | ||
| 693 | :func:`load_config` / :func:`validate_config`. | ||
| 694 | |||
| 568 | Args: | 695 | Args: |
| 569 | config: The raw config node to validate (usually a dict). | 696 | config: The raw config node to validate (usually a dict). |
| 570 | defaults: The corresponding node of the packaged defaults. | 697 | defaults: The corresponding node of the packaged defaults. |
| 571 | context: Human-readable name of the root node, used as the error | 698 | context: Human-readable name of the root node, used as the error |
| 586 | error_cls: A node of *config* is not a mapping where *defaults* has | 713 | error_cls: A node of *config* is not a mapping where *defaults* has |
| 587 | one, or holds a key absent from both *defaults* and | 714 | one, or holds a key absent from both *defaults* and |
| 588 | *allowed_extra_keys*. | 715 | *allowed_extra_keys*. |
| 589 | """ | 716 | """ |
| 717 | _warn_deprecated("validate_against_defaults") | ||
| 590 | _validate_against_defaults( | 718 | _validate_against_defaults( |
| 591 | config, | 719 | config, |
| 592 | defaults, | 720 | defaults, |
| 593 | context=context, | 721 | context=context, |
Old helpers kept alive for published leaf wheels; warn once per call site.
| 638 | 766 | ||
| 639 | def _dotted(prefix: str, key: str) -> str: | 767 | def _dotted(prefix: str, key: str) -> str: |
| 640 | """Join a dotted path prefix with a key.""" | 768 | """Join a dotted path prefix with a key.""" |
| 641 | return f"{prefix}.{key}" if prefix else key | 769 | return f"{prefix}.{key}" if prefix else key |
| 770 | |||
| 771 | |||
| 772 | def _warn_deprecated(name: str) -> None: | ||
| 773 | """Emit a `DeprecationWarning` for a legacy hand-rolled config helper.""" | ||
| 774 | warnings.warn( | ||
| 775 | f"config_loader.{name} is deprecated; derive a config_loader.ConfigModel " | ||
| 776 | f"and use config_loader.load_config/validate_config instead.", | ||
| 777 | DeprecationWarning, | ||
| 778 | stacklevel=3, | ||
| 779 | ) | ||
| 780 | |||
| 781 | |||
| 782 | def __getattr__(name: str) -> Any: | ||
| 783 | """Re-export the pydantic config layer lazily, avoiding an import cycle.""" | ||
| 784 | if name in _PYDANTIC_EXPORTS: | ||
| 785 | from iolabs.common import config_model | ||
| 786 | |||
| 787 | return getattr(config_model, name) | ||
| 788 | raise AttributeError(f"module {__name__!r} has no attribute {name!r}") | ||
| 789 | |||
| 790 | |||
| 791 | def __dir__() -> list[str]: | ||
| 792 | """List the lazy pydantic re-exports alongside the module's own names.""" | ||
| 793 | return sorted(set(globals()) | _PYDANTIC_EXPORTS) |
| 1 | """Tests for the pydantic-v2 config layer (`config_loader.ConfigModel`).""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import dataclasses | ||
| 6 | import enum | ||
| 7 | import json | ||
| 8 | import math | ||
| 9 | import warnings | ||
| 10 | from pathlib import Path | ||
| 11 | from typing import Any, Literal | ||
| 12 | from unittest.mock import patch | ||
| 13 | |||
| 14 | import pydantic | ||
| 15 | import pytest | ||
| 16 | |||
| 17 | from iolabs.common import config_loader, config_model | ||
| 18 | |||
| 19 | _PACKAGE = "iolabs.common" | ||
| 20 | _FIXTURE_PATH = Path(__file__).parent / "fixtures" / "config_loader.fixture.json" | ||
| 21 | |||
| 22 | |||
| 23 | class SampleMode(enum.StrEnum): | ||
| 24 | """Enum used for Literal-of-enum fields.""" | ||
| 25 | |||
| 26 | FAST = "fast" | ||
| 27 | EXACT = "exact" | ||
| 28 | |||
| 29 | |||
| 30 | class SampleConfigError(config_loader.ConfigError): | ||
| 31 | """Consumer-style config error.""" | ||
| 32 | |||
| 33 | |||
| 34 | class GroundSection(config_loader.ConfigModel): | ||
| 35 | """Nested section mirroring a packaged JSON sub-object.""" | ||
| 36 | |||
| 37 | cell_m: float = 0.5 | ||
| 38 | percentile: float = 5.0 | ||
| 39 | |||
| 40 | |||
| 41 | class SampleConfig(config_loader.ConfigModel): | ||
| 42 | """Sample config exercising every supported field kind.""" | ||
| 43 | |||
| 44 | name: str = "run" | ||
| 45 | count: int = 3 | ||
| 46 | threshold_m: float = 0.5 | ||
| 47 | enabled: bool = True | ||
| 48 | mechanism: Literal["csf", "plane"] = "csf" | ||
| 49 | tag: str | None = None | ||
| 50 | band_m: tuple[float, float] = (0.0, 1.0) | ||
| 51 | weights: list[int] = [1, 2] | ||
| 52 | ground: GroundSection = GroundSection() | ||
| 53 | |||
| 54 | |||
| 55 | class FixtureConfig(config_loader.ConfigModel): | ||
| 56 | """Config matching ``tests/fixtures/config_loader.fixture.json``.""" | ||
| 57 | |||
| 58 | enabled: bool = False | ||
| 59 | ground: GroundSection = GroundSection() | ||
| 60 | |||
| 61 | |||
| 62 | # --- coercion matrix --------------------------------------------------------- | ||
| 63 | |||
| 64 | |||
| 65 | @pytest.mark.parametrize( | ||
| 66 | ("value", "expected"), | ||
| 67 | [ | ||
| 68 | (True, True), | ||
| 69 | (False, False), | ||
| 70 | (1, True), | ||
| 71 | (0, False), | ||
| 72 | ("1", True), | ||
| 73 | ("TRUE", True), | ||
| 74 | (" yes ", True), | ||
| 75 | ("On", True), | ||
| 76 | ("0", False), | ||
| 77 | ("false", False), | ||
| 78 | ("NO", False), | ||
| 79 | ("off", False), | ||
| 80 | ], | ||
| 81 | ) | ||
| 82 | def test_bool_field_accepts_matrix(value: object, expected: bool) -> None: | ||
| 83 | """bool fields accept real bools, 0/1 and the documented tokens.""" | ||
| 84 | assert SampleConfig(enabled=value).enabled is expected | ||
| 85 | |||
| 86 | |||
| 87 | @pytest.mark.parametrize("value", ["flase", "y", "", 2, -1, 1.0, None, []]) | ||
| 88 | def test_bool_field_rejects_everything_else(value: object) -> None: | ||
| 89 | """bool fields reject typos, out-of-range ints and non-scalar values.""" | ||
| 90 | with pytest.raises(config_loader.ConfigError): | ||
| 91 | config_loader.validate_config( | ||
| 92 | SampleConfig, {"enabled": value}, context="sample config" | ||
| 93 | ) | ||
| 94 | |||
| 95 | |||
| 96 | @pytest.mark.parametrize( | ||
| 97 | ("value", "expected"), | ||
| 98 | [(7, 7), (3.0, 3), ("42", 42), (" 42 ", 42), ("1e3", 1000), ("-5", -5)], | ||
| 99 | ) | ||
| 100 | def test_int_field_accepts_matrix(value: object, expected: int) -> None: | ||
| 101 | """int fields accept ints, integral floats and numeric strings.""" | ||
| 102 | assert SampleConfig(count=value).count == expected | ||
| 103 | |||
| 104 | |||
| 105 | @pytest.mark.parametrize("value", [True, False, 3.7, "3.7", "abc", None, "1e3.5"]) | ||
| 106 | def test_int_field_rejects_bools_and_non_integral(value: object) -> None: | ||
| 107 | """int fields reject bools, fractional values and bad tokens.""" | ||
| 108 | with pytest.raises(config_loader.ConfigError): | ||
| 109 | config_loader.validate_config( | ||
| 110 | SampleConfig, {"count": value}, context="sample config" | ||
| 111 | ) | ||
| 112 | |||
| 113 | |||
| 114 | @pytest.mark.parametrize( | ||
| 115 | ("value", "expected"), | ||
| 116 | [(2, 2.0), (2.5, 2.5), ("2.5", 2.5), (" 1e-2 ", 0.01), ("inf", math.inf)], | ||
| 117 | ) | ||
| 118 | def test_float_field_accepts_matrix(value: object, expected: float) -> None: | ||
| 119 | """float fields accept ints, floats and numeric strings incl. inf.""" | ||
| 120 | assert SampleConfig(threshold_m=value).threshold_m == expected | ||
| 121 | |||
| 122 | |||
| 123 | def test_float_field_accepts_nan() -> None: | ||
| 124 | """float fields accept 'nan'; finiteness is a domain check, not a type one.""" | ||
| 125 | assert math.isnan(SampleConfig(threshold_m="nan").threshold_m) | ||
| 126 | |||
| 127 | |||
| 128 | @pytest.mark.parametrize("value", [True, False, "abc", None, []]) | ||
| 129 | def test_float_field_rejects_bools_and_bad_tokens(value: object) -> None: | ||
| 130 | """float fields reject bools and non-numeric values.""" | ||
| 131 | with pytest.raises(config_loader.ConfigError): | ||
| 132 | config_loader.validate_config( | ||
| 133 | SampleConfig, {"threshold_m": value}, context="sample config" | ||
| 134 | ) | ||
| 135 | |||
| 136 | |||
| 137 | @pytest.mark.parametrize("value", [b"3", b"2.5", object(), {"a": 1}]) | ||
| 138 | def test_numeric_fields_reject_non_numeric_types(value: object) -> None: | ||
| 139 | """int/float fields reject bytes and other types float() happens to eat.""" | ||
| 140 | for field in ("count", "threshold_m"): | ||
| 141 | with pytest.raises(config_loader.ConfigError): | ||
| 142 | config_loader.validate_config( | ||
| 143 | SampleConfig, {field: value}, context="sample config" | ||
| 144 | ) | ||
| 145 | |||
| 146 | |||
| 147 | @pytest.mark.parametrize("value", [3, 3.5, True, None, ["a"]]) | ||
| 148 | def test_str_field_rejects_non_strings(value: object) -> None: | ||
| 149 | """str fields accept strings only - no int -> str coercion.""" | ||
| 150 | with pytest.raises(config_loader.ConfigError): | ||
| 151 | config_loader.validate_config( | ||
| 152 | SampleConfig, {"name": value}, context="sample config" | ||
| 153 | ) | ||
| 154 | |||
| 155 | |||
| 156 | def test_str_field_accepts_strings() -> None: | ||
| 157 | """str fields pass strings through unchanged.""" | ||
| 158 | assert SampleConfig(name="lidar").name == "lidar" | ||
| 159 | |||
| 160 | |||
| 161 | def test_optional_field_accepts_none_and_inner_type() -> None: | ||
| 162 | """``str | None`` accepts None and applies the str rules otherwise.""" | ||
| 163 | assert SampleConfig(tag=None).tag is None | ||
| 164 | assert SampleConfig(tag="a").tag == "a" | ||
| 165 | with pytest.raises(config_loader.ConfigError): | ||
| 166 | config_loader.validate_config(SampleConfig, {"tag": 3}, context="sample config") | ||
| 167 | |||
| 168 | |||
| 169 | def test_literal_field_accepts_options_and_rejects_others() -> None: | ||
| 170 | """Literal fields accept declared options only.""" | ||
| 171 | assert SampleConfig(mechanism="plane").mechanism == "plane" | ||
| 172 | with pytest.raises(config_loader.ConfigError): | ||
| 173 | config_loader.validate_config( | ||
| 174 | SampleConfig, {"mechanism": "ransac"}, context="sample config" | ||
| 175 | ) | ||
| 176 | |||
| 177 | |||
| 178 | def test_literal_enum_field_accepts_the_plain_option_value() -> None: | ||
| 179 | """Enum-valued Literal fields accept the JSON value, as plain pydantic does.""" | ||
| 180 | |||
| 181 | class EnumConfig(config_loader.ConfigModel): | ||
| 182 | mode: Literal[SampleMode.FAST, SampleMode.EXACT] = SampleMode.FAST | ||
| 183 | |||
| 184 | assert EnumConfig.model_validate({"mode": "exact"}).mode is SampleMode.EXACT | ||
| 185 | assert EnumConfig(mode=SampleMode.FAST).mode is SampleMode.FAST | ||
| 186 | with pytest.raises(config_loader.ConfigError): | ||
| 187 | config_loader.validate_config(EnumConfig, {"mode": "ransac"}, context="enum config") | ||
| 188 | |||
| 189 | |||
| 190 | def test_int_literal_still_rejects_bools() -> None: | ||
| 191 | """Literal[int] keeps the fleet rule that a bool is not an int.""" | ||
| 192 | |||
| 193 | class IntLiteral(config_loader.ConfigModel): | ||
| 194 | level: Literal[1, 2] = 1 | ||
| 195 | |||
| 196 | assert IntLiteral(level=2).level == 2 | ||
| 197 | with pytest.raises(config_loader.ConfigError): | ||
| 198 | config_loader.validate_config(IntLiteral, {"level": True}, context="int config") | ||
| 199 | |||
| 200 | |||
| 201 | def test_sequence_items_are_coerced_with_the_same_matrix() -> None: | ||
| 202 | """tuple/list items follow the scalar matrix (and reject bools for int).""" | ||
| 203 | built = SampleConfig(band_m=["0.5", 2], weights=["3", 4.0]) | ||
| 204 | assert built.band_m == (0.5, 2.0) | ||
| 205 | assert built.weights == [3, 4] | ||
| 206 | with pytest.raises(config_loader.ConfigError): | ||
| 207 | config_loader.validate_config( | ||
| 208 | SampleConfig, {"weights": [True]}, context="sample config" | ||
| 209 | ) | ||
| 210 | |||
| 211 | |||
| 212 | def test_fixed_length_tuple_checks_item_count() -> None: | ||
| 213 | """A fixed-length tuple annotation rejects the wrong item count.""" | ||
| 214 | with pytest.raises(config_loader.ConfigError, match="expected 2 item"): | ||
| 215 | config_loader.validate_config( | ||
| 216 | SampleConfig, {"band_m": [1.0, 2.0, 3.0]}, context="sample config" | ||
| 217 | ) | ||
| 218 | |||
| 219 | |||
| 220 | def test_nested_section_is_validated_and_coerced() -> None: | ||
| 221 | """Nested models are built from mappings with the same scalar rules.""" | ||
| 222 | built = SampleConfig(ground={"cell_m": "0.75", "percentile": 8}) | ||
| 223 | assert built.ground.cell_m == 0.75 | ||
| 224 | assert built.ground.percentile == 8.0 | ||
| 225 | |||
| 226 | |||
| 227 | # --- error message shapes ---------------------------------------------------- | ||
| 228 | |||
| 229 | |||
| 230 | def test_unknown_top_level_key_message() -> None: | ||
| 231 | """Unknown top-level keys read like validate_allowed_keys.""" | ||
| 232 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 233 | config_loader.validate_config( | ||
| 234 | SampleConfig, {"nope": 1, "also": 2}, context="sample config" | ||
| 235 | ) | ||
| 236 | assert str(excinfo.value).startswith("Unknown sample config key(s): also, nope. ") | ||
| 237 | assert "Allowed keys: band_m, count, enabled, ground, mechanism," in str(excinfo.value) | ||
| 238 | |||
| 239 | |||
| 240 | def test_unknown_nested_key_uses_dotted_context() -> None: | ||
| 241 | """Unknown keys inside a section name the dotted section path.""" | ||
| 242 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 243 | config_loader.validate_config( | ||
| 244 | SampleConfig, {"ground": {"zzz": 1}}, context="sample config" | ||
| 245 | ) | ||
| 246 | assert str(excinfo.value) == ( | ||
| 247 | "Unknown sample config.ground key(s): zzz. Allowed keys: cell_m, percentile" | ||
| 248 | ) | ||
| 249 | |||
| 250 | |||
| 251 | def test_value_error_message_uses_dotted_field_path() -> None: | ||
| 252 | """Nested value errors name the dotted field path.""" | ||
| 253 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 254 | config_loader.validate_config( | ||
| 255 | SampleConfig, {"ground": {"cell_m": "abc"}}, context="sample config" | ||
| 256 | ) | ||
| 257 | assert str(excinfo.value) == "Invalid float for 'ground.cell_m': 'abc' (str)." | ||
| 258 | |||
| 259 | |||
| 260 | def test_value_error_message_top_level_shape() -> None: | ||
| 261 | """Top-level value errors keep the legacy 'Invalid <type> for ...' shape.""" | ||
| 262 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 263 | config_loader.validate_config( | ||
| 264 | SampleConfig, {"count": 3.7}, context="sample config" | ||
| 265 | ) | ||
| 266 | assert str(excinfo.value) == "Invalid int for 'count': 3.7 is not an integral value." | ||
| 267 | |||
| 268 | |||
| 269 | def test_missing_required_key_message() -> None: | ||
| 270 | """A missing required field is reported with the context name.""" | ||
| 271 | |||
| 272 | class Required(config_loader.ConfigModel): | ||
| 273 | name: str | ||
| 274 | |||
| 275 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 276 | config_loader.validate_config(Required, {}, context="sample config") | ||
| 277 | assert str(excinfo.value) == "Missing required sample config key: 'name'" | ||
| 278 | |||
| 279 | |||
| 280 | def test_error_cls_override_is_used() -> None: | ||
| 281 | """validate_config raises the caller's error class.""" | ||
| 282 | with pytest.raises(SampleConfigError): | ||
| 283 | config_loader.validate_config( | ||
| 284 | SampleConfig, {"nope": 1}, context="sample config", error_cls=SampleConfigError | ||
| 285 | ) | ||
| 286 | |||
| 287 | |||
| 288 | def test_format_validation_error_without_model_cls_lists_allowed_keys() -> None: | ||
| 289 | """The model is recovered from the error title, so allowed keys stay listed.""" | ||
| 290 | with pytest.raises(Exception) as excinfo: | ||
| 291 | SampleConfig(nope=1) | ||
| 292 | message = config_model.format_validation_error( | ||
| 293 | excinfo.value, context="sample config" | ||
| 294 | ) | ||
| 295 | assert message.startswith("Unknown sample config key(s): nope. Allowed keys: band_m, ") | ||
| 296 | |||
| 297 | |||
| 298 | def test_format_validation_error_omits_allowed_keys_for_unknown_model() -> None: | ||
| 299 | """A foreign model's error still reads well, just without the key list.""" | ||
| 300 | |||
| 301 | class Foreign(pydantic.BaseModel): | ||
| 302 | model_config = pydantic.ConfigDict(extra="forbid") | ||
| 303 | |||
| 304 | alpha: int = 1 | ||
| 305 | |||
| 306 | with pytest.raises(pydantic.ValidationError) as excinfo: | ||
| 307 | Foreign(nope=1) | ||
| 308 | message = config_model.format_validation_error(excinfo.value, context="probe config") | ||
| 309 | assert message == "Unknown probe config key(s): nope." | ||
| 310 | |||
| 311 | |||
| 312 | def test_format_validation_error_skips_ambiguous_model_names() -> None: | ||
| 313 | """Distinct ConfigModels sharing a name resolve to neither, so no key list.""" | ||
| 314 | |||
| 315 | def _first() -> type[config_loader.ConfigModel]: | ||
| 316 | class Twin(config_loader.ConfigModel): | ||
| 317 | alpha: int = 1 | ||
| 318 | |||
| 319 | return Twin | ||
| 320 | |||
| 321 | def _second() -> type[config_loader.ConfigModel]: | ||
| 322 | class Twin(config_loader.ConfigModel): | ||
| 323 | beta: int = 2 | ||
| 324 | |||
| 325 | return Twin | ||
| 326 | |||
| 327 | model, _other = _first(), _second() | ||
| 328 | with pytest.raises(pydantic.ValidationError) as excinfo: | ||
| 329 | model(nope=1) | ||
| 330 | message = config_model.format_validation_error(excinfo.value, context="twin config") | ||
| 331 | assert message == "Unknown twin config key(s): nope." | ||
| 332 | |||
| 333 | |||
| 334 | def test_format_validation_error_survives_class_redefinition() -> None: | ||
| 335 | """Re-running the same class statement is a redefinition, not an ambiguity.""" | ||
| 336 | |||
| 337 | def _make() -> type[config_loader.ConfigModel]: | ||
| 338 | class Reloaded(config_loader.ConfigModel): | ||
| 339 | alpha: int = 1 | ||
| 340 | |||
| 341 | return Reloaded | ||
| 342 | |||
| 343 | _stale, current = _make(), _make() | ||
| 344 | with pytest.raises(pydantic.ValidationError) as excinfo: | ||
| 345 | current(nope=1) | ||
| 346 | message = config_model.format_validation_error(excinfo.value, context="reload config") | ||
| 347 | assert message == "Unknown reload config key(s): nope. Allowed keys: alpha" | ||
| 348 | |||
| 349 | |||
| 350 | def test_alias_field_reports_the_alias_as_the_allowed_key() -> None: | ||
| 351 | """An aliased field is listed (and reported missing) under its alias.""" | ||
| 352 | |||
| 353 | class Aliased(config_loader.ConfigModel): | ||
| 354 | internal_name: int = pydantic.Field(alias="external-name") | ||
| 355 | |||
| 356 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 357 | config_loader.validate_config(Aliased, {"internal_name": 3}, context="alias config") | ||
| 358 | message = str(excinfo.value) | ||
| 359 | assert "Unknown alias config key(s): internal_name. Allowed keys: external-name" in message | ||
| 360 | assert "Missing required alias config key: 'external-name'" in message | ||
| 361 | assert config_loader.validate_config( | ||
| 362 | Aliased, {"external-name": "3"}, context="alias config" | ||
| 363 | ).internal_name == 3 | ||
| 364 | |||
| 365 | |||
| 366 | def test_aliased_section_resolves_nested_allowed_keys() -> None: | ||
| 367 | """Unknown keys inside an aliased section list that section's own keys.""" | ||
| 368 | |||
| 369 | class Outer(config_loader.ConfigModel): | ||
| 370 | ground: GroundSection = pydantic.Field( | ||
| 371 | default=GroundSection(), alias="ground-section" | ||
| 372 | ) | ||
| 373 | |||
| 374 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 375 | config_loader.validate_config( | ||
| 376 | Outer, {"ground-section": {"zzz": 1}}, context="outer config" | ||
| 377 | ) | ||
| 378 | assert str(excinfo.value) == ( | ||
| 379 | "Unknown outer config.ground-section key(s): zzz. Allowed keys: cell_m, percentile" | ||
| 380 | ) | ||
| 381 | |||
| 382 | |||
| 383 | # --- load_config ------------------------------------------------------------- | ||
| 384 | |||
| 385 | |||
| 386 | def test_load_config_reads_packaged_defaults() -> None: | ||
| 387 | """load_config validates the packaged JSON into the model.""" | ||
| 388 | with patch( | ||
| 389 | "iolabs.common.config_loader.default_config_path", | ||
| 390 | return_value=_FIXTURE_PATH, | ||
| 391 | ): | ||
| 392 | built = config_loader.load_config( | ||
| 393 | FixtureConfig, | ||
| 394 | package=_PACKAGE, | ||
| 395 | filename="config_loader.fixture.json", | ||
| 396 | context="fixture config", | ||
| 397 | ) | ||
| 398 | assert built.enabled is True | ||
| 399 | assert built.ground.cell_m == 0.75 | ||
| 400 | |||
| 401 | |||
| 402 | def test_load_config_deep_merges_overrides() -> None: | ||
| 403 | """Overrides are deep-merged onto the defaults before validation.""" | ||
| 404 | with patch( | ||
| 405 | "iolabs.common.config_loader.default_config_path", | ||
| 406 | return_value=_FIXTURE_PATH, | ||
| 407 | ): | ||
| 408 | built = config_loader.load_config( | ||
| 409 | FixtureConfig, | ||
| 410 | package=_PACKAGE, | ||
| 411 | filename="config_loader.fixture.json", | ||
| 412 | overrides={"ground": {"cell_m": "0.25"}, "enabled": "off"}, | ||
| 413 | context="fixture config", | ||
| 414 | ) | ||
| 415 | assert built.enabled is False | ||
| 416 | assert built.ground.cell_m == 0.25 | ||
| 417 | assert built.ground.percentile == 8.0 | ||
| 418 | |||
| 419 | |||
| 420 | def test_load_config_uses_config_path_when_given(tmp_path: Path) -> None: | ||
| 421 | """config_path replaces the packaged defaults.""" | ||
| 422 | path = tmp_path / "custom.json" | ||
| 423 | path.write_text(json.dumps({"ground": {"cell_m": 1.5}}), encoding="utf-8") | ||
| 424 | built = config_loader.load_config( | ||
| 425 | FixtureConfig, | ||
| 426 | package=_PACKAGE, | ||
| 427 | filename="config_loader.fixture.json", | ||
| 428 | config_path=path, | ||
| 429 | context="fixture config", | ||
| 430 | ) | ||
| 431 | assert built.enabled is False | ||
| 432 | assert built.ground.cell_m == 1.5 | ||
| 433 | |||
| 434 | |||
| 435 | def test_load_config_rejects_unknown_key_from_file(tmp_path: Path) -> None: | ||
| 436 | """Unknown keys in a config file are reported with the file's context.""" | ||
| 437 | path = tmp_path / "custom.json" | ||
| 438 | path.write_text(json.dumps({"zzz": 1}), encoding="utf-8") | ||
| 439 | with pytest.raises(config_loader.ConfigError, match="Unknown fixture config key"): | ||
| 440 | config_loader.load_config( | ||
| 441 | FixtureConfig, | ||
| 442 | package=_PACKAGE, | ||
| 443 | filename="config_loader.fixture.json", | ||
| 444 | config_path=path, | ||
| 445 | context="fixture config", | ||
| 446 | ) | ||
| 447 | |||
| 448 | |||
| 449 | def test_load_config_wraps_malformed_json(tmp_path: Path) -> None: | ||
| 450 | """Malformed JSON is wrapped in the caller's error class.""" | ||
| 451 | path = tmp_path / "custom.json" | ||
| 452 | path.write_text("{oops", encoding="utf-8") | ||
| 453 | with pytest.raises(SampleConfigError, match="Invalid JSON in config file"): | ||
| 454 | config_loader.load_config( | ||
| 455 | FixtureConfig, | ||
| 456 | package=_PACKAGE, | ||
| 457 | filename="config_loader.fixture.json", | ||
| 458 | config_path=path, | ||
| 459 | context="fixture config", | ||
| 460 | error_cls=SampleConfigError, | ||
| 461 | ) | ||
| 462 | |||
| 463 | |||
| 464 | def test_load_config_rejects_non_object_json(tmp_path: Path) -> None: | ||
| 465 | """A JSON file holding a list is rejected.""" | ||
| 466 | path = tmp_path / "custom.json" | ||
| 467 | path.write_text("[1, 2]", encoding="utf-8") | ||
| 468 | with pytest.raises(config_loader.ConfigError, match="must hold a JSON object"): | ||
| 469 | config_loader.load_config( | ||
| 470 | FixtureConfig, | ||
| 471 | package=_PACKAGE, | ||
| 472 | filename="config_loader.fixture.json", | ||
| 473 | config_path=path, | ||
| 474 | context="fixture config", | ||
| 475 | ) | ||
| 476 | |||
| 477 | |||
| 478 | def test_load_config_rejects_non_object_packaged_json(tmp_path: Path) -> None: | ||
| 479 | """A packaged default JSON holding a list is rejected like a file one.""" | ||
| 480 | path = tmp_path / "defaults.json" | ||
| 481 | path.write_text('[["enabled", false]]', encoding="utf-8") | ||
| 482 | with patch( | ||
| 483 | "iolabs.common.config_loader.default_config_path", | ||
| 484 | return_value=path, | ||
| 485 | ): | ||
| 486 | with pytest.raises(config_loader.ConfigError, match="must hold a JSON object"): | ||
| 487 | config_loader.load_config( | ||
| 488 | FixtureConfig, | ||
| 489 | package=_PACKAGE, | ||
| 490 | filename="defaults.json", | ||
| 491 | context="fixture config", | ||
| 492 | ) | ||
| 493 | |||
| 494 | |||
| 495 | # --- model behaviour --------------------------------------------------------- | ||
| 496 | |||
| 497 | |||
| 498 | def test_config_is_frozen() -> None: | ||
| 499 | """Built configs cannot be mutated.""" | ||
| 500 | built = SampleConfig() | ||
| 501 | with pytest.raises(Exception): # noqa: B017 - pydantic raises ValidationError | ||
| 502 | built.count = 5 | ||
| 503 | |||
| 504 | |||
| 505 | def test_model_dump_round_trip_produces_plain_dicts() -> None: | ||
| 506 | """model_dump yields plain dicts that validate back into the same config.""" | ||
| 507 | built = SampleConfig(ground={"cell_m": 0.25}) | ||
| 508 | dumped = built.model_dump() | ||
| 509 | assert isinstance(dumped, dict) | ||
| 510 | assert isinstance(dumped["ground"], dict) | ||
| 511 | assert dumped["ground"] == {"cell_m": 0.25, "percentile": 5.0} | ||
| 512 | assert config_loader.validate_config( | ||
| 513 | SampleConfig, dumped, context="sample config" | ||
| 514 | ) == built | ||
| 515 | |||
| 516 | |||
| 517 | def test_defaults_are_validated() -> None: | ||
| 518 | """validate_default=True catches a bad default at model build time.""" | ||
| 519 | |||
| 520 | class Bad(config_loader.ConfigModel): | ||
| 521 | count: int = "abc" # type: ignore[assignment] | ||
| 522 | |||
| 523 | with pytest.raises(config_loader.ConfigError): | ||
| 524 | config_loader.validate_config(Bad, {}, context="sample config") | ||
| 525 | |||
| 526 | |||
| 527 | def test_extra_keys_forbidden_on_direct_construction() -> None: | ||
| 528 | """extra='forbid' applies to plain construction too, not just load_config.""" | ||
| 529 | with pytest.raises(Exception): # noqa: B017 - pydantic raises ValidationError | ||
| 530 | SampleConfig(nope=1) | ||
| 531 | |||
| 532 | |||
| 533 | # --- deprecation shims ------------------------------------------------------- | ||
| 534 | |||
| 535 | |||
| 536 | @dataclasses.dataclass(frozen=True) | ||
| 537 | class LegacySample: | ||
| 538 | """Dataclass used to exercise the deprecated mapping helper.""" | ||
| 539 | |||
| 540 | count: int | ||
| 541 | |||
| 542 | |||
| 543 | @pytest.mark.parametrize( | ||
| 544 | ("name", "call"), | ||
| 545 | [ | ||
| 546 | ( | ||
| 547 | "validate_allowed_keys", | ||
| 548 | lambda: config_loader.validate_allowed_keys( | ||
| 549 | {"a": 1}, frozenset({"a"}), context="ctx" | ||
| 550 | ), | ||
| 551 | ), | ||
| 552 | ("coerce_to_field_type", lambda: config_loader.coerce_to_field_type("x", 1, int)), | ||
| 553 | ( | ||
| 554 | "dataclass_from_mapping", | ||
| 555 | lambda: config_loader.dataclass_from_mapping( | ||
| 556 | LegacySample, {"count": "2"}, context="ctx" | ||
| 557 | ), | ||
| 558 | ), | ||
| 559 | ( | ||
| 560 | "validate_against_defaults", | ||
| 561 | lambda: config_loader.validate_against_defaults({"a": 1}, {"a": 1}, context="ctx"), | ||
| 562 | ), | ||
| 563 | ], | ||
| 564 | ) | ||
| 565 | def test_legacy_helpers_warn(name: str, call: Any) -> None: | ||
| 566 | """The hand-rolled helpers still work but are deprecated.""" | ||
| 567 | with pytest.warns(DeprecationWarning, match=name): | ||
| 568 | call() | ||
| 569 | |||
| 570 | |||
| 571 | def test_lazy_reexports_are_visible_to_dir() -> None: | ||
| 572 | """The lazily re-exported pydantic names show up in dir(config_loader).""" | ||
| 573 | names = dir(config_loader) | ||
| 574 | assert {"ConfigModel", "load_config", "validate_config", "format_validation_error"} <= set( | ||
| 575 | names | ||
| 576 | ) | ||
| 577 | assert names == sorted(names) | ||
| 578 | |||
| 579 | |||
| 580 | def test_coerce_config_value_does_not_warn() -> None: | ||
| 581 | """The shared coercion entry point used by ConfigModel is not deprecated.""" | ||
| 582 | with warnings.catch_warnings(): | ||
| 583 | warnings.simplefilter("error", DeprecationWarning) | ||
| 584 | assert config_loader.coerce_config_value("x", "1e3", int) == 1000 | ||
| 0 |
| 1 | """Tests for packaged-JSON config loader helpers.""" | 1 | """Tests for packaged-JSON config loader helpers.""" |
| 2 | 2 | ||
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | 4 | ||
| 5 | import enum | ||
| 5 | import importlib | 6 | import importlib |
| 6 | import json | 7 | import json |
| 7 | import math | 8 | import math |
| 8 | import sys | 9 | import sys |
Old helpers kept alive for published leaf wheels; warn once per call site.
| 29 | _PACKAGE = "iolabs.common" | 30 | _PACKAGE = "iolabs.common" |
| 30 | _PACKAGED_MODULE_FILENAME = "config_loader.py" | 31 | _PACKAGED_MODULE_FILENAME = "config_loader.py" |
| 31 | 32 | ||
| 32 | 33 | ||
| 34 | # The hand-rolled helpers below are deprecated shims kept for published leaf | ||
| 35 | # wheels; their DeprecationWarning is expected here. | ||
| 36 | pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") | ||
| 37 | |||
| 38 | |||
| 33 | class CustomConfigError(ConfigError): | 39 | class CustomConfigError(ConfigError): |
| 34 | """Consumer-style ConfigError subclass for error_cls override tests.""" | 40 | """Consumer-style ConfigError subclass for error_cls override tests.""" |
| 35 | 41 | ||
| 36 | 42 |
| 316 | """Ints, integral floats and numeric strings (incl. exponents) coerce.""" | 322 | """Ints, integral floats and numeric strings (incl. exponents) coerce.""" |
| 317 | assert coerce_to_field_type("count", value, int) == expected | 323 | assert coerce_to_field_type("count", value, int) == expected |
| 318 | 324 | ||
| 319 | 325 | ||
| 320 | @pytest.mark.parametrize("value", [3.7, -3.7, "3.7", "abc", True, None, []]) | 326 | @pytest.mark.parametrize("value", [3.7, -3.7, "3.7", "abc", True, None, [], b"3", object()]) |
| 321 | def test_coerce_int_rejects_non_integral_and_bad_tokens(value: object) -> None: | 327 | def test_coerce_int_rejects_non_integral_and_bad_tokens(value: object) -> None: |
| 322 | """Truncation, bools and bad tokens raise the config error, not ValueError.""" | 328 | """Truncation, bools and bad tokens raise the config error, not ValueError.""" |
| 323 | with pytest.raises(ConfigError, match="Invalid int for 'count'"): | 329 | with pytest.raises(ConfigError, match="Invalid int for 'count'"): |
| 324 | coerce_to_field_type("count", value, int) | 330 | coerce_to_field_type("count", value, int) |
| 334 | assert isinstance(result, float) | 340 | assert isinstance(result, float) |
| 335 | assert result == expected | 341 | assert result == expected |
| 336 | 342 | ||
| 337 | 343 | ||
| 338 | @pytest.mark.parametrize("value", ["abc", None, [], True]) | 344 | @pytest.mark.parametrize("value", ["abc", None, [], True, b"2.5", object()]) |
| 339 | def test_coerce_float_rejects_bad_values(value: object) -> None: | 345 | def test_coerce_float_rejects_bad_values(value: object) -> None: |
| 340 | """Bad tokens and non-numeric types are wrapped in the config error.""" | 346 | """Bad tokens and non-numeric types are wrapped in the config error.""" |
| 341 | with pytest.raises(ConfigError, match="Invalid float for 'threshold_m'"): | 347 | with pytest.raises(ConfigError, match="Invalid float for 'threshold_m'"): |
| 342 | coerce_to_field_type("threshold_m", value, float) | 348 | coerce_to_field_type("threshold_m", value, float) |
| 389 | with pytest.raises(ConfigError, match="Expected one of: 'csf', 'plane'"): | 395 | with pytest.raises(ConfigError, match="Expected one of: 'csf', 'plane'"): |
| 390 | coerce_to_field_type("mechanism", "cloth", declared) | 396 | coerce_to_field_type("mechanism", "cloth", declared) |
| 391 | 397 | ||
| 392 | 398 | ||
| 399 | def test_coerce_literal_enum_accepts_the_plain_option_value() -> None: | ||
| 400 | """An enum option also matches its plain value and yields the member.""" | ||
| 401 | |||
| 402 | class Mechanism(enum.StrEnum): | ||
| 403 | CSF = "csf" | ||
| 404 | PLANE = "plane" | ||
| 405 | |||
| 406 | declared = Literal[Mechanism.CSF, Mechanism.PLANE] | ||
| 407 | assert coerce_to_field_type("mechanism", "plane", declared) is Mechanism.PLANE | ||
| 408 | assert coerce_to_field_type("mechanism", Mechanism.CSF, declared) is Mechanism.CSF | ||
| 409 | with pytest.raises(ConfigError, match="Expected one of"): | ||
| 410 | coerce_to_field_type("mechanism", "cloth", declared) | ||
| 411 | |||
| 412 | |||
| 413 | def test_coerce_int_literal_still_rejects_bools() -> None: | ||
| 414 | """A bool never satisfies Literal[1, 2], unlike plain pydantic.""" | ||
| 415 | with pytest.raises(ConfigError, match="Expected one of"): | ||
| 416 | coerce_to_field_type("level", True, Literal[1, 2]) | ||
| 417 | |||
| 418 | |||
| 393 | def test_coerce_unknown_declared_type_passes_through() -> None: | 419 | def test_coerce_unknown_declared_type_passes_through() -> None: |
| 394 | """Unsupported declared types return the value unchanged.""" | 420 | """Unsupported declared types return the value unchanged.""" |
| 395 | payload = {"a": 1} | 421 | payload = {"a": 1} |
| 396 | assert coerce_to_field_type("extras", payload, dict[str, int]) is payload | 422 | assert coerce_to_field_type("extras", payload, dict[str, int]) is payload |
| 4 | description = "Shared data structures for the 3D AI LIDAR processing pipeline" | 4 | description = "Shared data structures for the 3D AI LIDAR processing pipeline" |
| 5 | requires-python = ">=3.11" | 5 | requires-python = ">=3.11" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.20.0", | 7 | "numpy>=1.20.0", |
| 8 | "pydantic>=2.7", | ||
| 8 | ] | 9 | ] |
| 9 | [project.optional-dependencies] | 10 | [project.optional-dependencies] |
| 10 | dev = ["pytest>=7.0.0"] | 11 | dev = ["pytest>=7.0.0"] |
| 11 | crs = ["pyproj>=3.4.0"] | 12 | crs = ["pyproj>=3.4.0"] |
| 12 | 12 | ||
| 13 | ```python | 13 | ```python |
| 14 | from io_common.common_data import ColorIntensityData | 14 | from io_common.common_data import ColorIntensityData |
| 15 | ``` | 15 | ``` |
| 16 | |||
| 17 | ## Config models (pydantic v2) | ||
| 18 | |||
| 19 | Package configs derive from `config_loader.ConfigModel` (frozen, `extra="forbid"`, | ||
| 20 | fleet coercion matrix for `bool`/`int`/`float`/`str`) and are built with | ||
| 21 | `config_loader.load_config(...)`: | ||
| 22 | |||
| 23 | ```python | ||
| 24 | from collections.abc import Mapping | ||
| 25 | from pathlib import Path | ||
| 26 | from typing import Any, Literal | ||
| 27 | |||
| 28 | from iolabs.common import config_loader | ||
| 29 | |||
| 30 | |||
| 31 | class FooGroundConfig(config_loader.ConfigModel): | ||
| 32 | cell_m: float = 0.5 | ||
| 33 | |||
| 34 | |||
| 35 | class FooConfig(config_loader.ConfigModel): | ||
| 36 | mode: Literal["fast", "exact"] = "fast" | ||
| 37 | ground: FooGroundConfig = FooGroundConfig() | ||
| 38 | |||
| 39 | |||
| 40 | class FooConfigError(config_loader.ConfigError): | ||
| 41 | """Raised for an invalid foo config.""" | ||
| 42 | |||
| 43 | |||
| 44 | def build_foo_config( | ||
| 45 | overrides: Mapping[str, Any] | None = None, | ||
| 46 | config_path: str | Path | None = None, | ||
| 47 | ) -> dict[str, Any]: | ||
| 48 | return config_loader.load_config( | ||
| 49 | FooConfig, | ||
| 50 | package="iolabs_foo", | ||
| 51 | filename="default_config.json", | ||
| 52 | overrides=overrides, | ||
| 53 | config_path=config_path, | ||
| 54 | context="foo config", | ||
| 55 | error_cls=FooConfigError, | ||
| 56 | ).model_dump() | ||
| 57 | ``` | ||
| 58 | |||
| 59 | The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`, | ||
| 60 | `dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf | ||
| 61 | wheels but emit a `DeprecationWarning` (0.8.0). | ||
| 62 | |||
| 63 | Models are frozen, but only shallowly (as in pydantic itself): use `tuple` rather | ||
| 64 | than `list` for sequence fields that must not be mutated after construction. |
| 18 | wheels = [ | 18 | wheels = [ |
| 19 | { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, | 19 | { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, |
| 20 | ] | 20 | ] |
| 21 | 21 | ||
| 22 | [[package]] | ||
| 23 | name = "annotated-types" | ||
| 24 | version = "0.8.0" | ||
| 25 | source = { registry = "https://pypi.org/simple" } | ||
| 26 | sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } | ||
| 27 | wheels = [ | ||
| 28 | { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, | ||
| 29 | ] | ||
| 30 | |||
| 22 | [[package]] | 31 | [[package]] |
| 23 | name = "attrs" | 32 | name = "attrs" |
| 24 | version = "25.4.0" | 33 | version = "25.4.0" |
| 25 | source = { registry = "https://pypi.org/simple" } | 34 | source = { registry = "https://pypi.org/simple" } |
| 370 | version = "0.8.0" | 379 | version = "0.8.0" |
| 371 | source = { editable = "." } | 380 | source = { editable = "." } |
| 372 | dependencies = [ | 381 | dependencies = [ |
| 373 | { name = "numpy" }, | 382 | { name = "numpy" }, |
| 383 | { name = "pydantic" }, | ||
| 374 | ] | 384 | ] |
| 375 | 385 | ||
| 376 | [package.optional-dependencies] | 386 | [package.optional-dependencies] |
| 377 | crs = [ | 387 | crs = [ |
| 390 | requires-dist = [ | 400 | requires-dist = [ |
| 391 | { name = "numpy", specifier = ">=1.20.0" }, | 401 | { name = "numpy", specifier = ">=1.20.0" }, |
| 392 | { name = "open3d", marker = "extra == 'memory-guard'", specifier = ">=0.19.0" }, | 402 | { name = "open3d", marker = "extra == 'memory-guard'", specifier = ">=0.19.0" }, |
| 393 | { name = "psutil", marker = "extra == 'memory-guard'", specifier = ">=5.8.0" }, | 403 | { name = "psutil", marker = "extra == 'memory-guard'", specifier = ">=5.8.0" }, |
| 404 | { name = "pydantic", specifier = ">=2.7" }, | ||
| 394 | { name = "pyproj", marker = "extra == 'crs'", specifier = ">=3.4.0" }, | 405 | { name = "pyproj", marker = "extra == 'crs'", specifier = ">=3.4.0" }, |
| 395 | { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, | 406 | { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, |
| 396 | { name = "scikit-learn", marker = "extra == 'memory-guard'", specifier = ">=1.0.0" }, | 407 | { name = "scikit-learn", marker = "extra == 'memory-guard'", specifier = ">=1.0.0" }, |
| 397 | ] | 408 | ] |
| 1050 | { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, | 1061 | { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, |
| 1051 | { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, | 1062 | { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, |
| 1052 | ] | 1063 | ] |
| 1053 | 1064 | ||
| 1065 | [[package]] | ||
| 1066 | name = "pydantic" | ||
| 1067 | version = "2.13.5" | ||
| 1068 | source = { registry = "https://pypi.org/simple" } | ||
| 1069 | dependencies = [ | ||
| 1070 | { name = "annotated-types" }, | ||
| 1071 | { name = "pydantic-core" }, | ||
| 1072 | { name = "typing-extensions" }, | ||
| 1073 | { name = "typing-inspection" }, | ||
| 1074 | ] | ||
| 1075 | sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } | ||
| 1076 | wheels = [ | ||
| 1077 | { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, | ||
| 1078 | ] | ||
| 1079 | |||
| 1080 | [[package]] | ||
| 1081 | name = "pydantic-core" | ||
| 1082 | version = "2.46.5" | ||
| 1083 | source = { registry = "https://pypi.org/simple" } | ||
| 1084 | dependencies = [ | ||
| 1085 | { name = "typing-extensions" }, | ||
| 1086 | ] | ||
| 1087 | sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } | ||
| 1088 | wheels = [ | ||
| 1089 | { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, | ||
| 1090 | { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, | ||
| 1091 | { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, | ||
| 1092 | { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, | ||
| 1093 | { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, | ||
| 1094 | { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, | ||
| 1095 | { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, | ||
| 1096 | { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, | ||
| 1097 | { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, | ||
| 1098 | { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, | ||
| 1099 | { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, | ||
| 1100 | { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, | ||
| 1101 | { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, | ||
| 1102 | { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, | ||
| 1103 | { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, | ||
| 1104 | { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, | ||
| 1105 | { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, | ||
| 1106 | { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, | ||
| 1107 | { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, | ||
| 1108 | { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, | ||
| 1109 | { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, | ||
| 1110 | { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, | ||
| 1111 | { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, | ||
| 1112 | { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, | ||
| 1113 | { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, | ||
| 1114 | { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, | ||
| 1115 | { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, | ||
| 1116 | { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, | ||
| 1117 | { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, | ||
| 1118 | { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, | ||
| 1119 | { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, | ||
| 1120 | { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, | ||
| 1121 | { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, | ||
| 1122 | { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, | ||
| 1123 | { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, | ||
| 1124 | { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, | ||
| 1125 | { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, | ||
| 1126 | { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, | ||
| 1127 | { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, | ||
| 1128 | { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, | ||
| 1129 | { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, | ||
| 1130 | { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, | ||
| 1131 | { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, | ||
| 1132 | { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, | ||
| 1133 | { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, | ||
| 1134 | { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, | ||
| 1135 | { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, | ||
| 1136 | { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, | ||
| 1137 | { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, | ||
| 1138 | { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, | ||
| 1139 | { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, | ||
| 1140 | { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, | ||
| 1141 | { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, | ||
| 1142 | { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, | ||
| 1143 | { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, | ||
| 1144 | { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, | ||
| 1145 | { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, | ||
| 1146 | { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, | ||
| 1147 | { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, | ||
| 1148 | { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, | ||
| 1149 | { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, | ||
| 1150 | { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, | ||
| 1151 | { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, | ||
| 1152 | { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, | ||
| 1153 | { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, | ||
| 1154 | { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, | ||
| 1155 | { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, | ||
| 1156 | { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, | ||
| 1157 | { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, | ||
| 1158 | { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, | ||
| 1159 | { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, | ||
| 1160 | { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, | ||
| 1161 | { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, | ||
| 1162 | { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, | ||
| 1163 | { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, | ||
| 1164 | { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, | ||
| 1165 | { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, | ||
| 1166 | { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, | ||
| 1167 | { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, | ||
| 1168 | { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, | ||
| 1169 | { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, | ||
| 1170 | { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, | ||
| 1171 | { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, | ||
| 1172 | { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, | ||
| 1173 | { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, | ||
| 1174 | { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, | ||
| 1175 | { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, | ||
| 1176 | { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, | ||
| 1177 | { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, | ||
| 1178 | { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, | ||
| 1179 | { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, | ||
| 1180 | ] | ||
| 1181 | |||
| 1054 | [[package]] | 1182 | [[package]] |
| 1055 | name = "pygments" | 1183 | name = "pygments" |
| 1056 | version = "2.19.2" | 1184 | version = "2.19.2" |
| 1057 | source = { registry = "https://pypi.org/simple" } | 1185 | source = { registry = "https://pypi.org/simple" } |
| 1552 | wheels = [ | 1680 | wheels = [ |
| 1553 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, | 1681 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, |
| 1554 | ] | 1682 | ] |
| 1555 | 1683 | ||
| 1684 | [[package]] | ||
| 1685 | name = "typing-inspection" | ||
| 1686 | version = "0.4.4" | ||
| 1687 | source = { registry = "https://pypi.org/simple" } | ||
| 1688 | dependencies = [ | ||
| 1689 | { name = "typing-extensions" }, | ||
| 1690 | ] | ||
| 1691 | sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } | ||
| 1692 | wheels = [ | ||
| 1693 | { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, | ||
| 1694 | ] | ||
| 1695 | |||
| 1556 | [[package]] | 1696 | [[package]] |
| 1557 | name = "tzdata" | 1697 | name = "tzdata" |
| 1558 | version = "2025.3" | 1698 | version = "2025.3" |
| 1559 | source = { registry = "https://pypi.org/simple" } | 1699 | source = { registry = "https://pypi.org/simple" } |
| 4 | description = "Shared data structures for the 3D AI LIDAR processing pipeline" | 4 | description = "Shared data structures for the 3D AI LIDAR processing pipeline" |
| 5 | requires-python = ">=3.11" | 5 | requires-python = ">=3.11" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.20.0", | 7 | "numpy>=1.20.0", |
| 8 | "pydantic>=2.7", | ||
| 8 | ] | 9 | ] |
| 9 | [project.optional-dependencies] | 10 | [project.optional-dependencies] |
| 10 | dev = ["pytest>=7.0.0"] | 11 | dev = ["pytest>=7.0.0"] |
| 11 | crs = ["pyproj>=3.4.0"] | 12 | crs = ["pyproj>=3.4.0"] |
| 4 | atomic_io, | 4 | atomic_io, |
| 5 | cli, | 5 | cli, |
| 6 | color_intensity_data, | 6 | color_intensity_data, |
| 7 | config_loader, | 7 | config_loader, |
| 8 | config_model, | ||
| 8 | crs, | 9 | crs, |
| 9 | diagnostic_data, | 10 | diagnostic_data, |
| 10 | ground_mask_io, | 11 | ground_mask_io, |
| 11 | indexed_ordered_dict, | 12 | indexed_ordered_dict, |
| 23 | configure_logging, | 24 | configure_logging, |
| 24 | ) | 25 | ) |
| 25 | from .config_loader import ( | 26 | from .config_loader import ( |
| 26 | ConfigError, | 27 | ConfigError, |
| 28 | coerce_config_value, | ||
| 27 | coerce_to_field_type, | 29 | coerce_to_field_type, |
| 28 | dataclass_from_mapping, | 30 | dataclass_from_mapping, |
| 29 | deep_merge_dicts, | 31 | deep_merge_dicts, |
| 30 | default_config_path, | 32 | default_config_path, |
| 32 | parse_set_overrides, | 34 | parse_set_overrides, |
| 33 | validate_against_defaults, | 35 | validate_against_defaults, |
| 34 | validate_allowed_keys, | 36 | validate_allowed_keys, |
| 35 | ) | 37 | ) |
| 38 | from .config_model import ( | ||
| 39 | ConfigModel, | ||
| 40 | format_validation_error, | ||
| 41 | load_config, | ||
| 42 | validate_config, | ||
| 43 | ) | ||
| 36 | 44 | ||
| 37 | __all__ = [ | 45 | __all__ = [ |
| 38 | "atomic_io", | 46 | "atomic_io", |
| 39 | "cli", | 47 | "cli", |
| 40 | "color_intensity_data", | 48 | "color_intensity_data", |
| 41 | "config_loader", | 49 | "config_loader", |
| 50 | "config_model", | ||
| 42 | "crs", | 51 | "crs", |
| 43 | "diagnostic_data", | 52 | "diagnostic_data", |
| 44 | "ground_mask_io", | 53 | "ground_mask_io", |
| 45 | "indexed_ordered_dict", | 54 | "indexed_ordered_dict", |
| 53 | "LOG_LEVEL_CHOICES", | 62 | "LOG_LEVEL_CHOICES", |
| 54 | "add_log_level_argument", | 63 | "add_log_level_argument", |
| 55 | "configure_logging", | 64 | "configure_logging", |
| 56 | "ConfigError", | 65 | "ConfigError", |
| 66 | "ConfigModel", | ||
| 67 | "coerce_config_value", | ||
| 57 | "coerce_to_field_type", | 68 | "coerce_to_field_type", |
| 58 | "dataclass_from_mapping", | 69 | "dataclass_from_mapping", |
| 59 | "default_config_path", | 70 | "default_config_path", |
| 60 | "deep_merge_dicts", | 71 | "deep_merge_dicts", |
| 72 | "format_validation_error", | ||
| 73 | "load_config", | ||
| 61 | "load_packaged_json", | 74 | "load_packaged_json", |
| 75 | "validate_config", | ||
| 62 | "parse_set_overrides", | 76 | "parse_set_overrides", |
| 63 | "validate_against_defaults", | 77 | "validate_against_defaults", |
| 64 | "validate_allowed_keys", | 78 | "validate_allowed_keys", |
| 65 | ] | 79 | ] |
Old helpers kept alive for published leaf wheels; warn once per call site.
| 1 | """Packaged-JSON config loading, deep-merge, key validation and value coercion. | 1 | """Packaged-JSON config loading, deep-merge, key validation and value coercion. |
| 2 | 2 | ||
| 3 | Covers the whole config path shared by the pipeline packages: load the | 3 | Covers the whole config path shared by the pipeline packages: load the |
| 4 | packaged default JSON, deep-merge CLI ``--set`` overrides onto it, reject | 4 | packaged default JSON, deep-merge CLI ``--set`` overrides onto it, reject |
| 5 | unknown keys (flat or against the defaults tree) and coerce raw JSON/CLI | 5 | unknown keys and coerce raw JSON/CLI values to the declared field types. |
| 6 | values to the declared dataclass field types. | 6 | |
| 7 | The current way to declare a config is a pydantic model derived from | ||
| 8 | `ConfigModel`, validated by :func:`load_config`; both are defined in | ||
| 9 | :mod:`iolabs.common.config_model` and re-exported here, together with | ||
| 10 | :func:`validate_config` and :func:`format_validation_error`. The canonical | ||
| 11 | package pattern is:: | ||
| 12 | |||
| 13 | from typing import Any, Literal | ||
| 14 | |||
| 15 | from iolabs.common import config_loader | ||
| 16 | |||
| 17 | |||
| 18 | class FooGroundConfig(config_loader.ConfigModel): | ||
| 19 | cell_m: float = 0.5 | ||
| 20 | |||
| 21 | |||
| 22 | class FooConfig(config_loader.ConfigModel): | ||
| 23 | mode: Literal["fast", "exact"] = "fast" | ||
| 24 | ground: FooGroundConfig = FooGroundConfig() | ||
| 25 | |||
| 26 | |||
| 27 | class FooConfigError(config_loader.ConfigError): | ||
| 28 | \"\"\"Raised for an invalid foo config.\"\"\" | ||
| 29 | |||
| 30 | |||
| 31 | def build_foo_config(overrides=None, config_path=None) -> dict[str, Any]: | ||
| 32 | return config_loader.load_config( | ||
| 33 | FooConfig, | ||
| 34 | package="iolabs_foo", | ||
| 35 | filename="default_config.json", | ||
| 36 | overrides=overrides, | ||
| 37 | config_path=config_path, | ||
| 38 | context="foo config", | ||
| 39 | error_cls=FooConfigError, | ||
| 40 | ).model_dump() | ||
| 41 | |||
| 42 | `ConfigError`, `default_config_path`, `load_packaged_json`, `deep_merge_dicts` | ||
| 43 | and `parse_set_overrides` stay first-class. The hand-rolled dataclass helpers | ||
| 44 | (`validate_allowed_keys`, `coerce_to_field_type`, `dataclass_from_mapping`, | ||
| 45 | `validate_against_defaults`) still work for published leaf wheels but emit a | ||
| 46 | `DeprecationWarning`. | ||
| 7 | """ | 47 | """ |
| 8 | 48 | ||
| 9 | from __future__ import annotations | 49 | from __future__ import annotations |
| 10 | 50 | ||
| 51 | import enum | ||
| 11 | import json | 52 | import json |
| 53 | import logging | ||
| 54 | import numbers | ||
| 12 | import sys | 55 | import sys |
| 56 | import warnings | ||
| 13 | from collections.abc import Collection, Mapping, Sequence | 57 | from collections.abc import Collection, Mapping, Sequence |
| 14 | from dataclasses import fields, is_dataclass | 58 | from dataclasses import fields, is_dataclass |
| 15 | from importlib import resources | 59 | from importlib import resources |
| 16 | from pathlib import Path | 60 | from pathlib import Path |
| 17 | from types import UnionType | 61 | from types import UnionType |
| 18 | from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints | 62 | from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints |
| 19 | 63 | ||
| 64 | logger = logging.getLogger(__name__) | ||
| 65 | |||
| 66 | _PYDANTIC_EXPORTS = frozenset( | ||
| 67 | {"ConfigModel", "load_config", "validate_config", "format_validation_error"} | ||
| 68 | ) | ||
| 69 | |||
| 20 | 70 | ||
| 21 | class ConfigError(ValueError): | 71 | class ConfigError(ValueError): |
| 22 | """Raised when a packaged-JSON config contains unsupported keys/values.""" | 72 | """Raised when a packaged-JSON config contains unsupported keys/values.""" |
| 23 | 73 |
| 81 | error_cls: type[ValueError] = ConfigError, | 131 | error_cls: type[ValueError] = ConfigError, |
| 82 | ) -> None: | 132 | ) -> None: |
| 83 | """Raise *error_cls* when *config* contains keys outside *allowed*. | 133 | """Raise *error_cls* when *config* contains keys outside *allowed*. |
| 84 | 134 | ||
| 135 | Deprecated: derive a `ConfigModel` (``extra="forbid"``) and validate it with | ||
| 136 | :func:`load_config` / :func:`validate_config` instead. | ||
| 137 | |||
| 85 | Args: | 138 | Args: |
| 86 | config: The config mapping whose keys are checked. | 139 | config: The config mapping whose keys are checked. |
| 87 | allowed: The keys *config* may hold. | 140 | allowed: The keys *config* may hold. |
| 88 | context: Human-readable config name used in the error message, e.g. | 141 | context: Human-readable config name used in the error message, e.g. |
| 96 | 149 | ||
| 97 | Raises: | 150 | Raises: |
| 98 | error_cls: *config* holds one or more keys outside *allowed*. | 151 | error_cls: *config* holds one or more keys outside *allowed*. |
| 99 | """ | 152 | """ |
| 153 | _warn_deprecated("validate_allowed_keys") | ||
| 154 | _validate_allowed_keys(config, allowed, context=context, error_cls=error_cls) | ||
| 155 | |||
| 156 | |||
| 157 | def _validate_allowed_keys( | ||
| 158 | config: Mapping[str, Any], | ||
| 159 | allowed: frozenset[str], | ||
| 160 | *, | ||
| 161 | context: str, | ||
| 162 | error_cls: type[ValueError], | ||
| 163 | ) -> None: | ||
| 164 | """Raise *error_cls* when *config* contains keys outside *allowed*.""" | ||
| 100 | unknown = sorted(set(config) - allowed) | 165 | unknown = sorted(set(config) - allowed) |
| 101 | if unknown: | 166 | if unknown: |
| 102 | raise error_cls( | 167 | raise error_cls( |
| 103 | f"Unknown {context} key(s): {', '.join(unknown)}. " | 168 | f"Unknown {context} key(s): {', '.join(unknown)}. " |
| 227 | tokens ``1/true/yes/on`` and ``0/false/no/off``. Anything else raises, so | 292 | tokens ``1/true/yes/on`` and ``0/false/no/off``. Anything else raises, so |
| 228 | typos such as ``"flase"`` are rejected instead of read as ``False``. | 293 | typos such as ``"flase"`` are rejected instead of read as ``False``. |
| 229 | * ``int``: accepts ints, integral floats (``3.0``) and numeric strings | 294 | * ``int``: accepts ints, integral floats (``3.0``) and numeric strings |
| 230 | including exponent form (``"1e3"`` -> ``1000``). Non-integral values | 295 | including exponent form (``"1e3"`` -> ``1000``). Non-integral values |
| 231 | (``3.7``) and bools are rejected rather than truncated. | 296 | (``3.7``), bools and non-numeric types (``bytes``, ``None``, ...) are |
| 297 | rejected rather than truncated or re-parsed. | ||
| 232 | * ``float``: accepts ints, floats and numeric strings (including ``"nan"`` | 298 | * ``float``: accepts ints, floats and numeric strings (including ``"nan"`` |
| 233 | and ``"inf"``: finiteness is the caller's domain check, not this one). | 299 | and ``"inf"``: finiteness is the caller's domain check, not this one). |
| 234 | ``bool`` is rejected, as for ``int``; conversion errors are wrapped in | 300 | ``bool`` and non-numeric types (``bytes``, ``None``, ...) are rejected, |
| 235 | *error_cls*. | 301 | as for ``int``; conversion errors are wrapped in *error_cls*. |
| 236 | * ``str``: accepts ``str`` only. | 302 | * ``str``: accepts ``str`` only. |
| 237 | * ``X | None`` / ``Optional[X]``: ``None`` passes through, otherwise the | 303 | * ``X | None`` / ``Optional[X]``: ``None`` passes through, otherwise the |
| 238 | value is coerced to ``X``. Unions of two or more non-``None`` types pass | 304 | value is coerced to ``X``. Unions of two or more non-``None`` types pass |
| 239 | through unchanged. | 305 | through unchanged. |
| 240 | * ``tuple[...]`` / ``list[...]``: accepts a list or tuple (never a string or | 306 | * ``tuple[...]`` / ``list[...]``: accepts a list or tuple (never a string or |
| 241 | mapping), coercing each item to the declared item type. Fixed-length | 307 | mapping), coercing each item to the declared item type. Fixed-length |
| 242 | tuple annotations also check the item count. | 308 | tuple annotations also check the item count. |
| 243 | * ``Literal[...]``: the value must be one of the literal options. | 309 | * ``Literal[...]``: the value must be one of the literal options; an |
| 310 | ``enum`` option also matches its plain value (``"fast"`` for | ||
| 311 | ``Mode.FAST``) and is returned as the member. | ||
| 244 | * A nested dataclass type: a mapping value is built into that dataclass by | 312 | * A nested dataclass type: a mapping value is built into that dataclass by |
| 245 | :func:`dataclass_from_mapping` (unknown keys rejected, inner values | 313 | :func:`dataclass_from_mapping` (unknown keys rejected, inner values |
| 246 | coerced); a value that is already an instance passes through. | 314 | coerced); a value that is already an instance passes through. |
| 247 | 315 | ||
| 248 | Any other declared type (``dict``, ``Any``, an unresolved string | 316 | Any other declared type (``dict``, ``Any``, an unresolved string |
| 249 | annotation, a union of two or more non-``None`` types, ...) returns | 317 | annotation, a union of two or more non-``None`` types, ...) returns |
| 250 | *value* unchanged. | 318 | *value* unchanged. |
| 251 | 319 | ||
| 320 | Deprecated: declare a `ConfigModel` field and let it apply the same matrix. | ||
| 321 | |||
| 252 | Args: | 322 | Args: |
| 253 | name: Field name, used only in error messages. | 323 | name: Field name, used only in error messages. |
| 254 | value: The raw value from JSON or a parsed ``--set`` override. | 324 | value: The raw value from JSON or a parsed ``--set`` override. |
| 255 | declared: The field's declared type (``dataclasses.Field.type`` or a | 325 | declared: The field's declared type (``dataclasses.Field.type`` or a |
The legacy coercion matrix as a non-deprecated public function; strictness (bool never int, str only str) is preserved on purpose.
| 258 | 328 | ||
| 259 | Returns: | 329 | Returns: |
| 260 | The coerced value, or *value* unchanged for unsupported declared types. | 330 | The coerced value, or *value* unchanged for unsupported declared types. |
| 261 | 331 | ||
| 332 | Raises: | ||
| 333 | error_cls: *value* is not valid for *declared*. | ||
| 334 | """ | ||
| 335 | _warn_deprecated("coerce_to_field_type") | ||
| 336 | return coerce_config_value(name, value, declared, error_cls=error_cls) | ||
| 337 | |||
| 338 | |||
| 339 | def coerce_config_value( | ||
| 340 | name: str, | ||
| 341 | value: Any, | ||
| 342 | declared: Any, | ||
| 343 | *, | ||
| 344 | error_cls: type[ValueError] = ConfigError, | ||
| 345 | ) -> Any: | ||
| 346 | """Coerce one raw config value to *declared*, the fleet accepted-input matrix. | ||
| 347 | |||
| 348 | Implementation shared by `ConfigModel` (per-field before-validator) and the | ||
| 349 | deprecated :func:`coerce_to_field_type`, whose docstring is the spec of the | ||
| 350 | accepted inputs. | ||
| 351 | |||
| 352 | Args: | ||
| 353 | name: Field name, used only in error messages. | ||
| 354 | value: The raw value from JSON or a parsed ``--set`` override. | ||
| 355 | declared: The field's declared type. | ||
| 356 | error_cls: Exception class raised for values that cannot be coerced. | ||
| 357 | |||
| 358 | Returns: | ||
| 359 | The coerced value, or *value* unchanged for unsupported declared types. | ||
| 360 | |||
| 262 | Raises: | 361 | Raises: |
| 263 | error_cls: *value* is not valid for *declared*. | 362 | error_cls: *value* is not valid for *declared*. |
| 264 | """ | 363 | """ |
| 265 | if isinstance(declared, str): | 364 | if isinstance(declared, str): |
| 285 | return _coerce_sequence( | 384 | return _coerce_sequence( |
| 286 | name, value, get_args(declared), target=list, error_cls=error_cls | 385 | name, value, get_args(declared), target=list, error_cls=error_cls |
| 287 | ) | 386 | ) |
| 288 | if isinstance(declared, type) and is_dataclass(declared) and isinstance(value, Mapping): | 387 | if isinstance(declared, type) and is_dataclass(declared) and isinstance(value, Mapping): |
| 289 | return dataclass_from_mapping(declared, value, context=name, error_cls=error_cls) | 388 | return _dataclass_from_mapping(declared, value, context=name, error_cls=error_cls) |
| 290 | return value | 389 | return value |
| 291 | 390 | ||
| 292 | 391 | ||
| 293 | def _coerce_bool(name: str, value: Any, *, error_cls: type[ValueError]) -> bool: | 392 | def _coerce_bool(name: str, value: Any, *, error_cls: type[ValueError]) -> bool: |
| 332 | raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc | 431 | raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc |
| 333 | if not as_float.is_integer(): | 432 | if not as_float.is_integer(): |
| 334 | raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.") | 433 | raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.") |
| 335 | return int(as_float) | 434 | return int(as_float) |
| 336 | try: | 435 | if isinstance(value, numbers.Real): |
| 337 | as_float = float(value) | 436 | as_float = float(value) |
| 338 | except (TypeError, ValueError) as exc: | 437 | if not as_float.is_integer(): |
| 339 | raise error_cls( | 438 | raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.") |
| 340 | f"Invalid int for '{name}': {value!r} ({type(value).__name__})." | 439 | return int(as_float) |
| 341 | ) from exc | 440 | raise error_cls(f"Invalid int for '{name}': {value!r} ({type(value).__name__}).") |
| 342 | if not as_float.is_integer(): | ||
| 343 | raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.") | ||
| 344 | return int(as_float) | ||
| 345 | 441 | ||
| 346 | 442 | ||
| 347 | def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float: | 443 | def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float: |
| 348 | """Parse a float; wrap conversion errors in *error_cls*.""" | 444 | """Parse a float; wrap conversion errors in *error_cls*.""" |
| 349 | if isinstance(value, bool): | 445 | if isinstance(value, bool): |
| 350 | raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).") | 446 | raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).") |
| 351 | try: | 447 | if isinstance(value, str): |
| 448 | try: | ||
| 449 | return float(value) | ||
| 450 | except ValueError as exc: | ||
| 451 | raise error_cls( | ||
| 452 | f"Invalid float for '{name}': {value!r} ({type(value).__name__})." | ||
| 453 | ) from exc | ||
| 454 | if isinstance(value, numbers.Real): | ||
| 352 | return float(value) | 455 | return float(value) |
| 353 | except (TypeError, ValueError) as exc: | 456 | raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).") |
| 354 | raise error_cls( | ||
| 355 | f"Invalid float for '{name}': {value!r} ({type(value).__name__})." | ||
| 356 | ) from exc | ||
| 357 | 457 | ||
| 358 | 458 | ||
| 359 | def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str: | 459 | def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str: |
| 360 | """Accept a string as-is; reject every other type.""" | 460 | """Accept a string as-is; reject every other type.""" |
| 369 | options: tuple[Any, ...], | 469 | options: tuple[Any, ...], |
| 370 | *, | 470 | *, |
| 371 | error_cls: type[ValueError], | 471 | error_cls: type[ValueError], |
| 372 | ) -> Any: | 472 | ) -> Any: |
| 373 | """Check *value* against the options of a ``Literal`` annotation.""" | 473 | """Check *value* against the options of a ``Literal`` annotation. |
| 474 | |||
| 475 | An option that is an `enum.Enum` member also matches its plain | ||
| 476 | JSON-representable value (``"fast"`` for ``Mode.FAST``), as pydantic does; | ||
| 477 | the member is returned. Types must match exactly otherwise, so ``True`` | ||
| 478 | never satisfies ``Literal[1]``. | ||
| 479 | """ | ||
| 374 | for option in options: | 480 | for option in options: |
| 375 | if type(option) is type(value) and option == value: | 481 | if type(option) is type(value) and option == value: |
| 376 | return option | 482 | return option |
| 483 | if isinstance(option, enum.Enum) and type(option.value) is type(value): | ||
| 484 | if option.value == value: | ||
| 485 | return option | ||
| 377 | allowed = ", ".join(repr(option) for option in options) | 486 | allowed = ", ".join(repr(option) for option in options) |
| 378 | raise error_cls(f"Invalid value for '{name}': {value!r}. Expected one of: {allowed}.") | 487 | raise error_cls(f"Invalid value for '{name}': {value!r}. Expected one of: {allowed}.") |
| 379 | 488 | ||
| 380 | 489 |
| 390 | return None | 499 | return None |
| 391 | candidates = [member for member in members if member is not type(None)] | 500 | candidates = [member for member in members if member is not type(None)] |
| 392 | if len(candidates) != 1: | 501 | if len(candidates) != 1: |
| 393 | return value | 502 | return value |
| 394 | return coerce_to_field_type(name, value, candidates[0], error_cls=error_cls) | 503 | return coerce_config_value(name, value, candidates[0], error_cls=error_cls) |
| 395 | 504 | ||
| 396 | 505 | ||
| 397 | def _coerce_sequence( | 506 | def _coerce_sequence( |
| 398 | name: str, | 507 | name: str, |
| 420 | item_types = [args[0]] * len(items) | 529 | item_types = [args[0]] * len(items) |
| 421 | if not item_types: | 530 | if not item_types: |
| 422 | return target(items) | 531 | return target(items) |
| 423 | coerced = [ | 532 | coerced = [ |
| 424 | coerce_to_field_type(f"{name}[{index}]", item, item_type, error_cls=error_cls) | 533 | coerce_config_value(f"{name}[{index}]", item, item_type, error_cls=error_cls) |
| 425 | for index, (item, item_type) in enumerate(zip(items, item_types, strict=True)) | 534 | for index, (item, item_type) in enumerate(zip(items, item_types, strict=True)) |
| 426 | ] | 535 | ] |
| 427 | return target(coerced) | 536 | return target(coerced) |
| 428 | 537 |
| 468 | TypeError: *cls* is not a dataclass type, a field annotation cannot be | 577 | TypeError: *cls* is not a dataclass type, a field annotation cannot be |
| 469 | resolved at runtime (unless ``coerce=False``), or a required field | 578 | resolved at runtime (unless ``coerce=False``), or a required field |
| 470 | is missing from *raw*. | 579 | is missing from *raw*. |
| 471 | """ | 580 | """ |
| 581 | _warn_deprecated("dataclass_from_mapping") | ||
| 582 | return _dataclass_from_mapping( | ||
| 583 | cls, raw, context=context, error_cls=error_cls, coerce=coerce | ||
| 584 | ) | ||
| 585 | |||
| 586 | |||
| 587 | def _dataclass_from_mapping( | ||
| 588 | cls: type[_T], | ||
| 589 | raw: Mapping[str, Any], | ||
| 590 | *, | ||
| 591 | context: str, | ||
| 592 | error_cls: type[ValueError], | ||
| 593 | coerce: bool = True, | ||
| 594 | ) -> _T: | ||
| 595 | """Build a dataclass instance from a raw mapping (implementation).""" | ||
| 472 | if not is_dataclass(cls) or not isinstance(cls, type): | 596 | if not is_dataclass(cls) or not isinstance(cls, type): |
| 473 | raise TypeError(f"dataclass_from_mapping requires a dataclass type, got {cls!r}") | 597 | raise TypeError(f"dataclass_from_mapping requires a dataclass type, got {cls!r}") |
| 474 | init_fields = [field for field in fields(cls) if field.init] | 598 | init_fields = [field for field in fields(cls) if field.init] |
| 475 | allowed = frozenset(field.name for field in init_fields) | 599 | allowed = frozenset(field.name for field in init_fields) |
| 476 | validate_allowed_keys(raw, allowed, context=context, error_cls=error_cls) | 600 | _validate_allowed_keys(raw, allowed, context=context, error_cls=error_cls) |
| 477 | if not coerce: | 601 | if not coerce: |
| 478 | return cls(**dict(raw)) | 602 | return cls(**dict(raw)) |
| 479 | declared_types = _resolve_field_types(cls, init_fields) | 603 | declared_types = _resolve_field_types(cls, init_fields) |
| 480 | values = { | 604 | values = { |
| 481 | name: coerce_to_field_type(name, value, declared_types[name], error_cls=error_cls) | 605 | name: coerce_config_value(name, value, declared_types[name], error_cls=error_cls) |
| 482 | for name, value in raw.items() | 606 | for name, value in raw.items() |
| 483 | } | 607 | } |
| 484 | return cls(**values) | 608 | return cls(**values) |
| 485 | 609 |
| 564 | node's keys. Recursion stops where *defaults* holds a non-mapping leaf, so | 688 | node's keys. Recursion stops where *defaults* holds a non-mapping leaf, so |
| 565 | leaf values are never inspected. The first offending node raises, naming | 689 | leaf values are never inspected. The first offending node raises, naming |
| 566 | the dotted context path (``"cluster-stepper config.lane_segment_width"``). | 690 | the dotted context path (``"cluster-stepper config.lane_segment_width"``). |
| 567 | 691 | ||
| 692 | Deprecated: a `ConfigModel` with ``extra="forbid"`` is the schema now; use | ||
| 693 | :func:`load_config` / :func:`validate_config`. | ||
| 694 | |||
| 568 | Args: | 695 | Args: |
| 569 | config: The raw config node to validate (usually a dict). | 696 | config: The raw config node to validate (usually a dict). |
| 570 | defaults: The corresponding node of the packaged defaults. | 697 | defaults: The corresponding node of the packaged defaults. |
| 571 | context: Human-readable name of the root node, used as the error | 698 | context: Human-readable name of the root node, used as the error |
| 586 | error_cls: A node of *config* is not a mapping where *defaults* has | 713 | error_cls: A node of *config* is not a mapping where *defaults* has |
| 587 | one, or holds a key absent from both *defaults* and | 714 | one, or holds a key absent from both *defaults* and |
| 588 | *allowed_extra_keys*. | 715 | *allowed_extra_keys*. |
| 589 | """ | 716 | """ |
| 717 | _warn_deprecated("validate_against_defaults") | ||
| 590 | _validate_against_defaults( | 718 | _validate_against_defaults( |
| 591 | config, | 719 | config, |
| 592 | defaults, | 720 | defaults, |
| 593 | context=context, | 721 | context=context, |
Old helpers kept alive for published leaf wheels; warn once per call site.
| 638 | 766 | ||
| 639 | def _dotted(prefix: str, key: str) -> str: | 767 | def _dotted(prefix: str, key: str) -> str: |
| 640 | """Join a dotted path prefix with a key.""" | 768 | """Join a dotted path prefix with a key.""" |
| 641 | return f"{prefix}.{key}" if prefix else key | 769 | return f"{prefix}.{key}" if prefix else key |
| 770 | |||
| 771 | |||
| 772 | def _warn_deprecated(name: str) -> None: | ||
| 773 | """Emit a `DeprecationWarning` for a legacy hand-rolled config helper.""" | ||
| 774 | warnings.warn( | ||
| 775 | f"config_loader.{name} is deprecated; derive a config_loader.ConfigModel " | ||
| 776 | f"and use config_loader.load_config/validate_config instead.", | ||
| 777 | DeprecationWarning, | ||
| 778 | stacklevel=3, | ||
| 779 | ) | ||
| 780 | |||
| 781 | |||
| 782 | def __getattr__(name: str) -> Any: | ||
| 783 | """Re-export the pydantic config layer lazily, avoiding an import cycle.""" | ||
| 784 | if name in _PYDANTIC_EXPORTS: | ||
| 785 | from iolabs.common import config_model | ||
| 786 | |||
| 787 | return getattr(config_model, name) | ||
| 788 | raise AttributeError(f"module {__name__!r} has no attribute {name!r}") | ||
| 789 | |||
| 790 | |||
| 791 | def __dir__() -> list[str]: | ||
| 792 | """List the lazy pydantic re-exports alongside the module's own names.""" | ||
| 793 | return sorted(set(globals()) | _PYDANTIC_EXPORTS) |
Base model: extra=forbid, frozen, validate_default; the before-validator routes every field through the legacy coercion matrix.
| 1 | """Pydantic-v2 config layer for the pipeline packages. | ||
| 2 | |||
| 3 | `ConfigModel` is the base class every fleet package derives its config models | ||
| 4 | from. It keeps the accepted-input matrix of the hand-rolled coercion helpers in | ||
| 5 | :mod:`iolabs.common.config_loader` (see | ||
| 6 | :func:`iolabs.common.config_loader.coerce_to_field_type` for the spec) while | ||
| 7 | letting package authors write plain ``x: int`` / ``y: float`` / ``z: bool`` / | ||
| 8 | ``s: str`` fields; `Literal`, sequences, and nested models are validated by | ||
| 9 | pydantic itself. | ||
| 10 | |||
| 11 | Canonical package pattern:: | ||
| 12 | |||
| 13 | from collections.abc import Mapping | ||
| 14 | from pathlib import Path | ||
| 15 | from typing import Any, Literal | ||
| 16 | |||
| 17 | from iolabs.common import config_loader | ||
| 18 | |||
| 19 | |||
| 20 | class FooGroundConfig(config_loader.ConfigModel): | ||
| 21 | cell_m: float = 0.5 | ||
| 22 | enabled: bool = True | ||
| 23 | |||
| 24 | |||
| 25 | class FooConfig(config_loader.ConfigModel): | ||
| 26 | mode: Literal["fast", "exact"] = "fast" | ||
| 27 | ground: FooGroundConfig = FooGroundConfig() | ||
| 28 | |||
| 29 | |||
| 30 | class FooConfigError(config_loader.ConfigError): | ||
| 31 | \"\"\"Raised for an invalid foo config.\"\"\" | ||
| 32 | |||
| 33 | |||
| 34 | def build_foo_config( | ||
| 35 | overrides: Mapping[str, Any] | None = None, | ||
| 36 | config_path: str | Path | None = None, | ||
| 37 | ) -> dict[str, Any]: | ||
| 38 | return config_loader.load_config( | ||
| 39 | FooConfig, | ||
| 40 | package="iolabs_foo", | ||
| 41 | filename="default_config.json", | ||
| 42 | overrides=overrides, | ||
| 43 | config_path=config_path, | ||
| 44 | context="foo config", | ||
| 45 | error_cls=FooConfigError, | ||
| 46 | ).model_dump() | ||
| 47 | |||
| 48 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as | ||
| 49 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, | ||
| 50 | so no field can be rebound after construction. Freezing is shallow, as in | ||
| 51 | pydantic itself: a ``list``-valued field is still a mutable list, so prefer | ||
| 52 | ``tuple`` for sequence fields that must not change. | ||
| 53 | """ | ||
| 54 | |||
| 55 | from __future__ import annotations | ||
| 56 | |||
| 57 | import json | ||
| 58 | import logging | ||
| 59 | from collections.abc import Mapping | ||
| 60 | from pathlib import Path | ||
| 61 | from typing import Any, TypeVar, get_args | ||
| 62 | |||
| 63 | import pydantic | ||
| 64 | from pydantic import fields as pydantic_fields | ||
| 65 | |||
| 66 | from iolabs.common import config_loader | ||
| 67 | |||
| 68 | logger = logging.getLogger(__name__) | ||
| 69 | |||
| 70 | _M = TypeVar("_M", bound="ConfigModel") | ||
| 71 | |||
| 72 | _VALUE_ERROR_PREFIX = "Value error, " | ||
| 73 | |||
| 74 | _MODEL_REGISTRY: dict[str, type[ConfigModel] | None] = {} | ||
| 75 | |||
| 76 | |||
| 77 | class ConfigModel(pydantic.BaseModel): | ||
| 78 | """Frozen, extra-forbidding base model with fleet scalar coercion. | ||
| 79 | |||
| 80 | Every field is passed through the accepted-input matrix of | ||
| 81 | :func:`iolabs.common.config_loader.coerce_to_field_type` before pydantic | ||
| 82 | validates it, so ``"1e3"`` reaches an ``int`` field as ``1000``, ``"on"`` | ||
| 83 | reaches a ``bool`` field as ``True``, and ``True`` is rejected for an | ||
| 84 | ``int`` field instead of becoming ``1``. | ||
| 85 | """ | ||
| 86 | |||
| 87 | model_config = pydantic.ConfigDict( | ||
| 88 | extra="forbid", | ||
| 89 | frozen=True, | ||
| 90 | validate_default=True, | ||
| 91 | strict=False, | ||
| 92 | arbitrary_types_allowed=False, | ||
| 93 | ) | ||
| 94 | |||
| 95 | def __init_subclass__(cls, **kwargs: Any) -> None: | ||
| 96 | """Register the subclass so its error title can be resolved back to it.""" | ||
| 97 | super().__init_subclass__(**kwargs) | ||
| 98 | _register_model(cls) | ||
| 99 | |||
| 100 | @pydantic.field_validator("*", mode="before") | ||
| 101 | @classmethod | ||
| 102 | def _coerce_fleet_scalars(cls, value: Any, info: pydantic.ValidationInfo) -> Any: | ||
| 103 | """Coerce a raw JSON/CLI value to the field's declared annotation.""" | ||
| 104 | field = cls.model_fields.get(info.field_name or "") | ||
| 105 | if field is None or field.annotation is None: | ||
| 106 | return value | ||
| 107 | return config_loader.coerce_config_value( | ||
| 108 | info.field_name or "", value, field.annotation | ||
| 109 | ) | ||
| 110 | |||
| 111 | |||
| 112 | def _register_model(model_cls: type[ConfigModel]) -> None: | ||
| 113 | """Record *model_cls* under its name, flagging same-name classes ambiguous.""" | ||
| 114 | name = model_cls.__name__ | ||
| 115 | previous = _MODEL_REGISTRY.get(name, model_cls) | ||
| 116 | same_origin = previous is not None and (previous.__module__, previous.__qualname__) == ( | ||
| 117 | model_cls.__module__, | ||
| 118 | model_cls.__qualname__, | ||
| 119 | ) | ||
| 120 | _MODEL_REGISTRY[name] = model_cls if same_origin else None | ||
| 121 | |||
| 122 | |||
| 123 | def _model_from_title(title: str) -> type[ConfigModel] | None: | ||
| 124 | """Return the `ConfigModel` a validation error title names, when unambiguous.""" | ||
| 125 | return _MODEL_REGISTRY.get(title) | ||
| 126 | |||
| 127 | |||
| 128 | def validate_config( | ||
| 129 | model_cls: type[_M], | ||
| 130 | raw: Mapping[str, Any], | ||
| 131 | *, | ||
| 132 | context: str, | ||
| 133 | error_cls: type[ValueError] = config_loader.ConfigError, | ||
| 134 | ) -> _M: | ||
| 135 | """Validate a raw mapping into *model_cls*, wrapping pydantic errors. | ||
| 136 | |||
| 137 | Args: | ||
| 138 | model_cls: The `ConfigModel` subclass to build. | ||
| 139 | raw: The merged config mapping (packaged defaults plus overrides). | ||
| 140 | context: Human-readable config name used in error messages, e.g. | ||
| 141 | ``"foo config"``. | ||
| 142 | error_cls: Exception class raised for unknown keys and bad values. | ||
| 143 | |||
| 144 | Returns: | ||
| 145 | A validated, frozen instance of *model_cls*. | ||
| 146 | |||
| 147 | Raises: | ||
| 148 | error_cls: *raw* holds an unknown key, misses a required key, or holds | ||
| 149 | a value that is not valid for its declared field type. | ||
| 150 | """ | ||
| 151 | try: | ||
| 152 | return model_cls.model_validate(dict(raw)) | ||
| 153 | except pydantic.ValidationError as exc: | ||
| 154 | raise error_cls( | ||
| 155 | format_validation_error(exc, context=context, model_cls=model_cls) | ||
| 156 | ) from exc | ||
| 157 | |||
| 158 | |||
| 159 | def load_config( | ||
| 160 | model_cls: type[_M], | ||
| 161 | *, | ||
| 162 | package: str, | ||
| 163 | filename: str, | ||
| 164 | overrides: Mapping[str, Any] | None = None, | ||
| 165 | config_path: str | Path | None = None, | ||
| 166 | context: str, | ||
| 167 | error_cls: type[ValueError] = config_loader.ConfigError, | ||
| 168 | ) -> _M: | ||
| 169 | """Load, merge and validate a packaged JSON config into *model_cls*. | ||
| 170 | |||
| 171 | The packaged default JSON is read from *package*/*filename*, or from | ||
| 172 | *config_path* when that is given (the file then replaces the packaged | ||
| 173 | defaults rather than extending them). *overrides* is deep-merged on top by | ||
| 174 | :func:`iolabs.common.config_loader.deep_merge_dicts`, and the result is | ||
| 175 | validated by :func:`validate_config`. | ||
| 176 | |||
| 177 | Args: | ||
| 178 | model_cls: The `ConfigModel` subclass to build. | ||
| 179 | package: Import package holding the default JSON, e.g. ``"iolabs_foo"``. | ||
| 180 | filename: File name of the packaged JSON, e.g. ``"default_config.json"``. | ||
| 181 | overrides: Mapping merged onto the defaults, e.g. the result of | ||
| 182 | :func:`iolabs.common.config_loader.parse_set_overrides`. | ||
| 183 | config_path: Path to a JSON file used instead of the packaged defaults. | ||
| 184 | context: Human-readable config name used in error messages. | ||
| 185 | error_cls: Exception class raised for unreadable JSON, unknown keys and | ||
| 186 | bad values. | ||
| 187 | |||
| 188 | Returns: | ||
| 189 | A validated, frozen instance of *model_cls*. | ||
| 190 | |||
| 191 | Raises: | ||
| 192 | error_cls: The JSON is malformed, or the merged config is invalid. | ||
| 193 | OSError: The config file could not be read. | ||
| 194 | """ | ||
| 195 | if config_path is not None: | ||
| 196 | raw = _load_json_file(Path(config_path), error_cls=error_cls) | ||
| 197 | logger.debug("Loaded %s from %s", context, config_path) | ||
| 198 | else: | ||
| 199 | raw = _load_packaged(package, filename, error_cls=error_cls) | ||
| 200 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) | ||
| 201 | if overrides: | ||
| 202 | raw = config_loader.deep_merge_dicts(raw, dict(overrides)) | ||
| 203 | return validate_config(model_cls, raw, context=context, error_cls=error_cls) | ||
| 204 | |||
| 205 | |||
| 206 | def format_validation_error( | ||
| 207 | exc: pydantic.ValidationError, | ||
| 208 | *, | ||
| 209 | context: str, | ||
| 210 | model_cls: type[pydantic.BaseModel] | None = None, | ||
| 211 | ) -> str: | ||
| 212 | """Render a pydantic `ValidationError` as a fleet-style config message. | ||
| 213 | |||
| 214 | Unknown keys are grouped per section and reported as | ||
| 215 | ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is | ||
| 216 | dotted onto *context* (``"{context}.section"``). Value errors keep the | ||
| 217 | ``"Invalid <type> for '<dotted.field>': <value> ..."`` shape of the legacy | ||
| 218 | coercion helpers. | ||
| 219 | |||
| 220 | Args: | ||
| 221 | exc: The pydantic validation error. | ||
| 222 | context: Human-readable config name used as the message prefix. | ||
| 223 | model_cls: The validated model, used to list the allowed keys of the | ||
| 224 | offending section. Defaults to the `ConfigModel` named by | ||
| 225 | ``exc.title``; allowed-key lists are omitted when that name is | ||
| 226 | unknown or shared by several models. | ||
| 227 | |||
| 228 | Returns: | ||
| 229 | A newline-joined message covering every error in *exc*. | ||
| 230 | """ | ||
| 231 | if model_cls is None: | ||
| 232 | model_cls = _model_from_title(exc.title) | ||
| 233 | unknown: dict[tuple[Any, ...], list[str]] = {} | ||
| 234 | lines: list[str] = [] | ||
| 235 | for error in exc.errors(): | ||
| 236 | loc = tuple(error["loc"]) | ||
| 237 | if error["type"] == "extra_forbidden" and loc: | ||
| 238 | unknown.setdefault(loc[:-1], []).append(str(loc[-1])) | ||
| 239 | else: | ||
| 240 | lines.append(_format_single_error(error, context=context)) | ||
| 241 | unknown_lines = [ | ||
| 242 | _format_unknown_keys(prefix, keys, context=context, model_cls=model_cls) | ||
| 243 | for prefix, keys in sorted(unknown.items(), key=lambda item: [str(p) for p in item[0]]) | ||
| 244 | ] | ||
| 245 | return "\n".join(unknown_lines + lines) | ||
| 246 | |||
| 247 | |||
| 248 | def _format_unknown_keys( | ||
| 249 | prefix: tuple[Any, ...], | ||
| 250 | keys: list[str], | ||
| 251 | *, | ||
| 252 | context: str, | ||
| 253 | model_cls: type[pydantic.BaseModel] | None, | ||
| 254 | ) -> str: | ||
| 255 | """Return the ``Unknown ... key(s)`` line for one section.""" | ||
| 256 | node = ".".join([context, *(str(part) for part in prefix)]) | ||
| 257 | message = f"Unknown {node} key(s): {', '.join(sorted(keys))}." | ||
| 258 | allowed = _allowed_keys(model_cls, prefix) | ||
| 259 | if allowed: | ||
| 260 | message = f"{message} Allowed keys: {', '.join(sorted(allowed))}" | ||
| 261 | return message | ||
| 262 | |||
| 263 | |||
| 264 | def _format_single_error(error: Mapping[str, Any], *, context: str) -> str: | ||
| 265 | """Return one non-``extra_forbidden`` error as a readable line.""" | ||
| 266 | loc = tuple(error["loc"]) | ||
| 267 | dotted = ".".join(str(part) for part in loc) | ||
| 268 | message = str(error["msg"]) | ||
| 269 | if error["type"] == "missing": | ||
| 270 | return f"Missing required {context} key: '{dotted}'" | ||
| 271 | if message.startswith(_VALUE_ERROR_PREFIX): | ||
| 272 | message = message[len(_VALUE_ERROR_PREFIX):] | ||
| 273 | rewritten = _rewrite_field_name(message, loc) | ||
| 274 | if rewritten is not None: | ||
| 275 | return rewritten | ||
| 276 | return f"Invalid value for '{dotted}': {error.get('input')!r}. {message}." | ||
| 277 | |||
| 278 | |||
| 279 | def _rewrite_field_name(message: str, loc: tuple[Any, ...]) -> str | None: | ||
| 280 | """Replace the local field name of a coercion message with its dotted path. | ||
| 281 | |||
| 282 | Args: | ||
| 283 | message: A coercion message such as ``"Invalid int for 'x': 3.7 ..."``. | ||
| 284 | loc: The pydantic error location of the offending field. | ||
| 285 | |||
| 286 | Returns: | ||
| 287 | The message with the dotted path substituted, or ``None`` when | ||
| 288 | *message* does not have the coercion shape. | ||
| 289 | """ | ||
| 290 | head, _, tail = message.partition(" for '") | ||
| 291 | name, quote, rest = tail.partition("': ") | ||
| 292 | if not head.startswith("Invalid ") or not quote or "'" in name: | ||
| 293 | return None | ||
| 294 | dotted = ".".join([*(str(part) for part in loc[:-1]), name]) | ||
| 295 | return f"{head} for '{dotted}': {rest}" | ||
| 296 | |||
| 297 | |||
| 298 | def _allowed_keys( | ||
| 299 | model_cls: type[pydantic.BaseModel] | None, | ||
| 300 | loc: tuple[Any, ...], | ||
| 301 | ) -> list[str]: | ||
| 302 | """Return the keys accepted by the model reached by *loc*, if resolvable. | ||
| 303 | |||
| 304 | An aliased field is listed under its alias, i.e. under the key the config | ||
| 305 | file must actually use, plus its field name when the model also populates | ||
| 306 | by name. | ||
| 307 | """ | ||
| 308 | current = _model_at_loc(model_cls, loc) | ||
| 309 | if current is None: | ||
| 310 | return [] | ||
| 311 | by_name = bool( | ||
| 312 | current.model_config.get("populate_by_name") | ||
| 313 | or current.model_config.get("validate_by_name") | ||
| 314 | ) | ||
| 315 | keys: list[str] = [] | ||
| 316 | for name, field in current.model_fields.items(): | ||
| 317 | alias = _validation_alias(field) | ||
| 318 | if alias is None: | ||
| 319 | keys.append(name) | ||
| 320 | continue | ||
| 321 | keys.append(alias) | ||
| 322 | if by_name: | ||
| 323 | keys.append(name) | ||
| 324 | return keys | ||
| 325 | |||
| 326 | |||
| 327 | def _validation_alias(field: pydantic_fields.FieldInfo) -> str | None: | ||
| 328 | """Return the single string alias *field* is validated under, if any.""" | ||
| 329 | alias = field.validation_alias if field.validation_alias is not None else field.alias | ||
| 330 | return alias if isinstance(alias, str) else None | ||
| 331 | |||
| 332 | |||
| 333 | def _model_at_loc( | ||
| 334 | model_cls: type[pydantic.BaseModel] | None, | ||
| 335 | loc: tuple[Any, ...], | ||
| 336 | ) -> type[pydantic.BaseModel] | None: | ||
| 337 | """Walk *loc* from *model_cls* down to the model owning that location.""" | ||
| 338 | current = model_cls | ||
| 339 | for part in loc: | ||
| 340 | if current is None: | ||
| 341 | return None | ||
| 342 | if isinstance(part, int): | ||
| 343 | continue | ||
| 344 | field = _field_by_key(current, str(part)) | ||
| 345 | current = _unwrap_model(field.annotation) if field is not None else None | ||
| 346 | return current | ||
| 347 | |||
| 348 | |||
| 349 | def _field_by_key( | ||
| 350 | model_cls: type[pydantic.BaseModel], | ||
| 351 | key: str, | ||
| 352 | ) -> pydantic_fields.FieldInfo | None: | ||
| 353 | """Return the field of *model_cls* addressed by *key* (alias or name).""" | ||
| 354 | for name, field in model_cls.model_fields.items(): | ||
| 355 | if key in (_validation_alias(field), name): | ||
| 356 | return field | ||
| 357 | return None | ||
| 358 | |||
| 359 | |||
| 360 | def _unwrap_model(annotation: Any) -> type[pydantic.BaseModel] | None: | ||
| 361 | """Return the first `BaseModel` subclass inside *annotation*, if any.""" | ||
| 362 | if isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel): | ||
| 363 | return annotation | ||
| 364 | for arg in get_args(annotation): | ||
| 365 | found = _unwrap_model(arg) | ||
| 366 | if found is not None: | ||
| 367 | return found | ||
| 368 | return None | ||
| 369 | |||
| 370 | |||
| 371 | def _load_packaged(package: str, filename: str, *, error_cls: type[ValueError]) -> dict[str, Any]: | ||
| 372 | """Load the packaged default JSON, wrapping decode errors in *error_cls*.""" | ||
| 373 | try: | ||
| 374 | loaded = config_loader.load_packaged_json(package, filename) | ||
| 375 | except json.JSONDecodeError as exc: | ||
| 376 | raise error_cls(f"Invalid JSON in packaged config {package}:{filename}: {exc}") from exc | ||
| 377 | if not isinstance(loaded, dict): | ||
| 378 | raise error_cls( | ||
| 379 | f"Packaged config {package}:{filename} must hold a JSON object, " | ||
| 380 | f"got {type(loaded).__name__}" | ||
| 381 | ) | ||
| 382 | return loaded | ||
| 383 | |||
| 384 | |||
| 385 | def _load_json_file(path: Path, *, error_cls: type[ValueError]) -> dict[str, Any]: | ||
| 386 | """Load a JSON config file, wrapping decode errors in *error_cls*.""" | ||
| 387 | try: | ||
| 388 | with path.open("r", encoding="utf-8") as handle: | ||
| 389 | loaded = json.load(handle) | ||
| 390 | except json.JSONDecodeError as exc: | ||
| 391 | raise error_cls(f"Invalid JSON in config file {path}: {exc}") from exc | ||
| 392 | if not isinstance(loaded, dict): | ||
| 393 | raise error_cls(f"Config file {path} must hold a JSON object, got {type(loaded).__name__}") | ||
| 394 | return loaded | ||
| 0 |
| 1 | """Tests for packaged-JSON config loader helpers.""" | 1 | """Tests for packaged-JSON config loader helpers.""" |
| 2 | 2 | ||
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | 4 | ||
| 5 | import enum | ||
| 5 | import importlib | 6 | import importlib |
| 6 | import json | 7 | import json |
| 7 | import math | 8 | import math |
| 8 | import sys | 9 | import sys |
Old helpers kept alive for published leaf wheels; warn once per call site.
| 29 | _PACKAGE = "iolabs.common" | 30 | _PACKAGE = "iolabs.common" |
| 30 | _PACKAGED_MODULE_FILENAME = "config_loader.py" | 31 | _PACKAGED_MODULE_FILENAME = "config_loader.py" |
| 31 | 32 | ||
| 32 | 33 | ||
| 34 | # The hand-rolled helpers below are deprecated shims kept for published leaf | ||
| 35 | # wheels; their DeprecationWarning is expected here. | ||
| 36 | pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") | ||
| 37 | |||
| 38 | |||
| 33 | class CustomConfigError(ConfigError): | 39 | class CustomConfigError(ConfigError): |
| 34 | """Consumer-style ConfigError subclass for error_cls override tests.""" | 40 | """Consumer-style ConfigError subclass for error_cls override tests.""" |
| 35 | 41 | ||
| 36 | 42 |
| 316 | """Ints, integral floats and numeric strings (incl. exponents) coerce.""" | 322 | """Ints, integral floats and numeric strings (incl. exponents) coerce.""" |
| 317 | assert coerce_to_field_type("count", value, int) == expected | 323 | assert coerce_to_field_type("count", value, int) == expected |
| 318 | 324 | ||
| 319 | 325 | ||
| 320 | @pytest.mark.parametrize("value", [3.7, -3.7, "3.7", "abc", True, None, []]) | 326 | @pytest.mark.parametrize("value", [3.7, -3.7, "3.7", "abc", True, None, [], b"3", object()]) |
| 321 | def test_coerce_int_rejects_non_integral_and_bad_tokens(value: object) -> None: | 327 | def test_coerce_int_rejects_non_integral_and_bad_tokens(value: object) -> None: |
| 322 | """Truncation, bools and bad tokens raise the config error, not ValueError.""" | 328 | """Truncation, bools and bad tokens raise the config error, not ValueError.""" |
| 323 | with pytest.raises(ConfigError, match="Invalid int for 'count'"): | 329 | with pytest.raises(ConfigError, match="Invalid int for 'count'"): |
| 324 | coerce_to_field_type("count", value, int) | 330 | coerce_to_field_type("count", value, int) |
| 334 | assert isinstance(result, float) | 340 | assert isinstance(result, float) |
| 335 | assert result == expected | 341 | assert result == expected |
| 336 | 342 | ||
| 337 | 343 | ||
| 338 | @pytest.mark.parametrize("value", ["abc", None, [], True]) | 344 | @pytest.mark.parametrize("value", ["abc", None, [], True, b"2.5", object()]) |
| 339 | def test_coerce_float_rejects_bad_values(value: object) -> None: | 345 | def test_coerce_float_rejects_bad_values(value: object) -> None: |
| 340 | """Bad tokens and non-numeric types are wrapped in the config error.""" | 346 | """Bad tokens and non-numeric types are wrapped in the config error.""" |
| 341 | with pytest.raises(ConfigError, match="Invalid float for 'threshold_m'"): | 347 | with pytest.raises(ConfigError, match="Invalid float for 'threshold_m'"): |
| 342 | coerce_to_field_type("threshold_m", value, float) | 348 | coerce_to_field_type("threshold_m", value, float) |
| 389 | with pytest.raises(ConfigError, match="Expected one of: 'csf', 'plane'"): | 395 | with pytest.raises(ConfigError, match="Expected one of: 'csf', 'plane'"): |
| 390 | coerce_to_field_type("mechanism", "cloth", declared) | 396 | coerce_to_field_type("mechanism", "cloth", declared) |
| 391 | 397 | ||
| 392 | 398 | ||
| 399 | def test_coerce_literal_enum_accepts_the_plain_option_value() -> None: | ||
| 400 | """An enum option also matches its plain value and yields the member.""" | ||
| 401 | |||
| 402 | class Mechanism(enum.StrEnum): | ||
| 403 | CSF = "csf" | ||
| 404 | PLANE = "plane" | ||
| 405 | |||
| 406 | declared = Literal[Mechanism.CSF, Mechanism.PLANE] | ||
| 407 | assert coerce_to_field_type("mechanism", "plane", declared) is Mechanism.PLANE | ||
| 408 | assert coerce_to_field_type("mechanism", Mechanism.CSF, declared) is Mechanism.CSF | ||
| 409 | with pytest.raises(ConfigError, match="Expected one of"): | ||
| 410 | coerce_to_field_type("mechanism", "cloth", declared) | ||
| 411 | |||
| 412 | |||
| 413 | def test_coerce_int_literal_still_rejects_bools() -> None: | ||
| 414 | """A bool never satisfies Literal[1, 2], unlike plain pydantic.""" | ||
| 415 | with pytest.raises(ConfigError, match="Expected one of"): | ||
| 416 | coerce_to_field_type("level", True, Literal[1, 2]) | ||
| 417 | |||
| 418 | |||
| 393 | def test_coerce_unknown_declared_type_passes_through() -> None: | 419 | def test_coerce_unknown_declared_type_passes_through() -> None: |
| 394 | """Unsupported declared types return the value unchanged.""" | 420 | """Unsupported declared types return the value unchanged.""" |
| 395 | payload = {"a": 1} | 421 | payload = {"a": 1} |
| 396 | assert coerce_to_field_type("extras", payload, dict[str, int]) is payload | 422 | assert coerce_to_field_type("extras", payload, dict[str, int]) is payload |
| 1 | """Tests for the pydantic-v2 config layer (`config_loader.ConfigModel`).""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import dataclasses | ||
| 6 | import enum | ||
| 7 | import json | ||
| 8 | import math | ||
| 9 | import warnings | ||
| 10 | from pathlib import Path | ||
| 11 | from typing import Any, Literal | ||
| 12 | from unittest.mock import patch | ||
| 13 | |||
| 14 | import pydantic | ||
| 15 | import pytest | ||
| 16 | |||
| 17 | from iolabs.common import config_loader, config_model | ||
| 18 | |||
| 19 | _PACKAGE = "iolabs.common" | ||
| 20 | _FIXTURE_PATH = Path(__file__).parent / "fixtures" / "config_loader.fixture.json" | ||
| 21 | |||
| 22 | |||
| 23 | class SampleMode(enum.StrEnum): | ||
| 24 | """Enum used for Literal-of-enum fields.""" | ||
| 25 | |||
| 26 | FAST = "fast" | ||
| 27 | EXACT = "exact" | ||
| 28 | |||
| 29 | |||
| 30 | class SampleConfigError(config_loader.ConfigError): | ||
| 31 | """Consumer-style config error.""" | ||
| 32 | |||
| 33 | |||
| 34 | class GroundSection(config_loader.ConfigModel): | ||
| 35 | """Nested section mirroring a packaged JSON sub-object.""" | ||
| 36 | |||
| 37 | cell_m: float = 0.5 | ||
| 38 | percentile: float = 5.0 | ||
| 39 | |||
| 40 | |||
| 41 | class SampleConfig(config_loader.ConfigModel): | ||
| 42 | """Sample config exercising every supported field kind.""" | ||
| 43 | |||
| 44 | name: str = "run" | ||
| 45 | count: int = 3 | ||
| 46 | threshold_m: float = 0.5 | ||
| 47 | enabled: bool = True | ||
| 48 | mechanism: Literal["csf", "plane"] = "csf" | ||
| 49 | tag: str | None = None | ||
| 50 | band_m: tuple[float, float] = (0.0, 1.0) | ||
| 51 | weights: list[int] = [1, 2] | ||
| 52 | ground: GroundSection = GroundSection() | ||
| 53 | |||
| 54 | |||
| 55 | class FixtureConfig(config_loader.ConfigModel): | ||
| 56 | """Config matching ``tests/fixtures/config_loader.fixture.json``.""" | ||
| 57 | |||
| 58 | enabled: bool = False | ||
| 59 | ground: GroundSection = GroundSection() | ||
| 60 | |||
| 61 | |||
| 62 | # --- coercion matrix --------------------------------------------------------- | ||
| 63 | |||
| 64 | |||
| 65 | @pytest.mark.parametrize( | ||
| 66 | ("value", "expected"), | ||
| 67 | [ | ||
| 68 | (True, True), | ||
| 69 | (False, False), | ||
| 70 | (1, True), | ||
| 71 | (0, False), | ||
| 72 | ("1", True), | ||
| 73 | ("TRUE", True), | ||
| 74 | (" yes ", True), | ||
| 75 | ("On", True), | ||
| 76 | ("0", False), | ||
| 77 | ("false", False), | ||
| 78 | ("NO", False), | ||
| 79 | ("off", False), | ||
| 80 | ], | ||
| 81 | ) | ||
| 82 | def test_bool_field_accepts_matrix(value: object, expected: bool) -> None: | ||
| 83 | """bool fields accept real bools, 0/1 and the documented tokens.""" | ||
| 84 | assert SampleConfig(enabled=value).enabled is expected | ||
| 85 | |||
| 86 | |||
| 87 | @pytest.mark.parametrize("value", ["flase", "y", "", 2, -1, 1.0, None, []]) | ||
| 88 | def test_bool_field_rejects_everything_else(value: object) -> None: | ||
| 89 | """bool fields reject typos, out-of-range ints and non-scalar values.""" | ||
| 90 | with pytest.raises(config_loader.ConfigError): | ||
| 91 | config_loader.validate_config( | ||
| 92 | SampleConfig, {"enabled": value}, context="sample config" | ||
| 93 | ) | ||
| 94 | |||
| 95 | |||
| 96 | @pytest.mark.parametrize( | ||
| 97 | ("value", "expected"), | ||
| 98 | [(7, 7), (3.0, 3), ("42", 42), (" 42 ", 42), ("1e3", 1000), ("-5", -5)], | ||
| 99 | ) | ||
| 100 | def test_int_field_accepts_matrix(value: object, expected: int) -> None: | ||
| 101 | """int fields accept ints, integral floats and numeric strings.""" | ||
| 102 | assert SampleConfig(count=value).count == expected | ||
| 103 | |||
| 104 | |||
| 105 | @pytest.mark.parametrize("value", [True, False, 3.7, "3.7", "abc", None, "1e3.5"]) | ||
| 106 | def test_int_field_rejects_bools_and_non_integral(value: object) -> None: | ||
| 107 | """int fields reject bools, fractional values and bad tokens.""" | ||
| 108 | with pytest.raises(config_loader.ConfigError): | ||
| 109 | config_loader.validate_config( | ||
| 110 | SampleConfig, {"count": value}, context="sample config" | ||
| 111 | ) | ||
| 112 | |||
| 113 | |||
| 114 | @pytest.mark.parametrize( | ||
| 115 | ("value", "expected"), | ||
| 116 | [(2, 2.0), (2.5, 2.5), ("2.5", 2.5), (" 1e-2 ", 0.01), ("inf", math.inf)], | ||
| 117 | ) | ||
| 118 | def test_float_field_accepts_matrix(value: object, expected: float) -> None: | ||
| 119 | """float fields accept ints, floats and numeric strings incl. inf.""" | ||
| 120 | assert SampleConfig(threshold_m=value).threshold_m == expected | ||
| 121 | |||
| 122 | |||
| 123 | def test_float_field_accepts_nan() -> None: | ||
| 124 | """float fields accept 'nan'; finiteness is a domain check, not a type one.""" | ||
| 125 | assert math.isnan(SampleConfig(threshold_m="nan").threshold_m) | ||
| 126 | |||
| 127 | |||
| 128 | @pytest.mark.parametrize("value", [True, False, "abc", None, []]) | ||
| 129 | def test_float_field_rejects_bools_and_bad_tokens(value: object) -> None: | ||
| 130 | """float fields reject bools and non-numeric values.""" | ||
| 131 | with pytest.raises(config_loader.ConfigError): | ||
| 132 | config_loader.validate_config( | ||
| 133 | SampleConfig, {"threshold_m": value}, context="sample config" | ||
| 134 | ) | ||
| 135 | |||
| 136 | |||
| 137 | @pytest.mark.parametrize("value", [b"3", b"2.5", object(), {"a": 1}]) | ||
| 138 | def test_numeric_fields_reject_non_numeric_types(value: object) -> None: | ||
| 139 | """int/float fields reject bytes and other types float() happens to eat.""" | ||
| 140 | for field in ("count", "threshold_m"): | ||
| 141 | with pytest.raises(config_loader.ConfigError): | ||
| 142 | config_loader.validate_config( | ||
| 143 | SampleConfig, {field: value}, context="sample config" | ||
| 144 | ) | ||
| 145 | |||
| 146 | |||
| 147 | @pytest.mark.parametrize("value", [3, 3.5, True, None, ["a"]]) | ||
| 148 | def test_str_field_rejects_non_strings(value: object) -> None: | ||
| 149 | """str fields accept strings only - no int -> str coercion.""" | ||
| 150 | with pytest.raises(config_loader.ConfigError): | ||
| 151 | config_loader.validate_config( | ||
| 152 | SampleConfig, {"name": value}, context="sample config" | ||
| 153 | ) | ||
| 154 | |||
| 155 | |||
| 156 | def test_str_field_accepts_strings() -> None: | ||
| 157 | """str fields pass strings through unchanged.""" | ||
| 158 | assert SampleConfig(name="lidar").name == "lidar" | ||
| 159 | |||
| 160 | |||
| 161 | def test_optional_field_accepts_none_and_inner_type() -> None: | ||
| 162 | """``str | None`` accepts None and applies the str rules otherwise.""" | ||
| 163 | assert SampleConfig(tag=None).tag is None | ||
| 164 | assert SampleConfig(tag="a").tag == "a" | ||
| 165 | with pytest.raises(config_loader.ConfigError): | ||
| 166 | config_loader.validate_config(SampleConfig, {"tag": 3}, context="sample config") | ||
| 167 | |||
| 168 | |||
| 169 | def test_literal_field_accepts_options_and_rejects_others() -> None: | ||
| 170 | """Literal fields accept declared options only.""" | ||
| 171 | assert SampleConfig(mechanism="plane").mechanism == "plane" | ||
| 172 | with pytest.raises(config_loader.ConfigError): | ||
| 173 | config_loader.validate_config( | ||
| 174 | SampleConfig, {"mechanism": "ransac"}, context="sample config" | ||
| 175 | ) | ||
| 176 | |||
| 177 | |||
| 178 | def test_literal_enum_field_accepts_the_plain_option_value() -> None: | ||
| 179 | """Enum-valued Literal fields accept the JSON value, as plain pydantic does.""" | ||
| 180 | |||
| 181 | class EnumConfig(config_loader.ConfigModel): | ||
| 182 | mode: Literal[SampleMode.FAST, SampleMode.EXACT] = SampleMode.FAST | ||
| 183 | |||
| 184 | assert EnumConfig.model_validate({"mode": "exact"}).mode is SampleMode.EXACT | ||
| 185 | assert EnumConfig(mode=SampleMode.FAST).mode is SampleMode.FAST | ||
| 186 | with pytest.raises(config_loader.ConfigError): | ||
| 187 | config_loader.validate_config(EnumConfig, {"mode": "ransac"}, context="enum config") | ||
| 188 | |||
| 189 | |||
| 190 | def test_int_literal_still_rejects_bools() -> None: | ||
| 191 | """Literal[int] keeps the fleet rule that a bool is not an int.""" | ||
| 192 | |||
| 193 | class IntLiteral(config_loader.ConfigModel): | ||
| 194 | level: Literal[1, 2] = 1 | ||
| 195 | |||
| 196 | assert IntLiteral(level=2).level == 2 | ||
| 197 | with pytest.raises(config_loader.ConfigError): | ||
| 198 | config_loader.validate_config(IntLiteral, {"level": True}, context="int config") | ||
| 199 | |||
| 200 | |||
| 201 | def test_sequence_items_are_coerced_with_the_same_matrix() -> None: | ||
| 202 | """tuple/list items follow the scalar matrix (and reject bools for int).""" | ||
| 203 | built = SampleConfig(band_m=["0.5", 2], weights=["3", 4.0]) | ||
| 204 | assert built.band_m == (0.5, 2.0) | ||
| 205 | assert built.weights == [3, 4] | ||
| 206 | with pytest.raises(config_loader.ConfigError): | ||
| 207 | config_loader.validate_config( | ||
| 208 | SampleConfig, {"weights": [True]}, context="sample config" | ||
| 209 | ) | ||
| 210 | |||
| 211 | |||
| 212 | def test_fixed_length_tuple_checks_item_count() -> None: | ||
| 213 | """A fixed-length tuple annotation rejects the wrong item count.""" | ||
| 214 | with pytest.raises(config_loader.ConfigError, match="expected 2 item"): | ||
| 215 | config_loader.validate_config( | ||
| 216 | SampleConfig, {"band_m": [1.0, 2.0, 3.0]}, context="sample config" | ||
| 217 | ) | ||
| 218 | |||
| 219 | |||
| 220 | def test_nested_section_is_validated_and_coerced() -> None: | ||
| 221 | """Nested models are built from mappings with the same scalar rules.""" | ||
| 222 | built = SampleConfig(ground={"cell_m": "0.75", "percentile": 8}) | ||
| 223 | assert built.ground.cell_m == 0.75 | ||
| 224 | assert built.ground.percentile == 8.0 | ||
| 225 | |||
| 226 | |||
| 227 | # --- error message shapes ---------------------------------------------------- | ||
| 228 | |||
| 229 | |||
| 230 | def test_unknown_top_level_key_message() -> None: | ||
| 231 | """Unknown top-level keys read like validate_allowed_keys.""" | ||
| 232 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 233 | config_loader.validate_config( | ||
| 234 | SampleConfig, {"nope": 1, "also": 2}, context="sample config" | ||
| 235 | ) | ||
| 236 | assert str(excinfo.value).startswith("Unknown sample config key(s): also, nope. ") | ||
| 237 | assert "Allowed keys: band_m, count, enabled, ground, mechanism," in str(excinfo.value) | ||
| 238 | |||
| 239 | |||
| 240 | def test_unknown_nested_key_uses_dotted_context() -> None: | ||
| 241 | """Unknown keys inside a section name the dotted section path.""" | ||
| 242 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 243 | config_loader.validate_config( | ||
| 244 | SampleConfig, {"ground": {"zzz": 1}}, context="sample config" | ||
| 245 | ) | ||
| 246 | assert str(excinfo.value) == ( | ||
| 247 | "Unknown sample config.ground key(s): zzz. Allowed keys: cell_m, percentile" | ||
| 248 | ) | ||
| 249 | |||
| 250 | |||
| 251 | def test_value_error_message_uses_dotted_field_path() -> None: | ||
| 252 | """Nested value errors name the dotted field path.""" | ||
| 253 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 254 | config_loader.validate_config( | ||
| 255 | SampleConfig, {"ground": {"cell_m": "abc"}}, context="sample config" | ||
| 256 | ) | ||
| 257 | assert str(excinfo.value) == "Invalid float for 'ground.cell_m': 'abc' (str)." | ||
| 258 | |||
| 259 | |||
| 260 | def test_value_error_message_top_level_shape() -> None: | ||
| 261 | """Top-level value errors keep the legacy 'Invalid <type> for ...' shape.""" | ||
| 262 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 263 | config_loader.validate_config( | ||
| 264 | SampleConfig, {"count": 3.7}, context="sample config" | ||
| 265 | ) | ||
| 266 | assert str(excinfo.value) == "Invalid int for 'count': 3.7 is not an integral value." | ||
| 267 | |||
| 268 | |||
| 269 | def test_missing_required_key_message() -> None: | ||
| 270 | """A missing required field is reported with the context name.""" | ||
| 271 | |||
| 272 | class Required(config_loader.ConfigModel): | ||
| 273 | name: str | ||
| 274 | |||
| 275 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 276 | config_loader.validate_config(Required, {}, context="sample config") | ||
| 277 | assert str(excinfo.value) == "Missing required sample config key: 'name'" | ||
| 278 | |||
| 279 | |||
| 280 | def test_error_cls_override_is_used() -> None: | ||
| 281 | """validate_config raises the caller's error class.""" | ||
| 282 | with pytest.raises(SampleConfigError): | ||
| 283 | config_loader.validate_config( | ||
| 284 | SampleConfig, {"nope": 1}, context="sample config", error_cls=SampleConfigError | ||
| 285 | ) | ||
| 286 | |||
| 287 | |||
| 288 | def test_format_validation_error_without_model_cls_lists_allowed_keys() -> None: | ||
| 289 | """The model is recovered from the error title, so allowed keys stay listed.""" | ||
| 290 | with pytest.raises(Exception) as excinfo: | ||
| 291 | SampleConfig(nope=1) | ||
| 292 | message = config_model.format_validation_error( | ||
| 293 | excinfo.value, context="sample config" | ||
| 294 | ) | ||
| 295 | assert message.startswith("Unknown sample config key(s): nope. Allowed keys: band_m, ") | ||
| 296 | |||
| 297 | |||
| 298 | def test_format_validation_error_omits_allowed_keys_for_unknown_model() -> None: | ||
| 299 | """A foreign model's error still reads well, just without the key list.""" | ||
| 300 | |||
| 301 | class Foreign(pydantic.BaseModel): | ||
| 302 | model_config = pydantic.ConfigDict(extra="forbid") | ||
| 303 | |||
| 304 | alpha: int = 1 | ||
| 305 | |||
| 306 | with pytest.raises(pydantic.ValidationError) as excinfo: | ||
| 307 | Foreign(nope=1) | ||
| 308 | message = config_model.format_validation_error(excinfo.value, context="probe config") | ||
| 309 | assert message == "Unknown probe config key(s): nope." | ||
| 310 | |||
| 311 | |||
| 312 | def test_format_validation_error_skips_ambiguous_model_names() -> None: | ||
| 313 | """Distinct ConfigModels sharing a name resolve to neither, so no key list.""" | ||
| 314 | |||
| 315 | def _first() -> type[config_loader.ConfigModel]: | ||
| 316 | class Twin(config_loader.ConfigModel): | ||
| 317 | alpha: int = 1 | ||
| 318 | |||
| 319 | return Twin | ||
| 320 | |||
| 321 | def _second() -> type[config_loader.ConfigModel]: | ||
| 322 | class Twin(config_loader.ConfigModel): | ||
| 323 | beta: int = 2 | ||
| 324 | |||
| 325 | return Twin | ||
| 326 | |||
| 327 | model, _other = _first(), _second() | ||
| 328 | with pytest.raises(pydantic.ValidationError) as excinfo: | ||
| 329 | model(nope=1) | ||
| 330 | message = config_model.format_validation_error(excinfo.value, context="twin config") | ||
| 331 | assert message == "Unknown twin config key(s): nope." | ||
| 332 | |||
| 333 | |||
| 334 | def test_format_validation_error_survives_class_redefinition() -> None: | ||
| 335 | """Re-running the same class statement is a redefinition, not an ambiguity.""" | ||
| 336 | |||
| 337 | def _make() -> type[config_loader.ConfigModel]: | ||
| 338 | class Reloaded(config_loader.ConfigModel): | ||
| 339 | alpha: int = 1 | ||
| 340 | |||
| 341 | return Reloaded | ||
| 342 | |||
| 343 | _stale, current = _make(), _make() | ||
| 344 | with pytest.raises(pydantic.ValidationError) as excinfo: | ||
| 345 | current(nope=1) | ||
| 346 | message = config_model.format_validation_error(excinfo.value, context="reload config") | ||
| 347 | assert message == "Unknown reload config key(s): nope. Allowed keys: alpha" | ||
| 348 | |||
| 349 | |||
| 350 | def test_alias_field_reports_the_alias_as_the_allowed_key() -> None: | ||
| 351 | """An aliased field is listed (and reported missing) under its alias.""" | ||
| 352 | |||
| 353 | class Aliased(config_loader.ConfigModel): | ||
| 354 | internal_name: int = pydantic.Field(alias="external-name") | ||
| 355 | |||
| 356 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 357 | config_loader.validate_config(Aliased, {"internal_name": 3}, context="alias config") | ||
| 358 | message = str(excinfo.value) | ||
| 359 | assert "Unknown alias config key(s): internal_name. Allowed keys: external-name" in message | ||
| 360 | assert "Missing required alias config key: 'external-name'" in message | ||
| 361 | assert config_loader.validate_config( | ||
| 362 | Aliased, {"external-name": "3"}, context="alias config" | ||
| 363 | ).internal_name == 3 | ||
| 364 | |||
| 365 | |||
| 366 | def test_aliased_section_resolves_nested_allowed_keys() -> None: | ||
| 367 | """Unknown keys inside an aliased section list that section's own keys.""" | ||
| 368 | |||
| 369 | class Outer(config_loader.ConfigModel): | ||
| 370 | ground: GroundSection = pydantic.Field( | ||
| 371 | default=GroundSection(), alias="ground-section" | ||
| 372 | ) | ||
| 373 | |||
| 374 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 375 | config_loader.validate_config( | ||
| 376 | Outer, {"ground-section": {"zzz": 1}}, context="outer config" | ||
| 377 | ) | ||
| 378 | assert str(excinfo.value) == ( | ||
| 379 | "Unknown outer config.ground-section key(s): zzz. Allowed keys: cell_m, percentile" | ||
| 380 | ) | ||
| 381 | |||
| 382 | |||
| 383 | # --- load_config ------------------------------------------------------------- | ||
| 384 | |||
| 385 | |||
| 386 | def test_load_config_reads_packaged_defaults() -> None: | ||
| 387 | """load_config validates the packaged JSON into the model.""" | ||
| 388 | with patch( | ||
| 389 | "iolabs.common.config_loader.default_config_path", | ||
| 390 | return_value=_FIXTURE_PATH, | ||
| 391 | ): | ||
| 392 | built = config_loader.load_config( | ||
| 393 | FixtureConfig, | ||
| 394 | package=_PACKAGE, | ||
| 395 | filename="config_loader.fixture.json", | ||
| 396 | context="fixture config", | ||
| 397 | ) | ||
| 398 | assert built.enabled is True | ||
| 399 | assert built.ground.cell_m == 0.75 | ||
| 400 | |||
| 401 | |||
| 402 | def test_load_config_deep_merges_overrides() -> None: | ||
| 403 | """Overrides are deep-merged onto the defaults before validation.""" | ||
| 404 | with patch( | ||
| 405 | "iolabs.common.config_loader.default_config_path", | ||
| 406 | return_value=_FIXTURE_PATH, | ||
| 407 | ): | ||
| 408 | built = config_loader.load_config( | ||
| 409 | FixtureConfig, | ||
| 410 | package=_PACKAGE, | ||
| 411 | filename="config_loader.fixture.json", | ||
| 412 | overrides={"ground": {"cell_m": "0.25"}, "enabled": "off"}, | ||
| 413 | context="fixture config", | ||
| 414 | ) | ||
| 415 | assert built.enabled is False | ||
| 416 | assert built.ground.cell_m == 0.25 | ||
| 417 | assert built.ground.percentile == 8.0 | ||
| 418 | |||
| 419 | |||
| 420 | def test_load_config_uses_config_path_when_given(tmp_path: Path) -> None: | ||
| 421 | """config_path replaces the packaged defaults.""" | ||
| 422 | path = tmp_path / "custom.json" | ||
| 423 | path.write_text(json.dumps({"ground": {"cell_m": 1.5}}), encoding="utf-8") | ||
| 424 | built = config_loader.load_config( | ||
| 425 | FixtureConfig, | ||
| 426 | package=_PACKAGE, | ||
| 427 | filename="config_loader.fixture.json", | ||
| 428 | config_path=path, | ||
| 429 | context="fixture config", | ||
| 430 | ) | ||
| 431 | assert built.enabled is False | ||
| 432 | assert built.ground.cell_m == 1.5 | ||
| 433 | |||
| 434 | |||
| 435 | def test_load_config_rejects_unknown_key_from_file(tmp_path: Path) -> None: | ||
| 436 | """Unknown keys in a config file are reported with the file's context.""" | ||
| 437 | path = tmp_path / "custom.json" | ||
| 438 | path.write_text(json.dumps({"zzz": 1}), encoding="utf-8") | ||
| 439 | with pytest.raises(config_loader.ConfigError, match="Unknown fixture config key"): | ||
| 440 | config_loader.load_config( | ||
| 441 | FixtureConfig, | ||
| 442 | package=_PACKAGE, | ||
| 443 | filename="config_loader.fixture.json", | ||
| 444 | config_path=path, | ||
| 445 | context="fixture config", | ||
| 446 | ) | ||
| 447 | |||
| 448 | |||
| 449 | def test_load_config_wraps_malformed_json(tmp_path: Path) -> None: | ||
| 450 | """Malformed JSON is wrapped in the caller's error class.""" | ||
| 451 | path = tmp_path / "custom.json" | ||
| 452 | path.write_text("{oops", encoding="utf-8") | ||
| 453 | with pytest.raises(SampleConfigError, match="Invalid JSON in config file"): | ||
| 454 | config_loader.load_config( | ||
| 455 | FixtureConfig, | ||
| 456 | package=_PACKAGE, | ||
| 457 | filename="config_loader.fixture.json", | ||
| 458 | config_path=path, | ||
| 459 | context="fixture config", | ||
| 460 | error_cls=SampleConfigError, | ||
| 461 | ) | ||
| 462 | |||
| 463 | |||
| 464 | def test_load_config_rejects_non_object_json(tmp_path: Path) -> None: | ||
| 465 | """A JSON file holding a list is rejected.""" | ||
| 466 | path = tmp_path / "custom.json" | ||
| 467 | path.write_text("[1, 2]", encoding="utf-8") | ||
| 468 | with pytest.raises(config_loader.ConfigError, match="must hold a JSON object"): | ||
| 469 | config_loader.load_config( | ||
| 470 | FixtureConfig, | ||
| 471 | package=_PACKAGE, | ||
| 472 | filename="config_loader.fixture.json", | ||
| 473 | config_path=path, | ||
| 474 | context="fixture config", | ||
| 475 | ) | ||
| 476 | |||
| 477 | |||
| 478 | def test_load_config_rejects_non_object_packaged_json(tmp_path: Path) -> None: | ||
| 479 | """A packaged default JSON holding a list is rejected like a file one.""" | ||
| 480 | path = tmp_path / "defaults.json" | ||
| 481 | path.write_text('[["enabled", false]]', encoding="utf-8") | ||
| 482 | with patch( | ||
| 483 | "iolabs.common.config_loader.default_config_path", | ||
| 484 | return_value=path, | ||
| 485 | ): | ||
| 486 | with pytest.raises(config_loader.ConfigError, match="must hold a JSON object"): | ||
| 487 | config_loader.load_config( | ||
| 488 | FixtureConfig, | ||
| 489 | package=_PACKAGE, | ||
| 490 | filename="defaults.json", | ||
| 491 | context="fixture config", | ||
| 492 | ) | ||
| 493 | |||
| 494 | |||
| 495 | # --- model behaviour --------------------------------------------------------- | ||
| 496 | |||
| 497 | |||
| 498 | def test_config_is_frozen() -> None: | ||
| 499 | """Built configs cannot be mutated.""" | ||
| 500 | built = SampleConfig() | ||
| 501 | with pytest.raises(Exception): # noqa: B017 - pydantic raises ValidationError | ||
| 502 | built.count = 5 | ||
| 503 | |||
| 504 | |||
| 505 | def test_model_dump_round_trip_produces_plain_dicts() -> None: | ||
| 506 | """model_dump yields plain dicts that validate back into the same config.""" | ||
| 507 | built = SampleConfig(ground={"cell_m": 0.25}) | ||
| 508 | dumped = built.model_dump() | ||
| 509 | assert isinstance(dumped, dict) | ||
| 510 | assert isinstance(dumped["ground"], dict) | ||
| 511 | assert dumped["ground"] == {"cell_m": 0.25, "percentile": 5.0} | ||
| 512 | assert config_loader.validate_config( | ||
| 513 | SampleConfig, dumped, context="sample config" | ||
| 514 | ) == built | ||
| 515 | |||
| 516 | |||
| 517 | def test_defaults_are_validated() -> None: | ||
| 518 | """validate_default=True catches a bad default at model build time.""" | ||
| 519 | |||
| 520 | class Bad(config_loader.ConfigModel): | ||
| 521 | count: int = "abc" # type: ignore[assignment] | ||
| 522 | |||
| 523 | with pytest.raises(config_loader.ConfigError): | ||
| 524 | config_loader.validate_config(Bad, {}, context="sample config") | ||
| 525 | |||
| 526 | |||
| 527 | def test_extra_keys_forbidden_on_direct_construction() -> None: | ||
| 528 | """extra='forbid' applies to plain construction too, not just load_config.""" | ||
| 529 | with pytest.raises(Exception): # noqa: B017 - pydantic raises ValidationError | ||
| 530 | SampleConfig(nope=1) | ||
| 531 | |||
| 532 | |||
| 533 | # --- deprecation shims ------------------------------------------------------- | ||
| 534 | |||
| 535 | |||
| 536 | @dataclasses.dataclass(frozen=True) | ||
| 537 | class LegacySample: | ||
| 538 | """Dataclass used to exercise the deprecated mapping helper.""" | ||
| 539 | |||
| 540 | count: int | ||
| 541 | |||
| 542 | |||
| 543 | @pytest.mark.parametrize( | ||
| 544 | ("name", "call"), | ||
| 545 | [ | ||
| 546 | ( | ||
| 547 | "validate_allowed_keys", | ||
| 548 | lambda: config_loader.validate_allowed_keys( | ||
| 549 | {"a": 1}, frozenset({"a"}), context="ctx" | ||
| 550 | ), | ||
| 551 | ), | ||
| 552 | ("coerce_to_field_type", lambda: config_loader.coerce_to_field_type("x", 1, int)), | ||
| 553 | ( | ||
| 554 | "dataclass_from_mapping", | ||
| 555 | lambda: config_loader.dataclass_from_mapping( | ||
| 556 | LegacySample, {"count": "2"}, context="ctx" | ||
| 557 | ), | ||
| 558 | ), | ||
| 559 | ( | ||
| 560 | "validate_against_defaults", | ||
| 561 | lambda: config_loader.validate_against_defaults({"a": 1}, {"a": 1}, context="ctx"), | ||
| 562 | ), | ||
| 563 | ], | ||
| 564 | ) | ||
| 565 | def test_legacy_helpers_warn(name: str, call: Any) -> None: | ||
| 566 | """The hand-rolled helpers still work but are deprecated.""" | ||
| 567 | with pytest.warns(DeprecationWarning, match=name): | ||
| 568 | call() | ||
| 569 | |||
| 570 | |||
| 571 | def test_lazy_reexports_are_visible_to_dir() -> None: | ||
| 572 | """The lazily re-exported pydantic names show up in dir(config_loader).""" | ||
| 573 | names = dir(config_loader) | ||
| 574 | assert {"ConfigModel", "load_config", "validate_config", "format_validation_error"} <= set( | ||
| 575 | names | ||
| 576 | ) | ||
| 577 | assert names == sorted(names) | ||
| 578 | |||
| 579 | |||
| 580 | def test_coerce_config_value_does_not_warn() -> None: | ||
| 581 | """The shared coercion entry point used by ConfigModel is not deprecated.""" | ||
| 582 | with warnings.catch_warnings(): | ||
| 583 | warnings.simplefilter("error", DeprecationWarning) | ||
| 584 | assert config_loader.coerce_config_value("x", "1e3", int) == 1000 | ||
| 0 |
| 18 | wheels = [ | 18 | wheels = [ |
| 19 | { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, | 19 | { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, |
| 20 | ] | 20 | ] |
| 21 | 21 | ||
| 22 | [[package]] | ||
| 23 | name = "annotated-types" | ||
| 24 | version = "0.8.0" | ||
| 25 | source = { registry = "https://pypi.org/simple" } | ||
| 26 | sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } | ||
| 27 | wheels = [ | ||
| 28 | { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, | ||
| 29 | ] | ||
| 30 | |||
| 22 | [[package]] | 31 | [[package]] |
| 23 | name = "attrs" | 32 | name = "attrs" |
| 24 | version = "25.4.0" | 33 | version = "25.4.0" |
| 25 | source = { registry = "https://pypi.org/simple" } | 34 | source = { registry = "https://pypi.org/simple" } |
| 370 | version = "0.8.0" | 379 | version = "0.8.0" |
| 371 | source = { editable = "." } | 380 | source = { editable = "." } |
| 372 | dependencies = [ | 381 | dependencies = [ |
| 373 | { name = "numpy" }, | 382 | { name = "numpy" }, |
| 383 | { name = "pydantic" }, | ||
| 374 | ] | 384 | ] |
| 375 | 385 | ||
| 376 | [package.optional-dependencies] | 386 | [package.optional-dependencies] |
| 377 | crs = [ | 387 | crs = [ |
| 390 | requires-dist = [ | 400 | requires-dist = [ |
| 391 | { name = "numpy", specifier = ">=1.20.0" }, | 401 | { name = "numpy", specifier = ">=1.20.0" }, |
| 392 | { name = "open3d", marker = "extra == 'memory-guard'", specifier = ">=0.19.0" }, | 402 | { name = "open3d", marker = "extra == 'memory-guard'", specifier = ">=0.19.0" }, |
| 393 | { name = "psutil", marker = "extra == 'memory-guard'", specifier = ">=5.8.0" }, | 403 | { name = "psutil", marker = "extra == 'memory-guard'", specifier = ">=5.8.0" }, |
| 404 | { name = "pydantic", specifier = ">=2.7" }, | ||
| 394 | { name = "pyproj", marker = "extra == 'crs'", specifier = ">=3.4.0" }, | 405 | { name = "pyproj", marker = "extra == 'crs'", specifier = ">=3.4.0" }, |
| 395 | { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, | 406 | { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, |
| 396 | { name = "scikit-learn", marker = "extra == 'memory-guard'", specifier = ">=1.0.0" }, | 407 | { name = "scikit-learn", marker = "extra == 'memory-guard'", specifier = ">=1.0.0" }, |
| 397 | ] | 408 | ] |
| 1050 | { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, | 1061 | { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, |
| 1051 | { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, | 1062 | { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, |
| 1052 | ] | 1063 | ] |
| 1053 | 1064 | ||
| 1065 | [[package]] | ||
| 1066 | name = "pydantic" | ||
| 1067 | version = "2.13.5" | ||
| 1068 | source = { registry = "https://pypi.org/simple" } | ||
| 1069 | dependencies = [ | ||
| 1070 | { name = "annotated-types" }, | ||
| 1071 | { name = "pydantic-core" }, | ||
| 1072 | { name = "typing-extensions" }, | ||
| 1073 | { name = "typing-inspection" }, | ||
| 1074 | ] | ||
| 1075 | sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } | ||
| 1076 | wheels = [ | ||
| 1077 | { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, | ||
| 1078 | ] | ||
| 1079 | |||
| 1080 | [[package]] | ||
| 1081 | name = "pydantic-core" | ||
| 1082 | version = "2.46.5" | ||
| 1083 | source = { registry = "https://pypi.org/simple" } | ||
| 1084 | dependencies = [ | ||
| 1085 | { name = "typing-extensions" }, | ||
| 1086 | ] | ||
| 1087 | sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } | ||
| 1088 | wheels = [ | ||
| 1089 | { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, | ||
| 1090 | { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, | ||
| 1091 | { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, | ||
| 1092 | { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, | ||
| 1093 | { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, | ||
| 1094 | { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, | ||
| 1095 | { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, | ||
| 1096 | { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, | ||
| 1097 | { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, | ||
| 1098 | { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, | ||
| 1099 | { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, | ||
| 1100 | { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, | ||
| 1101 | { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, | ||
| 1102 | { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, | ||
| 1103 | { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, | ||
| 1104 | { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, | ||
| 1105 | { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, | ||
| 1106 | { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, | ||
| 1107 | { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, | ||
| 1108 | { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, | ||
| 1109 | { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, | ||
| 1110 | { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, | ||
| 1111 | { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, | ||
| 1112 | { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, | ||
| 1113 | { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, | ||
| 1114 | { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, | ||
| 1115 | { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, | ||
| 1116 | { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, | ||
| 1117 | { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, | ||
| 1118 | { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, | ||
| 1119 | { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, | ||
| 1120 | { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, | ||
| 1121 | { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, | ||
| 1122 | { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, | ||
| 1123 | { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, | ||
| 1124 | { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, | ||
| 1125 | { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, | ||
| 1126 | { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, | ||
| 1127 | { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, | ||
| 1128 | { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, | ||
| 1129 | { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, | ||
| 1130 | { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, | ||
| 1131 | { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, | ||
| 1132 | { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, | ||
| 1133 | { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, | ||
| 1134 | { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, | ||
| 1135 | { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, | ||
| 1136 | { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, | ||
| 1137 | { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, | ||
| 1138 | { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, | ||
| 1139 | { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, | ||
| 1140 | { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, | ||
| 1141 | { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, | ||
| 1142 | { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, | ||
| 1143 | { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, | ||
| 1144 | { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, | ||
| 1145 | { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, | ||
| 1146 | { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, | ||
| 1147 | { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, | ||
| 1148 | { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, | ||
| 1149 | { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, | ||
| 1150 | { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, | ||
| 1151 | { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, | ||
| 1152 | { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, | ||
| 1153 | { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, | ||
| 1154 | { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, | ||
| 1155 | { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, | ||
| 1156 | { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, | ||
| 1157 | { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, | ||
| 1158 | { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, | ||
| 1159 | { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, | ||
| 1160 | { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, | ||
| 1161 | { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, | ||
| 1162 | { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, | ||
| 1163 | { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, | ||
| 1164 | { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, | ||
| 1165 | { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, | ||
| 1166 | { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, | ||
| 1167 | { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, | ||
| 1168 | { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, | ||
| 1169 | { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, | ||
| 1170 | { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, | ||
| 1171 | { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, | ||
| 1172 | { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, | ||
| 1173 | { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, | ||
| 1174 | { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, | ||
| 1175 | { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, | ||
| 1176 | { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, | ||
| 1177 | { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, | ||
| 1178 | { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, | ||
| 1179 | { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, | ||
| 1180 | ] | ||
| 1181 | |||
| 1054 | [[package]] | 1182 | [[package]] |
| 1055 | name = "pygments" | 1183 | name = "pygments" |
| 1056 | version = "2.19.2" | 1184 | version = "2.19.2" |
| 1057 | source = { registry = "https://pypi.org/simple" } | 1185 | source = { registry = "https://pypi.org/simple" } |
| 1552 | wheels = [ | 1680 | wheels = [ |
| 1553 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, | 1681 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, |
| 1554 | ] | 1682 | ] |
| 1555 | 1683 | ||
| 1684 | [[package]] | ||
| 1685 | name = "typing-inspection" | ||
| 1686 | version = "0.4.4" | ||
| 1687 | source = { registry = "https://pypi.org/simple" } | ||
| 1688 | dependencies = [ | ||
| 1689 | { name = "typing-extensions" }, | ||
| 1690 | ] | ||
| 1691 | sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } | ||
| 1692 | wheels = [ | ||
| 1693 | { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, | ||
| 1694 | ] | ||
| 1695 | |||
| 1556 | [[package]] | 1696 | [[package]] |
| 1557 | name = "tzdata" | 1697 | name = "tzdata" |
| 1558 | version = "2025.3" | 1698 | version = "2025.3" |
| 1559 | source = { registry = "https://pypi.org/simple" } | 1699 | source = { registry = "https://pypi.org/simple" } |
config_model.py:ConfigModel(pydantic v2,extra=forbid, frozen,validate_default),load_config(packaged JSON โ optional replacement file โ deep-merged overrides โ validated model),validate_config,format_validation_error(legacy-shaped messages: unknown keys with allowed-key hints, dotted field paths).field_validator('*', mode='before')routes every field through the legacy coercion matrix (coerce_config_value) so bool/int/float/str/Literal/Optional/list/tuple behave exactly as the hand-rolled helpers did.validate_allowed_keys,coerce_to_field_type,dataclass_from_mapping,validate_against_defaultskept asDeprecationWarningshims for published wheels.