From 9d033e2296a7a820cdb2c3e94a9696cc501daecb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Jul 2026 12:33:11 +0000 Subject: [PATCH 1/2] Run a metadata coercer before a producing Annotated base A dataclass field's Annotated base is pinned at position 0 by Python and is also the field's static type, so metadata cannot be placed before it. probatio ordered the base relative to its metadata by the base's role: a plain type asserts and runs last (a coercer in the metadata runs first, the type confirms, ADR-008), but a producing base (an enum, a nested schema, a container) runs first. That left no way to reshape a raw value before a producing base coerced it, so a Map that folds an enum sentinel to None never ran: the enum rejected the sentinel first. Extend the ADR-008 rule the other way: when the metadata itself carries a coercer (a Coerce or Map, including one wrapped in Maybe or a union), the coercer runs first and the producing base confirms the result. Only the coercion markers flip the order; an asserting constraint like In, or a bare callable that cannot be told apart from a plain check, keeps the base-first order and sees the produced value. This keeps an enum-typed field typed as the enum while a Map cleans its sentinels ahead of it, so a from_dict override is no longer needed for that pattern. --- docs/src/content/docs/guides/dataclasses.md | 5 +++ docs/src/content/docs/recipes/cookbook.md | 27 +++++++++++++ src/probatio/dataclass_schema.py | 37 ++++++++++++++++- tests/test_dataclass_schema.py | 45 +++++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/guides/dataclasses.md b/docs/src/content/docs/guides/dataclasses.md index 79e6781..1cdcdf4 100644 --- a/docs/src/content/docs/guides/dataclasses.md +++ b/docs/src/content/docs/guides/dataclasses.md @@ -142,6 +142,11 @@ constraint next to the field instead of in a separate map. The rules: produced. - It composes inside containers: `list[Annotated[int, Range(min=1)]]` checks every element. +- A producing base (an enum, a nested dataclass, a container) coerces first, so + an asserting constraint after it checks the produced value. A coercer in the + metadata (`Coerce` or `Map`) still runs ahead of it, so you can reshape a raw + value, like folding an enum sentinel to `None`, before the base sees it (see + the [cookbook](/recipes/cookbook/#device-and-sensor-conversions)). - Metadata that is not callable is left alone, so an `Annotated` value you share with another tool passes through untouched. diff --git a/docs/src/content/docs/recipes/cookbook.md b/docs/src/content/docs/recipes/cookbook.md index 19109d0..bb063f1 100644 --- a/docs/src/content/docs/recipes/cookbook.md +++ b/docs/src/content/docs/recipes/cookbook.md @@ -165,6 +165,33 @@ vehicle_type("Personenauto") # is left. Order matters: put the `Map` before the enum in the `All`, or the enum rejects the sentinel before the table ever sees it. +The same works inside a dataclass field, and the field stays typed as the enum. A +coercer in the metadata (`Map` or `Coerce`) runs before a producing base like an +enum, so the sentinel is folded ahead of the enum without dropping to a `str` field: + +```python +from dataclasses import dataclass +from typing import Annotated + +from probatio import DataclassSchema, Key + +@dataclass +class Vehicle: + vehicle_type: Annotated[ + VehicleType | None, + Key(alias="voertuigsoort"), + Map({"N.v.t.": None}, default=PASSTHROUGH), + ] = None + +schema = DataclassSchema(Vehicle) +schema({"voertuigsoort": "N.v.t."}) # Vehicle(vehicle_type=None) +schema({"voertuigsoort": "Personenauto"}) # Vehicle(vehicle_type=) +``` + +An asserting constraint keeps the opposite order. An `In([...])` in the metadata runs +_after_ the enum, so it checks the produced member, not the raw string. Only a coercer +moves ahead of a producing base. + RSSI to a signal percentage is the classic case with no single agreed formula. `Remap` lets you pick the input range, and `Clamp` keeps the result in bounds when a reading runs past it: diff --git a/src/probatio/dataclass_schema.py b/src/probatio/dataclass_schema.py index 9261a03..bc1aedc 100644 --- a/src/probatio/dataclass_schema.py +++ b/src/probatio/dataclass_schema.py @@ -84,7 +84,7 @@ CompiledSchema, Schema, ) -from probatio.validators import All, Any, ExactSequence, In, Maybe, Union +from probatio.validators import All, Any, Coerce, ExactSequence, In, Map, Maybe, Union __all__ = [ "DataclassSchema", @@ -176,6 +176,32 @@ def _base_asserts_the_result(base_schema: TypingAny) -> bool: return False +def _metadata_coerces(item: TypingAny) -> bool: + """Whether an ``Annotated`` metadata item *coerces* (so it should run first). + + A plain type base runs its metadata first and then confirms the result (ADR-008): + ``Annotated[int, Coerce(int)]`` coerces, then the ``int`` asserts. A *producing* + base (an ``Enum``, a nested schema, a container) normally runs first instead, so a + constraint layers on top of the produced value. That leaves no way to preprocess a + raw value before a producing base coerces it, which is the enum-sentinel case: + ``Map`` a `"N.v.t."` sentinel to ``None`` before the enum sees it. + + So the same rule extends the other way: if the metadata itself carries a coercer, it + runs first and the base confirms, even when the base produces. Only the coercion + markers (``Coerce`` and ``Map``, and a ``Maybe``/union wrapping one, so + ``Maybe(Coerce(...))`` counts) flip the order. An asserting constraint like ``In`` or + a bare callable does not: it cannot be told apart from a plain check, so it keeps the + old base-first order and sees the produced value. + """ + if isinstance(item, (Coerce, Map)): + return True + if isinstance(item, Maybe): + return _metadata_coerces(item.validator) + if isinstance(item, (Any, Union)): + return any(_metadata_coerces(member) for member in item.validators) + return False + + def _annotation_to_schema( # noqa: PLR0911, PLR0912 annotation: TypingAny, self_refs: dict[type, _RecursiveSchemaRef], @@ -222,7 +248,14 @@ def _annotation_to_schema( # noqa: PLR0911, PLR0912 # also a bare type that coerces when compiled (an ``Enum`` class, or a type with a # ``__probatio_validate__`` protocol from ADR-007, both turn a raw value into the # validated form). - if _base_asserts_the_result(base_schema): + # + # The exception: if the metadata itself coerces (a ``Coerce`` or ``Map``), it runs + # first even against a producing base, so a raw value can be reshaped before the + # base coerces it. That is what lets ``Map`` fold an enum sentinel to ``None`` + # ahead of the enum. The base then only confirms an already-produced member. + if _base_asserts_the_result(base_schema) or any( + _metadata_coerces(item) for item in extras + ): return All(*extras, base_schema) return All(base_schema, *extras) diff --git a/tests/test_dataclass_schema.py b/tests/test_dataclass_schema.py index 6290162..e5490a9 100644 --- a/tests/test_dataclass_schema.py +++ b/tests/test_dataclass_schema.py @@ -23,6 +23,7 @@ from probatio import ( ALLOW_EXTRA, + PASSTHROUGH, PREVENT_EXTRA, REMOVE_EXTRA, AsDatetime, @@ -33,6 +34,8 @@ Invalid, Key, Length, + Map, + Maybe, MultipleInvalid, Range, SchemaError, @@ -516,6 +519,48 @@ def test_annotated_self_validating_base_coerces_first() -> None: ) +@dataclass +class _MapSentinelShirt: + """A Map in the metadata rewrites a raw value before the enum base coerces it.""" + + color: Annotated[_AnnColor | None, Map({"n/a": None}, default=PASSTHROUGH)] = None + + +@dataclass +class _MaybeCoerceShirt: + """A Maybe(Coerce(...)) in the metadata coerces before the producing enum base.""" + + color: Annotated[_AnnColor | None, Maybe(Coerce(_AnnColor))] = None + + +@dataclass +class _UnionCoerceShirt: + """A coercer nested in a union metadata item still flips the order.""" + + color: Annotated[_AnnColor, AnyOf(Map({"scarlet": "red"}), Coerce(_AnnColor))] + + +def test_map_metadata_runs_before_a_producing_enum_base() -> None: + """A Map maps a sentinel to None before the enum coerces, keeping the enum type.""" + schema = DataclassSchema(_MapSentinelShirt) + assert schema({"color": "n/a"}).color is None + assert schema({"color": "red"}).color is _AnnColor.RED + + +def test_maybe_coerce_metadata_runs_before_a_producing_enum_base() -> None: + """A Maybe(Coerce(...)) metadata item is recognized as a coercer through the Maybe.""" + schema = DataclassSchema(_MaybeCoerceShirt) + assert schema({"color": "blue"}).color is _AnnColor.BLUE + assert schema({"color": None}).color is None + + +def test_union_wrapped_coercer_metadata_runs_before_a_producing_base() -> None: + """A coercer inside a union metadata item is seen, so the order still flips.""" + schema = DataclassSchema(_UnionCoerceShirt) + assert schema({"color": "scarlet"}).color is _AnnColor.RED + assert schema({"color": "blue"}).color is _AnnColor.BLUE + + def test_annotated_applies_every_validator_in_order() -> None: """Multiple callables in Annotated metadata all apply, through All.""" schema = DataclassSchema(AnnotatedFields) From 08e83f7115c3b958a73684feaa7fd58bab1490c3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Jul 2026 13:28:15 +0000 Subject: [PATCH 2/2] Make the cookbook dataclass example self-contained The block used Map, PASSTHROUGH, and VehicleType from an earlier block on the page. It runs on the docs site (blocks share a namespace) but the cookbook's promise is that a single block copies and runs on its own, so import the coercers and define the enum in the block. --- docs/src/content/docs/recipes/cookbook.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/recipes/cookbook.md b/docs/src/content/docs/recipes/cookbook.md index bb063f1..c14fab4 100644 --- a/docs/src/content/docs/recipes/cookbook.md +++ b/docs/src/content/docs/recipes/cookbook.md @@ -171,9 +171,13 @@ enum, so the sentinel is folded ahead of the enum without dropping to a `str` fi ```python from dataclasses import dataclass +from enum import StrEnum from typing import Annotated -from probatio import DataclassSchema, Key +from probatio import DataclassSchema, Key, Map, PASSTHROUGH + +class VehicleType(StrEnum): + CAR = "Personenauto" @dataclass class Vehicle: