diff --git a/HISTORY.md b/HISTORY.md index a6c70b79..9036e451 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,6 +19,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#486](https://github.com/python-attrs/cattrs/pull/486)) - **Minor change**: {py:func}`cattrs.gen.make_dict_structure_fn` will use the value for the `prefer_attrib_converters` parameter from the given converter by default now. If you're using this function directly, the old behavior can be restored by passing in the desired values explicitly. + ([#527](https://github.com/python-attrs/cattrs/issues/527) [#528](https://github.com/python-attrs/cattrs/pull/528)) - Introduce {meth}`BaseConverter.get_structure_hook` and {meth}`BaseConverter.get_unstructure_hook` methods. ([#432](https://github.com/python-attrs/cattrs/issues/432) [#472](https://github.com/python-attrs/cattrs/pull/472)) - {meth}`BaseConverter.register_structure_hook`, {meth}`BaseConverter.register_unstructure_hook`, @@ -53,6 +54,8 @@ can now be used as decorators and have gained new features. ([#460](https://github.com/python-attrs/cattrs/issues/460) [#467](https://github.com/python-attrs/cattrs/pull/467)) - `typing_extensions.Any` is now supported and handled like `typing.Any`. ([#488](https://github.com/python-attrs/cattrs/issues/488) [#490](https://github.com/python-attrs/cattrs/pull/490)) +- `Optional` types can now be consistently customized using `register_structure_hook` and `register_unstructure_hook`. + ([#529](https://github.com/python-attrs/cattrs/issues/529) [#530](https://github.com/python-attrs/cattrs/pull/530)) - The BaseConverter now properly generates detailed validation errors for mappings. ([#496](https://github.com/python-attrs/cattrs/pull/496)) - [PEP 695](https://peps.python.org/pep-0695/) generics are now tested. diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index c2f72b36..cee50627 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -36,12 +36,10 @@ Any of these hooks can be overriden if pure validation is required instead. ```{doctest} >>> c = Converter() ->>> def validate(value, type): +>>> @c.register_structure_hook +... def validate(value, type) -> int: ... if not isinstance(value, type): ... raise ValueError(f'{value!r} not an instance of {type}') -... - ->>> c.register_structure_hook(int, validate) >>> c.structure("1", int) Traceback (most recent call last): @@ -110,12 +108,28 @@ Traceback (most recent call last): ... TypeError: int() argument must be a string, a bytes-like object or a number, not 'NoneType' ->>> cattrs.structure(None, int | None) ->>> # None was returned. +>>> print(cattrs.structure(None, int | None)) +None ``` Bare `Optional` s (non-parameterized, just `Optional`, as opposed to `Optional[str]`) aren't supported; `Optional[Any]` should be used instead. +`Optionals` handling can be customized using {meth}`register_structure_hook` and {meth}`register_unstructure_hook`. + +```{doctest} +>>> converter = Converter() + +>>> @converter.register_structure_hook +... def hook(val: Any, type: Any) -> str | None: +... if val in ("", None): +... return None +... return str(val) +... + +>>> print(converter.structure("", str | None)) +None +``` + ### Lists @@ -585,4 +599,4 @@ Protocols are unstructured according to the actual runtime type of the value. ```{versionadded} 1.9.0 -``` \ No newline at end of file +``` diff --git a/docs/recipes.md b/docs/recipes.md index 5d356e46..aeed4b92 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -10,14 +10,10 @@ In certain situations, you might want to deviate from this behavior and use alte For example, consider the following `Point` class describing points in 2D space, which offers two `classmethod`s for alternative creation: -```{doctest} ->>> from __future__ import annotations - +```{doctest} point_group >>> import math - >>> from attrs import define - >>> @define ... class Point: ... """A point in 2D space.""" @@ -25,12 +21,12 @@ For example, consider the following `Point` class describing points in 2D space, ... y: float ... ... @classmethod -... def from_tuple(cls, coordinates: tuple[float, float]) -> Point: +... def from_tuple(cls, coordinates: tuple[float, float]) -> "Point": ... """Create a point from a tuple of Cartesian coordinates.""" ... return Point(*coordinates) ... ... @classmethod -... def from_polar(cls, radius: float, angle: float) -> Point: +... def from_polar(cls, radius: float, angle: float) -> "Point": ... """Create a point from its polar coordinates.""" ... return Point(radius * math.cos(angle), radius * math.sin(angle)) ``` @@ -40,7 +36,7 @@ For example, consider the following `Point` class describing points in 2D space, A simple way to _statically_ set one of the `classmethod`s as initializer is to register a structuring hook that holds a reference to the respective callable: -```{doctest} +```{doctest} point_group >>> from inspect import signature >>> from typing import Callable, TypedDict @@ -48,9 +44,10 @@ A simple way to _statically_ set one of the `classmethod`s as initializer is to >>> from cattrs.dispatch import StructureHook >>> def signature_to_typed_dict(fn: Callable) -> type[TypedDict]: -... """Create a TypedDict reflecting a callable's signature.""" +... """Create a TypedDict reflecting a callable's signature.""" ... params = {p: t.annotation for p, t in signature(fn).parameters.items()} ... return TypedDict(f"{fn.__name__}_args", params) +... >>> def make_initializer_from(fn: Callable, conv: Converter) -> StructureHook: ... """Return a structuring hook from a given callable.""" @@ -61,7 +58,7 @@ A simple way to _statically_ set one of the `classmethod`s as initializer is to Now, you can easily structure `Point`s from the specified alternative representation: -```{doctest} +```{doctest} point_group >>> c = Converter() >>> c.register_structure_hook(Point, make_initializer_from(Point.from_polar, c)) @@ -78,7 +75,7 @@ A typical scenario would be when object structuring happens behind an API and yo In such situations, the following hook factory can help you achieve your goal: -```{doctest} +```{doctest} point_group >>> from inspect import signature >>> from typing import Callable, TypedDict @@ -90,6 +87,7 @@ In such situations, the following hook factory can help you achieve your goal: ... params = {p: t.annotation for p, t in signature(fn).parameters.items()} ... return TypedDict(f"{fn.__name__}_args", params) +>>> T = TypeVar("T") >>> def make_initializer_selection_hook( ... initializer_key: str, ... converter: Converter, @@ -116,7 +114,7 @@ In such situations, the following hook factory can help you achieve your goal: Specifying the key that determines the initializer to be used now lets you dynamically select the `classmethod` as part of the object specification itself: -```{doctest} +```{doctest} point_group >>> c = Converter() >>> c.register_structure_hook(Point, make_initializer_selection_hook("initializer", c)) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 4bed9991..5ca1d065 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -23,6 +23,7 @@ OriginMutableSet, Sequence, Set, + TypeAlias, fields, get_final_base, get_newtype_base, @@ -245,12 +246,12 @@ def __init__( (is_namedtuple, namedtuple_structure_factory, "extended"), (is_mapping, self._structure_dict), (is_supported_union, self._gen_attrs_union_structure, True), + (is_optional, self._structure_optional), ( lambda t: is_union_type(t) and t in self._union_struct_registry, self._union_struct_registry.__getitem__, True, ), - (is_optional, self._structure_optional), (has, self._structure_attrs), ] ) @@ -1382,4 +1383,4 @@ def copy( return res -GenConverter = Converter +GenConverter: TypeAlias = Converter diff --git a/tests/test_optionals.py b/tests/test_optionals.py index 5eec5c0b..2fca1de6 100644 --- a/tests/test_optionals.py +++ b/tests/test_optionals.py @@ -3,7 +3,7 @@ import pytest from attrs import define -from cattrs import Converter +from cattrs import BaseConverter, Converter from ._compat import is_py310_plus @@ -51,3 +51,23 @@ class A: pass assert converter.unstructure(A(), Optional[Any]) == {} + + +def test_override_optional(converter: BaseConverter): + """Optionals can be overridden using singledispatch.""" + + @converter.register_structure_hook + def _(val, _) -> Optional[int]: + if val in ("", None): + return None + return int(val) + + assert converter.structure("", Optional[int]) is None + + @converter.register_unstructure_hook + def _(val: Optional[int]) -> Any: + if val in (None, 0): + return None + return val + + assert converter.unstructure(0, Optional[int]) is None