Back to report index

iolabs-common (shared config layer) c98fc43: AI3D-379 Review fixes: restore float() fallback in int/float coercion, list all AliasChoices keys, drop dead logger, document by_alias dumps

Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:09:53+02:00

Commit #4 ยท 21 snippets

 README.md                          |  4 ++++
 src/iolabs/common/config_loader.py | 32 +++++++++++++++++++++-----------
 src/iolabs/common/config_model.py  | 26 +++++++++++++++++++-------
 tests/test_config_loader.py        | 21 +++++++++++++++++++++
 tests/test_config_model.py         | 13 +++++++++++++
 5 files changed, 78 insertions(+), 18 deletions(-)
Importance #1: src/iolabs/common/config_model.py @@ -44,8 +44,11 @@
44 context="foo config",44 context="foo config",
45 error_cls=FooConfigError,45 error_cls=FooConfigError,
46 ).model_dump()46 ).model_dump()
4747
48Aliased fields must be dumped with ``model_dump(by_alias=True)``, so the result
49stays keyed by the config-file keys rather than the Python field names.
50
48Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass51Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass
49section is coerced, but its errors do not carry the section path. Writers that52section is coerced, but its errors do not carry the section path. Writers that
50serialise a config (run stats, provenance) should use ``model_dump(mode="json")``53serialise a config (run stats, provenance) should use ``model_dump(mode="json")``
51so tuples, paths and enums become JSON-native values.54so tuples, paths and enums become JSON-native values.
Importance #2: src/iolabs/common/config_model.py @@ -338,22 +341,31 @@
338 or current.model_config.get("validate_by_name")341 or current.model_config.get("validate_by_name")
339 )342 )
340 keys: list[str] = []343 keys: list[str] = []
341 for name, field in current.model_fields.items():344 for name, field in current.model_fields.items():
342 alias = _validation_alias(field)345 aliases = _validation_aliases(field)
343 if alias is None:346 if not aliases:
344 keys.append(name)347 keys.append(name)
345 continue348 continue
346 keys.append(alias)349 keys.extend(aliases)
347 if by_name:350 if by_name:
348 keys.append(name)351 keys.append(name)
349 return keys352 return keys
350353
351354
352def _validation_alias(field: pydantic_fields.FieldInfo) -> str | None:355def _validation_aliases(field: pydantic_fields.FieldInfo) -> list[str]:
353 """Return the single string alias *field* is validated under, if any."""356 """Return every string alias *field* is validated under.
357
358 A `pydantic.AliasChoices` contributes each of its string choices; an
359 `pydantic.AliasPath` addresses nested input and has no single config key,
360 so it contributes nothing.
361 """
354 alias = field.validation_alias if field.validation_alias is not None else field.alias362 alias = field.validation_alias if field.validation_alias is not None else field.alias
355 return alias if isinstance(alias, str) else None363 if isinstance(alias, str):
364 return [alias]
365 if isinstance(alias, pydantic.AliasChoices):
366 return [choice for choice in alias.choices if isinstance(choice, str)]
367 return []
356368
357369
358def _model_at_loc(370def _model_at_loc(
359 model_cls: type[pydantic.BaseModel] | None,371 model_cls: type[pydantic.BaseModel] | None,
Importance #3: src/iolabs/common/config_model.py @@ -376,9 +388,9 @@
376 key: str,388 key: str,
377) -> pydantic_fields.FieldInfo | None:389) -> pydantic_fields.FieldInfo | None:
378 """Return the field of *model_cls* addressed by *key* (alias or name)."""390 """Return the field of *model_cls* addressed by *key* (alias or name)."""
379 for name, field in model_cls.model_fields.items():391 for name, field in model_cls.model_fields.items():
380 if key in (_validation_alias(field), name):392 if key == name or key in _validation_aliases(field):
381 return field393 return field
382 return None394 return None
383395
384396
Importance #4: src/iolabs/common/config_loader.py @@ -445,14 +441,21 @@
445 raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc441 raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc
446 if not as_float.is_integer():442 if not as_float.is_integer():
447 raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.")443 raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.")
448 return int(as_float)444 return int(as_float)
449 if isinstance(value, numbers.Real):445 if isinstance(value, bytes | bytearray | memoryview):
446 raise error_cls(f"Invalid int for '{name}': {value!r} ({type(value).__name__}).")
447 # Anything else float() accepts (Decimal, numpy scalars, 0-d arrays) is
448 # coerced through float, as the pre-pydantic helper did.
449 try:
450 as_float = float(value)450 as_float = float(value)
451 if not as_float.is_integer():451 except (TypeError, ValueError) as exc:
452 raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.")452 raise error_cls(
453 return int(as_float)453 f"Invalid int for '{name}': {value!r} ({type(value).__name__})."
454 raise error_cls(f"Invalid int for '{name}': {value!r} ({type(value).__name__}).")454 ) from exc
455 if not as_float.is_integer():
456 raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.")
457 return int(as_float)
455458
456459
457def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float:460def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float:
458 """Parse a float; wrap conversion errors in *error_cls*."""461 """Parse a float; wrap conversion errors in *error_cls*."""
Importance #5: src/iolabs/common/config_loader.py @@ -49,10 +49,8 @@
49from __future__ import annotations49from __future__ import annotations
5050
51import enum51import enum
52import json52import json
53import logging
54import numbers
55import sys53import sys
56import warnings54import warnings
57from collections.abc import Collection, Mapping, Sequence55from collections.abc import Collection, Mapping, Sequence
58from dataclasses import fields, is_dataclass56from dataclasses import fields, is_dataclass
Importance #6: src/iolabs/common/config_loader.py @@ -69,10 +67,8 @@
69 get_origin,67 get_origin,
70 get_type_hints,68 get_type_hints,
71)69)
7270
73logger = logging.getLogger(__name__)
74
75_PYDANTIC_EXPORTS = frozenset(71_PYDANTIC_EXPORTS = frozenset(
76 {"ConfigModel", "load_config", "validate_config", "format_validation_error"}72 {"ConfigModel", "load_config", "validate_config", "format_validation_error"}
77)73)
7874
Importance #7: src/iolabs/common/config_loader.py @@ -464,11 +467,18 @@
464 except ValueError as exc:467 except ValueError as exc:
465 raise error_cls(468 raise error_cls(
466 f"Invalid float for '{name}': {value!r} ({type(value).__name__})."469 f"Invalid float for '{name}': {value!r} ({type(value).__name__})."
467 ) from exc470 ) from exc
468 if isinstance(value, numbers.Real):471 if isinstance(value, bytes | bytearray | memoryview):
472 raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).")
473 # Anything else float() accepts (Decimal, numpy scalars, 0-d arrays) is
474 # coerced through float, as the pre-pydantic helper did.
475 try:
469 return float(value)476 return float(value)
470 raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).")477 except (TypeError, ValueError) as exc:
478 raise error_cls(
479 f"Invalid float for '{name}': {value!r} ({type(value).__name__})."
480 ) from exc
471481
472482
473def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str:483def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str:
474 """Accept a string as-is; reject every other type."""484 """Accept a string as-is; reject every other type."""
Importance #8: tests/test_config_model.py @@ -686,4 +686,17 @@
686 config_loader.validate_config(686 config_loader.validate_config(
687 Aliased, {"external-name": "abc"}, context="aliased config"687 Aliased, {"external-name": "abc"}, context="aliased config"
688 )688 )
689 assert str(excinfo.value).startswith("Invalid int for 'external-name': 'abc'")689 assert str(excinfo.value).startswith("Invalid int for 'external-name': 'abc'")
690
691
692def test_allowed_keys_lists_every_alias_choice() -> None:
693 """An ``AliasChoices`` field is reported under each of its config keys."""
694
695 class ChoicesConfig(config_loader.ConfigModel):
696 count: int = pydantic.Field(
697 default=1, validation_alias=pydantic.AliasChoices("count", "n-points")
698 )
699
700 with pytest.raises(config_loader.ConfigError) as excinfo:
701 config_model.validate_config(ChoicesConfig, {"bogus": 1}, context="cfg")
702 assert "Allowed keys: count, n-points" in str(excinfo.value)
Importance #9: 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 decimal
5import enum6import enum
6import importlib7import importlib
7import json8import json
8import math9import math
Importance #10: tests/test_config_loader.py @@ -748,4 +749,24 @@
748@pytest.mark.parametrize(("value", "check"), [("nan", math.isnan), ("inf", math.isinf)])749@pytest.mark.parametrize(("value", "check"), [("nan", math.isnan), ("inf", math.isinf)])
749def test_coerce_float_accepts_non_finite_tokens(value: str, check: Any) -> None:750def test_coerce_float_accepts_non_finite_tokens(value: str, check: Any) -> None:
750 """Finiteness is a domain check for the consumer, not for the coercer."""751 """Finiteness is a domain check for the consumer, not for the coercer."""
751 assert check(coerce_to_field_type("threshold_m", value, float))752 assert check(coerce_to_field_type("threshold_m", value, float))
753
754
755@pytest.mark.parametrize(
756 ("value", "expected"),
757 [(decimal.Decimal("3"), 3), (decimal.Decimal("-2"), -2)],
758)
759def test_coerce_int_accepts_float_convertible_values(value: Any, expected: int) -> None:
760 """Non-``int`` numerics keep the pre-pydantic ``float()`` fallback."""
761 assert coerce_to_field_type("count", value, int) == expected
762
763
764def test_coerce_int_rejects_non_integral_float_convertible_value() -> None:
765 """The fallback still refuses to truncate."""
766 with pytest.raises(ConfigError, match="not an integral value"):
767 coerce_to_field_type("count", decimal.Decimal("3.5"), int)
768
769
770def test_coerce_float_accepts_float_convertible_values() -> None:
771 """``float()`` conversion stays the fallback for float fields too."""
772 assert coerce_to_field_type("threshold_m", decimal.Decimal("3.5"), float) == 3.5
Importance #11: README.md @@ -55,8 +55,12 @@
55 error_cls=FooConfigError,55 error_cls=FooConfigError,
56 ).model_dump()56 ).model_dump()
57```57```
5858
59A model whose fields carry aliases must dump with ``model_dump(by_alias=True)``,
60otherwise the returned mapping is keyed by the Python field names and no longer
61round-trips through the config JSON.
62
59The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`,63The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`,
60`dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf64`dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf
61wheels but emit a `DeprecationWarning` (0.9.0).65wheels but emit a `DeprecationWarning` (0.9.0).
6266
Importance #12: src/iolabs/common/config_loader.py @@ -49,10 +49,8 @@
49from __future__ import annotations49from __future__ import annotations
5050
51import enum51import enum
52import json52import json
53import logging
54import numbers
55import sys53import sys
56import warnings54import warnings
57from collections.abc import Collection, Mapping, Sequence55from collections.abc import Collection, Mapping, Sequence
58from dataclasses import fields, is_dataclass56from dataclasses import fields, is_dataclass
Importance #13: src/iolabs/common/config_loader.py @@ -69,10 +67,8 @@
69 get_origin,67 get_origin,
70 get_type_hints,68 get_type_hints,
71)69)
7270
73logger = logging.getLogger(__name__)
74
75_PYDANTIC_EXPORTS = frozenset(71_PYDANTIC_EXPORTS = frozenset(
76 {"ConfigModel", "load_config", "validate_config", "format_validation_error"}72 {"ConfigModel", "load_config", "validate_config", "format_validation_error"}
77)73)
7874
Importance #14: src/iolabs/common/config_loader.py @@ -445,14 +441,21 @@
445 raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc441 raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc
446 if not as_float.is_integer():442 if not as_float.is_integer():
447 raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.")443 raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.")
448 return int(as_float)444 return int(as_float)
449 if isinstance(value, numbers.Real):445 if isinstance(value, bytes | bytearray | memoryview):
446 raise error_cls(f"Invalid int for '{name}': {value!r} ({type(value).__name__}).")
447 # Anything else float() accepts (Decimal, numpy scalars, 0-d arrays) is
448 # coerced through float, as the pre-pydantic helper did.
449 try:
450 as_float = float(value)450 as_float = float(value)
451 if not as_float.is_integer():451 except (TypeError, ValueError) as exc:
452 raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.")452 raise error_cls(
453 return int(as_float)453 f"Invalid int for '{name}': {value!r} ({type(value).__name__})."
454 raise error_cls(f"Invalid int for '{name}': {value!r} ({type(value).__name__}).")454 ) from exc
455 if not as_float.is_integer():
456 raise error_cls(f"Invalid int for '{name}': {value!r} is not an integral value.")
457 return int(as_float)
455458
456459
457def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float:460def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float:
458 """Parse a float; wrap conversion errors in *error_cls*."""461 """Parse a float; wrap conversion errors in *error_cls*."""
Importance #15: src/iolabs/common/config_loader.py @@ -464,11 +467,18 @@
464 except ValueError as exc:467 except ValueError as exc:
465 raise error_cls(468 raise error_cls(
466 f"Invalid float for '{name}': {value!r} ({type(value).__name__})."469 f"Invalid float for '{name}': {value!r} ({type(value).__name__})."
467 ) from exc470 ) from exc
468 if isinstance(value, numbers.Real):471 if isinstance(value, bytes | bytearray | memoryview):
472 raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).")
473 # Anything else float() accepts (Decimal, numpy scalars, 0-d arrays) is
474 # coerced through float, as the pre-pydantic helper did.
475 try:
469 return float(value)476 return float(value)
470 raise error_cls(f"Invalid float for '{name}': {value!r} ({type(value).__name__}).")477 except (TypeError, ValueError) as exc:
478 raise error_cls(
479 f"Invalid float for '{name}': {value!r} ({type(value).__name__})."
480 ) from exc
471481
472482
473def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str:483def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str:
474 """Accept a string as-is; reject every other type."""484 """Accept a string as-is; reject every other type."""
Importance #16: src/iolabs/common/config_model.py @@ -44,8 +44,11 @@
44 context="foo config",44 context="foo config",
45 error_cls=FooConfigError,45 error_cls=FooConfigError,
46 ).model_dump()46 ).model_dump()
4747
48Aliased fields must be dumped with ``model_dump(by_alias=True)``, so the result
49stays keyed by the config-file keys rather than the Python field names.
50
48Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass51Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass
49section is coerced, but its errors do not carry the section path. Writers that52section is coerced, but its errors do not carry the section path. Writers that
50serialise a config (run stats, provenance) should use ``model_dump(mode="json")``53serialise a config (run stats, provenance) should use ``model_dump(mode="json")``
51so tuples, paths and enums become JSON-native values.54so tuples, paths and enums become JSON-native values.
Importance #17: src/iolabs/common/config_model.py @@ -338,22 +341,31 @@
338 or current.model_config.get("validate_by_name")341 or current.model_config.get("validate_by_name")
339 )342 )
340 keys: list[str] = []343 keys: list[str] = []
341 for name, field in current.model_fields.items():344 for name, field in current.model_fields.items():
342 alias = _validation_alias(field)345 aliases = _validation_aliases(field)
343 if alias is None:346 if not aliases:
344 keys.append(name)347 keys.append(name)
345 continue348 continue
346 keys.append(alias)349 keys.extend(aliases)
347 if by_name:350 if by_name:
348 keys.append(name)351 keys.append(name)
349 return keys352 return keys
350353
351354
352def _validation_alias(field: pydantic_fields.FieldInfo) -> str | None:355def _validation_aliases(field: pydantic_fields.FieldInfo) -> list[str]:
353 """Return the single string alias *field* is validated under, if any."""356 """Return every string alias *field* is validated under.
357
358 A `pydantic.AliasChoices` contributes each of its string choices; an
359 `pydantic.AliasPath` addresses nested input and has no single config key,
360 so it contributes nothing.
361 """
354 alias = field.validation_alias if field.validation_alias is not None else field.alias362 alias = field.validation_alias if field.validation_alias is not None else field.alias
355 return alias if isinstance(alias, str) else None363 if isinstance(alias, str):
364 return [alias]
365 if isinstance(alias, pydantic.AliasChoices):
366 return [choice for choice in alias.choices if isinstance(choice, str)]
367 return []
356368
357369
358def _model_at_loc(370def _model_at_loc(
359 model_cls: type[pydantic.BaseModel] | None,371 model_cls: type[pydantic.BaseModel] | None,
Importance #18: src/iolabs/common/config_model.py @@ -376,9 +388,9 @@
376 key: str,388 key: str,
377) -> pydantic_fields.FieldInfo | None:389) -> pydantic_fields.FieldInfo | None:
378 """Return the field of *model_cls* addressed by *key* (alias or name)."""390 """Return the field of *model_cls* addressed by *key* (alias or name)."""
379 for name, field in model_cls.model_fields.items():391 for name, field in model_cls.model_fields.items():
380 if key in (_validation_alias(field), name):392 if key == name or key in _validation_aliases(field):
381 return field393 return field
382 return None394 return None
383395
384396
Importance #19: 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 decimal
5import enum6import enum
6import importlib7import importlib
7import json8import json
8import math9import math
Importance #20: tests/test_config_loader.py @@ -748,4 +749,24 @@
748@pytest.mark.parametrize(("value", "check"), [("nan", math.isnan), ("inf", math.isinf)])749@pytest.mark.parametrize(("value", "check"), [("nan", math.isnan), ("inf", math.isinf)])
749def test_coerce_float_accepts_non_finite_tokens(value: str, check: Any) -> None:750def test_coerce_float_accepts_non_finite_tokens(value: str, check: Any) -> None:
750 """Finiteness is a domain check for the consumer, not for the coercer."""751 """Finiteness is a domain check for the consumer, not for the coercer."""
751 assert check(coerce_to_field_type("threshold_m", value, float))752 assert check(coerce_to_field_type("threshold_m", value, float))
753
754
755@pytest.mark.parametrize(
756 ("value", "expected"),
757 [(decimal.Decimal("3"), 3), (decimal.Decimal("-2"), -2)],
758)
759def test_coerce_int_accepts_float_convertible_values(value: Any, expected: int) -> None:
760 """Non-``int`` numerics keep the pre-pydantic ``float()`` fallback."""
761 assert coerce_to_field_type("count", value, int) == expected
762
763
764def test_coerce_int_rejects_non_integral_float_convertible_value() -> None:
765 """The fallback still refuses to truncate."""
766 with pytest.raises(ConfigError, match="not an integral value"):
767 coerce_to_field_type("count", decimal.Decimal("3.5"), int)
768
769
770def test_coerce_float_accepts_float_convertible_values() -> None:
771 """``float()`` conversion stays the fallback for float fields too."""
772 assert coerce_to_field_type("threshold_m", decimal.Decimal("3.5"), float) == 3.5
Importance #21: tests/test_config_model.py @@ -686,4 +686,17 @@
686 config_loader.validate_config(686 config_loader.validate_config(
687 Aliased, {"external-name": "abc"}, context="aliased config"687 Aliased, {"external-name": "abc"}, context="aliased config"
688 )688 )
689 assert str(excinfo.value).startswith("Invalid int for 'external-name': 'abc'")689 assert str(excinfo.value).startswith("Invalid int for 'external-name': 'abc'")
690
691
692def test_allowed_keys_lists_every_alias_choice() -> None:
693 """An ``AliasChoices`` field is reported under each of its config keys."""
694
695 class ChoicesConfig(config_loader.ConfigModel):
696 count: int = pydantic.Field(
697 default=1, validation_alias=pydantic.AliasChoices("count", "n-points")
698 )
699
700 with pytest.raises(config_loader.ConfigError) as excinfo:
701 config_model.validate_config(ChoicesConfig, {"bogus": 1}, context="cfg")
702 assert "Allowed keys: count, n-points" in str(excinfo.value)