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
1 change: 1 addition & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions docs/defaulthooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 24 additions & 3 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
is_tuple,
is_typeddict,
is_union_type,
sentinel,
signature,
)
from .cols import (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
),
]
)

Expand Down Expand Up @@ -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__,
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
92 changes: 92 additions & 0 deletions src/cattrs/sentinels.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions src/cattrs/strategies/_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
76 changes: 75 additions & 1 deletion tests/test_preconf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# ruff: noqa: PLC0415
import sys
import time
from collections.abc import Callable, Iterator, Set
from datetime import date, datetime, timezone
Expand All @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading