From e5487feafced37490815756a47ef7ee2eab4678c Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Tue, 20 Oct 2026 00:08:42 +0200 Subject: [PATCH] Initial support for PEP 661 sentinels Signed-off-by: Tin Tvrtkovic --- HISTORY.md | 1 + docs/defaulthooks.md | 32 +++++++ src/cattrs/_compat.py | 3 +- src/cattrs/converters.py | 27 +++++- src/cattrs/sentinels.py | 92 +++++++++++++++++++ src/cattrs/strategies/_unions.py | 7 ++ tests/test_preconf.py | 76 +++++++++++++++- tests/test_sentinels_315.py | 146 +++++++++++++++++++++++++++++++ 8 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 src/cattrs/sentinels.py create mode 100644 tests/test_sentinels_315.py diff --git a/HISTORY.md b/HISTORY.md index e8e631d9..e2e023d1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -17,6 +17,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#803](https://github.com/python-attrs/cattrs/pull/803)) - Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. ([#238](https://github.com/python-attrs/cattrs/issues/238)) +- Support Python 3.15+ ([PEP 661](https://peps.python.org/pep-0661/)) sentinels, un/structured by name, including sentinel annotations and unions. - Support the [`frozendict`](https://docs.python.org/3.15/builtins/stdtypes.html#frozendict) built-in on Python 3.15+. ([#787](https://github.com/python-attrs/cattrs/pull/787)) - Python 3.15 is now supported and part of the test matrix. diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 949f4452..59c05ab1 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -592,6 +592,38 @@ Previously, the unstructuring rules for `Any` were underspecified, leading to in `typing_extensions.Any` is now also supported. ``` +### Sentinels + +On Python 3.15+, built-in [`sentinel`](https://docs.python.org/3.15/builtins/functions.html#sentinel) values are unstructured to their names. +Structuring a matching name into a sentinel annotation restores that exact sentinel instance; other values raise a `CattrsError`. + +```{doctest} +:skipif: __import__("sys").version_info < (3, 15) + +>>> MISSING = sentinel("MISSING") + +>>> cattrs.unstructure(MISSING) +'MISSING' +>>> cattrs.structure("MISSING", MISSING) is MISSING +True +>>> cattrs.structure("MISSING", int | MISSING) is MISSING +True +>>> cattrs.structure("1", int | MISSING) +1 +``` + +In unions, sentinel names take precedence over the remaining members, including `str` and `Any`. +Sentinels in the same union must have distinct names. +The remaining members must form a type the converter supports. +Sentinel defaults work with the existing `omit_if_default` option; they are serialized normally unless that option is enabled. + +Hooks can be registered for individual sentinel instances using `register_structure_hook` and `register_unstructure_hook`. +If custom hooks change a sentinel's representation, register a hook for any containing union as well: the default union hook matches sentinel names. + +```{versionadded} NEXT + +``` + ### `typing.Literal` When structuring, [PEP 586](https://peps.python.org/pep-0586/) literals are validated to be in the allowed set of values. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 5db97a9a..45ed2e52 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -98,9 +98,10 @@ NoneType = type(None) if sys.version_info >= (3, 15): - from builtins import frozendict + from builtins import frozendict, sentinel else: frozendict = None + sentinel = None def is_optional(typ: Any) -> bool: diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index b537deac..70220802 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -57,6 +57,7 @@ is_tuple, is_typeddict, is_union_type, + sentinel, signature, ) from .cols import ( @@ -105,6 +106,14 @@ from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn from .literals import is_literal_containing_enums +from .sentinels import ( + is_sentinel, + is_sentinel_union, + sentinel_class_unstructure_factory, + sentinel_structure, + sentinel_union_structure_factory, + sentinel_unstructure, +) from .typealiases import ( get_type_alias_base, is_type_alias, @@ -266,6 +275,12 @@ def __init__( (has, self._unstructure_attrs), (is_union_type, self._unstructure_union), (lambda t: t in ANIES, self.unstructure), + (is_sentinel, sentinel_unstructure), + ( + lambda t: sentinel is not None and t is sentinel, + sentinel_class_unstructure_factory, + "extended", + ), ] ) @@ -314,6 +329,8 @@ def __init__( else [] ), (is_optional, self._structure_optional), + (is_sentinel, sentinel_structure), + (is_sentinel_union, sentinel_union_structure_factory, "extended"), ( lambda t: is_union_type(t) and t in self._union_struct_registry, self._union_struct_registry.__getitem__, @@ -375,6 +392,8 @@ def register_unstructure_hook( This method may now be used as a decorator. .. versionchanged:: 25.1.0 Modern type aliases are now supported. + .. versionchanged:: NEXT + Individual Python 3.15+ sentinel instances are now supported. """ if func is None: # Autodetecting decorator. @@ -389,7 +408,7 @@ def register_unstructure_hook( resolve_types(cls) if is_union_type(cls): self._unstructure_func.register_func_list([(lambda t: t == cls, func)]) - elif is_type_alias(cls): + elif is_type_alias(cls) or is_sentinel(cls): self._unstructure_func.register_func_list([(lambda t: t is cls, func)]) elif get_newtype_base(cls) is not None: # This is a newtype, so we handle it specially. @@ -516,6 +535,8 @@ def register_structure_hook( This method may now be used as a decorator. .. versionchanged:: 25.1.0 Modern type aliases are now supported. + .. versionchanged:: NEXT + Individual Python 3.15+ sentinel instances are now supported. """ if func is None: # The autodetecting decorator. @@ -529,8 +550,8 @@ def register_structure_hook( if is_union_type(cl): self._union_struct_registry[cl] = func self._structure_func.clear_cache() - elif is_type_alias(cl): - # Type aliases are special-cased. + elif is_type_alias(cl) or is_sentinel(cl): + # Type aliases and sentinel instances are special-cased. self._structure_func.register_func_list([(lambda t: t is cl, func)]) elif get_newtype_base(cl) is not None: # This is a newtype, so we handle it specially. diff --git a/src/cattrs/sentinels.py b/src/cattrs/sentinels.py new file mode 100644 index 00000000..d0a75c54 --- /dev/null +++ b/src/cattrs/sentinels.py @@ -0,0 +1,92 @@ +"""Support for Python 3.15 sentinel values. + +.. versionadded:: NEXT +""" + +from typing import TYPE_CHECKING, Any, Union + +from ._compat import NoneType, is_union_type, sentinel +from .errors import CattrsError + +if TYPE_CHECKING: + from .converters import BaseConverter + from .dispatch import StructureHook, UnstructureHook + + +def is_sentinel(typ: Any) -> bool: + """Is this a Python 3.15+ sentinel instance? + + .. versionadded:: NEXT + """ + return sentinel is not None and isinstance(typ, sentinel) + + +def sentinel_unstructure(value: Any) -> str: + """Serialize a sentinel by name. + + .. versionadded:: NEXT + """ + return value.__name__ + + +def sentinel_class_unstructure_factory( + _: Any, converter: "BaseConverter" +) -> "UnstructureHook": + """Dispatch sentinel values by instance only after matching their runtime class. + + .. versionadded:: NEXT + """ + dispatch = converter._unstructure_func.dispatch + + def unstructure_sentinel(value: Any) -> Any: + return dispatch(value)(value) + + return unstructure_sentinel + + +def sentinel_structure(value: Any, target: Any) -> Any: + """Restore a sentinel from its name. + + .. versionadded:: NEXT + """ + if isinstance(value, str) and value == target.__name__: + return target + raise CattrsError(f"{value!r} is not the name of sentinel {target!r}") + + +def is_sentinel_union(typ: Any) -> bool: + """Is this a union containing a Python 3.15+ sentinel instance? + + .. versionadded:: NEXT + """ + return is_union_type(typ) and any(is_sentinel(t) for t in typ.__args__) + + +def sentinel_union_structure_factory( + union: Any, converter: "BaseConverter" +) -> "StructureHook": + """Match sentinel names before structuring the remaining union members. + + .. versionadded:: NEXT + """ + sentinels = [t for t in union.__args__ if is_sentinel(t)] + by_name = {t.__name__: t for t in sentinels} + if len(by_name) != len(sentinels): + raise ValueError(f"Sentinels in {union!r} must have distinct names") + hooks = {name: converter.get_structure_hook(t) for name, t in by_name.items()} + has_none = NoneType in union.__args__ + rest = tuple(t for t in union.__args__ if not is_sentinel(t) and t is not NoneType) + if rest: + target = rest[0] if len(rest) == 1 else Union[rest] + hook = converter.get_structure_hook(target) + + def structure_sentinel_union(value: Any, _: Any) -> Any: + if value is None and has_none: + return None + if isinstance(value, str) and value in by_name: + return hooks[value](value, by_name[value]) + if rest: + return hook(value, target) + raise CattrsError(f"{value!r} is not a sentinel name in {union!r}") + + return structure_sentinel_union diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index b69ba763..52ac6a95 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -5,6 +5,7 @@ from .. import BaseConverter from .._compat import get_newtype_base, is_literal, is_subclass, is_union_type +from ..sentinels import is_sentinel from ..typealiases import get_type_alias_base, is_type_alias __all__ = [ @@ -175,6 +176,9 @@ def configure_union_passthrough( Introduced the `accept_ints_as_floats` parameter. .. versionchanged:: 26.2.0 PEP 695 type aliases are now supported as union members. + .. versionchanged:: NEXT + Unions containing Python 3.15+ sentinels defer to sentinel handling, + matching sentinel names before passing through other values. """ def get_passthrough_base(type: Any) -> Any: @@ -269,6 +273,9 @@ def contains_native_union(exact_type: Any) -> bool: """Can we handle this type?""" if is_union_type(exact_type): type_args = set(exact_type.__args__) + # Sentinel names must be matched before passing through strings. + if any(is_sentinel(t) for t in type_args): + return False # We special case optionals, since they are very common # and are handled a little more efficiently by default. if len(type_args) == 2 and type(None) in type_args: diff --git a/tests/test_preconf.py b/tests/test_preconf.py index d258b11b..18258502 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -1,4 +1,5 @@ # ruff: noqa: PLC0415 +import sys import time from collections.abc import Callable, Iterator, Set from datetime import date, datetime, timezone @@ -9,7 +10,7 @@ from typing import Any, Dict, Final, List, Literal, NamedTuple, NewType, Union import pytest -from attrs import define, fields +from attrs import define, field, fields from bson import CodecOptions, ObjectId from hypothesis import given, settings from hypothesis.strategies import ( @@ -63,6 +64,79 @@ else: NO_CBOR2 = False +if not NO_CBOR2: + from cattrs.preconf.cbor2 import make_converter as cbor2_make_converter +if not NO_ORJSON: + from cattrs.preconf.orjson import make_converter as orjson_make_converter +if not NO_MSGSPEC: + from cattrs.preconf.msgspec import make_converter as msgspec_make_converter + + +@pytest.mark.skipif(sys.version_info < (3, 15), reason="sentinels require Python 3.15+") +@pytest.mark.parametrize( + "converter_factory", + [ + pytest.param(bson_make_converter, id="bson"), + pytest.param(json_make_converter, id="json"), + pytest.param(msgpack_make_converter, id="msgpack"), + pytest.param(pyyaml_make_converter, id="pyyaml"), + pytest.param(tomlkit_make_converter, id="tomlkit"), + pytest.param(tomllib_make_converter, id="tomllib"), + pytest.param(ujson_make_converter, id="ujson"), + pytest.param( + None if NO_CBOR2 else cbor2_make_converter, + marks=pytest.mark.skipif(NO_CBOR2, reason="cbor2 not available"), + id="cbor2", + ), + pytest.param( + None if NO_ORJSON else orjson_make_converter, + marks=pytest.mark.skipif(NO_ORJSON, reason="orjson not available"), + id="orjson", + ), + pytest.param( + None if NO_MSGSPEC else msgspec_make_converter, + marks=pytest.mark.skipif(NO_MSGSPEC, reason="msgspec not available"), + id="msgspec", + ), + ], +) +@pytest.mark.parametrize("detailed_validation", [True, False]) +def test_sentinels( + converter_factory: Callable[..., Converter], detailed_validation: bool +): + """All backends serialize sentinel names and restore singleton fields and unions.""" + from builtins import sentinel + + missing = sentinel("MISSING") + unset = sentinel("UNSET") + + @define + class Example: + single: missing = missing + number: int | missing = missing + text: str | missing = missing + multiple: missing | unset = unset + values: list[int | missing] = field(factory=lambda: [missing, 1]) + + converter = converter_factory(detailed_validation=detailed_validation) + assert converter.unstructure(missing) == "MISSING" + assert converter.structure("MISSING", missing) is missing + + for original in (Example(), Example(number=1, text="other")): + unstructured = converter.unstructure(original) + assert unstructured == { + "single": "MISSING", + "number": "MISSING" if original.number is missing else 1, + "text": "MISSING" if original.text is missing else "other", + "multiple": "UNSET", + "values": ["MISSING", 1], + } + restored = converter.loads(converter.dumps(original), Example) + assert restored == original + assert restored.single is missing + assert restored.multiple is unset + assert restored.values[0] is missing + @define class A: diff --git a/tests/test_sentinels_315.py b/tests/test_sentinels_315.py new file mode 100644 index 00000000..955e5ac2 --- /dev/null +++ b/tests/test_sentinels_315.py @@ -0,0 +1,146 @@ +"""Support for the built-in sentinels on Python 3.15+.""" + +from builtins import sentinel +from dataclasses import dataclass +from typing import Any + +import pytest +from attrs import define + +from cattrs import BaseConverter, Converter +from cattrs.errors import CattrsError +from cattrs.preconf.json import make_converter + +MISSING = sentinel("MISSING") +UNSET = sentinel("UNSET") + + +def test_sentinel_roundtrip(converter: BaseConverter): + """Sentinels serialize by name and restore the exact instance.""" + assert converter.unstructure(MISSING) == "MISSING" + assert converter.unstructure(MISSING, MISSING) == "MISSING" + assert converter.unstructure(MISSING, sentinel) == "MISSING" + assert converter.structure("MISSING", MISSING) is MISSING + assert converter.structure(["MISSING"], list[MISSING]) == [MISSING] + assert converter.unstructure([MISSING], list[MISSING]) == ["MISSING"] + + +@pytest.mark.parametrize("value", ["UNSET", None, 1, [], {}, MISSING]) +def test_sentinel_validation(converter: BaseConverter, value): + """A sentinel annotation accepts only its serialized name.""" + with pytest.raises(CattrsError): + converter.structure(value, MISSING) + + +@pytest.mark.parametrize("target", [int | MISSING, MISSING | int]) +def test_sentinel_union(converter: BaseConverter, target): + """Sentinel unions recognize names and convert their other members.""" + assert converter.structure("MISSING", target) is MISSING + assert converter.structure("1", target) == 1 + assert converter.unstructure(MISSING, target) == "MISSING" + assert converter.unstructure(1, target) == 1 + + +@pytest.mark.parametrize("target", [MISSING | UNSET, MISSING | UNSET | None]) +def test_sentinel_only_union(converter: BaseConverter, target): + """Unions of sentinels restore each distinct singleton.""" + assert converter.structure("MISSING", target) is MISSING + assert converter.structure("UNSET", target) is UNSET + with pytest.raises(CattrsError): + converter.structure([], target) + + +@pytest.mark.parametrize("target", [MISSING | None, int | MISSING | UNSET | None]) +def test_optional_sentinel(converter: BaseConverter, target): + """Sentinel unions also preserve None.""" + assert converter.structure(None, target) is None + assert converter.structure("MISSING", target) is MISSING + assert converter.unstructure(None, target) is None + assert converter.unstructure(MISSING, target) == "MISSING" + + +@pytest.mark.parametrize("other", [str, Any]) +def test_sentinel_name_precedence(converter: BaseConverter, other): + """Sentinel names take precedence over strings and Any in unions.""" + assert converter.structure("MISSING", MISSING | other) is MISSING + assert converter.structure("other", MISSING | other) == "other" + + +def test_duplicate_names(converter: BaseConverter): + """Sentinels with identical names cannot be distinguished in a union.""" + duplicate = sentinel("MISSING") + assert converter.structure("MISSING", duplicate) is duplicate + with pytest.raises(ValueError, match="distinct names"): + converter.get_structure_hook(MISSING | duplicate) + + +@pytest.mark.parametrize("decorator", [define, dataclass]) +def test_sentinel_fields(converter: BaseConverter, decorator): + """Sentinel fields and defaults roundtrip in attrs and dataclasses.""" + + @decorator + class Example: + value: int | MISSING = MISSING + + assert converter.structure({}, Example).value is MISSING + assert converter.structure({"value": "MISSING"}, Example).value is MISSING + assert converter.structure({"value": "2"}, Example).value == 2 + assert converter.unstructure(Example()) == {"value": "MISSING"} + + +def test_omit_sentinel_default(): + """The existing omit-if-default option works with sentinel defaults.""" + + @define + class Example: + value: int | MISSING = MISSING + + converter = Converter(omit_if_default=True) + assert converter.unstructure(Example()) == {} + assert converter.structure({}, Example).value is MISSING + + +def test_sentinel_hooks(converter: BaseConverter): + """Per-sentinel hooks can override serialization and survive copying.""" + converter.register_unstructure_hook(MISSING, lambda _: "custom") + converter.register_structure_hook(MISSING, lambda value, _: MISSING) + for current in (converter, converter.copy()): + assert current.unstructure(MISSING) == "custom" + assert current.unstructure(MISSING, int | MISSING) == "custom" + assert current.structure("custom", MISSING) is MISSING + assert current.unstructure(UNSET) == "UNSET" + + +def test_json_sentinels(): + """Preconfigured JSON converters roundtrip sentinel fields.""" + + @define + class Example: + value: int | MISSING = MISSING + + converter = make_converter() + assert converter.loads(converter.dumps(Example()), Example).value is MISSING + assert converter.loads(converter.dumps(Example(1)), Example).value == 1 + + +def test_sentinel_runtime_dispatch(converter: BaseConverter): + """Runtime sentinel dispatch honors hook changes and converter copy isolation.""" + assert converter.unstructure(MISSING) == "MISSING" + converter.register_unstructure_hook(MISSING, lambda _: "original") + assert converter.unstructure(MISSING) == "original" + copied = converter.copy() + copied.register_unstructure_hook(MISSING, lambda _: "copy") + for current, expected in ((converter, "original"), (copied, "copy")): + assert current.unstructure(MISSING) == expected + assert current.unstructure(MISSING, sentinel) == expected + assert current.unstructure(MISSING, int | MISSING) == expected + assert current.unstructure([MISSING]) == [expected] + assert current.unstructure({"value": MISSING}) == {"value": expected} + + +@pytest.mark.parametrize("target", [str | MISSING, str | int | MISSING | None]) +def test_json_sentinel_name_precedence(target): + """JSON union passthrough preserves sentinel name precedence.""" + converter = make_converter() + assert converter.structure("MISSING", target) is MISSING + assert converter.structure("other", target) == "other"