Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/content/docs/guides/dataclasses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
31 changes: 31 additions & 0 deletions docs/src/content/docs/recipes/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,37 @@ vehicle_type("Personenauto") # <VehicleType.CAR: '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 enum import StrEnum
from typing import Annotated

from probatio import DataclassSchema, Key, Map, PASSTHROUGH

class VehicleType(StrEnum):
CAR = "Personenauto"

@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=<VehicleType.CAR: 'Personenauto'>)
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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:
Expand Down
37 changes: 35 additions & 2 deletions src/probatio/dataclass_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)

Expand Down
45 changes: 45 additions & 0 deletions tests/test_dataclass_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from probatio import (
ALLOW_EXTRA,
PASSTHROUGH,
PREVENT_EXTRA,
REMOVE_EXTRA,
AsDatetime,
Expand All @@ -33,6 +34,8 @@
Invalid,
Key,
Length,
Map,
Maybe,
MultipleInvalid,
Range,
SchemaError,
Expand Down Expand Up @@ -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)
Expand Down
Loading