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
14 changes: 14 additions & 0 deletions docs/src/content/docs/guides/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,20 @@ Schema(Map({0: "off", 1: "on", 2: "auto"}))(1) # 'on'
Schema(Map({0: "off"}, default="unknown"))(9) # 'unknown'
```

A fixed `default` folds every miss to one value. To rewrite only the keys you list
and leave everything else untouched, pass `default=PASSTHROUGH`. That is the
difference between "unknown means this value" and "only touch what I named", which is
what you want when a table just cleans up a few sentinel strings ahead of a stricter
validator.

```python
from probatio import Schema, Map, PASSTHROUGH

sentinels = Map({"N.v.t.": None, "n/a": None}, default=PASSTHROUGH)
Schema(sentinels)("N.v.t.") # None
Schema(sentinels)("Personenauto") # 'Personenauto' (unmapped, left as-is)
```

`Msg` rewrites the message on failure:

<!-- verify: raises MultipleInvalid -->
Expand Down
25 changes: 25 additions & 0 deletions docs/src/content/docs/recipes/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,31 @@ Schema(Scale(9, divisor=5, offset=32))(20) # 68.0
Schema(Map({0: "off", 1: "on", 2: "auto"}))(2) # 'auto'
```

`Map` rejects a key it does not know, which is right for a closed set of codes. When
a table exists only to rewrite a few sentinel values ahead of a stricter step, pass
`default=PASSTHROUGH` so an unlisted value flows through untouched. A scraped source
that sends `"N.v.t."` (not applicable) for a missing enum is the common case: fold
the sentinel to `None`, let a real value reach the enum.

```python
from enum import StrEnum

from probatio import Schema, All, Map, Maybe, PASSTHROUGH

class VehicleType(StrEnum):
CAR = "Personenauto"

vehicle_type = Schema(
All(Map({"N.v.t.": None}, default=PASSTHROUGH), Maybe(VehicleType))
)
vehicle_type("N.v.t.") # None
vehicle_type("Personenauto") # <VehicleType.CAR: 'Personenauto'>
```

`Map` runs first here and rewrites only what it names, so the enum coerces whatever
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.

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
2 changes: 2 additions & 0 deletions src/probatio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
ASCII,
E164,
IBAN,
PASSTHROUGH,
ULID,
UUID,
Abs,
Expand Down Expand Up @@ -326,6 +327,7 @@ def __getattr__(name: str) -> object:
"ASCII",
"E164",
"IBAN",
"PASSTHROUGH",
"PREVENT_EXTRA",
"REMOVE_EXTRA",
"ULID",
Expand Down
2 changes: 2 additions & 0 deletions src/probatio/validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

from probatio.validators.coerce import (
PASSTHROUGH,
Boolean,
Coerce,
DefaultTo,
Expand Down Expand Up @@ -166,6 +167,7 @@
"ASCII",
"E164",
"IBAN",
"PASSTHROUGH",
"ULID",
"UUID",
"Abs",
Expand Down
30 changes: 25 additions & 5 deletions src/probatio/validators/coerce.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,15 +224,32 @@ def __call__(self, value: typing.Any) -> typing.Any:
return value


class _Passthrough:
"""Type of the ``PASSTHROUGH`` sentinel: a ``Map`` miss returns the value unchanged."""

def __repr__(self) -> str:
"""Render as ``PASSTHROUGH`` so it reads clearly in debug output."""
return "PASSTHROUGH"


PASSTHROUGH = _Passthrough()


class Map(_SafeValidator):
"""Translate a value through a mapping, like a status code to a name.

``Map({0: "off", 1: "on", 2: "auto"})`` returns the mapped value for a key it
knows. This is the bring-your-own-table generalization of a domain converter:
probatio does not pick the mapping, you do, so a disputed or app-specific lookup
(an RSSI bucket, a vendor status code) stays yours. A value not in the mapping is
rejected, unless a ``default`` is given, in which case the default is returned. An
unhashable value, which cannot be a mapping key, is treated as a miss.
(an RSSI bucket, a vendor status code) stays yours. An unhashable value, which
cannot be a mapping key, is treated as a miss.

A value not in the mapping is rejected. Two escapes change that. A ``default``
value is returned in place of any miss (``Map({...}, default=None)`` folds every
unknown key to ``None``). Passing ``default=PASSTHROUGH`` instead returns the
value *unchanged* on a miss, so a ``Map`` rewrites only the keys it lists and
leaves everything else alone. That is the difference between "unknown means this
fixed value" and "only touch what I named".
"""

def __init__(
Expand Down Expand Up @@ -260,8 +277,11 @@ def __call__(self, value: typing.Any) -> typing.Any:
return self.mapping[value]
except Exception as exc:
# A miss (KeyError), an unhashable value (TypeError), or a hostile
# ``__hash__``/``__eq__``: none is a valid key. Fall back to the default
# if one was given, otherwise report the allowed keys.
# ``__hash__``/``__eq__``: none is a valid key. ``PASSTHROUGH`` leaves the
# value as-is; any other default replaces the miss; otherwise report the
# allowed keys.
if self.default is PASSTHROUGH:
return value
if self.default is not UNDEFINED:
return self.default
raise Invalid(
Expand Down
3 changes: 3 additions & 0 deletions tests/__snapshots__/test_public_surface.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,9 @@
'kwargs VAR_KEYWORD required',
]),
}),
'PASSTHROUGH': dict({
'kind': 'value',
}),
'PREVENT_EXTRA': dict({
'kind': 'value',
}),
Expand Down
18 changes: 18 additions & 0 deletions tests/validators/test_coerce.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import voluptuous

from probatio import (
PASSTHROUGH,
Boolean,
Coerce,
DefaultTo,
Expand Down Expand Up @@ -266,6 +267,23 @@ def test_map_treats_an_unhashable_value_as_a_miss() -> None:
assert Schema(Map({0: "off"}, default=None))([1, 2]) is None


def test_map_passthrough_returns_an_unmapped_value_unchanged() -> None:
"""With default=PASSTHROUGH, a miss returns the value as-is, a hit still maps."""
remap = Map({"N.v.t.": None, "Niet geregistreerd": None}, default=PASSTHROUGH)
assert Schema(remap)("N.v.t.") is None
assert Schema(remap)("Personenauto") == "Personenauto"


def test_map_passthrough_returns_an_unhashable_value_unchanged() -> None:
"""PASSTHROUGH leaves even an unhashable miss alone rather than rejecting it."""
assert Schema(Map({0: "off"}, default=PASSTHROUGH))([1, 2]) == [1, 2]


def test_passthrough_repr() -> None:
"""The PASSTHROUGH sentinel renders as its name."""
assert repr(PASSTHROUGH) == "PASSTHROUGH"


def test_map_rejects_a_non_dict_mapping_at_build_time() -> None:
"""A mapping that is not a dict is a schema error, raised when the validator is built."""
with pytest.raises(SchemaError):
Expand Down
Loading