Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:43:00+02:00
Commit #2 ยท 11 snippets
README.md | 2 +- pyproject.toml | 2 +- src/iolabs/common/config_model.py | 10 +++++++++- tests/test_config_model.py | 41 +++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 53 insertions(+), 4 deletions(-)
| 214 | Unknown keys are grouped per section and reported as | 214 | Unknown keys are grouped per section and reported as |
| 215 | ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is | 215 | ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is |
| 216 | dotted onto *context* (``"{context}.section"``). Value errors keep the | 216 | dotted onto *context* (``"{context}.section"``). Value errors keep the |
| 217 | ``"Invalid <type> for '<dotted.field>': <value> ..."`` shape of the legacy | 217 | ``"Invalid <type> for '<dotted.field>': <value> ..."`` shape of the legacy |
| 218 | coercion helpers. | 218 | coercion helpers. A `pydantic.model_validator` rejection carries no field |
| 219 | location, so its own message is passed through verbatim. | ||
| 219 | 220 | ||
| 220 | Args: | 221 | Args: |
| 221 | exc: The pydantic validation error. | 222 | exc: The pydantic validation error. |
| 222 | context: Human-readable config name used as the message prefix. | 223 | context: Human-readable config name used as the message prefix. |
| 269 | if error["type"] == "missing": | 270 | if error["type"] == "missing": |
| 270 | return f"Missing required {context} key: '{dotted}'" | 271 | return f"Missing required {context} key: '{dotted}'" |
| 271 | if message.startswith(_VALUE_ERROR_PREFIX): | 272 | if message.startswith(_VALUE_ERROR_PREFIX): |
| 272 | message = message[len(_VALUE_ERROR_PREFIX):] | 273 | message = message[len(_VALUE_ERROR_PREFIX):] |
| 274 | if not loc: | ||
| 275 | # A whole-model validator (a cross-field rule) reports no field | ||
| 276 | # location, and its input is the entire config mapping -- naming | ||
| 277 | # the field and echoing the input, as below, would bury the rule's | ||
| 278 | # own message under a dump of every key. The rule names the fields | ||
| 279 | # it is about, so pass its message through unchanged. | ||
| 280 | return message | ||
| 273 | rewritten = _rewrite_field_name(message, loc) | 281 | rewritten = _rewrite_field_name(message, loc) |
| 274 | if rewritten is not None: | 282 | if rewritten is not None: |
| 275 | return rewritten | 283 | return rewritten |
| 276 | return f"Invalid value for '{dotted}': {error.get('input')!r}. {message}." | 284 | return f"Invalid value for '{dotted}': {error.get('input')!r}. {message}." |
Old helpers kept alive for published leaf wheels; warn once per call site.
| 581 | """The shared coercion entry point used by ConfigModel is not deprecated.""" | 581 | """The shared coercion entry point used by ConfigModel is not deprecated.""" |
| 582 | with warnings.catch_warnings(): | 582 | with warnings.catch_warnings(): |
| 583 | warnings.simplefilter("error", DeprecationWarning) | 583 | warnings.simplefilter("error", DeprecationWarning) |
| 584 | assert config_loader.coerce_config_value("x", "1e3", int) == 1000 | 584 | assert config_loader.coerce_config_value("x", "1e3", int) == 1000 |
| 585 | |||
| 586 | |||
| 587 | def test_model_validator_message_is_passed_through() -> None: | ||
| 588 | """A cross-field rule reports its own message, not a dump of the config.""" | ||
| 589 | |||
| 590 | class Banded(config_loader.ConfigModel): | ||
| 591 | low_max_m: float = 0.7 | ||
| 592 | medium_max_m: float = 2.0 | ||
| 593 | |||
| 594 | @pydantic.model_validator(mode="after") | ||
| 595 | def _check_bands(self) -> Banded: | ||
| 596 | if self.low_max_m > self.medium_max_m: | ||
| 597 | raise ValueError( | ||
| 598 | f"low_max_m={self.low_max_m!r} must not exceed " | ||
| 599 | f"medium_max_m={self.medium_max_m!r}." | ||
| 600 | ) | ||
| 601 | return self | ||
| 602 | |||
| 603 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 604 | config_loader.validate_config( | ||
| 605 | Banded, {"low_max_m": 5.0}, context="banded config" | ||
| 606 | ) | ||
| 607 | assert str(excinfo.value) == "low_max_m=5.0 must not exceed medium_max_m=2.0." | ||
| 608 | |||
| 609 | |||
| 610 | def test_field_error_short_circuits_the_model_validator() -> None: | ||
| 611 | """A failed field stops the whole-model rules, so only the key error shows.""" | ||
| 612 | |||
| 613 | class Pair(config_loader.ConfigModel): | ||
| 614 | alpha: int = 1 | ||
| 615 | beta: int = 2 | ||
| 616 | |||
| 617 | @pydantic.model_validator(mode="after") | ||
| 618 | def _check_pair(self) -> Pair: | ||
| 619 | raise ValueError("alpha and beta disagree.") | ||
| 620 | |||
| 621 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 622 | config_loader.validate_config(Pair, {"nope": 1}, context="pair config") | ||
| 623 | assert str(excinfo.value) == ( | ||
| 624 | "Unknown pair config key(s): nope. Allowed keys: alpha, beta" | ||
| 625 | ) |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-common" | 2 | name = "iolabs-common" |
| 3 | version = "0.8.0" | 3 | version = "0.9.0" |
| 4 | description = "Shared data structures for the 3D AI LIDAR processing pipeline" | 4 | description = "Shared data structures for the 3D AI LIDAR processing pipeline" |
| 5 | requires-python = ">=3.11" | 5 | requires-python = ">=3.11" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.20.0", | 7 | "numpy>=1.20.0", |
| 57 | ``` | 57 | ``` |
| 58 | 58 | ||
| 59 | The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`, | 59 | The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`, |
| 60 | `dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf | 60 | `dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf |
| 61 | wheels but emit a `DeprecationWarning` (0.8.0). | 61 | wheels but emit a `DeprecationWarning` (0.9.0). |
| 62 | 62 | ||
| 63 | Models are frozen, but only shallowly (as in pydantic itself): use `tuple` rather | 63 | Models are frozen, but only shallowly (as in pydantic itself): use `tuple` rather |
| 64 | than `list` for sequence fields that must not be mutated after construction. | 64 | than `list` for sequence fields that must not be mutated after construction. |
| 375 | ] | 375 | ] |
| 376 | 376 | ||
| 377 | [[package]] | 377 | [[package]] |
| 378 | name = "iolabs-common" | 378 | name = "iolabs-common" |
| 379 | version = "0.8.0" | 379 | version = "0.9.0" |
| 380 | source = { editable = "." } | 380 | source = { editable = "." } |
| 381 | dependencies = [ | 381 | dependencies = [ |
| 382 | { name = "numpy" }, | 382 | { name = "numpy" }, |
| 383 | { name = "pydantic" }, | 383 | { name = "pydantic" }, |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-common" | 2 | name = "iolabs-common" |
| 3 | version = "0.8.0" | 3 | version = "0.9.0" |
| 4 | description = "Shared data structures for the 3D AI LIDAR processing pipeline" | 4 | description = "Shared data structures for the 3D AI LIDAR processing pipeline" |
| 5 | requires-python = ">=3.11" | 5 | requires-python = ">=3.11" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.20.0", | 7 | "numpy>=1.20.0", |
| 214 | Unknown keys are grouped per section and reported as | 214 | Unknown keys are grouped per section and reported as |
| 215 | ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is | 215 | ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is |
| 216 | dotted onto *context* (``"{context}.section"``). Value errors keep the | 216 | dotted onto *context* (``"{context}.section"``). Value errors keep the |
| 217 | ``"Invalid <type> for '<dotted.field>': <value> ..."`` shape of the legacy | 217 | ``"Invalid <type> for '<dotted.field>': <value> ..."`` shape of the legacy |
| 218 | coercion helpers. | 218 | coercion helpers. A `pydantic.model_validator` rejection carries no field |
| 219 | location, so its own message is passed through verbatim. | ||
| 219 | 220 | ||
| 220 | Args: | 221 | Args: |
| 221 | exc: The pydantic validation error. | 222 | exc: The pydantic validation error. |
| 222 | context: Human-readable config name used as the message prefix. | 223 | context: Human-readable config name used as the message prefix. |
| 269 | if error["type"] == "missing": | 270 | if error["type"] == "missing": |
| 270 | return f"Missing required {context} key: '{dotted}'" | 271 | return f"Missing required {context} key: '{dotted}'" |
| 271 | if message.startswith(_VALUE_ERROR_PREFIX): | 272 | if message.startswith(_VALUE_ERROR_PREFIX): |
| 272 | message = message[len(_VALUE_ERROR_PREFIX):] | 273 | message = message[len(_VALUE_ERROR_PREFIX):] |
| 274 | if not loc: | ||
| 275 | # A whole-model validator (a cross-field rule) reports no field | ||
| 276 | # location, and its input is the entire config mapping -- naming | ||
| 277 | # the field and echoing the input, as below, would bury the rule's | ||
| 278 | # own message under a dump of every key. The rule names the fields | ||
| 279 | # it is about, so pass its message through unchanged. | ||
| 280 | return message | ||
| 273 | rewritten = _rewrite_field_name(message, loc) | 281 | rewritten = _rewrite_field_name(message, loc) |
| 274 | if rewritten is not None: | 282 | if rewritten is not None: |
| 275 | return rewritten | 283 | return rewritten |
| 276 | return f"Invalid value for '{dotted}': {error.get('input')!r}. {message}." | 284 | return f"Invalid value for '{dotted}': {error.get('input')!r}. {message}." |
Old helpers kept alive for published leaf wheels; warn once per call site.
| 581 | """The shared coercion entry point used by ConfigModel is not deprecated.""" | 581 | """The shared coercion entry point used by ConfigModel is not deprecated.""" |
| 582 | with warnings.catch_warnings(): | 582 | with warnings.catch_warnings(): |
| 583 | warnings.simplefilter("error", DeprecationWarning) | 583 | warnings.simplefilter("error", DeprecationWarning) |
| 584 | assert config_loader.coerce_config_value("x", "1e3", int) == 1000 | 584 | assert config_loader.coerce_config_value("x", "1e3", int) == 1000 |
| 585 | |||
| 586 | |||
| 587 | def test_model_validator_message_is_passed_through() -> None: | ||
| 588 | """A cross-field rule reports its own message, not a dump of the config.""" | ||
| 589 | |||
| 590 | class Banded(config_loader.ConfigModel): | ||
| 591 | low_max_m: float = 0.7 | ||
| 592 | medium_max_m: float = 2.0 | ||
| 593 | |||
| 594 | @pydantic.model_validator(mode="after") | ||
| 595 | def _check_bands(self) -> Banded: | ||
| 596 | if self.low_max_m > self.medium_max_m: | ||
| 597 | raise ValueError( | ||
| 598 | f"low_max_m={self.low_max_m!r} must not exceed " | ||
| 599 | f"medium_max_m={self.medium_max_m!r}." | ||
| 600 | ) | ||
| 601 | return self | ||
| 602 | |||
| 603 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 604 | config_loader.validate_config( | ||
| 605 | Banded, {"low_max_m": 5.0}, context="banded config" | ||
| 606 | ) | ||
| 607 | assert str(excinfo.value) == "low_max_m=5.0 must not exceed medium_max_m=2.0." | ||
| 608 | |||
| 609 | |||
| 610 | def test_field_error_short_circuits_the_model_validator() -> None: | ||
| 611 | """A failed field stops the whole-model rules, so only the key error shows.""" | ||
| 612 | |||
| 613 | class Pair(config_loader.ConfigModel): | ||
| 614 | alpha: int = 1 | ||
| 615 | beta: int = 2 | ||
| 616 | |||
| 617 | @pydantic.model_validator(mode="after") | ||
| 618 | def _check_pair(self) -> Pair: | ||
| 619 | raise ValueError("alpha and beta disagree.") | ||
| 620 | |||
| 621 | with pytest.raises(config_loader.ConfigError) as excinfo: | ||
| 622 | config_loader.validate_config(Pair, {"nope": 1}, context="pair config") | ||
| 623 | assert str(excinfo.value) == ( | ||
| 624 | "Unknown pair config key(s): nope. Allowed keys: alpha, beta" | ||
| 625 | ) |
| 375 | ] | 375 | ] |
| 376 | 376 | ||
| 377 | [[package]] | 377 | [[package]] |
| 378 | name = "iolabs-common" | 378 | name = "iolabs-common" |
| 379 | version = "0.8.0" | 379 | version = "0.9.0" |
| 380 | source = { editable = "." } | 380 | source = { editable = "." } |
| 381 | dependencies = [ | 381 | dependencies = [ |
| 382 | { name = "numpy" }, | 382 | { name = "numpy" }, |
| 383 | { name = "pydantic" }, | 383 | { name = "pydantic" }, |
model_validators now pass through verbatim instead of being re-wrapped.