Back to report index

iolabs-common (shared config layer) be21bff: AI3D-379 Release as 0.9.0 (0.8.0 already shipped AI3D-382); pass model_validator messages through

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(-)
Importance #1: src/iolabs/common/config_model.py @@ -214,9 +214,10 @@
214 Unknown keys are grouped per section and reported as214 Unknown keys are grouped per section and reported as
215 ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is215 ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is
216 dotted onto *context* (``"{context}.section"``). Value errors keep the216 dotted onto *context* (``"{context}.section"``). Value errors keep the
217 ``"Invalid <type> for '<dotted.field>': <value> ..."`` shape of the legacy217 ``"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.
219220
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.
Importance #2: src/iolabs/common/config_model.py @@ -269,8 +270,15 @@
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 rewritten283 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}."
Importance #3: tests/test_config_model.py @@ -581,4 +581,45 @@

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) == 1000584 assert config_loader.coerce_config_value("x", "1e3", int) == 1000
585
586
587def 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
610def 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 )
Importance #4: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-common"2name = "iolabs-common"
3version = "0.8.0"3version = "0.9.0"
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",
Importance #5: README.md @@ -57,8 +57,8 @@
57```57```
5858
59The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`,59The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`,
60`dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf60`dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf
61wheels but emit a `DeprecationWarning` (0.8.0).61wheels but emit a `DeprecationWarning` (0.9.0).
6262
63Models are frozen, but only shallowly (as in pydantic itself): use `tuple` rather63Models are frozen, but only shallowly (as in pydantic itself): use `tuple` rather
64than `list` for sequence fields that must not be mutated after construction.64than `list` for sequence fields that must not be mutated after construction.
Importance #6: uv.lock @@ -375,9 +375,9 @@
375]375]
376376
377[[package]]377[[package]]
378name = "iolabs-common"378name = "iolabs-common"
379version = "0.8.0"379version = "0.9.0"
380source = { editable = "." }380source = { editable = "." }
381dependencies = [381dependencies = [
382 { name = "numpy" },382 { name = "numpy" },
383 { name = "pydantic" },383 { name = "pydantic" },
Importance #7: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-common"2name = "iolabs-common"
3version = "0.8.0"3version = "0.9.0"
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",
Importance #8: src/iolabs/common/config_model.py @@ -214,9 +214,10 @@
214 Unknown keys are grouped per section and reported as214 Unknown keys are grouped per section and reported as
215 ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is215 ``"Unknown {context} key(s): a, b. Allowed keys: ..."``; the section path is
216 dotted onto *context* (``"{context}.section"``). Value errors keep the216 dotted onto *context* (``"{context}.section"``). Value errors keep the
217 ``"Invalid <type> for '<dotted.field>': <value> ..."`` shape of the legacy217 ``"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.
219220
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.
Importance #9: src/iolabs/common/config_model.py @@ -269,8 +270,15 @@
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 rewritten283 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}."
Importance #10: tests/test_config_model.py @@ -581,4 +581,45 @@

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) == 1000584 assert config_loader.coerce_config_value("x", "1e3", int) == 1000
585
586
587def 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
610def 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 )
Importance #11: uv.lock @@ -375,9 +375,9 @@
375]375]
376376
377[[package]]377[[package]]
378name = "iolabs-common"378name = "iolabs-common"
379version = "0.8.0"379version = "0.9.0"
380source = { editable = "." }380source = { editable = "." }
381dependencies = [381dependencies = [
382 { name = "numpy" },382 { name = "numpy" },
383 { name = "pydantic" },383 { name = "pydantic" },