Back to report index

iolabs-common (shared config layer) c080353: AI3D-379 Pydantic config layer (ConfigModel, load_config), deprecate hand-rolled coercion; 0.8.0

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(-)
Importance #1: src/iolabs/common/config_model.py @@ -0,0 +1,394 @@

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
4from. 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
7letting package authors write plain ``x: int`` / ``y: float`` / ``z: bool`` /
8``s: str`` fields; `Literal`, sequences, and nested models are validated by
9pydantic itself.
10
11Canonical 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
48Unknown keys are rejected (``extra="forbid"``) with the same message shape as
49:func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen,
50so no field can be rebound after construction. Freezing is shallow, as in
51pydantic itself: a ``list``-valued field is still a mutable list, so prefer
52``tuple`` for sequence fields that must not change.
53"""
54
55from __future__ import annotations
56
57import json
58import logging
59from collections.abc import Mapping
60from pathlib import Path
61from typing import Any, TypeVar, get_args
62
63import pydantic
64from pydantic import fields as pydantic_fields
65
66from iolabs.common import config_loader
67
68logger = 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
77class 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
112def _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
123def _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
128def 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
159def 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
206def 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
248def _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
264def _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
279def _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
298def _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
327def _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
333def _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
349def _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
360def _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
371def _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
385def _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
Importance #2: src/iolabs/common/config_loader.py @@ -227,29 +292,34 @@
227 tokens ``1/true/yes/on`` and ``0/false/no/off``. Anything else raises, so292 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 strings294 * ``int``: accepts ints, integral floats (``3.0``) and numeric strings
230 including exponent form (``"1e3"`` -> ``1000``). Non-integral values295 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 in300 ``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 the303 * ``X | None`` / ``Optional[X]``: ``None`` passes through, otherwise the
238 value is coerced to ``X``. Unions of two or more non-``None`` types pass304 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 or306 * ``tuple[...]`` / ``list[...]``: accepts a list or tuple (never a string or
241 mapping), coercing each item to the declared item type. Fixed-length307 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 by312 * A nested dataclass type: a mapping value is built into that dataclass by
245 :func:`dataclass_from_mapping` (unknown keys rejected, inner values313 :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.
247315
248 Any other declared type (``dict``, ``Any``, an unresolved string316 Any other declared type (``dict``, ``Any``, an unresolved string
249 annotation, a union of two or more non-``None`` types, ...) returns317 annotation, a union of two or more non-``None`` types, ...) returns
250 *value* unchanged.318 *value* unchanged.
251319
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 a325 declared: The field's declared type (``dataclasses.Field.type`` or a
Importance #3: src/iolabs/common/config_loader.py @@ -332,29 +431,30 @@
332 raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc431 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 exc440 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)
345441
346442
347def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float:443def _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
357457
358458
359def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str:459def _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."""
Importance #4: src/iolabs/common/config_loader.py @@ -1,23 +1,73 @@

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.
22
3Covers the whole config path shared by the pipeline packages: load the3Covers the whole config path shared by the pipeline packages: load the
4packaged default JSON, deep-merge CLI ``--set`` overrides onto it, reject4packaged default JSON, deep-merge CLI ``--set`` overrides onto it, reject
5unknown keys (flat or against the defaults tree) and coerce raw JSON/CLI5unknown keys and coerce raw JSON/CLI values to the declared field types.
6values to the declared dataclass field types.6
7The 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
11package 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`
43and `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"""
848
9from __future__ import annotations49from __future__ import annotations
1050
51import enum
11import json52import json
53import logging
54import numbers
12import sys55import sys
56import warnings
13from collections.abc import Collection, Mapping, Sequence57from collections.abc import Collection, Mapping, Sequence
14from dataclasses import fields, is_dataclass58from dataclasses import fields, is_dataclass
15from importlib import resources59from importlib import resources
16from pathlib import Path60from pathlib import Path
17from types import UnionType61from types import UnionType
18from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints62from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints
1963
64logger = logging.getLogger(__name__)
65
66_PYDANTIC_EXPORTS = frozenset(
67 {"ConfigModel", "load_config", "validate_config", "format_validation_error"}
68)
69
2070
21class ConfigError(ValueError):71class ConfigError(ValueError):
22 """Raised when a packaged-JSON config contains unsupported keys/values."""72 """Raised when a packaged-JSON config contains unsupported keys/values."""
2373
Importance #5: src/iolabs/common/config_loader.py @@ -258,8 +328,37 @@

The legacy coercion matrix as a non-deprecated public function; strictness (bool never int, str only str) is preserved on purpose.

258328
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.
261331
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
339def 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):
Importance #6: src/iolabs/common/__init__.py @@ -4,8 +4,9 @@
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,
Importance #7: src/iolabs/common/__init__.py @@ -23,8 +24,9 @@
23 configure_logging,24 configure_logging,
24)25)
25from .config_loader import (26from .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,
Importance #8: src/iolabs/common/__init__.py @@ -32,14 +34,21 @@
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)
38from .config_model import (
39 ConfigModel,
40 format_validation_error,
41 load_config,
42 validate_config,
43)
3644
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",
Importance #9: src/iolabs/common/__init__.py @@ -53,13 +62,18 @@
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]
Importance #10: src/iolabs/common/config_loader.py @@ -81,8 +131,11 @@
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*.
84134
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.
Importance #11: src/iolabs/common/config_loader.py @@ -96,8 +149,20 @@
96149
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
157def _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)}. "
Importance #12: src/iolabs/common/config_loader.py @@ -285,9 +384,9 @@
285 return _coerce_sequence(384 return _coerce_sequence(
286 name, value, get_args(declared), target=list, error_cls=error_cls385 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 value389 return value
291390
292391
293def _coerce_bool(name: str, value: Any, *, error_cls: type[ValueError]) -> bool:392def _coerce_bool(name: str, value: Any, *, error_cls: type[ValueError]) -> bool:
Importance #13: src/iolabs/common/config_loader.py @@ -369,12 +469,21 @@
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 option482 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}.")
379488
380489
Importance #14: src/iolabs/common/config_loader.py @@ -390,9 +499,9 @@
390 return None499 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 value502 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)
395504
396505
397def _coerce_sequence(506def _coerce_sequence(
398 name: str,507 name: str,
Importance #15: src/iolabs/common/config_loader.py @@ -420,9 +529,9 @@
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)
428537
Importance #16: src/iolabs/common/config_loader.py @@ -468,18 +577,33 @@
468 TypeError: *cls* is not a dataclass type, a field annotation cannot be577 TypeError: *cls* is not a dataclass type, a field annotation cannot be
469 resolved at runtime (unless ``coerce=False``), or a required field578 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
587def _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)
485609
Importance #17: src/iolabs/common/config_loader.py @@ -564,8 +688,11 @@
564 node's keys. Recursion stops where *defaults* holds a non-mapping leaf, so688 node's keys. Recursion stops where *defaults* holds a non-mapping leaf, so
565 leaf values are never inspected. The first offending node raises, naming689 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"``).
567691
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 error698 context: Human-readable name of the root node, used as the error
Importance #18: src/iolabs/common/config_loader.py @@ -586,8 +713,9 @@
586 error_cls: A node of *config* is not a mapping where *defaults* has713 error_cls: A node of *config* is not a mapping where *defaults* has
587 one, or holds a key absent from both *defaults* and714 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,
Importance #19: src/iolabs/common/config_loader.py @@ -638,4 +766,28 @@

Old helpers kept alive for published leaf wheels; warn once per call site.

638766
639def _dotted(prefix: str, key: str) -> str:767def _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 key769 return f"{prefix}.{key}" if prefix else key
770
771
772def _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
782def __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
791def __dir__() -> list[str]:
792 """List the lazy pydantic re-exports alongside the module's own names."""
793 return sorted(set(globals()) | _PYDANTIC_EXPORTS)
Importance #20: tests/test_config_model.py @@ -0,0 +1,584 @@
1"""Tests for the pydantic-v2 config layer (`config_loader.ConfigModel`)."""
2
3from __future__ import annotations
4
5import dataclasses
6import enum
7import json
8import math
9import warnings
10from pathlib import Path
11from typing import Any, Literal
12from unittest.mock import patch
13
14import pydantic
15import pytest
16
17from 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
23class SampleMode(enum.StrEnum):
24 """Enum used for Literal-of-enum fields."""
25
26 FAST = "fast"
27 EXACT = "exact"
28
29
30class SampleConfigError(config_loader.ConfigError):
31 """Consumer-style config error."""
32
33
34class 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
41class 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
55class 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)
82def 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, []])
88def 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)
100def 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"])
106def 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)
118def 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
123def 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, []])
129def 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}])
138def 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"]])
148def 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
156def test_str_field_accepts_strings() -> None:
157 """str fields pass strings through unchanged."""
158 assert SampleConfig(name="lidar").name == "lidar"
159
160
161def 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
169def 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
178def 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
190def 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
201def 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
212def 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
220def 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
230def 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
240def 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
251def 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
260def 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
269def 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
280def 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
288def 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
298def 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
312def 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
334def 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
350def 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
366def 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
386def 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
402def 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
420def 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
435def 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
449def 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
464def 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
478def 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
498def 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
505def 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
517def 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
527def 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)
537class 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)
565def 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
571def 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
580def 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
Importance #21: tests/test_config_loader.py @@ -1,8 +1,9 @@
1"""Tests for packaged-JSON config loader helpers."""1"""Tests for packaged-JSON config loader helpers."""
22
3from __future__ import annotations3from __future__ import annotations
44
5import enum
5import importlib6import importlib
6import json7import json
7import math8import math
8import sys9import sys
Importance #22: tests/test_config_loader.py @@ -29,8 +30,13 @@

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"
3132
3233
34# The hand-rolled helpers below are deprecated shims kept for published leaf
35# wheels; their DeprecationWarning is expected here.
36pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning")
37
38
33class CustomConfigError(ConfigError):39class CustomConfigError(ConfigError):
34 """Consumer-style ConfigError subclass for error_cls override tests."""40 """Consumer-style ConfigError subclass for error_cls override tests."""
3541
3642
Importance #23: tests/test_config_loader.py @@ -316,9 +322,9 @@
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) == expected323 assert coerce_to_field_type("count", value, int) == expected
318324
319325
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()])
321def test_coerce_int_rejects_non_integral_and_bad_tokens(value: object) -> None:327def 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)
Importance #24: tests/test_config_loader.py @@ -334,9 +340,9 @@
334 assert isinstance(result, float)340 assert isinstance(result, float)
335 assert result == expected341 assert result == expected
336342
337343
338@pytest.mark.parametrize("value", ["abc", None, [], True])344@pytest.mark.parametrize("value", ["abc", None, [], True, b"2.5", object()])
339def test_coerce_float_rejects_bad_values(value: object) -> None:345def 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)
Importance #25: tests/test_config_loader.py @@ -389,8 +395,28 @@
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)
391397
392398
399def 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
413def 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
393def test_coerce_unknown_declared_type_passes_through() -> None:419def 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 payload422 assert coerce_to_field_type("extras", payload, dict[str, int]) is payload
Importance #26: pyproject.toml @@ -4,8 +4,9 @@
4description = "Shared data structures for the 3D AI LIDAR processing pipeline"4description = "Shared data structures for the 3D AI LIDAR processing pipeline"
5requires-python = ">=3.11"5requires-python = ">=3.11"
6dependencies = [6dependencies = [
7 "numpy>=1.20.0",7 "numpy>=1.20.0",
8 "pydantic>=2.7",
8]9]
9[project.optional-dependencies]10[project.optional-dependencies]
10dev = ["pytest>=7.0.0"]11dev = ["pytest>=7.0.0"]
11crs = ["pyproj>=3.4.0"]12crs = ["pyproj>=3.4.0"]
Importance #27: README.md @@ -12,4 +12,53 @@
1212
13```python13```python
14from io_common.common_data import ColorIntensityData14from io_common.common_data import ColorIntensityData
15```15```
16
17## Config models (pydantic v2)
18
19Package configs derive from `config_loader.ConfigModel` (frozen, `extra="forbid"`,
20fleet coercion matrix for `bool`/`int`/`float`/`str`) and are built with
21`config_loader.load_config(...)`:
22
23```python
24from collections.abc import Mapping
25from pathlib import Path
26from typing import Any, Literal
27
28from iolabs.common import config_loader
29
30
31class FooGroundConfig(config_loader.ConfigModel):
32 cell_m: float = 0.5
33
34
35class FooConfig(config_loader.ConfigModel):
36 mode: Literal["fast", "exact"] = "fast"
37 ground: FooGroundConfig = FooGroundConfig()
38
39
40class FooConfigError(config_loader.ConfigError):
41 """Raised for an invalid foo config."""
42
43
44def 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
59The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`,
60`dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf
61wheels but emit a `DeprecationWarning` (0.8.0).
62
63Models are frozen, but only shallowly (as in pydantic itself): use `tuple` rather
64than `list` for sequence fields that must not be mutated after construction.
Importance #28: uv.lock @@ -18,8 +18,17 @@
18wheels = [18wheels = [
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]
2121
22[[package]]
23name = "annotated-types"
24version = "0.8.0"
25source = { registry = "https://pypi.org/simple" }
26sdist = { 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" }
27wheels = [
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]]
23name = "attrs"32name = "attrs"
24version = "25.4.0"33version = "25.4.0"
25source = { registry = "https://pypi.org/simple" }34source = { registry = "https://pypi.org/simple" }
Importance #29: uv.lock @@ -370,8 +379,9 @@
370version = "0.8.0"379version = "0.8.0"
371source = { editable = "." }380source = { editable = "." }
372dependencies = [381dependencies = [
373 { name = "numpy" },382 { name = "numpy" },
383 { name = "pydantic" },
374]384]
375385
376[package.optional-dependencies]386[package.optional-dependencies]
377crs = [387crs = [
Importance #30: uv.lock @@ -390,8 +400,9 @@
390requires-dist = [400requires-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]
Importance #31: uv.lock @@ -1050,8 +1061,125 @@
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]
10531064
1065[[package]]
1066name = "pydantic"
1067version = "2.13.5"
1068source = { registry = "https://pypi.org/simple" }
1069dependencies = [
1070 { name = "annotated-types" },
1071 { name = "pydantic-core" },
1072 { name = "typing-extensions" },
1073 { name = "typing-inspection" },
1074]
1075sdist = { 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" }
1076wheels = [
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]]
1081name = "pydantic-core"
1082version = "2.46.5"
1083source = { registry = "https://pypi.org/simple" }
1084dependencies = [
1085 { name = "typing-extensions" },
1086]
1087sdist = { 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" }
1088wheels = [
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]]
1055name = "pygments"1183name = "pygments"
1056version = "2.19.2"1184version = "2.19.2"
1057source = { registry = "https://pypi.org/simple" }1185source = { registry = "https://pypi.org/simple" }
Importance #32: uv.lock @@ -1552,8 +1680,20 @@
1552wheels = [1680wheels = [
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]
15551683
1684[[package]]
1685name = "typing-inspection"
1686version = "0.4.4"
1687source = { registry = "https://pypi.org/simple" }
1688dependencies = [
1689 { name = "typing-extensions" },
1690]
1691sdist = { 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" }
1692wheels = [
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]]
1557name = "tzdata"1697name = "tzdata"
1558version = "2025.3"1698version = "2025.3"
1559source = { registry = "https://pypi.org/simple" }1699source = { registry = "https://pypi.org/simple" }
Importance #33: pyproject.toml @@ -4,8 +4,9 @@
4description = "Shared data structures for the 3D AI LIDAR processing pipeline"4description = "Shared data structures for the 3D AI LIDAR processing pipeline"
5requires-python = ">=3.11"5requires-python = ">=3.11"
6dependencies = [6dependencies = [
7 "numpy>=1.20.0",7 "numpy>=1.20.0",
8 "pydantic>=2.7",
8]9]
9[project.optional-dependencies]10[project.optional-dependencies]
10dev = ["pytest>=7.0.0"]11dev = ["pytest>=7.0.0"]
11crs = ["pyproj>=3.4.0"]12crs = ["pyproj>=3.4.0"]
Importance #34: src/iolabs/common/__init__.py @@ -4,8 +4,9 @@
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,
Importance #35: src/iolabs/common/__init__.py @@ -23,8 +24,9 @@
23 configure_logging,24 configure_logging,
24)25)
25from .config_loader import (26from .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,
Importance #36: src/iolabs/common/__init__.py @@ -32,14 +34,21 @@
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)
38from .config_model import (
39 ConfigModel,
40 format_validation_error,
41 load_config,
42 validate_config,
43)
3644
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",
Importance #37: src/iolabs/common/__init__.py @@ -53,13 +62,18 @@
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]
Importance #38: src/iolabs/common/config_loader.py @@ -1,23 +1,73 @@

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.
22
3Covers the whole config path shared by the pipeline packages: load the3Covers the whole config path shared by the pipeline packages: load the
4packaged default JSON, deep-merge CLI ``--set`` overrides onto it, reject4packaged default JSON, deep-merge CLI ``--set`` overrides onto it, reject
5unknown keys (flat or against the defaults tree) and coerce raw JSON/CLI5unknown keys and coerce raw JSON/CLI values to the declared field types.
6values to the declared dataclass field types.6
7The 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
11package 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`
43and `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"""
848
9from __future__ import annotations49from __future__ import annotations
1050
51import enum
11import json52import json
53import logging
54import numbers
12import sys55import sys
56import warnings
13from collections.abc import Collection, Mapping, Sequence57from collections.abc import Collection, Mapping, Sequence
14from dataclasses import fields, is_dataclass58from dataclasses import fields, is_dataclass
15from importlib import resources59from importlib import resources
16from pathlib import Path60from pathlib import Path
17from types import UnionType61from types import UnionType
18from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints62from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints
1963
64logger = logging.getLogger(__name__)
65
66_PYDANTIC_EXPORTS = frozenset(
67 {"ConfigModel", "load_config", "validate_config", "format_validation_error"}
68)
69
2070
21class ConfigError(ValueError):71class ConfigError(ValueError):
22 """Raised when a packaged-JSON config contains unsupported keys/values."""72 """Raised when a packaged-JSON config contains unsupported keys/values."""
2373
Importance #39: src/iolabs/common/config_loader.py @@ -81,8 +131,11 @@
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*.
84134
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.
Importance #40: src/iolabs/common/config_loader.py @@ -96,8 +149,20 @@
96149
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
157def _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)}. "
Importance #41: src/iolabs/common/config_loader.py @@ -227,29 +292,34 @@
227 tokens ``1/true/yes/on`` and ``0/false/no/off``. Anything else raises, so292 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 strings294 * ``int``: accepts ints, integral floats (``3.0``) and numeric strings
230 including exponent form (``"1e3"`` -> ``1000``). Non-integral values295 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 in300 ``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 the303 * ``X | None`` / ``Optional[X]``: ``None`` passes through, otherwise the
238 value is coerced to ``X``. Unions of two or more non-``None`` types pass304 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 or306 * ``tuple[...]`` / ``list[...]``: accepts a list or tuple (never a string or
241 mapping), coercing each item to the declared item type. Fixed-length307 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 by312 * A nested dataclass type: a mapping value is built into that dataclass by
245 :func:`dataclass_from_mapping` (unknown keys rejected, inner values313 :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.
247315
248 Any other declared type (``dict``, ``Any``, an unresolved string316 Any other declared type (``dict``, ``Any``, an unresolved string
249 annotation, a union of two or more non-``None`` types, ...) returns317 annotation, a union of two or more non-``None`` types, ...) returns
250 *value* unchanged.318 *value* unchanged.
251319
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 a325 declared: The field's declared type (``dataclasses.Field.type`` or a
Importance #42: src/iolabs/common/config_loader.py @@ -258,8 +328,37 @@

The legacy coercion matrix as a non-deprecated public function; strictness (bool never int, str only str) is preserved on purpose.

258328
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.
261331
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
339def 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):
Importance #43: src/iolabs/common/config_loader.py @@ -285,9 +384,9 @@
285 return _coerce_sequence(384 return _coerce_sequence(
286 name, value, get_args(declared), target=list, error_cls=error_cls385 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 value389 return value
291390
292391
293def _coerce_bool(name: str, value: Any, *, error_cls: type[ValueError]) -> bool:392def _coerce_bool(name: str, value: Any, *, error_cls: type[ValueError]) -> bool:
Importance #44: src/iolabs/common/config_loader.py @@ -332,29 +431,30 @@
332 raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc431 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 exc440 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)
345441
346442
347def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float:443def _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
357457
358458
359def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str:459def _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."""
Importance #45: src/iolabs/common/config_loader.py @@ -369,12 +469,21 @@
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 option482 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}.")
379488
380489
Importance #46: src/iolabs/common/config_loader.py @@ -390,9 +499,9 @@
390 return None499 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 value502 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)
395504
396505
397def _coerce_sequence(506def _coerce_sequence(
398 name: str,507 name: str,
Importance #47: src/iolabs/common/config_loader.py @@ -420,9 +529,9 @@
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)
428537
Importance #48: src/iolabs/common/config_loader.py @@ -468,18 +577,33 @@
468 TypeError: *cls* is not a dataclass type, a field annotation cannot be577 TypeError: *cls* is not a dataclass type, a field annotation cannot be
469 resolved at runtime (unless ``coerce=False``), or a required field578 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
587def _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)
485609
Importance #49: src/iolabs/common/config_loader.py @@ -564,8 +688,11 @@
564 node's keys. Recursion stops where *defaults* holds a non-mapping leaf, so688 node's keys. Recursion stops where *defaults* holds a non-mapping leaf, so
565 leaf values are never inspected. The first offending node raises, naming689 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"``).
567691
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 error698 context: Human-readable name of the root node, used as the error
Importance #50: src/iolabs/common/config_loader.py @@ -586,8 +713,9 @@
586 error_cls: A node of *config* is not a mapping where *defaults* has713 error_cls: A node of *config* is not a mapping where *defaults* has
587 one, or holds a key absent from both *defaults* and714 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,
Importance #51: src/iolabs/common/config_loader.py @@ -638,4 +766,28 @@

Old helpers kept alive for published leaf wheels; warn once per call site.

638766
639def _dotted(prefix: str, key: str) -> str:767def _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 key769 return f"{prefix}.{key}" if prefix else key
770
771
772def _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
782def __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
791def __dir__() -> list[str]:
792 """List the lazy pydantic re-exports alongside the module's own names."""
793 return sorted(set(globals()) | _PYDANTIC_EXPORTS)
Importance #52: src/iolabs/common/config_model.py @@ -0,0 +1,394 @@

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
4from. 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
7letting package authors write plain ``x: int`` / ``y: float`` / ``z: bool`` /
8``s: str`` fields; `Literal`, sequences, and nested models are validated by
9pydantic itself.
10
11Canonical 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
48Unknown keys are rejected (``extra="forbid"``) with the same message shape as
49:func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen,
50so no field can be rebound after construction. Freezing is shallow, as in
51pydantic itself: a ``list``-valued field is still a mutable list, so prefer
52``tuple`` for sequence fields that must not change.
53"""
54
55from __future__ import annotations
56
57import json
58import logging
59from collections.abc import Mapping
60from pathlib import Path
61from typing import Any, TypeVar, get_args
62
63import pydantic
64from pydantic import fields as pydantic_fields
65
66from iolabs.common import config_loader
67
68logger = 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
77class 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
112def _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
123def _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
128def 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
159def 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
206def 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
248def _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
264def _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
279def _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
298def _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
327def _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
333def _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
349def _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
360def _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
371def _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
385def _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
Importance #53: tests/test_config_loader.py @@ -1,8 +1,9 @@
1"""Tests for packaged-JSON config loader helpers."""1"""Tests for packaged-JSON config loader helpers."""
22
3from __future__ import annotations3from __future__ import annotations
44
5import enum
5import importlib6import importlib
6import json7import json
7import math8import math
8import sys9import sys
Importance #54: tests/test_config_loader.py @@ -29,8 +30,13 @@

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"
3132
3233
34# The hand-rolled helpers below are deprecated shims kept for published leaf
35# wheels; their DeprecationWarning is expected here.
36pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning")
37
38
33class CustomConfigError(ConfigError):39class CustomConfigError(ConfigError):
34 """Consumer-style ConfigError subclass for error_cls override tests."""40 """Consumer-style ConfigError subclass for error_cls override tests."""
3541
3642
Importance #55: tests/test_config_loader.py @@ -316,9 +322,9 @@
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) == expected323 assert coerce_to_field_type("count", value, int) == expected
318324
319325
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()])
321def test_coerce_int_rejects_non_integral_and_bad_tokens(value: object) -> None:327def 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)
Importance #56: tests/test_config_loader.py @@ -334,9 +340,9 @@
334 assert isinstance(result, float)340 assert isinstance(result, float)
335 assert result == expected341 assert result == expected
336342
337343
338@pytest.mark.parametrize("value", ["abc", None, [], True])344@pytest.mark.parametrize("value", ["abc", None, [], True, b"2.5", object()])
339def test_coerce_float_rejects_bad_values(value: object) -> None:345def 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)
Importance #57: tests/test_config_loader.py @@ -389,8 +395,28 @@
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)
391397
392398
399def 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
413def 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
393def test_coerce_unknown_declared_type_passes_through() -> None:419def 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 payload422 assert coerce_to_field_type("extras", payload, dict[str, int]) is payload
Importance #58: tests/test_config_model.py @@ -0,0 +1,584 @@
1"""Tests for the pydantic-v2 config layer (`config_loader.ConfigModel`)."""
2
3from __future__ import annotations
4
5import dataclasses
6import enum
7import json
8import math
9import warnings
10from pathlib import Path
11from typing import Any, Literal
12from unittest.mock import patch
13
14import pydantic
15import pytest
16
17from 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
23class SampleMode(enum.StrEnum):
24 """Enum used for Literal-of-enum fields."""
25
26 FAST = "fast"
27 EXACT = "exact"
28
29
30class SampleConfigError(config_loader.ConfigError):
31 """Consumer-style config error."""
32
33
34class 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
41class 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
55class 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)
82def 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, []])
88def 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)
100def 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"])
106def 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)
118def 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
123def 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, []])
129def 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}])
138def 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"]])
148def 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
156def test_str_field_accepts_strings() -> None:
157 """str fields pass strings through unchanged."""
158 assert SampleConfig(name="lidar").name == "lidar"
159
160
161def 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
169def 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
178def 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
190def 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
201def 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
212def 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
220def 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
230def 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
240def 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
251def 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
260def 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
269def 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
280def 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
288def 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
298def 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
312def 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
334def 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
350def 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
366def 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
386def 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
402def 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
420def 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
435def 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
449def 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
464def 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
478def 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
498def 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
505def 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
517def 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
527def 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)
537class 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)
565def 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
571def 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
580def 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
Importance #59: uv.lock @@ -18,8 +18,17 @@
18wheels = [18wheels = [
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]
2121
22[[package]]
23name = "annotated-types"
24version = "0.8.0"
25source = { registry = "https://pypi.org/simple" }
26sdist = { 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" }
27wheels = [
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]]
23name = "attrs"32name = "attrs"
24version = "25.4.0"33version = "25.4.0"
25source = { registry = "https://pypi.org/simple" }34source = { registry = "https://pypi.org/simple" }
Importance #60: uv.lock @@ -370,8 +379,9 @@
370version = "0.8.0"379version = "0.8.0"
371source = { editable = "." }380source = { editable = "." }
372dependencies = [381dependencies = [
373 { name = "numpy" },382 { name = "numpy" },
383 { name = "pydantic" },
374]384]
375385
376[package.optional-dependencies]386[package.optional-dependencies]
377crs = [387crs = [
Importance #61: uv.lock @@ -390,8 +400,9 @@
390requires-dist = [400requires-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]
Importance #62: uv.lock @@ -1050,8 +1061,125 @@
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]
10531064
1065[[package]]
1066name = "pydantic"
1067version = "2.13.5"
1068source = { registry = "https://pypi.org/simple" }
1069dependencies = [
1070 { name = "annotated-types" },
1071 { name = "pydantic-core" },
1072 { name = "typing-extensions" },
1073 { name = "typing-inspection" },
1074]
1075sdist = { 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" }
1076wheels = [
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]]
1081name = "pydantic-core"
1082version = "2.46.5"
1083source = { registry = "https://pypi.org/simple" }
1084dependencies = [
1085 { name = "typing-extensions" },
1086]
1087sdist = { 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" }
1088wheels = [
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]]
1055name = "pygments"1183name = "pygments"
1056version = "2.19.2"1184version = "2.19.2"
1057source = { registry = "https://pypi.org/simple" }1185source = { registry = "https://pypi.org/simple" }
Importance #63: uv.lock @@ -1552,8 +1680,20 @@
1552wheels = [1680wheels = [
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]
15551683
1684[[package]]
1685name = "typing-inspection"
1686version = "0.4.4"
1687source = { registry = "https://pypi.org/simple" }
1688dependencies = [
1689 { name = "typing-extensions" },
1690]
1691sdist = { 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" }
1692wheels = [
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]]
1557name = "tzdata"1697name = "tzdata"
1558version = "2025.3"1698version = "2025.3"
1559source = { registry = "https://pypi.org/simple" }1699source = { registry = "https://pypi.org/simple" }