Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:37:11+02:00
Commit #5 ยท 20 snippets
README.md | 50 ++++++++++++++++++----- src/iolabs/common/config_loader.py | 40 +++++++++++++++---- src/iolabs/common/config_model.py | 81 ++++++++++++++++++++++++++++++++------ tests/test_config_model.py | 43 ++++++++++++++++++-- 4 files changed, 183 insertions(+), 31 deletions(-)
| 9 | pydantic itself. | 9 | pydantic itself. |
| 10 | 10 | ||
| 11 | Canonical package pattern:: | 11 | Canonical package pattern:: |
| 12 | 12 | ||
| 13 | import logging | ||
| 13 | from collections.abc import Mapping | 14 | from collections.abc import Mapping |
| 14 | from pathlib import Path | 15 | from pathlib import Path |
| 15 | from typing import Any, Literal | 16 | from typing import Any, Literal |
| 16 | 17 | ||
| 17 | from iolabs.common import config_loader | 18 | from iolabs.common import config_loader |
| 18 | 19 | ||
| 20 | logger = logging.getLogger(__name__) | ||
| 21 | |||
| 22 | _PACKAGE_NAME = "iolabs_foo" | ||
| 23 | _DEFAULT_FILENAME = "foo.default.json" | ||
| 24 | _CONTEXT = "foo config" | ||
| 25 | |||
| 19 | 26 | ||
| 20 | class FooGroundConfig(config_loader.ConfigModel): | 27 | class FooGroundConfig(config_loader.ConfigModel): |
| 21 | cell_m: float = 0.5 | 28 | cell_m: float = 0.5 |
| 22 | enabled: bool = True | 29 | enabled: bool = True |
| 27 | ground: FooGroundConfig = FooGroundConfig() | 34 | ground: FooGroundConfig = FooGroundConfig() |
| 28 | 35 | ||
| 29 | 36 | ||
| 30 | class FooConfigError(config_loader.ConfigError): | 37 | class FooConfigError(config_loader.ConfigError): |
| 31 | \"\"\"Raised for an invalid foo config.\"\"\" | 38 | \"\"\"Raised when foo config contains unsupported keys or values.\"\"\" |
| 32 | 39 | ||
| 33 | 40 | ||
| 34 | def build_foo_config( | 41 | def build_foo_config( |
| 42 | *, | ||
| 35 | overrides: Mapping[str, Any] | None = None, | 43 | overrides: Mapping[str, Any] | None = None, |
| 36 | config_path: str | Path | None = None, | 44 | config_path: str | Path | None = None, |
| 37 | ) -> dict[str, Any]: | 45 | ) -> dict[str, Any]: |
| 38 | return config_loader.load_config( | 46 | return config_loader.load_config( |
| 39 | FooConfig, | 47 | FooConfig, |
| 40 | package="iolabs_foo", | 48 | package=_PACKAGE_NAME, |
| 41 | filename="default_config.json", | 49 | filename=_DEFAULT_FILENAME, |
| 42 | overrides=overrides, | 50 | overrides=overrides, |
| 43 | config_path=config_path, | 51 | config_path=config_path, |
| 44 | context="foo config", | 52 | context=_CONTEXT, |
| 45 | error_cls=FooConfigError, | 53 | error_cls=FooConfigError, |
| 46 | ).model_dump() | 54 | ).model_dump() |
| 47 | 55 | ||
| 48 | Aliased fields must be dumped with ``model_dump(by_alias=True)``, so the result | 56 | Naming is part of the pattern. The root model is ``<Name>Config`` and every |
| 49 | stays keyed by the config-file keys rather than the Python field names. | 57 | nested section is ``<Name><Section>Config`` (``FooGroundConfig``, never a bare |
| 58 | ``GroundConfig``): same-named models collide in the process-global registry that | ||
| 59 | resolves the ``Allowed keys: ...`` hints, and a lanefinder run imports many | ||
| 60 | packages into one process. Each package declares exactly one error class, | ||
| 61 | ``<Name>ConfigError``, and passes it as ``error_cls=`` to every call in this | ||
| 62 | module. The three module constants above replace inline ``package=`` / | ||
| 63 | ``filename=`` / ``context=`` literals, and ``_CONTEXT`` names the package | ||
| 64 | (``"mask clustering config"``, not ``"config"``). Entry points take their | ||
| 65 | ``overrides`` and ``config_path`` arguments keyword-only. | ||
| 66 | |||
| 67 | Do not use field aliases: the config-file key is the Python field name. The | ||
| 68 | ``alias`` / `pydantic.AliasChoices` handling in this module -- and the | ||
| 69 | ``model_dump(by_alias=True)`` that an aliased model would need in order to stay | ||
| 70 | keyed by config-file keys -- is a documented escape hatch, not fleet practice. | ||
| 50 | 71 | ||
| 51 | Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass | 72 | Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass |
| 52 | section is coerced, but its errors do not carry the section path. Writers that | 73 | section is coerced, but its errors do not carry the section path. Writers that |
| 53 | serialise a config (run stats, provenance) should use ``model_dump(mode="json")`` | 74 | serialise a config (run stats, provenance) should use ``model_dump(mode="json")`` |
| 54 | so tuples, paths and enums become JSON-native values. | 75 | so tuples, paths and enums become JSON-native values. |
| 55 | 76 | ||
| 77 | A user-supplied JSON override file is read with | ||
| 78 | :func:`iolabs.common.config_loader.load_json_overrides`, which raises the | ||
| 79 | package error class -- never with a hand-rolled reader. | ||
| 80 | |||
| 56 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as | 81 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as |
| 57 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, | 82 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, |
| 58 | so no field can be rebound after construction. Freezing is shallow, as in | 83 | so no field can be rebound after construction. Freezing is shallow, as in |
| 59 | pydantic itself: a ``list``-valued field is still a mutable list, so prefer | 84 | pydantic itself: a ``list``-valued field is still a mutable list, so sequence |
| 60 | ``tuple`` for sequence fields that must not change. | 85 | fields are declared ``tuple[...]``. That is a hard rule for a package whose |
| 86 | entry points return the model; ``list`` is acceptable only where the entry point | ||
| 87 | returns a plain dict that the caller is meant to mutate. | ||
| 88 | |||
| 89 | Package tests live in ``tests/test_config.py`` under fixed names: | ||
| 90 | ``test_model_defaults_match_packaged_json`` (whole-dict parity between | ||
| 91 | ``<X>Config().model_dump()`` and the packaged JSON), | ||
| 92 | ``test_load_<x>_config_returns_packaged_defaults``, | ||
| 93 | ``test_error_class_is_config_error``, ``test_unknown_top_level_key_is_rejected``, | ||
| 94 | ``test_unknown_nested_key_is_rejected``, ``test_overrides_deep_merge_onto_defaults`` | ||
| 95 | and ``test_set_override_coercion_and_rejection``. | ||
| 61 | """ | 96 | """ |
| 62 | 97 | ||
| 63 | from __future__ import annotations | 98 | from __future__ import annotations |
| 64 | 99 |
Sanctioned reader for user-supplied JSON override files; raises the package error class.
| 418 | ) | 453 | ) |
| 419 | return loaded | 454 | return loaded |
| 420 | 455 | ||
| 421 | 456 | ||
| 422 | def _load_json_file(path: Path, *, error_cls: type[ValueError]) -> dict[str, Any]: | 457 | def load_json_overrides( |
| 423 | """Load a JSON config file, wrapping decode errors in *error_cls*.""" | 458 | path: str | Path, |
| 459 | *, | ||
| 460 | error_cls: type[ValueError] = config_loader.ConfigError, | ||
| 461 | ) -> dict[str, Any]: | ||
| 462 | """Read a user-supplied JSON config/override file as a plain mapping. | ||
| 463 | |||
| 464 | This is the one sanctioned way for a package to read an override file the | ||
| 465 | user passed on the command line; the result is normally handed to | ||
| 466 | :func:`load_config` as ``overrides=`` (merge semantics) or used directly. | ||
| 467 | Packages must not hand-roll their own reader. | ||
| 468 | |||
| 469 | Args: | ||
| 470 | path: Path to the JSON file. | ||
| 471 | error_cls: Exception class raised for malformed JSON or a non-object | ||
| 472 | top level. Defaults to `iolabs.common.config_loader.ConfigError`; | ||
| 473 | packages pass their own ``<Name>ConfigError``. | ||
| 474 | |||
| 475 | Returns: | ||
| 476 | The decoded JSON object as a dict. | ||
| 477 | |||
| 478 | Raises: | ||
| 479 | error_cls: The file is not valid JSON, or does not hold a JSON object. | ||
| 480 | OSError: The file could not be read. | ||
| 481 | """ | ||
| 482 | path = Path(path) | ||
| 424 | try: | 483 | try: |
| 425 | with path.open("r", encoding="utf-8") as handle: | 484 | with path.open("r", encoding="utf-8") as handle: |
| 426 | loaded = json.load(handle) | 485 | loaded = json.load(handle) |
| 427 | except json.JSONDecodeError as exc: | 486 | except json.JSONDecodeError as exc: |
| 202 | error_cls: The JSON is malformed, or the merged config is invalid. | 237 | error_cls: The JSON is malformed, or the merged config is invalid. |
| 203 | OSError: The config file could not be read. | 238 | OSError: The config file could not be read. |
| 204 | """ | 239 | """ |
| 205 | if config_path is not None: | 240 | if config_path is not None: |
| 206 | raw = _load_json_file(Path(config_path), error_cls=error_cls) | 241 | raw = load_json_overrides(config_path, error_cls=error_cls) |
| 207 | logger.debug("Loaded %s from %s", context, config_path) | 242 | logger.debug("Loaded %s from %s", context, config_path) |
| 208 | else: | 243 | else: |
| 209 | raw = _load_packaged(package, filename, error_cls=error_cls) | 244 | raw = _load_packaged(package, filename, error_cls=error_cls) |
| 210 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) | 245 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) |
| 6 | 6 | ||
| 7 | The current way to declare a config is a pydantic model derived from | 7 | The current way to declare a config is a pydantic model derived from |
| 8 | `ConfigModel`, validated by :func:`load_config`; both are defined in | 8 | `ConfigModel`, validated by :func:`load_config`; both are defined in |
| 9 | :mod:`iolabs.common.config_model` and re-exported here, together with | 9 | :mod:`iolabs.common.config_model` and re-exported here, together with |
| 10 | :func:`validate_config` and :func:`format_validation_error`. The canonical | 10 | :func:`validate_config`, :func:`format_validation_error` and |
| 11 | package pattern is:: | 11 | :func:`load_json_overrides`. The canonical package pattern is:: |
| 12 | 12 | ||
| 13 | import logging | ||
| 14 | from collections.abc import Mapping | ||
| 15 | from pathlib import Path | ||
| 13 | from typing import Any, Literal | 16 | from typing import Any, Literal |
| 14 | 17 | ||
| 15 | from iolabs.common import config_loader | 18 | from iolabs.common import config_loader |
| 16 | 19 | ||
| 20 | logger = logging.getLogger(__name__) | ||
| 21 | |||
| 22 | _PACKAGE_NAME = "iolabs_foo" | ||
| 23 | _DEFAULT_FILENAME = "foo.default.json" | ||
| 24 | _CONTEXT = "foo config" | ||
| 25 | |||
| 17 | 26 | ||
| 18 | class FooGroundConfig(config_loader.ConfigModel): | 27 | class FooGroundConfig(config_loader.ConfigModel): |
| 19 | cell_m: float = 0.5 | 28 | cell_m: float = 0.5 |
| 20 | 29 |
| 24 | ground: FooGroundConfig = FooGroundConfig() | 33 | ground: FooGroundConfig = FooGroundConfig() |
| 25 | 34 | ||
| 26 | 35 | ||
| 27 | class FooConfigError(config_loader.ConfigError): | 36 | class FooConfigError(config_loader.ConfigError): |
| 28 | \"\"\"Raised for an invalid foo config.\"\"\" | 37 | \"\"\"Raised when foo config contains unsupported keys or values.\"\"\" |
| 29 | 38 | ||
| 30 | 39 | ||
| 31 | def build_foo_config(overrides=None, config_path=None) -> dict[str, Any]: | 40 | def build_foo_config( |
| 41 | *, | ||
| 42 | overrides: Mapping[str, Any] | None = None, | ||
| 43 | config_path: str | Path | None = None, | ||
| 44 | ) -> dict[str, Any]: | ||
| 32 | return config_loader.load_config( | 45 | return config_loader.load_config( |
| 33 | FooConfig, | 46 | FooConfig, |
| 34 | package="iolabs_foo", | 47 | package=_PACKAGE_NAME, |
| 35 | filename="default_config.json", | 48 | filename=_DEFAULT_FILENAME, |
| 36 | overrides=overrides, | 49 | overrides=overrides, |
| 37 | config_path=config_path, | 50 | config_path=config_path, |
| 38 | context="foo config", | 51 | context=_CONTEXT, |
| 39 | error_cls=FooConfigError, | 52 | error_cls=FooConfigError, |
| 40 | ).model_dump() | 53 | ).model_dump() |
| 41 | 54 | ||
| 55 | The naming rules (``<Name>Config`` root, ``<Name><Section>Config`` sections, one | ||
| 56 | ``<Name>ConfigError`` per package), the keyword-only entry-point signature, the | ||
| 57 | "no field aliases" rule and the canonical package test names are spelled out in | ||
| 58 | :mod:`iolabs.common.config_model`. | ||
| 59 | |||
| 42 | `ConfigError`, `default_config_path`, `load_packaged_json`, `deep_merge_dicts` | 60 | `ConfigError`, `default_config_path`, `load_packaged_json`, `deep_merge_dicts` |
| 43 | and `parse_set_overrides` stay first-class. The hand-rolled dataclass helpers | 61 | and `parse_set_overrides` stay first-class. The hand-rolled dataclass helpers |
| 44 | (`validate_allowed_keys`, `coerce_to_field_type`, `dataclass_from_mapping`, | 62 | (`validate_allowed_keys`, `coerce_to_field_type`, `dataclass_from_mapping`, |
| 45 | `validate_against_defaults`) still work for published leaf wheels but emit a | 63 | `validate_against_defaults`) still work for published leaf wheels but emit a |
| 68 | get_type_hints, | 86 | get_type_hints, |
| 69 | ) | 87 | ) |
| 70 | 88 | ||
| 71 | _PYDANTIC_EXPORTS = frozenset( | 89 | _PYDANTIC_EXPORTS = frozenset( |
| 72 | {"ConfigModel", "load_config", "validate_config", "format_validation_error"} | 90 | { |
| 91 | "ConfigModel", | ||
| 92 | "load_config", | ||
| 93 | "validate_config", | ||
| 94 | "format_validation_error", | ||
| 95 | "load_json_overrides", | ||
| 96 | } | ||
| 73 | ) | 97 | ) |
| 74 | 98 | ||
| 75 | 99 | ||
| 76 | class ConfigError(ValueError): | 100 | class ConfigError(ValueError): |
| 570 | 570 | ||
| 571 | def test_lazy_reexports_are_visible_to_dir() -> None: | 571 | def test_lazy_reexports_are_visible_to_dir() -> None: |
| 572 | """The lazily re-exported pydantic names show up in dir(config_loader).""" | 572 | """The lazily re-exported pydantic names show up in dir(config_loader).""" |
| 573 | names = dir(config_loader) | 573 | names = dir(config_loader) |
| 574 | assert {"ConfigModel", "load_config", "validate_config", "format_validation_error"} <= set( | 574 | assert { |
| 575 | names | 575 | "ConfigModel", |
| 576 | ) | 576 | "load_config", |
| 577 | "validate_config", | ||
| 578 | "format_validation_error", | ||
| 579 | "load_json_overrides", | ||
| 580 | } <= set(names) | ||
| 577 | assert names == sorted(names) | 581 | assert names == sorted(names) |
| 578 | 582 | ||
| 579 | 583 | ||
| 580 | def test_coerce_config_value_does_not_warn() -> None: | 584 | def test_coerce_config_value_does_not_warn() -> None: |
| 699 | 703 | ||
| 700 | with pytest.raises(config_loader.ConfigError) as excinfo: | 704 | with pytest.raises(config_loader.ConfigError) as excinfo: |
| 701 | config_model.validate_config(ChoicesConfig, {"bogus": 1}, context="cfg") | 705 | config_model.validate_config(ChoicesConfig, {"bogus": 1}, context="cfg") |
| 702 | assert "Allowed keys: count, n-points" in str(excinfo.value) | 706 | assert "Allowed keys: count, n-points" in str(excinfo.value) |
| 707 | |||
| 708 | |||
| 709 | def test_load_json_overrides_reads_a_json_object(tmp_path: Path) -> None: | ||
| 710 | """load_json_overrides returns the decoded object of a user config file.""" | ||
| 711 | path = tmp_path / "overrides.json" | ||
| 712 | path.write_text(json.dumps({"mode": "exact", "ground": {"cell_m": 0.25}}), encoding="utf-8") | ||
| 713 | assert config_loader.load_json_overrides(path) == { | ||
| 714 | "mode": "exact", | ||
| 715 | "ground": {"cell_m": 0.25}, | ||
| 716 | } | ||
| 717 | |||
| 718 | |||
| 719 | def test_load_json_overrides_accepts_a_string_path(tmp_path: Path) -> None: | ||
| 720 | """A ``str`` path is accepted, like every other config_path argument.""" | ||
| 721 | path = tmp_path / "overrides.json" | ||
| 722 | path.write_text('{"mode": "fast"}', encoding="utf-8") | ||
| 723 | assert config_loader.load_json_overrides(str(path)) == {"mode": "fast"} | ||
| 724 | |||
| 725 | |||
| 726 | def test_load_json_overrides_rejects_malformed_json(tmp_path: Path) -> None: | ||
| 727 | """Malformed JSON is reported through the caller's error class.""" | ||
| 728 | path = tmp_path / "overrides.json" | ||
| 729 | path.write_text("{not json", encoding="utf-8") | ||
| 730 | with pytest.raises(SampleConfigError, match="Invalid JSON in config file"): | ||
| 731 | config_loader.load_json_overrides(path, error_cls=SampleConfigError) | ||
| 732 | |||
| 733 | |||
| 734 | def test_load_json_overrides_rejects_non_object_top_level(tmp_path: Path) -> None: | ||
| 735 | """A JSON array is not a config mapping.""" | ||
| 736 | path = tmp_path / "overrides.json" | ||
| 737 | path.write_text("[1, 2]", encoding="utf-8") | ||
| 738 | with pytest.raises(SampleConfigError, match="must hold a JSON object"): | ||
| 739 | config_loader.load_json_overrides(path, error_cls=SampleConfigError) |
| 20 | fleet coercion matrix for `bool`/`int`/`float`/`str`) and are built with | 20 | fleet coercion matrix for `bool`/`int`/`float`/`str`) and are built with |
| 21 | `config_loader.load_config(...)`: | 21 | `config_loader.load_config(...)`: |
| 22 | 22 | ||
| 23 | ```python | 23 | ```python |
| 24 | import logging | ||
| 24 | from collections.abc import Mapping | 25 | from collections.abc import Mapping |
| 25 | from pathlib import Path | 26 | from pathlib import Path |
| 26 | from typing import Any, Literal | 27 | from typing import Any, Literal |
| 27 | 28 | ||
| 28 | from iolabs.common import config_loader | 29 | from iolabs.common import config_loader |
| 29 | 30 | ||
| 31 | logger = logging.getLogger(__name__) | ||
| 32 | |||
| 33 | _PACKAGE_NAME = "iolabs_foo" | ||
| 34 | _DEFAULT_FILENAME = "foo.default.json" | ||
| 35 | _CONTEXT = "foo config" | ||
| 36 | |||
| 30 | 37 | ||
| 31 | class FooGroundConfig(config_loader.ConfigModel): | 38 | class FooGroundConfig(config_loader.ConfigModel): |
| 32 | cell_m: float = 0.5 | 39 | cell_m: float = 0.5 |
| 33 | 40 |
| 37 | ground: FooGroundConfig = FooGroundConfig() | 44 | ground: FooGroundConfig = FooGroundConfig() |
| 38 | 45 | ||
| 39 | 46 | ||
| 40 | class FooConfigError(config_loader.ConfigError): | 47 | class FooConfigError(config_loader.ConfigError): |
| 41 | """Raised for an invalid foo config.""" | 48 | """Raised when foo config contains unsupported keys or values.""" |
| 42 | 49 | ||
| 43 | 50 | ||
| 44 | def build_foo_config( | 51 | def build_foo_config( |
| 52 | *, | ||
| 45 | overrides: Mapping[str, Any] | None = None, | 53 | overrides: Mapping[str, Any] | None = None, |
| 46 | config_path: str | Path | None = None, | 54 | config_path: str | Path | None = None, |
| 47 | ) -> dict[str, Any]: | 55 | ) -> dict[str, Any]: |
| 48 | return config_loader.load_config( | 56 | return config_loader.load_config( |
| 49 | FooConfig, | 57 | FooConfig, |
| 50 | package="iolabs_foo", | 58 | package=_PACKAGE_NAME, |
| 51 | filename="default_config.json", | 59 | filename=_DEFAULT_FILENAME, |
| 52 | overrides=overrides, | 60 | overrides=overrides, |
| 53 | config_path=config_path, | 61 | config_path=config_path, |
| 54 | context="foo config", | 62 | context=_CONTEXT, |
| 55 | error_cls=FooConfigError, | 63 | error_cls=FooConfigError, |
| 56 | ).model_dump() | 64 | ).model_dump() |
| 57 | ``` | 65 | ``` |
| 58 | 66 | ||
| 59 | A model whose fields carry aliases must dump with ``model_dump(by_alias=True)``, | 67 | Naming is part of the pattern: the root model is `<Name>Config`, nested sections are |
| 60 | otherwise the returned mapping is keyed by the Python field names and no longer | 68 | `<Name><Section>Config` (`FooGroundConfig`, never a bare `GroundConfig` โ same-named |
| 61 | round-trips through the config JSON. | 69 | models collide in the registry that resolves the `Allowed keys: ...` hints), and each |
| 70 | package declares exactly one `<Name>ConfigError` that is passed as `error_cls=` to every | ||
| 71 | shared-layer call. The three module constants replace inline `package=` / `filename=` / | ||
| 72 | `context=` literals; entry points take `overrides` and `config_path` keyword-only. | ||
| 73 | |||
| 74 | Do not use field aliases: the config-file key is the Python field name. The alias | ||
| 75 | handling (and the `model_dump(by_alias=True)` an aliased model would need to stay keyed | ||
| 76 | by config-file keys) is a documented escape hatch, not fleet practice. | ||
| 77 | |||
| 78 | A user-supplied JSON override file is read with | ||
| 79 | `config_loader.load_json_overrides(path, error_cls=FooConfigError)` โ packages must not | ||
| 80 | hand-roll their own reader. | ||
| 81 | |||
| 82 | **To add a config key: add the field (with its type, default and any `Field` range) to | ||
| 83 | the model and the same key with the same default to the packaged JSON โ nothing else.** | ||
| 84 | Unknown keys are rejected. Runtime overrides come from repeatable `--set KEY=VALUE`, | ||
| 85 | never repo-local JSON. | ||
| 62 | 86 | ||
| 63 | The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`, | 87 | The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`, |
| 64 | `dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf | 88 | `dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf |
| 65 | wheels but emit a `DeprecationWarning` (0.9.0). | 89 | wheels but emit a `DeprecationWarning` (0.9.0). |
| 66 | 90 | ||
| 67 | Models are frozen, but only shallowly (as in pydantic itself): use `tuple` rather | 91 | Models are frozen, but only shallowly (as in pydantic itself): sequence fields are |
| 68 | than `list` for sequence fields that must not be mutated after construction. | 92 | declared `tuple[...]`, not `list[...]`. That is a hard rule for a package whose entry |
| 93 | points return the model; `list` is acceptable only where the entry point returns a plain | ||
| 94 | dict the caller is meant to mutate. | ||
| 95 | |||
| 96 | Package tests live in `tests/test_config.py` under fixed names: | ||
| 97 | `test_model_defaults_match_packaged_json` (whole-dict parity with the packaged JSON), | ||
| 98 | `test_load_<x>_config_returns_packaged_defaults`, `test_error_class_is_config_error`, | ||
| 99 | `test_unknown_top_level_key_is_rejected`, `test_unknown_nested_key_is_rejected`, | ||
| 100 | `test_overrides_deep_merge_onto_defaults`, `test_set_override_coercion_and_rejection`. |
| 6 | 6 | ||
| 7 | The current way to declare a config is a pydantic model derived from | 7 | The current way to declare a config is a pydantic model derived from |
| 8 | `ConfigModel`, validated by :func:`load_config`; both are defined in | 8 | `ConfigModel`, validated by :func:`load_config`; both are defined in |
| 9 | :mod:`iolabs.common.config_model` and re-exported here, together with | 9 | :mod:`iolabs.common.config_model` and re-exported here, together with |
| 10 | :func:`validate_config` and :func:`format_validation_error`. The canonical | 10 | :func:`validate_config`, :func:`format_validation_error` and |
| 11 | package pattern is:: | 11 | :func:`load_json_overrides`. The canonical package pattern is:: |
| 12 | 12 | ||
| 13 | import logging | ||
| 14 | from collections.abc import Mapping | ||
| 15 | from pathlib import Path | ||
| 13 | from typing import Any, Literal | 16 | from typing import Any, Literal |
| 14 | 17 | ||
| 15 | from iolabs.common import config_loader | 18 | from iolabs.common import config_loader |
| 16 | 19 | ||
| 20 | logger = logging.getLogger(__name__) | ||
| 21 | |||
| 22 | _PACKAGE_NAME = "iolabs_foo" | ||
| 23 | _DEFAULT_FILENAME = "foo.default.json" | ||
| 24 | _CONTEXT = "foo config" | ||
| 25 | |||
| 17 | 26 | ||
| 18 | class FooGroundConfig(config_loader.ConfigModel): | 27 | class FooGroundConfig(config_loader.ConfigModel): |
| 19 | cell_m: float = 0.5 | 28 | cell_m: float = 0.5 |
| 20 | 29 |
| 24 | ground: FooGroundConfig = FooGroundConfig() | 33 | ground: FooGroundConfig = FooGroundConfig() |
| 25 | 34 | ||
| 26 | 35 | ||
| 27 | class FooConfigError(config_loader.ConfigError): | 36 | class FooConfigError(config_loader.ConfigError): |
| 28 | \"\"\"Raised for an invalid foo config.\"\"\" | 37 | \"\"\"Raised when foo config contains unsupported keys or values.\"\"\" |
| 29 | 38 | ||
| 30 | 39 | ||
| 31 | def build_foo_config(overrides=None, config_path=None) -> dict[str, Any]: | 40 | def build_foo_config( |
| 41 | *, | ||
| 42 | overrides: Mapping[str, Any] | None = None, | ||
| 43 | config_path: str | Path | None = None, | ||
| 44 | ) -> dict[str, Any]: | ||
| 32 | return config_loader.load_config( | 45 | return config_loader.load_config( |
| 33 | FooConfig, | 46 | FooConfig, |
| 34 | package="iolabs_foo", | 47 | package=_PACKAGE_NAME, |
| 35 | filename="default_config.json", | 48 | filename=_DEFAULT_FILENAME, |
| 36 | overrides=overrides, | 49 | overrides=overrides, |
| 37 | config_path=config_path, | 50 | config_path=config_path, |
| 38 | context="foo config", | 51 | context=_CONTEXT, |
| 39 | error_cls=FooConfigError, | 52 | error_cls=FooConfigError, |
| 40 | ).model_dump() | 53 | ).model_dump() |
| 41 | 54 | ||
| 55 | The naming rules (``<Name>Config`` root, ``<Name><Section>Config`` sections, one | ||
| 56 | ``<Name>ConfigError`` per package), the keyword-only entry-point signature, the | ||
| 57 | "no field aliases" rule and the canonical package test names are spelled out in | ||
| 58 | :mod:`iolabs.common.config_model`. | ||
| 59 | |||
| 42 | `ConfigError`, `default_config_path`, `load_packaged_json`, `deep_merge_dicts` | 60 | `ConfigError`, `default_config_path`, `load_packaged_json`, `deep_merge_dicts` |
| 43 | and `parse_set_overrides` stay first-class. The hand-rolled dataclass helpers | 61 | and `parse_set_overrides` stay first-class. The hand-rolled dataclass helpers |
| 44 | (`validate_allowed_keys`, `coerce_to_field_type`, `dataclass_from_mapping`, | 62 | (`validate_allowed_keys`, `coerce_to_field_type`, `dataclass_from_mapping`, |
| 45 | `validate_against_defaults`) still work for published leaf wheels but emit a | 63 | `validate_against_defaults`) still work for published leaf wheels but emit a |
| 68 | get_type_hints, | 86 | get_type_hints, |
| 69 | ) | 87 | ) |
| 70 | 88 | ||
| 71 | _PYDANTIC_EXPORTS = frozenset( | 89 | _PYDANTIC_EXPORTS = frozenset( |
| 72 | {"ConfigModel", "load_config", "validate_config", "format_validation_error"} | 90 | { |
| 91 | "ConfigModel", | ||
| 92 | "load_config", | ||
| 93 | "validate_config", | ||
| 94 | "format_validation_error", | ||
| 95 | "load_json_overrides", | ||
| 96 | } | ||
| 73 | ) | 97 | ) |
| 74 | 98 | ||
| 75 | 99 | ||
| 76 | class ConfigError(ValueError): | 100 | class ConfigError(ValueError): |
| 9 | pydantic itself. | 9 | pydantic itself. |
| 10 | 10 | ||
| 11 | Canonical package pattern:: | 11 | Canonical package pattern:: |
| 12 | 12 | ||
| 13 | import logging | ||
| 13 | from collections.abc import Mapping | 14 | from collections.abc import Mapping |
| 14 | from pathlib import Path | 15 | from pathlib import Path |
| 15 | from typing import Any, Literal | 16 | from typing import Any, Literal |
| 16 | 17 | ||
| 17 | from iolabs.common import config_loader | 18 | from iolabs.common import config_loader |
| 18 | 19 | ||
| 20 | logger = logging.getLogger(__name__) | ||
| 21 | |||
| 22 | _PACKAGE_NAME = "iolabs_foo" | ||
| 23 | _DEFAULT_FILENAME = "foo.default.json" | ||
| 24 | _CONTEXT = "foo config" | ||
| 25 | |||
| 19 | 26 | ||
| 20 | class FooGroundConfig(config_loader.ConfigModel): | 27 | class FooGroundConfig(config_loader.ConfigModel): |
| 21 | cell_m: float = 0.5 | 28 | cell_m: float = 0.5 |
| 22 | enabled: bool = True | 29 | enabled: bool = True |
| 27 | ground: FooGroundConfig = FooGroundConfig() | 34 | ground: FooGroundConfig = FooGroundConfig() |
| 28 | 35 | ||
| 29 | 36 | ||
| 30 | class FooConfigError(config_loader.ConfigError): | 37 | class FooConfigError(config_loader.ConfigError): |
| 31 | \"\"\"Raised for an invalid foo config.\"\"\" | 38 | \"\"\"Raised when foo config contains unsupported keys or values.\"\"\" |
| 32 | 39 | ||
| 33 | 40 | ||
| 34 | def build_foo_config( | 41 | def build_foo_config( |
| 42 | *, | ||
| 35 | overrides: Mapping[str, Any] | None = None, | 43 | overrides: Mapping[str, Any] | None = None, |
| 36 | config_path: str | Path | None = None, | 44 | config_path: str | Path | None = None, |
| 37 | ) -> dict[str, Any]: | 45 | ) -> dict[str, Any]: |
| 38 | return config_loader.load_config( | 46 | return config_loader.load_config( |
| 39 | FooConfig, | 47 | FooConfig, |
| 40 | package="iolabs_foo", | 48 | package=_PACKAGE_NAME, |
| 41 | filename="default_config.json", | 49 | filename=_DEFAULT_FILENAME, |
| 42 | overrides=overrides, | 50 | overrides=overrides, |
| 43 | config_path=config_path, | 51 | config_path=config_path, |
| 44 | context="foo config", | 52 | context=_CONTEXT, |
| 45 | error_cls=FooConfigError, | 53 | error_cls=FooConfigError, |
| 46 | ).model_dump() | 54 | ).model_dump() |
| 47 | 55 | ||
| 48 | Aliased fields must be dumped with ``model_dump(by_alias=True)``, so the result | 56 | Naming is part of the pattern. The root model is ``<Name>Config`` and every |
| 49 | stays keyed by the config-file keys rather than the Python field names. | 57 | nested section is ``<Name><Section>Config`` (``FooGroundConfig``, never a bare |
| 58 | ``GroundConfig``): same-named models collide in the process-global registry that | ||
| 59 | resolves the ``Allowed keys: ...`` hints, and a lanefinder run imports many | ||
| 60 | packages into one process. Each package declares exactly one error class, | ||
| 61 | ``<Name>ConfigError``, and passes it as ``error_cls=`` to every call in this | ||
| 62 | module. The three module constants above replace inline ``package=`` / | ||
| 63 | ``filename=`` / ``context=`` literals, and ``_CONTEXT`` names the package | ||
| 64 | (``"mask clustering config"``, not ``"config"``). Entry points take their | ||
| 65 | ``overrides`` and ``config_path`` arguments keyword-only. | ||
| 66 | |||
| 67 | Do not use field aliases: the config-file key is the Python field name. The | ||
| 68 | ``alias`` / `pydantic.AliasChoices` handling in this module -- and the | ||
| 69 | ``model_dump(by_alias=True)`` that an aliased model would need in order to stay | ||
| 70 | keyed by config-file keys -- is a documented escape hatch, not fleet practice. | ||
| 50 | 71 | ||
| 51 | Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass | 72 | Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass |
| 52 | section is coerced, but its errors do not carry the section path. Writers that | 73 | section is coerced, but its errors do not carry the section path. Writers that |
| 53 | serialise a config (run stats, provenance) should use ``model_dump(mode="json")`` | 74 | serialise a config (run stats, provenance) should use ``model_dump(mode="json")`` |
| 54 | so tuples, paths and enums become JSON-native values. | 75 | so tuples, paths and enums become JSON-native values. |
| 55 | 76 | ||
| 77 | A user-supplied JSON override file is read with | ||
| 78 | :func:`iolabs.common.config_loader.load_json_overrides`, which raises the | ||
| 79 | package error class -- never with a hand-rolled reader. | ||
| 80 | |||
| 56 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as | 81 | Unknown keys are rejected (``extra="forbid"``) with the same message shape as |
| 57 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, | 82 | :func:`iolabs.common.config_loader.validate_allowed_keys`; instances are frozen, |
| 58 | so no field can be rebound after construction. Freezing is shallow, as in | 83 | so no field can be rebound after construction. Freezing is shallow, as in |
| 59 | pydantic itself: a ``list``-valued field is still a mutable list, so prefer | 84 | pydantic itself: a ``list``-valued field is still a mutable list, so sequence |
| 60 | ``tuple`` for sequence fields that must not change. | 85 | fields are declared ``tuple[...]``. That is a hard rule for a package whose |
| 86 | entry points return the model; ``list`` is acceptable only where the entry point | ||
| 87 | returns a plain dict that the caller is meant to mutate. | ||
| 88 | |||
| 89 | Package tests live in ``tests/test_config.py`` under fixed names: | ||
| 90 | ``test_model_defaults_match_packaged_json`` (whole-dict parity between | ||
| 91 | ``<X>Config().model_dump()`` and the packaged JSON), | ||
| 92 | ``test_load_<x>_config_returns_packaged_defaults``, | ||
| 93 | ``test_error_class_is_config_error``, ``test_unknown_top_level_key_is_rejected``, | ||
| 94 | ``test_unknown_nested_key_is_rejected``, ``test_overrides_deep_merge_onto_defaults`` | ||
| 95 | and ``test_set_override_coercion_and_rejection``. | ||
| 61 | """ | 96 | """ |
| 62 | 97 | ||
| 63 | from __future__ import annotations | 98 | from __future__ import annotations |
| 64 | 99 |
| 202 | error_cls: The JSON is malformed, or the merged config is invalid. | 237 | error_cls: The JSON is malformed, or the merged config is invalid. |
| 203 | OSError: The config file could not be read. | 238 | OSError: The config file could not be read. |
| 204 | """ | 239 | """ |
| 205 | if config_path is not None: | 240 | if config_path is not None: |
| 206 | raw = _load_json_file(Path(config_path), error_cls=error_cls) | 241 | raw = load_json_overrides(config_path, error_cls=error_cls) |
| 207 | logger.debug("Loaded %s from %s", context, config_path) | 242 | logger.debug("Loaded %s from %s", context, config_path) |
| 208 | else: | 243 | else: |
| 209 | raw = _load_packaged(package, filename, error_cls=error_cls) | 244 | raw = _load_packaged(package, filename, error_cls=error_cls) |
| 210 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) | 245 | logger.debug("Loaded %s defaults from %s:%s", context, package, filename) |
Sanctioned reader for user-supplied JSON override files; raises the package error class.
| 418 | ) | 453 | ) |
| 419 | return loaded | 454 | return loaded |
| 420 | 455 | ||
| 421 | 456 | ||
| 422 | def _load_json_file(path: Path, *, error_cls: type[ValueError]) -> dict[str, Any]: | 457 | def load_json_overrides( |
| 423 | """Load a JSON config file, wrapping decode errors in *error_cls*.""" | 458 | path: str | Path, |
| 459 | *, | ||
| 460 | error_cls: type[ValueError] = config_loader.ConfigError, | ||
| 461 | ) -> dict[str, Any]: | ||
| 462 | """Read a user-supplied JSON config/override file as a plain mapping. | ||
| 463 | |||
| 464 | This is the one sanctioned way for a package to read an override file the | ||
| 465 | user passed on the command line; the result is normally handed to | ||
| 466 | :func:`load_config` as ``overrides=`` (merge semantics) or used directly. | ||
| 467 | Packages must not hand-roll their own reader. | ||
| 468 | |||
| 469 | Args: | ||
| 470 | path: Path to the JSON file. | ||
| 471 | error_cls: Exception class raised for malformed JSON or a non-object | ||
| 472 | top level. Defaults to `iolabs.common.config_loader.ConfigError`; | ||
| 473 | packages pass their own ``<Name>ConfigError``. | ||
| 474 | |||
| 475 | Returns: | ||
| 476 | The decoded JSON object as a dict. | ||
| 477 | |||
| 478 | Raises: | ||
| 479 | error_cls: The file is not valid JSON, or does not hold a JSON object. | ||
| 480 | OSError: The file could not be read. | ||
| 481 | """ | ||
| 482 | path = Path(path) | ||
| 424 | try: | 483 | try: |
| 425 | with path.open("r", encoding="utf-8") as handle: | 484 | with path.open("r", encoding="utf-8") as handle: |
| 426 | loaded = json.load(handle) | 485 | loaded = json.load(handle) |
| 427 | except json.JSONDecodeError as exc: | 486 | except json.JSONDecodeError as exc: |
| 570 | 570 | ||
| 571 | def test_lazy_reexports_are_visible_to_dir() -> None: | 571 | def test_lazy_reexports_are_visible_to_dir() -> None: |
| 572 | """The lazily re-exported pydantic names show up in dir(config_loader).""" | 572 | """The lazily re-exported pydantic names show up in dir(config_loader).""" |
| 573 | names = dir(config_loader) | 573 | names = dir(config_loader) |
| 574 | assert {"ConfigModel", "load_config", "validate_config", "format_validation_error"} <= set( | 574 | assert { |
| 575 | names | 575 | "ConfigModel", |
| 576 | ) | 576 | "load_config", |
| 577 | "validate_config", | ||
| 578 | "format_validation_error", | ||
| 579 | "load_json_overrides", | ||
| 580 | } <= set(names) | ||
| 577 | assert names == sorted(names) | 581 | assert names == sorted(names) |
| 578 | 582 | ||
| 579 | 583 | ||
| 580 | def test_coerce_config_value_does_not_warn() -> None: | 584 | def test_coerce_config_value_does_not_warn() -> None: |
| 699 | 703 | ||
| 700 | with pytest.raises(config_loader.ConfigError) as excinfo: | 704 | with pytest.raises(config_loader.ConfigError) as excinfo: |
| 701 | config_model.validate_config(ChoicesConfig, {"bogus": 1}, context="cfg") | 705 | config_model.validate_config(ChoicesConfig, {"bogus": 1}, context="cfg") |
| 702 | assert "Allowed keys: count, n-points" in str(excinfo.value) | 706 | assert "Allowed keys: count, n-points" in str(excinfo.value) |
| 707 | |||
| 708 | |||
| 709 | def test_load_json_overrides_reads_a_json_object(tmp_path: Path) -> None: | ||
| 710 | """load_json_overrides returns the decoded object of a user config file.""" | ||
| 711 | path = tmp_path / "overrides.json" | ||
| 712 | path.write_text(json.dumps({"mode": "exact", "ground": {"cell_m": 0.25}}), encoding="utf-8") | ||
| 713 | assert config_loader.load_json_overrides(path) == { | ||
| 714 | "mode": "exact", | ||
| 715 | "ground": {"cell_m": 0.25}, | ||
| 716 | } | ||
| 717 | |||
| 718 | |||
| 719 | def test_load_json_overrides_accepts_a_string_path(tmp_path: Path) -> None: | ||
| 720 | """A ``str`` path is accepted, like every other config_path argument.""" | ||
| 721 | path = tmp_path / "overrides.json" | ||
| 722 | path.write_text('{"mode": "fast"}', encoding="utf-8") | ||
| 723 | assert config_loader.load_json_overrides(str(path)) == {"mode": "fast"} | ||
| 724 | |||
| 725 | |||
| 726 | def test_load_json_overrides_rejects_malformed_json(tmp_path: Path) -> None: | ||
| 727 | """Malformed JSON is reported through the caller's error class.""" | ||
| 728 | path = tmp_path / "overrides.json" | ||
| 729 | path.write_text("{not json", encoding="utf-8") | ||
| 730 | with pytest.raises(SampleConfigError, match="Invalid JSON in config file"): | ||
| 731 | config_loader.load_json_overrides(path, error_cls=SampleConfigError) | ||
| 732 | |||
| 733 | |||
| 734 | def test_load_json_overrides_rejects_non_object_top_level(tmp_path: Path) -> None: | ||
| 735 | """A JSON array is not a config mapping.""" | ||
| 736 | path = tmp_path / "overrides.json" | ||
| 737 | path.write_text("[1, 2]", encoding="utf-8") | ||
| 738 | with pytest.raises(SampleConfigError, match="must hold a JSON object"): | ||
| 739 | config_loader.load_json_overrides(path, error_cls=SampleConfigError) |
<Name>Config/<Name><Section>Confignaming, one<Name>ConfigError, keyword-onlyoverrides/config_path, no field aliases, tuple sequence fields, fixed test names intests/test_config.py.load_json_overrides(path, error_cls=): the one sanctioned reader for user JSON override files.