diff --git a/docs/src/content/docs/guides/validators.md b/docs/src/content/docs/guides/validators.md index 66469de..6deb557 100644 --- a/docs/src/content/docs/guides/validators.md +++ b/docs/src/content/docs/guides/validators.md @@ -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: diff --git a/docs/src/content/docs/recipes/cookbook.md b/docs/src/content/docs/recipes/cookbook.md index 53d9cc9..19109d0 100644 --- a/docs/src/content/docs/recipes/cookbook.md +++ b/docs/src/content/docs/recipes/cookbook.md @@ -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") # +``` + +`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: diff --git a/src/probatio/__init__.py b/src/probatio/__init__.py index d9fb294..a08f7fc 100644 --- a/src/probatio/__init__.py +++ b/src/probatio/__init__.py @@ -123,6 +123,7 @@ ASCII, E164, IBAN, + PASSTHROUGH, ULID, UUID, Abs, @@ -326,6 +327,7 @@ def __getattr__(name: str) -> object: "ASCII", "E164", "IBAN", + "PASSTHROUGH", "PREVENT_EXTRA", "REMOVE_EXTRA", "ULID", diff --git a/src/probatio/validators/__init__.py b/src/probatio/validators/__init__.py index 34536be..9303470 100644 --- a/src/probatio/validators/__init__.py +++ b/src/probatio/validators/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations from probatio.validators.coerce import ( + PASSTHROUGH, Boolean, Coerce, DefaultTo, @@ -166,6 +167,7 @@ "ASCII", "E164", "IBAN", + "PASSTHROUGH", "ULID", "UUID", "Abs", diff --git a/src/probatio/validators/coerce.py b/src/probatio/validators/coerce.py index d847b55..36064cd 100644 --- a/src/probatio/validators/coerce.py +++ b/src/probatio/validators/coerce.py @@ -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__( @@ -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( diff --git a/tests/__snapshots__/test_public_surface.ambr b/tests/__snapshots__/test_public_surface.ambr index f7d17a1..130ccca 100644 --- a/tests/__snapshots__/test_public_surface.ambr +++ b/tests/__snapshots__/test_public_surface.ambr @@ -1215,6 +1215,9 @@ 'kwargs VAR_KEYWORD required', ]), }), + 'PASSTHROUGH': dict({ + 'kind': 'value', + }), 'PREVENT_EXTRA': dict({ 'kind': 'value', }), diff --git a/tests/validators/test_coerce.py b/tests/validators/test_coerce.py index 639383f..5faf1c7 100644 --- a/tests/validators/test_coerce.py +++ b/tests/validators/test_coerce.py @@ -9,6 +9,7 @@ import voluptuous from probatio import ( + PASSTHROUGH, Boolean, Coerce, DefaultTo, @@ -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):