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(-)
| 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 | Aliased fields must be dumped with ``model_dump(by_alias=True)``, so the result | ||
| 49 | stays keyed by the config-file keys rather than the Python field names. | ||
| 50 | |||
| 48 | Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass | 51 | 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 | 52 | 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")`` | 53 | serialise a config (run stats, provenance) should use ``model_dump(mode="json")`` |
| 51 | so tuples, paths and enums become JSON-native values. | 54 | so tuples, paths and enums become JSON-native values. |
| 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 | continue | 348 | 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 keys | 352 | return keys |
| 350 | 353 | ||
| 351 | 354 | ||
| 352 | def _validation_alias(field: pydantic_fields.FieldInfo) -> str | None: | 355 | def _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.alias | 362 | alias = field.validation_alias if field.validation_alias is not None else field.alias |
| 355 | return alias if isinstance(alias, str) else None | 363 | 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 [] | ||
| 356 | 368 | ||
| 357 | 369 | ||
| 358 | def _model_at_loc( | 370 | def _model_at_loc( |
| 359 | model_cls: type[pydantic.BaseModel] | None, | 371 | model_cls: type[pydantic.BaseModel] | None, |
| 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 field | 393 | return field |
| 382 | return None | 394 | return None |
| 383 | 395 | ||
| 384 | 396 |
| 445 | raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc | 441 | 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) | ||
| 455 | 458 | ||
| 456 | 459 | ||
| 457 | def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float: | 460 | def _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*.""" |
| 49 | from __future__ import annotations | 49 | from __future__ import annotations |
| 50 | 50 | ||
| 51 | import enum | 51 | import enum |
| 52 | import json | 52 | import json |
| 53 | import logging | ||
| 54 | import numbers | ||
| 55 | import sys | 53 | import sys |
| 56 | import warnings | 54 | import warnings |
| 57 | from collections.abc import Collection, Mapping, Sequence | 55 | from collections.abc import Collection, Mapping, Sequence |
| 58 | from dataclasses import fields, is_dataclass | 56 | from dataclasses import fields, is_dataclass |
| 69 | get_origin, | 67 | get_origin, |
| 70 | get_type_hints, | 68 | get_type_hints, |
| 71 | ) | 69 | ) |
| 72 | 70 | ||
| 73 | logger = 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 | ) |
| 78 | 74 |
| 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 exc | 470 | ) 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 | ||
| 471 | 481 | ||
| 472 | 482 | ||
| 473 | def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str: | 483 | def _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.""" |
| 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 | |||
| 692 | def 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) |
| 1 | """Tests for packaged-JSON config loader helpers.""" | 1 | """Tests for packaged-JSON config loader helpers.""" |
| 2 | 2 | ||
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | 4 | ||
| 5 | import decimal | ||
| 5 | import enum | 6 | import enum |
| 6 | import importlib | 7 | import importlib |
| 7 | import json | 8 | import json |
| 8 | import math | 9 | import math |
| 748 | @pytest.mark.parametrize(("value", "check"), [("nan", math.isnan), ("inf", math.isinf)]) | 749 | @pytest.mark.parametrize(("value", "check"), [("nan", math.isnan), ("inf", math.isinf)]) |
| 749 | def test_coerce_float_accepts_non_finite_tokens(value: str, check: Any) -> None: | 750 | def 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 | ) | ||
| 759 | def 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 | |||
| 764 | def 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 | |||
| 770 | def 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 |
| 55 | error_cls=FooConfigError, | 55 | error_cls=FooConfigError, |
| 56 | ).model_dump() | 56 | ).model_dump() |
| 57 | ``` | 57 | ``` |
| 58 | 58 | ||
| 59 | A model whose fields carry aliases must dump with ``model_dump(by_alias=True)``, | ||
| 60 | otherwise the returned mapping is keyed by the Python field names and no longer | ||
| 61 | round-trips through the config JSON. | ||
| 62 | |||
| 59 | The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`, | 63 | The hand-rolled dataclass helpers (`validate_allowed_keys`, `coerce_to_field_type`, |
| 60 | `dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf | 64 | `dataclass_from_mapping`, `validate_against_defaults`) still work for published leaf |
| 61 | wheels but emit a `DeprecationWarning` (0.9.0). | 65 | wheels but emit a `DeprecationWarning` (0.9.0). |
| 62 | 66 |
| 49 | from __future__ import annotations | 49 | from __future__ import annotations |
| 50 | 50 | ||
| 51 | import enum | 51 | import enum |
| 52 | import json | 52 | import json |
| 53 | import logging | ||
| 54 | import numbers | ||
| 55 | import sys | 53 | import sys |
| 56 | import warnings | 54 | import warnings |
| 57 | from collections.abc import Collection, Mapping, Sequence | 55 | from collections.abc import Collection, Mapping, Sequence |
| 58 | from dataclasses import fields, is_dataclass | 56 | from dataclasses import fields, is_dataclass |
| 69 | get_origin, | 67 | get_origin, |
| 70 | get_type_hints, | 68 | get_type_hints, |
| 71 | ) | 69 | ) |
| 72 | 70 | ||
| 73 | logger = 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 | ) |
| 78 | 74 |
| 445 | raise error_cls(f"Invalid int for '{name}': {value!r}.") from exc | 441 | 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) | ||
| 455 | 458 | ||
| 456 | 459 | ||
| 457 | def _coerce_float(name: str, value: Any, *, error_cls: type[ValueError]) -> float: | 460 | def _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*.""" |
| 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 exc | 470 | ) 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 | ||
| 471 | 481 | ||
| 472 | 482 | ||
| 473 | def _coerce_str(name: str, value: Any, *, error_cls: type[ValueError]) -> str: | 483 | def _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.""" |
| 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 | Aliased fields must be dumped with ``model_dump(by_alias=True)``, so the result | ||
| 49 | stays keyed by the config-file keys rather than the Python field names. | ||
| 50 | |||
| 48 | Nest sections as `ConfigModel` subclasses, not stdlib dataclasses: a dataclass | 51 | 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 | 52 | 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")`` | 53 | serialise a config (run stats, provenance) should use ``model_dump(mode="json")`` |
| 51 | so tuples, paths and enums become JSON-native values. | 54 | so tuples, paths and enums become JSON-native values. |
| 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 | continue | 348 | 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 keys | 352 | return keys |
| 350 | 353 | ||
| 351 | 354 | ||
| 352 | def _validation_alias(field: pydantic_fields.FieldInfo) -> str | None: | 355 | def _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.alias | 362 | alias = field.validation_alias if field.validation_alias is not None else field.alias |
| 355 | return alias if isinstance(alias, str) else None | 363 | 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 [] | ||
| 356 | 368 | ||
| 357 | 369 | ||
| 358 | def _model_at_loc( | 370 | def _model_at_loc( |
| 359 | model_cls: type[pydantic.BaseModel] | None, | 371 | model_cls: type[pydantic.BaseModel] | None, |
| 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 field | 393 | return field |
| 382 | return None | 394 | return None |
| 383 | 395 | ||
| 384 | 396 |
| 1 | """Tests for packaged-JSON config loader helpers.""" | 1 | """Tests for packaged-JSON config loader helpers.""" |
| 2 | 2 | ||
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | 4 | ||
| 5 | import decimal | ||
| 5 | import enum | 6 | import enum |
| 6 | import importlib | 7 | import importlib |
| 7 | import json | 8 | import json |
| 8 | import math | 9 | import math |
| 748 | @pytest.mark.parametrize(("value", "check"), [("nan", math.isnan), ("inf", math.isinf)]) | 749 | @pytest.mark.parametrize(("value", "check"), [("nan", math.isnan), ("inf", math.isinf)]) |
| 749 | def test_coerce_float_accepts_non_finite_tokens(value: str, check: Any) -> None: | 750 | def 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 | ) | ||
| 759 | def 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 | |||
| 764 | def 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 | |||
| 770 | def 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 |
| 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 | |||
| 692 | def 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) |
float(value)for Decimal / numpy scalars / 0-d arrays (bytes still rejected), matching the pre-pydantic helper.AliasChoicesentry. Dead module logger removed.