Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:56:50+02:00
Commit #3 ยท 14 snippets
src/iolabs/common/config_loader.py | 16 ++++++++- src/iolabs/common/config_model.py | 17 ++++++++++ tests/test_config_model.py | 66 +++++++++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-)
| 44 | context="foo config", | 44 | context="foo config", |
| 45 | error_cls=FooConfigError, | 45 | error_cls=FooConfigError, |
| 46 | ).model_dump() | 46 | ).model_dump() |
| 47 | 47 | ||
| 48 | Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass | ||
| 49 | section is coerced, but its errors do not carry the section path. Writers that | ||
| 50 | serialise a config (run stats, provenance) should use ``model_dump(mode="json")`` | ||
| 51 | so tuples, paths and enums become JSON-native values. | ||
| 52 | |||
| 48 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as | 53 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as |
| 49 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, | 54 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, |
| 50 | so no field can be rebound after construction. Freezing is shallow, as in | 55 | so no field can be rebound after construction. Freezing is shallow, as in |
| 51 | pydantic itself: a ``list``-valued field is still a mutable list, so prefer | 56 | pydantic itself: a ``list``-valued field is still a mutable list, so prefer |
| 147 | Raises: | 152 | Raises: |
| 148 | error_cls: *raw* holds an unknown key, misses a required key, or holds | 153 | 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. | 154 | a value that is not valid for its declared field type. |
| 150 | """ | 155 | """ |
| 156 | if not isinstance(raw, Mapping): | ||
| 157 | raise error_cls(f"{context} must be a mapping, got {type(raw).__name__}") | ||
| 151 | try: | 158 | try: |
| 152 | return model_cls.model_validate(dict(raw)) | 159 | return model_cls.model_validate(dict(raw)) |
| 153 | except pydantic.ValidationError as exc: | 160 | except pydantic.ValidationError as exc: |
| 154 | raise error_cls( | 161 | raise error_cls( |
| 197 | logger.debug("Loaded %s from %s", context, config_path) | 204 | logger.debug("Loaded %s from %s", context, config_path) |
| 198 | else: | 205 | else: |
| 199 | raw = _load_packaged(package, filename, error_cls=error_cls) | 206 | raw = _load_packaged(package, filename, error_cls=error_cls) |
| 200 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) | 207 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) |
| 208 | if overrides is not None and not isinstance(overrides, Mapping): | ||
| 209 | raise error_cls( | ||
| 210 | f"{context} overrides must be a mapping, got {type(overrides).__name__}" | ||
| 211 | ) | ||
| 201 | if overrides: | 212 | if overrides: |
| 202 | raw = config_loader.deep_merge_dicts(raw, dict(overrides)) | 213 | raw = config_loader.deep_merge_dicts(raw, dict(overrides)) |
| 203 | return validate_config(model_cls, raw, context=context, error_cls=error_cls) | 214 | return validate_config(model_cls, raw, context=context, error_cls=error_cls) |
| 204 | 215 |
| 298 | head, _, tail = message.partition(" for '") | 309 | head, _, tail = message.partition(" for '") |
| 299 | name, quote, rest = tail.partition("': ") | 310 | name, quote, rest = tail.partition("': ") |
| 300 | if not head.startswith("Invalid ") or not quote or "'" in name: | 311 | if not head.startswith("Invalid ") or not quote or "'" in name: |
| 301 | return None | 312 | return None |
| 313 | # The coercion names the Python field (plus any ``[index]`` suffix); the | ||
| 314 | # config key may differ when the field is aliased, so prefer the key | ||
| 315 | # pydantic reports unless the message carries an item suffix. | ||
| 316 | key = str(loc[-1]) | ||
| 317 | if not name.startswith(key): | ||
| 318 | name = key | ||
| 302 | dotted = ".".join([*(str(part) for part in loc[:-1]), name]) | 319 | dotted = ".".join([*(str(part) for part in loc[:-1]), name]) |
| 303 | return f"{head} for '{dotted}': {rest}" | 320 | return f"{head} for '{dotted}': {rest}" |
| 304 | 321 | ||
| 305 | 322 |
| 58 | from dataclasses import fields, is_dataclass | 58 | from dataclasses import fields, is_dataclass |
| 59 | from importlib import resources | 59 | from importlib import resources |
| 60 | from pathlib import Path | 60 | from pathlib import Path |
| 61 | from types import UnionType | 61 | from types import UnionType |
| 62 | from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints | 62 | from typing import ( |
| 63 | Annotated, | ||
| 64 | Any, | ||
| 65 | Literal, | ||
| 66 | TypeVar, | ||
| 67 | Union, | ||
| 68 | get_args, | ||
| 69 | get_origin, | ||
| 70 | get_type_hints, | ||
| 71 | ) | ||
| 63 | 72 | ||
| 64 | logger = logging.getLogger(__name__) | 73 | logger = logging.getLogger(__name__) |
| 65 | 74 | ||
| 66 | _PYDANTIC_EXPORTS = frozenset( | 75 | _PYDANTIC_EXPORTS = frozenset( |
| 362 | error_cls: *value* is not valid for *declared*. | 371 | error_cls: *value* is not valid for *declared*. |
| 363 | """ | 372 | """ |
| 364 | if isinstance(declared, str): | 373 | if isinstance(declared, str): |
| 365 | declared = _SCALAR_ALIASES.get(declared, declared) | 374 | declared = _SCALAR_ALIASES.get(declared, declared) |
| 375 | # pydantic strips only the outermost ``Annotated`` from a field annotation; | ||
| 376 | # one nested in ``Optional[...]``/``list[...]``/``tuple[...]`` still carries | ||
| 377 | # its metadata here and must be unwrapped so the matrix applies to it. | ||
| 378 | while get_origin(declared) is Annotated: | ||
| 379 | declared = get_args(declared)[0] | ||
| 366 | origin = get_origin(declared) | 380 | origin = get_origin(declared) |
| 367 | if origin is Literal: | 381 | if origin is Literal: |
| 368 | return _coerce_literal(name, value, get_args(declared), error_cls=error_cls) | 382 | return _coerce_literal(name, value, get_args(declared), error_cls=error_cls) |
| 369 | if origin is Union or origin is UnionType: | 383 | if origin is Union or origin is UnionType: |
| 7 | import json | 7 | import json |
| 8 | import math | 8 | import math |
| 9 | import warnings | 9 | import warnings |
| 10 | from pathlib import Path | 10 | from pathlib import Path |
| 11 | from typing import Any, Literal | 11 | from typing import Annotated, Any, Literal |
| 12 | from unittest.mock import patch | 12 | from unittest.mock import patch |
| 13 | 13 | ||
| 14 | import pydantic | 14 | import pydantic |
| 15 | import pytest | 15 | import pytest |
| 622 | config_loader.validate_config(Pair, {"nope": 1}, context="pair config") | 622 | config_loader.validate_config(Pair, {"nope": 1}, context="pair config") |
| 623 | assert str(excinfo.value) == ( | 623 | assert str(excinfo.value) == ( |
| 624 | "Unknown pair config key(s): nope. Allowed keys: alpha, beta" | 624 | "Unknown pair config key(s): nope. Allowed keys: alpha, beta" |
| 625 | ) | 625 | ) |
| 626 | |||
| 627 | |||
| 628 | def test_annotated_inside_optional_and_sequences_uses_fleet_matrix() -> None: | ||
| 629 | """``Annotated`` nested in Optional/list/tuple still gets the coercion matrix.""" | ||
| 630 | |||
| 631 | class Nested(config_loader.ConfigModel): | ||
| 632 | maybe: Annotated[int, pydantic.Field(ge=0)] | None = None | ||
| 633 | items: list[Annotated[float, pydantic.Field(gt=0)]] = [1.0] | ||
| 634 | pair: tuple[Annotated[int, pydantic.Field(ge=0)], Annotated[int, pydantic.Field(ge=0)]] = ( | ||
| 635 | 1, | ||
| 636 | 2, | ||
| 637 | ) | ||
| 638 | |||
| 639 | assert Nested(maybe="1e3").maybe == 1000 | ||
| 640 | assert Nested(items=["2.5"]).items == [2.5] | ||
| 641 | assert Nested(pair=[3.0, "4"]).pair == (3, 4) | ||
| 642 | for bad in ({"maybe": True}, {"items": [True]}, {"pair": [True, 1]}): | ||
| 643 | with pytest.raises(pydantic.ValidationError): | ||
| 644 | Nested(**bad) | ||
| 645 | with pytest.raises(pydantic.ValidationError): | ||
| 646 | Nested(maybe=-1) | ||
| 647 | |||
| 648 | |||
| 649 | def test_validate_config_rejects_non_mapping_input() -> None: | ||
| 650 | """A non-mapping raw config raises the config error, not a bare TypeError.""" | ||
| 651 | |||
| 652 | class Simple(config_loader.ConfigModel): | ||
| 653 | count: int = 1 | ||
| 654 | |||
| 655 | for raw in (None, "count", [("count", 1)]): | ||
| 656 | with pytest.raises(config_loader.ConfigError, match="must be a mapping"): | ||
| 657 | config_loader.validate_config(Simple, raw, context="simple config") # type: ignore[arg-type] | ||
| 658 | |||
| 659 | |||
| 660 | def test_load_config_rejects_non_mapping_overrides(tmp_path: Path) -> None: | ||
| 661 | """String overrides (an unparsed --set) raise the config error.""" | ||
| 662 | |||
| 663 | class Simple(config_loader.ConfigModel): | ||
| 664 | count: int = 1 | ||
| 665 | |||
| 666 | config_file = tmp_path / "simple.json" | ||
| 667 | config_file.write_text(json.dumps({"count": 2}), encoding="utf-8") | ||
| 668 | with pytest.raises(config_loader.ConfigError, match="overrides must be a mapping"): | ||
| 669 | config_loader.load_config( | ||
| 670 | Simple, | ||
| 671 | package="iolabs.common", | ||
| 672 | filename="unused.json", | ||
| 673 | overrides="count=3", # type: ignore[arg-type] | ||
| 674 | config_path=config_file, | ||
| 675 | context="simple config", | ||
| 676 | ) | ||
| 677 | |||
| 678 | |||
| 679 | def test_value_error_names_the_alias_of_an_aliased_field() -> None: | ||
| 680 | """Coercion errors name the config key (alias), matching unknown-key messages.""" | ||
| 681 | |||
| 682 | class Aliased(config_loader.ConfigModel): | ||
| 683 | internal_name: int = pydantic.Field(default=1, alias="external-name") | ||
| 684 | |||
| 685 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 686 | config_loader.validate_config( | ||
| 687 | Aliased, {"external-name": "abc"}, context="aliased config" | ||
| 688 | ) | ||
| 689 | assert str(excinfo.value).startswith("Invalid int for 'external-name': 'abc'") |
| 44 | context="foo config", | 44 | context="foo config", |
| 45 | error_cls=FooConfigError, | 45 | error_cls=FooConfigError, |
| 46 | ).model_dump() | 46 | ).model_dump() |
| 47 | 47 | ||
| 48 | Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass | ||
| 49 | section is coerced, but its errors do not carry the section path. Writers that | ||
| 50 | serialise a config (run stats, provenance) should use ``model_dump(mode="json")`` | ||
| 51 | so tuples, paths and enums become JSON-native values. | ||
| 52 | |||
| 48 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as | 53 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as |
| 49 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, | 54 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, |
| 50 | so no field can be rebound after construction. Freezing is shallow, as in | 55 | so no field can be rebound after construction. Freezing is shallow, as in |
| 51 | pydantic itself: a ``list``-valued field is still a mutable list, so prefer | 56 | pydantic itself: a ``list``-valued field is still a mutable list, so prefer |
| 147 | Raises: | 152 | Raises: |
| 148 | error_cls: *raw* holds an unknown key, misses a required key, or holds | 153 | 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. | 154 | a value that is not valid for its declared field type. |
| 150 | """ | 155 | """ |
| 156 | if not isinstance(raw, Mapping): | ||
| 157 | raise error_cls(f"{context} must be a mapping, got {type(raw).__name__}") | ||
| 151 | try: | 158 | try: |
| 152 | return model_cls.model_validate(dict(raw)) | 159 | return model_cls.model_validate(dict(raw)) |
| 153 | except pydantic.ValidationError as exc: | 160 | except pydantic.ValidationError as exc: |
| 154 | raise error_cls( | 161 | raise error_cls( |
| 197 | logger.debug("Loaded %s from %s", context, config_path) | 204 | logger.debug("Loaded %s from %s", context, config_path) |
| 198 | else: | 205 | else: |
| 199 | raw = _load_packaged(package, filename, error_cls=error_cls) | 206 | raw = _load_packaged(package, filename, error_cls=error_cls) |
| 200 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) | 207 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) |
| 208 | if overrides is not None and not isinstance(overrides, Mapping): | ||
| 209 | raise error_cls( | ||
| 210 | f"{context} overrides must be a mapping, got {type(overrides).__name__}" | ||
| 211 | ) | ||
| 201 | if overrides: | 212 | if overrides: |
| 202 | raw = config_loader.deep_merge_dicts(raw, dict(overrides)) | 213 | raw = config_loader.deep_merge_dicts(raw, dict(overrides)) |
| 203 | return validate_config(model_cls, raw, context=context, error_cls=error_cls) | 214 | return validate_config(model_cls, raw, context=context, error_cls=error_cls) |
| 204 | 215 |
| 298 | head, _, tail = message.partition(" for '") | 309 | head, _, tail = message.partition(" for '") |
| 299 | name, quote, rest = tail.partition("': ") | 310 | name, quote, rest = tail.partition("': ") |
| 300 | if not head.startswith("Invalid ") or not quote or "'" in name: | 311 | if not head.startswith("Invalid ") or not quote or "'" in name: |
| 301 | return None | 312 | return None |
| 313 | # The coercion names the Python field (plus any ``[index]`` suffix); the | ||
| 314 | # config key may differ when the field is aliased, so prefer the key | ||
| 315 | # pydantic reports unless the message carries an item suffix. | ||
| 316 | key = str(loc[-1]) | ||
| 317 | if not name.startswith(key): | ||
| 318 | name = key | ||
| 302 | dotted = ".".join([*(str(part) for part in loc[:-1]), name]) | 319 | dotted = ".".join([*(str(part) for part in loc[:-1]), name]) |
| 303 | return f"{head} for '{dotted}': {rest}" | 320 | return f"{head} for '{dotted}': {rest}" |
| 304 | 321 | ||
| 305 | 322 |
| 7 | import json | 7 | import json |
| 8 | import math | 8 | import math |
| 9 | import warnings | 9 | import warnings |
| 10 | from pathlib import Path | 10 | from pathlib import Path |
| 11 | from typing import Any, Literal | 11 | from typing import Annotated, Any, Literal |
| 12 | from unittest.mock import patch | 12 | from unittest.mock import patch |
| 13 | 13 | ||
| 14 | import pydantic | 14 | import pydantic |
| 15 | import pytest | 15 | import pytest |
| 622 | config_loader.validate_config(Pair, {"nope": 1}, context="pair config") | 622 | config_loader.validate_config(Pair, {"nope": 1}, context="pair config") |
| 623 | assert str(excinfo.value) == ( | 623 | assert str(excinfo.value) == ( |
| 624 | "Unknown pair config key(s): nope. Allowed keys: alpha, beta" | 624 | "Unknown pair config key(s): nope. Allowed keys: alpha, beta" |
| 625 | ) | 625 | ) |
| 626 | |||
| 627 | |||
| 628 | def test_annotated_inside_optional_and_sequences_uses_fleet_matrix() -> None: | ||
| 629 | """``Annotated`` nested in Optional/list/tuple still gets the coercion matrix.""" | ||
| 630 | |||
| 631 | class Nested(config_loader.ConfigModel): | ||
| 632 | maybe: Annotated[int, pydantic.Field(ge=0)] | None = None | ||
| 633 | items: list[Annotated[float, pydantic.Field(gt=0)]] = [1.0] | ||
| 634 | pair: tuple[Annotated[int, pydantic.Field(ge=0)], Annotated[int, pydantic.Field(ge=0)]] = ( | ||
| 635 | 1, | ||
| 636 | 2, | ||
| 637 | ) | ||
| 638 | |||
| 639 | assert Nested(maybe="1e3").maybe == 1000 | ||
| 640 | assert Nested(items=["2.5"]).items == [2.5] | ||
| 641 | assert Nested(pair=[3.0, "4"]).pair == (3, 4) | ||
| 642 | for bad in ({"maybe": True}, {"items": [True]}, {"pair": [True, 1]}): | ||
| 643 | with pytest.raises(pydantic.ValidationError): | ||
| 644 | Nested(**bad) | ||
| 645 | with pytest.raises(pydantic.ValidationError): | ||
| 646 | Nested(maybe=-1) | ||
| 647 | |||
| 648 | |||
| 649 | def test_validate_config_rejects_non_mapping_input() -> None: | ||
| 650 | """A non-mapping raw config raises the config error, not a bare TypeError.""" | ||
| 651 | |||
| 652 | class Simple(config_loader.ConfigModel): | ||
| 653 | count: int = 1 | ||
| 654 | |||
| 655 | for raw in (None, "count", [("count", 1)]): | ||
| 656 | with pytest.raises(config_loader.ConfigError, match="must be a mapping"): | ||
| 657 | config_loader.validate_config(Simple, raw, context="simple config") # type: ignore[arg-type] | ||
| 658 | |||
| 659 | |||
| 660 | def test_load_config_rejects_non_mapping_overrides(tmp_path: Path) -> None: | ||
| 661 | """String overrides (an unparsed --set) raise the config error.""" | ||
| 662 | |||
| 663 | class Simple(config_loader.ConfigModel): | ||
| 664 | count: int = 1 | ||
| 665 | |||
| 666 | config_file = tmp_path / "simple.json" | ||
| 667 | config_file.write_text(json.dumps({"count": 2}), encoding="utf-8") | ||
| 668 | with pytest.raises(config_loader.ConfigError, match="overrides must be a mapping"): | ||
| 669 | config_loader.load_config( | ||
| 670 | Simple, | ||
| 671 | package="iolabs.common", | ||
| 672 | filename="unused.json", | ||
| 673 | overrides="count=3", # type: ignore[arg-type] | ||
| 674 | config_path=config_file, | ||
| 675 | context="simple config", | ||
| 676 | ) | ||
| 677 | |||
| 678 | |||
| 679 | def test_value_error_names_the_alias_of_an_aliased_field() -> None: | ||
| 680 | """Coercion errors name the config key (alias), matching unknown-key messages.""" | ||
| 681 | |||
| 682 | class Aliased(config_loader.ConfigModel): | ||
| 683 | internal_name: int = pydantic.Field(default=1, alias="external-name") | ||
| 684 | |||
| 685 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 686 | config_loader.validate_config( | ||
| 687 | Aliased, {"external-name": "abc"}, context="aliased config" | ||
| 688 | ) | ||
| 689 | assert str(excinfo.value).startswith("Invalid int for 'external-name': 'abc'") |
Annotated[...]insideOptional/sequences is unwrapped before coercion (real hit: visualizationoverlays RGBA tuples were bypassing the matrix).