diff --git a/HISTORY.md b/HISTORY.md index 7bcef0a4..7407829d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,6 +4,9 @@ ## 24.1.0 (UNRELEASED) +- **Potentially breaking**: Unstructuring hooks for `typing.Any` are consistent now: values are unstructured using their runtime type. + Previously this behavior was underspecified and inconsistent, but followed this rule in the majority of cases. + ([#473](https://github.com/python-attrs/cattrs/pull/473)) - 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)) - The default union handler now properly takes renamed fields into account. @@ -26,6 +29,8 @@ - Tests are run with the pytest-xdist plugin by default. - Rework the introductory parts of the documentation, introducing the Basics section. ([#472](https://github.com/python-attrs/cattrs/pull/472)) +- The documentation has been significantly reworked. + ([#473](https://github.com/python-attrs/cattrs/pull/473)) - The docs now use the Inter font. ## 23.2.3 (2023-11-30) diff --git a/README.md b/README.md index 704e6aad..0419682f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # cattrs +

+ Great software needs great data structures. +

+ Documentation Status @@ -103,7 +107,7 @@ When you're done, `unstructure` the data to its unstructured form and pass it al Use [attrs type metadata](http://attrs.readthedocs.io/en/stable/examples.html#types) to add type metadata to attributes, so _cattrs_ will know how to structure and destructure them. - Free software: MIT license -- Documentation: https://catt.rs +- Documentation: [https://catt.rs](https://catt.rs) - Python versions supported: 3.8 and up. (Older Python versions are supported by older versions; see the changelog.) ## Features diff --git a/docs/Makefile b/docs/Makefile index c13822c9..00a11a8f 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -171,8 +171,9 @@ pseudoxml: @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." +.PHONY: apidoc apidoc: - pdm run sphinx-apidoc -o . ../src/cattrs/ -f + pdm run sphinx-apidoc -o . ../src/cattrs/ '../**/converters.py' -f -M ## htmlview to open the index page built by the html target in your browser .PHONY: htmlview diff --git a/docs/basics.md b/docs/basics.md index 23441547..cf978de6 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -3,16 +3,16 @@ ``` All _cattrs_ functionality is exposed through a {class}`cattrs.Converter` object. -A global converter is provided for convenience as {data}`cattrs.global_converter` but more complex customizations should be performed on private instances. +A global converter is provided for convenience as {data}`cattrs.global_converter` but more complex customizations should be performed on private instances, any number of which can be made. -## Converters +## Converters and Hooks -The core functionality of a converter is [structuring](structuring.md) and [unstructuring](unstructuring.md) data by composing provided and [custom handling functions](customizing.md), called _hooks_. +The core functionality of a converter is structuring and unstructuring data by composing [provided](defaulthooks.md) and [custom handling functions](customizing.md), called _hooks_. To create a private converter, instantiate a {class}`cattrs.Converter`. Converters are relatively cheap; users are encouraged to have as many as they need. -The two main methods are {meth}`structure ` and {meth}`unstructure `, these are used to convert between _structured_ and _unstructured_ data. +The two main methods, {meth}`structure ` and {meth}`unstructure `, are used to convert between _structured_ and _unstructured_ data. ```python >>> from cattrs import structure, unstructure @@ -28,53 +28,54 @@ The two main methods are {meth}`structure ` and Model(a=1) ``` -_cattrs_ comes with a rich library of un/structuring rules by default, but it excels at composing custom rules with built-in ones. +_cattrs_ comes with a rich library of un/structuring hooks by default but it excels at composing custom hooks with built-in ones. -The simplest approach to customization is wrapping an existing hook with your own function. -A base hook can be obtained from a converter and be subjected to the very rich machinery of function composition in Python. +The simplest approach to customization is writing a new hook from scratch. +For example, we can write our own hook for the `int` class. ```python ->>> from cattrs import get_structure_hook +>>> def int_hook(value, type): +... if not isinstance(value, int): +... raise ValueError('not an int!') +... return value +``` ->>> base_hook = get_structure_hook(Model) +We can then register this hook to a converter and any other hook converting an `int` will use it. ->>> def my_hook(value, type): +```python +>>> from cattrs import Converter + +>>> converter = Converter() +>>> converter.register_structure_hook(int, int_hook) +``` + +Another approach to customization is wrapping an existing hook with your own function. +A base hook can be obtained from a converter and then be subjected to the very rich machinery of function composition that Python offers. + + +```python +>>> base_hook = converter.get_structure_hook(Model) + +>>> def my_model_hook(value, type): ... # Apply any preprocessing to the value. ... result = base_hook(value, type) -... # Apply any postprocessing to the value. +... # Apply any postprocessing to the model. ... return result ``` -This new hook can be used directly or registered to a converter (the original instance, or a different one). - (`cattrs.structure({}, Model)` is shorthand for `cattrs.get_structure_hook(Model)({}, Model)`.) -Another approach is to write a hook from scratch instead of wrapping an existing one. -For example, we can write our own hook for the `int` class. +This new hook can be used directly or registered to a converter (the original instance, or a different one): ```python ->>> def my_int_hook(value, type): -... if not isinstance(value, int): -... raise ValueError('not an int!') -... return value +>>> converter.register_structure_hook(Model, my_model_hook) ``` -We can then register this hook to a converter, and any other hook converting an `int` will use it. -Since this is an impactful change, we will switch to using a private converter. - -```python ->>> from cattrs import Converter - ->>> c = Converter() - ->>> c.register_structure_hook(int, my_int_hook) -``` -Now, if we ask our new converter for a `Model` hook, through the ✨magic of function composition✨ that hook will use our new `my_int_hook`. +Now if we use this hook to structure a `Model`, through the ✨magic of function composition✨ that hook will use our old `int_hook`. ```python ->>> base_hook = c.get_structure_hook(Model) ->>> base_hook({"a": "1"}, Model) +>>> converter.structure({"a": "1"}, Model) + Exception Group Traceback (most recent call last): | File "...", line 22, in | base_hook({"a": "1"}, Model) @@ -95,7 +96,7 @@ More advanced structuring customizations are commonly called [](strategies.md). ## Global Converter -Global _cattrs_ functions, such as {meth}`cattrs.unstructure`, use a single {data}`global converter `. +Global _cattrs_ functions, such as {meth}`cattrs.structure`, use a single {data}`global converter `. Changes done to this global converter, such as registering new structure and unstructure hooks, affect all code using the global functions. The following functions implicitly use this global converter: diff --git a/docs/cattrs.gen.rst b/docs/cattrs.gen.rst index 1968fcae..390cfca7 100644 --- a/docs/cattrs.gen.rst +++ b/docs/cattrs.gen.rst @@ -1,6 +1,11 @@ cattrs.gen package ================== +.. automodule:: cattrs.gen + :members: + :undoc-members: + :show-inheritance: + Submodules ---------- @@ -11,11 +16,3 @@ cattrs.gen.typeddicts module :members: :undoc-members: :show-inheritance: - -Module contents ---------------- - -.. automodule:: cattrs.gen - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/cattrs.preconf.rst b/docs/cattrs.preconf.rst index f51586a2..61a94d2c 100644 --- a/docs/cattrs.preconf.rst +++ b/docs/cattrs.preconf.rst @@ -1,6 +1,11 @@ cattrs.preconf package ====================== +.. automodule:: cattrs.preconf + :members: + :undoc-members: + :show-inheritance: + Submodules ---------- @@ -67,11 +72,3 @@ cattrs.preconf.ujson module :members: :undoc-members: :show-inheritance: - -Module contents ---------------- - -.. automodule:: cattrs.preconf - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/cattrs.rst b/docs/cattrs.rst index df008424..5170d264 100644 --- a/docs/cattrs.rst +++ b/docs/cattrs.rst @@ -1,6 +1,11 @@ cattrs package ============== +.. automodule:: cattrs + :members: + :undoc-members: + :show-inheritance: + Subpackages ----------- @@ -14,14 +19,6 @@ Subpackages Submodules ---------- -cattrs.converters module ------------------------- - -.. automodule:: cattrs.converters - :members: - :undoc-members: - :show-inheritance: - cattrs.disambiguators module ---------------------------- @@ -61,11 +58,3 @@ cattrs.v module :members: :undoc-members: :show-inheritance: - -Module contents ---------------- - -.. automodule:: cattrs - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/cattrs.strategies.rst b/docs/cattrs.strategies.rst index bce804b2..d85e24a0 100644 --- a/docs/cattrs.strategies.rst +++ b/docs/cattrs.strategies.rst @@ -1,9 +1,6 @@ cattrs.strategies package ========================= -Module contents ---------------- - .. automodule:: cattrs.strategies :members: :undoc-members: diff --git a/docs/conf.py b/docs/conf.py index e1130b3b..5badbac3 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # cattrs documentation build configuration file, created by # sphinx-quickstart on Tue Jul 9 22:26:36 2013. # @@ -289,6 +287,7 @@ "from typing import *;" "from enum import Enum, unique" ) +autodoc_member_order = "bysource" autodoc_typehints = "description" autosectionlabel_prefix_document = True copybutton_prompt_text = r">>> |\.\.\. " diff --git a/docs/customizing.md b/docs/customizing.md index 43c43220..7efc229b 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -62,8 +62,7 @@ Hook factories are registered using {meth}`Converter.register_unstructure_hook_f Here's an example showing how to use hook factories to apply the `forbid_extra_keys` to all attrs classes: -```{doctest} - +```python >>> from attrs import define, has >>> from cattrs.gen import make_dict_structure_fn @@ -135,7 +134,7 @@ So we apply the `omit_if_default` rule to the class, but not to the `dateTime` f >>> @define ... class TestClass: ... a: Optional[int] = None -... b: dateTime = Factory(datetime.utcnow) +... b: datetime = Factory(datetime.utcnow) >>> c = cattrs.Converter() >>> hook = make_dict_unstructure_fn(TestClass, c, _cattrs_omit_if_default=True, b=override(omit_if_default=False)) diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md new file mode 100644 index 00000000..ad7cb34e --- /dev/null +++ b/docs/defaulthooks.md @@ -0,0 +1,572 @@ +# Built-in Hooks + +```{currentmodule} cattrs +``` + +_cattrs_ converters come with with a large repertoire of un/structuring hooks built-in. +As always, complex hooks compose with simpler ones. + +## Primitive Values + +### `int`, `float`, `str`, `bytes` + +When structuring, use any of these types to coerce the object to that type. + +```{doctest} + +>>> cattrs.structure(1, str) +'1' +>>> cattrs.structure("1", float) +1.0 +``` + +In case the conversion isn't possible the expected exceptions will be propagated out. +The particular exceptions are the same as if you'd tried to do the coercion directly. + +```python +>>> cattrs.structure("not-an-int", int) +Traceback (most recent call last): +... +ValueError: invalid literal for int() with base 10: 'not-an-int' +``` + +Coercion is performed for performance and compatibility reasons. +Any of these hooks can be overriden if pure validation is required instead. + +```{doctest} +>>> c = Converter() + +>>> def validate(value, type): +... 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): +... +ValueError: '1' not an instance of +``` + +When unstructuring, these types are passed through unchanged. + +### Enums + +Enums are structured by their values, and unstructured to their values. +This works even for complex values, like tuples. + +```{doctest} + +>>> @unique +... class CatBreed(Enum): +... SIAMESE = "siamese" +... MAINE_COON = "maine_coon" +... SACRED_BIRMAN = "birman" + +>>> cattrs.structure("siamese", CatBreed) + + +>>> cattrs.unstructure(CatBreed.SIAMESE) +'siamese' +``` + +Again, in case of errors, the expected exceptions are raised. + +### `pathlib.Path` + +[`pathlib.Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) objects are structured using their string value, +and unstructured into their string value. + +```{doctest} +>>> from pathlib import Path + +>>> cattrs.structure("/root", Path) +PosixPath('/root') + +>>> cattrs.unstructure(Path("/root")) +'/root' +``` + +In case the conversion isn't possible, the resulting exception is propagated out. + +```{versionadded} 23.1.0 + +``` + + +## Collections and Related Generics + + +### Optionals + +`Optional` primitives and collections are supported out of the box. +[PEP 604](https://peps.python.org/pep-0604/) optionals (`T | None`) are also supported on Python 3.10 and later. + +```{doctest} + +>>> cattrs.structure(None, int) +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. +``` + +Bare `Optional` s (non-parameterized, just `Optional`, as opposed to `Optional[str]`) aren't supported; `Optional[Any]` should be used instead. + + +### Lists + +Lists can be structured from any iterable object. +Types converting to lists are: + +- `typing.Sequence[T]` +- `typing.MutableSequence[T]` +- `typing.List[T]` +- `list[T]` + +In all cases, a new list will be returned, so this operation can be used to copy an iterable into a list. +A bare type, for example `Sequence` instead of `Sequence[int]`, is equivalent to `Sequence[Any]`. + +```{doctest} + +>>> cattrs.structure((1, 2, 3), MutableSequence[int]) +[1, 2, 3] +``` + +When unstructuring, lists are copied and their contents are handled according to their inner type. +A useful use case for unstructuring collections is to create a deep copy of a complex or recursive collection. + +### Dictionaries + +Dictionaries can be produced from other mapping objects. +More precisely, the unstructured object must expose an [`items()`](https://docs.python.org/3/library/stdtypes.html#dict.items) method producing an iterable of key-value tuples, and be able to be passed to the `dict` constructor as an argument. +Types converting to dictionaries are: + +- `typing.Dict[K, V]` +- `typing.MutableMapping[K, V]` +- `typing.Mapping[K, V]` +- `dict[K, V]` + +In all cases, a new dict will be returned, so this operation can be used to copy a mapping into a dict. +Any type parameters set to `typing.Any` will be passed through unconverted. +If both type parameters are absent, they will be treated as `Any` too. + +```{doctest} + +>>> from collections import OrderedDict +>>> cattrs.structure(OrderedDict([(1, 2), (3, 4)]), dict) +{1: 2, 3: 4} +``` + +Both keys and values are converted. + +```{doctest} + +>>> cattrs.structure({1: None, 2: 2.0}, dict[str, Optional[int]]) +{'1': None, '2': 2} +``` + + +### Homogeneous and Heterogeneous Tuples + +Homogeneous and heterogeneous tuples can be structured from iterable objects. +Heterogeneous tuples require an iterable with the number of elements matching the number of type parameters exactly. + +Use: + +- `Tuple[A, B, C, D]` +- `tuple[A, B, C, D]` + +Homogeneous tuples use: + +- `Tuple[T, ...]` +- `tuple[T, ...]` + +In all cases a tuple will be produced. +Any type parameters set to `typing.Any` will be passed through unconverted. + +```{doctest} + +>>> cattrs.structure([1, 2, 3], tuple[int, str, float]) +(1, '2', 3.0) +``` + + +### Deques + +Deques can be structured from any iterable object. +Types converting to deques are: + +- `typing.Deque[T]` +- `collections.deque[T]` + +In all cases, a new **unbounded** deque (`maxlen=None`) will be produced, so this operation can be used to copy an iterable into a deque. +If you want to convert into bounded `deque`, registering a custom structuring hook is a good approach. + +```{doctest} + +>>> from collections import deque +>>> cattrs.structure((1, 2, 3), deque[int]) +deque([1, 2, 3]) +``` + +Deques are unstructured into lists, or into deques when using the {class}`BaseConverter`. + +```{versionadded} 23.1.0 + +``` + + +### Sets and Frozensets + +Sets and frozensets can be structured from any iterable object. +Types converting to sets are: + +- `typing.Set[T]` +- `typing.MutableSet[T]` +- `set[T]` + +Types converting to frozensets are: + +- `typing.FrozenSet[T]` +- `frozenset[T]` + +In all cases, a new set or frozenset will be returned. +A bare type, for example `MutableSet` instead of `MutableSet[int]`, is equivalent to `MutableSet[Any]`. + +```{doctest} + +>>> cattrs.structure([1, 2, 3, 4], set) +{1, 2, 3, 4} +``` + +Sets and frozensets are unstructured into the same class. + + +### Typed Dicts + +[TypedDicts](https://peps.python.org/pep-0589/) can be structured from mapping objects, usually dictionaries. + +```{doctest} +>>> from typing import TypedDict + +>>> class MyTypedDict(TypedDict): +... a: int + +>>> cattrs.structure({"a": "1"}, MyTypedDict) +{'a': 1} +``` + +Both [_total_ and _non-total_](https://peps.python.org/pep-0589/#totality) TypedDicts are supported, and inheritance between any combination works (except on 3.8 when `typing.TypedDict` is used, see below). +Generic TypedDicts work on Python 3.11 and later, since that is the first Python version that supports them in general. + +[`typing.Required` and `typing.NotRequired`](https://peps.python.org/pep-0655/) are supported. + +On Python 3.8, using `typing_extensions.TypedDict` is recommended since `typing.TypedDict` doesn't support all necessary features so certain combinations of subclassing, totality and `typing.Required` won't work. + +[Similar to _attrs_ classes](customizing.md#using-cattrsgen-generators), un/structuring can be customized using {meth}`cattrs.gen.typeddicts.make_dict_structure_fn` and {meth}`cattrs.gen.typeddicts.make_dict_unstructure_fn`. + +```{doctest} +>>> from typing import TypedDict +>>> from cattrs import Converter +>>> from cattrs.gen import override +>>> from cattrs.gen.typeddicts import make_dict_structure_fn + +>>> class MyTypedDict(TypedDict): +... a: int +... b: int + +>>> c = Converter() +>>> c.register_structure_hook( +... MyTypedDict, +... make_dict_structure_fn( +... MyTypedDict, +... c, +... a=override(rename="a-with-dash") +... ) +... ) + +>>> c.structure({"a-with-dash": 1, "b": 2}, MyTypedDict) +{'b': 2, 'a': 1} +``` + +TypedDicts unstructure into dictionaries, potentially unchanged (depending on the exact field types and registered hooks). + +```{doctest} +>>> from typing import TypedDict +>>> from datetime import datetime, timezone +>>> from cattrs import Converter + +>>> class MyTypedDict(TypedDict): +... a: datetime + +>>> c = Converter() +>>> c.register_unstructure_hook(datetime, lambda d: d.timestamp()) + +>>> c.unstructure({"a": datetime(1970, 1, 1, tzinfo=timezone.utc)}, unstructure_as=MyTypedDict) +{'a': 0.0} +``` + +```{versionadded} 23.1.0 + +``` + + +## _attrs_ Classes and Dataclasses + +_attrs_ classes and dataclasses work out of the box. +The fields require type annotations (even if static type-checking is not being used), or they will be treated as [](#typingany). + +When structuring, given a mapping `d` and class `A`, _cattrs_ will instantiate `A` with `d` unpacked. + +```{doctest} + +>>> @define +... class A: +... a: int +... b: int + +>>> cattrs.structure({'a': 1, 'b': '2'}, A) +A(a=1, b=2) +``` + +Tuples can be structured into classes using {meth}`structure_attrs_fromtuple() ` (`fromtuple` as in the opposite of [`attrs.astuple`](https://www.attrs.org/en/stable/api.html#attrs.astuple) and {meth}`BaseConverter.unstructure_attrs_astuple`). + +```{doctest} + +>>> @define +... class A: +... a: str +... b: int + +>>> cattrs.structure_attrs_fromtuple(['string', '2'], A) +A(a='string', b=2) +``` + +Loading from tuples can be made the default by creating a new {class}`Converter ` with `unstruct_strat=cattr.UnstructureStrategy.AS_TUPLE`. + +```{doctest} + +>>> converter = cattrs.Converter(unstruct_strat=cattr.UnstructureStrategy.AS_TUPLE) +>>> @define +... class A: +... a: str +... b: int + +>>> converter.structure(['string', '2'], A) +A(a='string', b=2) +``` + +Structuring from tuples can also be made the default for specific classes only by registering a hook the usual way. + +```{doctest} + +>>> converter = cattrs.Converter() + +>>> @define +... class A: +... a: str +... b: int + +>>> converter.register_structure_hook(A, converter.structure_attrs_fromtuple) +``` + + +### Generics + +Generic _attrs_ classes and dataclasses are fully supported, both using `typing.Generic` and [PEP 695](https://peps.python.org/pep-0695/). + +```python +>>> @define +... class A[T]: +... a: T + +>>> cattrs.structure({"a": "1"}, A[int]) +A(a=1) +``` + + +### Using Attribute Types and Converters + +By default, {meth}`structure() ` will use hooks registered using {meth}`register_structure_hook() ` +to convert values to the attribute type, and proceed to invoking any converters registered on attributes with `field`. + +```{doctest} + +>>> from ipaddress import IPv4Address, ip_address +>>> converter = cattrs.Converter() + +# Note: register_structure_hook has not been called, so this will fallback to 'ip_address' +>>> @define +... class A: +... a: IPv4Address = field(converter=ip_address) + +>>> converter.structure({'a': '127.0.0.1'}, A) +A(a=IPv4Address('127.0.0.1')) +``` + +Priority is still given to hooks registered with {meth}`register_structure_hook() `, +but this priority can be inverted by setting `prefer_attrib_converters` to `True`. + +```{doctest} + +>>> converter = cattrs.Converter(prefer_attrib_converters=True) + +>>> @define +... class A: +... a: int = field(converter=lambda v: int(v) + 5) + +>>> converter.structure({'a': '10'}, A) +A(a=15) +``` + +```{seealso} +If an _attrs_ or dataclass class uses inheritance and as such has one or several subclasses, it can be structured automatically to its exact subtype by using the [include subclasses](strategies.md#include-subclasses-strategy) strategy. +``` + + +## Unions + +Unions of `NoneType` and a single other type (also known as optionals) are supported by a [special case](#optionals). + + +### Automatic Disambiguation + +_cattrs_ includes an opinionated strategy for automatically handling unions of _attrs_ classes; see [](unions.md#default-union-strategy) for details. + +When unstructuring these kinds of unions, each union member will be unstructured according to the hook for that type. + + +### Unions of Simple Types + +_cattrs_ comes with the [](strategies.md#union-passthrough), which enables converters to structure unions of many primitive types and literals. +This strategy can be applied to any converter, and is pre-applied to all [preconf](preconf.md) converters according to their underlying protocols. + + +## Special Typing Forms + + +### `typing.Any` + +When structuring, use `typing.Any` to avoid applying any conversions to the object you're structuring; it will simply be passed through. + +```{doctest} + +>>> cattrs.structure(1, Any) +1 +>>> d = {1: 1} +>>> cattrs.structure(d, Any) is d +True +``` + +When unstructuring, `typing.Any` will make the value be unstructured according to its runtime class. + +```{versionchanged} 24.1.0 +Previously, the unstructuring rules for `Any` were underspecified, leading to inconsistent behavior. +``` + +### `typing.Literal` + +When structuring, [PEP 586](https://peps.python.org/pep-0586/) literals are validated to be in the allowed set of values. + +```{doctest} +>>> from typing import Literal + +>>> cattrs.structure(1, Literal[1, 2]) +1 +``` + +When unstructuring, literals are passed through. + +```{versionadded} 1.7.0 + +``` + + +### `typing.Final` + +[PEP 591](https://peps.python.org/pep-0591/) Final attribute types (`Final[int]`) are supported and handled according to the inner type (in this case, `int`). + +```{versionadded} 23.1.0 + +``` + + +### `typing.Annotated` + +[PEP 593](https://www.python.org/dev/peps/pep-0593/) annotations (`typing.Annotated[type, ...]`) are supported and are handled using the first type present in the annotated type. + +```{versionadded} 1.4.0 + +``` + + +### Type Aliases + +[Type aliases](https://docs.python.org/3/library/typing.html#type-aliases) are supported on Python 3.12+ and are handled according to the rules for their underlying type. +Their hooks can also be overriden using [](customizing.md#predicate-hooks). + +```{warning} +Type aliases using [`typing.TypeAlias`](https://docs.python.org/3/library/typing.html#typing.TypeAlias) aren't supported since there is no way at runtime to distinguish them from their underlying types. +``` + +```python +>>> from datetime import datetime, UTC + +>>> type IsoDate = datetime + +>>> converter = cattrs.Converter() +>>> converter.register_structure_hook_func( +... lambda t: t is IsoDate, lambda v, _: datetime.fromisoformat(v) +... ) +>>> converter.register_unstructure_hook_func( +... lambda t: t is IsoDate, lambda v: v.isoformat() +... ) + +>>> converter.structure("2022-01-01", IsoDate) +datetime.datetime(2022, 1, 1, 0, 0) +>>> converter.unstructure(datetime.now(UTC), unstructure_as=IsoDate) +'2023-11-20T23:10:46.728394+00:00' +``` + +```{versionadded} 24.1.0 + +``` + + +### `typing.NewType` + +[NewTypes](https://docs.python.org/3/library/typing.html#newtype) are supported and are handled according to the rules for their underlying type. +Their hooks can also be overriden using {meth}`Converter.register_structure_hook() `. + +```{doctest} + +>>> from typing import NewType +>>> from datetime import datetime + +>>> IsoDate = NewType("IsoDate", datetime) + +>>> converter = cattrs.Converter() +>>> converter.register_structure_hook(IsoDate, lambda v, _: datetime.fromisoformat(v)) + +>>> converter.structure("2022-01-01", IsoDate) +datetime.datetime(2022, 1, 1, 0, 0) +``` + +```{versionadded} 22.2.0 + +``` + + +### `typing.Protocol` + +[Protocols](https://peps.python.org/pep-0544/) cannot be structured by default and so require custom hooks. + +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/indepth.md b/docs/indepth.md index 0d7802e2..94048349 100644 --- a/docs/indepth.md +++ b/docs/indepth.md @@ -2,8 +2,6 @@ ```{currentmodule} cattrs ``` -## Converters - Converters are registries of rules _cattrs_ uses to perform function composition and generate its un/structuring functions. Currently, a converter contains the following state: @@ -18,7 +16,57 @@ Currently, a converter contains the following state: Converters may be cloned using the {meth}`Converter.copy() ` method. The new copy may be changed through the `copy` arguments, but will retain all manually registered hooks from the original. -### Fallback Hook Factories + +## Customizing Collection Unstructuring + +```{important} +This feature is supported for Python 3.9 and later. +``` + +Overriding collection unstructuring in a generic way can be a very useful feature. +A common example is using a JSON library that doesn't support sets, but expects lists and tuples instead. + +Using ordinary unstructuring hooks for this is unwieldy due to the semantics of +[singledispatch](https://docs.python.org/3/library/functools.html#functools.singledispatch); +in other words, you'd need to register hooks for all specific types of set you're using (`set[int]`, `set[float]`, +`set[str]`...), which is not useful. + +Function-based hooks can be used instead, but come with their own set of challenges - they're complicated to write efficiently. + +The {class}`Converter` supports easy customizations of collection unstructuring using its `unstruct_collection_overrides` parameter. +For example, to unstructure all sets into lists, use the following: + +```{doctest} + +>>> from collections.abc import Set +>>> converter = cattrs.Converter(unstruct_collection_overrides={Set: list}) + +>>> converter.unstructure({1, 2, 3}) +[1, 2, 3] +``` + +Going even further, the `Converter` contains heuristics to support the following Python types, in order of decreasing generality: + +- `typing.Sequence`, `typing.MutableSequence`, `list`, `deque`, `tuple` +- `typing.Set`, `frozenset`, `typing.MutableSet`, `set` +- `typing.Mapping`, `typing.MutableMapping`, `dict`, `defaultdict`, `collections.OrderedDict`, `collections.Counter` + +For example, if you override the unstructure type for `Sequence`, but not for `MutableSequence`, `list` or `tuple`, the override will also affect those types. +An easy way to remember the rule: + +- all `MutableSequence` s are `Sequence` s, so the override will apply +- all `list` s are `MutableSequence` s, so the override will apply +- all `tuple` s are `Sequence` s, so the override will apply + +If, however, you override only `MutableSequence`, fields annotated as `Sequence` will not be affected (since not all sequences are mutable sequences), and fields annotated as tuples will not be affected (since tuples +are not mutable sequences in the first place). + +Similar logic applies to the set and mapping hierarchies. + +Make sure you're using the types from `collections.abc` on Python 3.9+, and from `typing` on older Python versions. + + +## Fallback Hook Factories By default, when a {class}`converter ` cannot handle a type it will: @@ -54,7 +102,7 @@ This also enables converters to be chained. ``` -### `cattrs.Converter` +## `cattrs.Converter` The {class}`Converter` is a converter variant that automatically generates, compiles and caches specialized structuring and unstructuring hooks for _attrs_ classes, dataclasses and TypedDicts. @@ -68,7 +116,7 @@ The {class}`Converter` is a converter variant that automatically generates, comp The {class}`Converter` used to be called `GenConverter`, and that alias is still present for backwards compatibility. -### `cattrs.BaseConverter` +## `cattrs.BaseConverter` The {class}`BaseConverter` is a simpler and slower converter variant. It does no code generation, so it may be faster on first-use which can be useful in specific cases, like CLI applications where startup time is more important than throughput. diff --git a/docs/index.md b/docs/index.md index 691836e9..7e7eb8a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,8 +6,7 @@ hidden: true self basics -structuring -unstructuring +defaulthooks customizing strategies validation diff --git a/docs/preconf.md b/docs/preconf.md index d68b3dfa..48d75ce3 100644 --- a/docs/preconf.md +++ b/docs/preconf.md @@ -13,13 +13,8 @@ For example, to get a converter configured for BSON: Converters obtained this way can be customized further, just like any other converter. -These converters support the following classes and type annotations, both for structuring and unstructuring: +These converters support the following additional classes and type annotations, both for structuring and unstructuring: -- `str`, `bytes`, `int`, `float`, `pathlib.Path` int enums, string enums -- _attrs_ classes and dataclasses -- lists, homogenous tuples, heterogenous tuples, dictionaries, counters, sets, frozensets -- optionals -- sequences, mutable sequences, mappings, mutable mappings, sets, mutable sets - `datetime.datetime`, `datetime.date` ```{versionadded} 22.1.0 @@ -42,23 +37,25 @@ All preconf converters now have `loads` and `dumps` methods, which combine un/st Particular libraries may have additional constraints documented below. -Third-party libraries can be specified as optional (extra) dependencies on `cattrs` during installation. +Third-party libraries can be specified as optional (extra) dependencies on _cattrs_ during installation. Optional install targets should match the name of the {mod}`cattrs.preconf` modules. ```console # Using pip -pip install cattrs[ujson] +$ pip install cattrs[ujson] # Using poetry -poetry add --extras tomlkit cattrs +$ poetry add --extras tomlkit cattrs ``` + ## Standard Library _json_ Found at {mod}`cattrs.preconf.json`. Bytes are serialized as base 85 strings. Counters are serialized as dictionaries. Sets are serialized as lists, and deserialized back into sets. `datetime` s and `date` s are serialized as ISO 8601 strings. + ## _ujson_ Found at {mod}`cattrs.preconf.ujson`. @@ -67,15 +64,19 @@ Bytes are serialized as base 85 strings. Sets are serialized as lists, and deser `ujson` doesn't support integers less than -9223372036854775808, and greater than 9223372036854775807, nor does it support `float('inf')`. + ## _orjson_ Found at {mod}`cattrs.preconf.orjson`. -Bytes are serialized as base 85 strings. Sets are serialized as lists, and deserialized back into sets. `datetime` s and `date` s are serialized as ISO 8601 strings. +Bytes are un/structured as base 85 strings. +Sets are unstructured into lists, and structured back into sets. +`datetime` s and `date` s are passed through to be unstructured into RFC 3339 by _orjson_ itself. _orjson_ doesn't support integers less than -9223372036854775808, and greater than 9223372036854775807. _orjson_ only supports mappings with string keys so mappings will have their keys stringified before serialization, and destringified during deserialization. + ## _msgpack_ Found at {mod}`cattrs.preconf.msgpack`. @@ -86,6 +87,7 @@ _msgpack_ doesn't support integers less than -9223372036854775808, and greater t When parsing msgpack data from bytes, the library needs to be passed `strict_map_key=False` to get the full range of compatibility. + ## _cbor2_ ```{versionadded} 23.1.0 @@ -110,6 +112,7 @@ Use keyword argument `canonical=True` for efficient encoding to the smallest bin Floats can be forced to smaller output by casting to lower-precision formats by casting to `numpy` floats (and back to Python floats). Example: `float(np.float32(value))` or `float(np.float16(value))` + ## _bson_ Found at {mod}`cattrs.preconf.bson`. Tested against the _bson_ module bundled with the _pymongo_ library, not the standalone PyPI _bson_ package. @@ -124,12 +127,14 @@ The _bson_ datetime representation doesn't support microsecond accuracy. When encoding and decoding, the library needs to be passed `codec_options=bson.CodecOptions(tz_aware=True)` to get the full range of compatibility. + ## _pyyaml_ Found at {mod}`cattrs.preconf.pyyaml`. Frozensets are serialized as lists, and deserialized back into frozensets. `date` s are serialized as ISO 8601 strings. + ## _tomlkit_ Found at {mod}`cattrs.preconf.tomlkit`. diff --git a/docs/structuring.md b/docs/structuring.md deleted file mode 100644 index 8ca19a81..00000000 --- a/docs/structuring.md +++ /dev/null @@ -1,603 +0,0 @@ -# What You Can Structure and How - -The philosophy of _cattrs_ structuring is simple: give it an instance of Python built-in types and collections, and a type describing the data you want out. -_cattrs_ will convert the input data into the type you want, or throw an exception. - -All structuring conversions are composable, where applicable. -This is demonstrated further in the examples. - -## Primitive Values - -### `typing.Any` - -Use `typing.Any` to avoid applying any conversions to the object you're -structuring; it will simply be passed through. - -```{doctest} - ->>> cattrs.structure(1, Any) -1 ->>> d = {1: 1} ->>> cattrs.structure(d, Any) is d -True -``` - -### `int`, `float`, `str`, `bytes` - -Use any of these primitive types to convert the object to the type. - -```{doctest} - ->>> cattrs.structure(1, str) -'1' ->>> cattrs.structure("1", float) -1.0 -``` - -In case the conversion isn't possible, the expected exceptions will be -propagated out. The particular exceptions are the same as if you'd tried to -do the conversion yourself, directly. - -```python ->>> cattrs.structure("not-an-int", int) -Traceback (most recent call last): -... -ValueError: invalid literal for int() with base 10: 'not-an-int' -``` - -### Enums - -Enums will be structured by their values. This works even for complex values, -like tuples. - -```{doctest} - ->>> @unique -... class CatBreed(Enum): -... SIAMESE = "siamese" -... MAINE_COON = "maine_coon" -... SACRED_BIRMAN = "birman" - ->>> cattrs.structure("siamese", CatBreed) - -``` - -Again, in case of errors, the expected exceptions will fly out. - -```python ->>> cattrs.structure("alsatian", CatBreed) -Traceback (most recent call last): -... -ValueError: 'alsatian' is not a valid CatBreed -``` - -### `pathlib.Path` - -[`pathlib.Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) objects are structured using their string value. - -```{doctest} ->>> from pathlib import Path - ->>> cattrs.structure("/root", Path) -PosixPath('/root') -``` - -In case the conversion isn't possible, the resulting exception is propagated out. - -```{versionadded} 23.1.0 - -``` - -## Collections and Other Generics - -### Optionals - -`Optional` primitives and collections are supported out of the box. - -```{doctest} - ->>> cattrs.structure(None, int) -Traceback (most recent call last): -... -TypeError: int() argument must be a string, a bytes-like object or a number, not 'NoneType' ->>> cattrs.structure(None, Optional[int]) ->>> # None was returned. -``` - -Bare `Optional` s (non-parameterized, just `Optional`, as opposed to -`Optional[str]`) aren't supported, use `Optional[Any]` instead. - -The Python 3.10 more readable syntax, `str | None` instead of `Optional[str]`, is also supported. - -This generic type is composable with all other converters. - -```{doctest} - ->>> cattrs.structure(1, Optional[float]) -1.0 -``` - -### Lists - -Lists can be produced from any iterable object. Types converting to lists are: - -- `Sequence[T]` -- `MutableSequence[T]` -- `List[T]` -- `list[T]` - -In all cases, a new list will be returned, so this operation can be used to -copy an iterable into a list. A bare type, for example `Sequence` instead of -`Sequence[int]`, is equivalent to `Sequence[Any]`. - -```{doctest} - ->>> cattrs.structure((1, 2, 3), MutableSequence[int]) -[1, 2, 3] -``` - -These generic types are composable with all other converters. - -```{doctest} - ->>> cattrs.structure((1, None, 3), list[Optional[str]]) -['1', None, '3'] -``` - -### Deques - -Deques can be produced from any iterable object. Types converting -to deques are: - -- `Deque[T]` -- `deque[T]` - -In all cases, a new **unbounded** deque (`maxlen=None`) will be returned, -so this operation can be used to copy an iterable into a deque. -If you want to convert into bounded `deque`, registering a custom structuring hook is a good approach. - -```{doctest} - ->>> from collections import deque ->>> cattrs.structure((1, 2, 3), deque[int]) -deque([1, 2, 3]) -``` - -These generic types are composable with all other converters. - -```{doctest} ->>> cattrs.structure((1, None, 3), deque[Optional[str]]) -deque(['1', None, '3']) -``` - -```{versionadded} 23.1.0 - -``` - -### Sets and Frozensets - -Sets and frozensets can be produced from any iterable object. Types converting -to sets are: - -- `Set[T]` -- `MutableSet[T]` -- `set[T]` - -Types converting to frozensets are: - -- `FrozenSet[T]` -- `frozenset[T]` - -In all cases, a new set or frozenset will be returned, so this operation can be -used to copy an iterable into a set. A bare type, for example `MutableSet` -instead of `MutableSet[int]`, is equivalent to `MutableSet[Any]`. - -```{doctest} - ->>> cattrs.structure([1, 2, 3, 4], Set) -{1, 2, 3, 4} -``` - -These generic types are composable with all other converters. - -```{doctest} - ->>> cattrs.structure([[1, 2], [3, 4]], set[frozenset[str]]) -{frozenset({'2', '1'}), frozenset({'4', '3'})} -``` - -### Dictionaries - -Dicts can be produced from other mapping objects. To be more precise, the -object being converted must expose an `items()` method producing an iterable -key-value tuples, and be able to be passed to the `dict` constructor as an -argument. Types converting to dictionaries are: - -- `Dict[K, V]` -- `MutableMapping[K, V]` -- `Mapping[K, V]` -- `dict[K, V]` - -In all cases, a new dict will be returned, so this operation can be -used to copy a mapping into a dict. Any type parameters set to `typing.Any` -will be passed through unconverted. If both type parameters are absent, -they will be treated as `Any` too. - -```{doctest} - ->>> from collections import OrderedDict ->>> cattrs.structure(OrderedDict([(1, 2), (3, 4)]), Dict) -{1: 2, 3: 4} -``` - -These generic types are composable with all other converters. Note both keys -and values can be converted. - -```{doctest} - ->>> cattrs.structure({1: None, 2: 2.0}, dict[str, Optional[int]]) -{'1': None, '2': 2} -``` - -### Typed Dicts - -[TypedDicts](https://peps.python.org/pep-0589/) can be produced from mapping objects, usually dictionaries. - -```{doctest} ->>> from typing import TypedDict - ->>> class MyTypedDict(TypedDict): -... a: int - ->>> cattrs.structure({"a": "1"}, MyTypedDict) -{'a': 1} -``` - -Both [_total_ and _non-total_](https://peps.python.org/pep-0589/#totality) TypedDicts are supported, and inheritance between any combination works (except on 3.8 when `typing.TypedDict` is used, see below). -Generic TypedDicts work on Python 3.11 and later, since that is the first Python version that supports them in general. - -[`typing.Required` and `typing.NotRequired`](https://peps.python.org/pep-0655/) are supported. - -On Python 3.8, using `typing_extensions.TypedDict` is recommended since `typing.TypedDict` doesn't support all necessary features, so certain combinations of subclassing, totality and `typing.Required` won't work. - -[Similar to _attrs_ classes](customizing.md#using-cattrsgen-generators), structuring can be customized using {meth}`cattrs.gen.typeddicts.make_dict_structure_fn`. - -```{doctest} ->>> from typing import TypedDict ->>> from cattrs import Converter ->>> from cattrs.gen import override ->>> from cattrs.gen.typeddicts import make_dict_structure_fn - ->>> class MyTypedDict(TypedDict): -... a: int -... b: int - ->>> c = Converter() ->>> c.register_structure_hook( -... MyTypedDict, -... make_dict_structure_fn( -... MyTypedDict, -... c, -... a=override(rename="a-with-dash") -... ) -... ) - ->>> c.structure({"a-with-dash": 1, "b": 2}, MyTypedDict) -{'b': 2, 'a': 1} -``` - -```{seealso} [Unstructuring TypedDicts.](unstructuring.md#typed-dicts) - -``` - -```{versionadded} 23.1.0 - -``` - -### Homogeneous and Heterogeneous Tuples - -Homogeneous and heterogeneous tuples can be produced from iterable objects. -Heterogeneous tuples require an iterable with the number of elements matching -the number of type parameters exactly. Use: - -- `Tuple[A, B, C, D]` -- `tuple[A, B, C, D]` - -Homogeneous tuples use: - -- `Tuple[T, ...]` -- `tuple[T, ...]` - -In all cases a tuple will be returned. Any type parameters set to -`typing.Any` will be passed through unconverted. - -```{doctest} - ->>> cattrs.structure([1, 2, 3], tuple[int, str, float]) -(1, '2', 3.0) -``` - -The tuple conversion is composable with all other converters. - -```{doctest} - ->>> cattrs.structure([{1: 1}, {2: 2}], tuple[dict[str, float], ...]) -({'1': 1.0}, {'2': 2.0}) -``` - -### Unions - -Unions of `NoneType` and a single other type are supported (also known as -`Optional` s). All other unions require a disambiguation function. - -#### Automatic Disambiguation - -In the case of a union consisting exclusively of `attrs` classes, `cattrs` -will attempt to generate a disambiguation function automatically; this will -succeed only if each class has a unique field. Given the following classes: - -```python ->>> @define -... class A: -... a = field() -... x = field() - ->>> @define -... class B: -... a = field() -... y = field() - ->>> @define -... class C: -... a = field() -... z = field() -``` - -`cattrs` can deduce only instances of `A` will contain `x`, only instances -of `B` will contain `y`, etc. A disambiguation function using this -information will then be generated and cached. This will happen automatically, -the first time an appropriate union is structured. - -#### Manual Disambiguation - -To support arbitrary unions, register a custom structuring hook for the union -(see [Registering custom structuring hooks](structuring.md#registering-custom-structuring-hooks)). - -Another option is to use a custom tagged union strategy (see [Strategies - Tagged Unions](strategies.md#tagged-unions-strategy)). - -### `typing.Final` - -[PEP 591](https://peps.python.org/pep-0591/) Final attribute types (`Final[int]`) are supported and structured appropriately. - -```{versionadded} 23.1.0 - -``` - -```{seealso} [Unstructuring Final.](unstructuring.md#typingfinal) - -``` - -## `typing.Annotated` - -[PEP 593](https://www.python.org/dev/peps/pep-0593/) annotations (`typing.Annotated[type, ...]`) are supported and are matched using the first type present in the annotated type. - -## Type Aliases - -[Type aliases](https://docs.python.org/3/library/typing.html#type-aliases) are supported on Python 3.12+ and are structured according to the rules for their underlying type. -Their hooks can also be overriden using {meth}`Converter.register_structure_hook_func() `. -(Since type aliases aren't proper classes they cannot be used with {meth}`Converter.register_structure_hook() `.) - -```{warning} -Type aliases using [`typing.TypeAlias`](https://docs.python.org/3/library/typing.html#typing.TypeAlias) aren't supported since there is no way at runtime to distinguish them from their underlying types. -``` - -```python ->>> from datetime import datetime - ->>> type IsoDate = datetime - ->>> converter = cattrs.Converter() ->>> converter.register_structure_hook_func( -... lambda t: t is IsoDate, lambda v, _: datetime.fromisoformat(v) -... ) - ->>> converter.structure("2022-01-01", IsoDate) -datetime.datetime(2022, 1, 1, 0, 0) -``` - -```{versionadded} 24.1.0 - -``` - -```{seealso} [Unstructuring Type Aliases.](unstructuring.md#type-aliases) - -``` - -## `typing.NewType` - -[NewTypes](https://docs.python.org/3/library/typing.html#newtype) are supported and are structured according to the rules for their underlying type. -Their hooks can also be overriden using {meth}`Converter.register_structure_hook() `. - -```{doctest} - ->>> from typing import NewType ->>> from datetime import datetime - ->>> IsoDate = NewType("IsoDate", datetime) - ->>> converter = cattrs.Converter() ->>> converter.register_structure_hook(IsoDate, lambda v, _: datetime.fromisoformat(v)) - ->>> converter.structure("2022-01-01", IsoDate) -datetime.datetime(2022, 1, 1, 0, 0) -``` - -```{versionadded} 22.2.0 - -``` - -```{seealso} [Unstructuring NewTypes.](unstructuring.md#typingnewtype) - -``` - -## _attrs_ Classes and Dataclasses - -### Simple _attrs_ Classes and Dataclasses - -_attrs_ classes and dataclasses using primitives, collections of primitives -and their own converters work out of the box. Given a mapping `d` and class -`A`, _cattrs_ will simply instantiate `A` with `d` unpacked. - -```{doctest} - ->>> @define -... class A: -... a: int -... b: int - ->>> cattrs.structure({'a': 1, 'b': '2'}, A) -A(a=1, b=2) -``` - -Classes like these deconstructed into tuples can be structured using -{meth}`structure_attrs_fromtuple() ` (`fromtuple` as in the opposite of -`attr.astuple` and `converter.unstructure_attrs_astuple`). - -```{doctest} - ->>> @define -... class A: -... a: str -... b: int - ->>> cattrs.structure_attrs_fromtuple(['string', '2'], A) -A(a='string', b=2) -``` - -Loading from tuples can be made the default by creating a new {class}`Converter ` with -`unstruct_strat=cattr.UnstructureStrategy.AS_TUPLE`. - -```{doctest} - ->>> converter = cattrs.Converter(unstruct_strat=cattr.UnstructureStrategy.AS_TUPLE) ->>> @define -... class A: -... a: str -... b: int - ->>> converter.structure(['string', '2'], A) -A(a='string', b=2) -``` - -Structuring from tuples can also be made the default for specific classes only; -see registering custom structure hooks below. - -### Using Attribute Types and Converters - -By default, {meth}`structure() ` will use hooks registered using {meth}`register_structure_hook() `, -to convert values to the attribute type, and fallback to invoking any converters registered on -attributes with `field`. - -```{doctest} - ->>> from ipaddress import IPv4Address, ip_address ->>> converter = cattrs.Converter() - -# Note: register_structure_hook has not been called, so this will fallback to 'ip_address' ->>> @define -... class A: -... a: IPv4Address = field(converter=ip_address) - ->>> converter.structure({'a': '127.0.0.1'}, A) -A(a=IPv4Address('127.0.0.1')) -``` - -Priority is still given to hooks registered with {meth}`register_structure_hook() `, -but this priority can be inverted by setting `prefer_attrib_converters` to `True`. - -```{doctest} - ->>> converter = cattrs.Converter(prefer_attrib_converters=True) - ->>> @define -... class A: -... a: int = field(converter=lambda v: int(v) + 5) - ->>> converter.structure({'a': '10'}, A) -A(a=15) -``` - -### Complex _attrs_ Classes and Dataclasses - -Complex _attrs_ classes and dataclasses are classes with type information available for some or all attributes. -These classes support almost arbitrary nesting. - -```{doctest} - ->>> @define -... class A: -... a: int - ->>> attrs.fields(A).a -Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='a') -``` - -Type information can be used for all attribute types, not only attributes holding _attrs_ classes and dataclasses. - -```{doctest} - ->>> @define -... class A: -... a: int = 0 - ->>> @define -... class B: -... b: A - ->>> cattrs.structure({'b': {'a': '1'}}, B) -B(b=A(a=1)) -``` - -Generic _attrs_ classes and dataclasses are fully supported, both using `typing.Generic` and [PEP 695](https://peps.python.org/pep-0695/). - -```python ->>> @define -... class A[T]: -... a: T - ->>> cattrs.structure({"a": "1"}, A[int]) -A(a=1) -``` - -Finally, if an _attrs_ or dataclass class uses inheritance and as such has one or several subclasses, it can be structured automatically to its exact subtype by using the [include subclasses](strategies.md#include-subclasses-strategy) strategy. - -## Registering Custom Structuring Hooks - -_cattrs_ doesn't know how to structure non-_attrs_ classes by default, so it has to be taught. -This can be done by registering structuring hooks on a converter instance (including the global converter). - -Here's an example involving a simple, classic (i.e. non-_attrs_) Python class. - -```{doctest} - ->>> class C: -... def __init__(self, a): -... self.a = a -... def __repr__(self): -... return f'C(a={self.a})' - ->>> cattrs.structure({'a': 1}, C) -Traceback (most recent call last): -... -StructureHandlerNotFoundError: Unsupported type: . Register a structure hook for it. - ->>> cattrs.register_structure_hook(C, lambda d, t: C(**d)) ->>> cattrs.structure({'a': 1}, C) -C(a=1) -``` - -The structuring hooks are callables that take two arguments: the object to convert to the desired class and the type to convert to. -(The type may seem redundant but is useful when dealing with generic types.) - -When using {meth}`cattrs.register_structure_hook`, the hook will be registered on the global converter. -If you want to avoid changing the global converter, create an instance of {class}`cattrs.Converter` and register the hook on that. \ No newline at end of file diff --git a/docs/unions.md b/docs/unions.md index 29e9352b..c564b9ec 100644 --- a/docs/unions.md +++ b/docs/unions.md @@ -1,8 +1,6 @@ -# Tips for Handling Unions +# Handling Unions -This sections contains information for advanced union handling. - -As mentioned in the structuring section, _cattrs_ is able to handle simple unions of _attrs_ classes [automatically](#default-union-strategy). +_cattrs_ is able to handle simple unions of _attrs_ classes [automatically](#default-union-strategy). More complex cases require converter customization (since there are many ways of handling unions). _cattrs_ also comes with a number of strategies to help handle unions: @@ -34,7 +32,7 @@ class ClassB: In this case, a payload containing `{"field_one": "one"}` will produce an instance of `ClassA`. ````{note} -The following snippet can be used to disable the use of literal fields, restoring the previous behavior. +The following snippet can be used to disable the use of literal fields, restoring legacy behavior. ```python from functools import partial diff --git a/docs/unstructuring.md b/docs/unstructuring.md deleted file mode 100644 index 2918ec47..00000000 --- a/docs/unstructuring.md +++ /dev/null @@ -1,333 +0,0 @@ -# What You Can Unstructure and How - -Unstructuring is intended to convert high-level, structured Python data (like -instances of complex classes) into simple, unstructured data (like -dictionaries). - -Unstructuring is simpler than structuring in that no target types are required. -Simply provide an argument to {meth}`Converter.unstructure() ` and _cattrs_ will produce a -result based on the registered unstructuring hooks. -A number of default unstructuring hooks are documented here. - -## Primitive Types and Collections - -Primitive types (integers, floats, strings...) are simply passed through. -Collections are copied. There's relatively little value in unstructuring -these types directly as they are already unstructured and third-party -libraries tend to support them directly. - -A useful use case for unstructuring collections is to create a deep copy of -a complex or recursive collection. - -```{doctest} - ->>> # A dictionary of strings to lists of tuples of floats. ->>> data = {'a': [[1.0, 2.0], [3.0, 4.0]]} - ->>> copy = cattrs.unstructure(data) ->>> data == copy -True ->>> data is copy -False -``` - -### Typed Dicts - -[TypedDicts](https://peps.python.org/pep-0589/) unstructure into dictionaries, potentially unchanged (depending on the exact field types and registered hooks). - -```{doctest} ->>> from typing import TypedDict ->>> from datetime import datetime, timezone ->>> from cattrs import Converter - ->>> class MyTypedDict(TypedDict): -... a: datetime - ->>> c = Converter() ->>> c.register_unstructure_hook(datetime, lambda d: d.timestamp()) - ->>> c.unstructure({"a": datetime(1970, 1, 1, tzinfo=timezone.utc)}, unstructure_as=MyTypedDict) -{'a': 0.0} -``` - -Generic TypedDicts work on Python 3.11 and later, since that is the first Python version that supports them in general. - -On Python 3.8, using `typing_extensions.TypedDict` is recommended since `typing.TypedDict` doesn't support all necessary features, so certain combinations of subclassing, totality and `typing.Required` won't work. - -[Similar to _attrs_ classes](customizing.md#using-cattrsgen-generators), unstructuring can be customized using {meth}`cattrs.gen.typeddicts.make_dict_unstructure_fn`. - -```{doctest} ->>> from typing import TypedDict ->>> from cattrs import Converter ->>> from cattrs.gen import override ->>> from cattrs.gen.typeddicts import make_dict_unstructure_fn - ->>> class MyTypedDict(TypedDict): -... a: int -... b: int - ->>> c = Converter() ->>> c.register_unstructure_hook( -... MyTypedDict, -... make_dict_unstructure_fn( -... MyTypedDict, -... c, -... a=override(omit=True) -... ) -... ) - ->>> c.unstructure({"a": 1, "b": 2}, unstructure_as=MyTypedDict) -{'b': 2} -``` - -```{seealso} [Structuring TypedDicts.](structuring.md#typed-dicts) - -``` - -```{versionadded} 23.1.0 - -``` - -## `pathlib.Path` - -[`pathlib.Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) objects are unstructured into their string value. - -```{doctest} ->>> from pathlib import Path - ->>> cattrs.unstructure(Path("/root")) -'/root' -``` - -```{versionadded} 23.1.0 - -``` - -## Customizing Collection Unstructuring - -```{important} -This feature is supported for Python 3.9 and later. -``` - -Sometimes it's useful to be able to override collection unstructuring in a -generic way. A common example is using a JSON library that doesn't support -sets, but expects lists and tuples instead. - -Using ordinary unstructuring hooks for this is unwieldy due to the semantics of -[singledispatch](https://docs.python.org/3/library/functools.html#functools.singledispatch); -in other words, you'd need to register hooks for all specific types of set you're using (`set[int]`, `set[float]`, -`set[str]`...), which is not useful. - -Function-based hooks can be used instead, but come with their own set of -challenges - they're complicated to write efficiently. - -The {class}`Converter ` supports easy customizations of collection unstructuring -using its `unstruct_collection_overrides` parameter. For example, to -unstructure all sets into lists, try the following: - -```{doctest} - ->>> from collections.abc import Set ->>> converter = cattrs.Converter(unstruct_collection_overrides={Set: list}) - ->>> converter.unstructure({1, 2, 3}) -[1, 2, 3] -``` - -Going even further, the Converter contains heuristics to support the -following Python types, in order of decreasing generality: - -- `Sequence`, `MutableSequence`, `list`, `deque`, `tuple` -- `Set`, `frozenset`, `MutableSet`, `set` -- `Mapping`, `MutableMapping`, `dict`, `defaultdict`, `OrderedDict`, `Counter` - -For example, if you override the unstructure type for `Sequence`, but not for -`MutableSequence`, `list` or `tuple`, the override will also affect those -types. An easy way to remember the rule: - -- all `MutableSequence` s are `Sequence` s, so the override will apply -- all `list` s are `MutableSequence` s, so the override will apply -- all `tuple` s are `Sequence` s, so the override will apply - -If, however, you override only `MutableSequence`, fields annotated as -`Sequence` will not be affected (since not all sequences are mutable -sequences), and fields annotated as tuples will not be affected (since tuples -are not mutable sequences in the first place). - -Similar logic applies to the set and mapping hierarchies. - -Make sure you're using the types from `collections.abc` on Python 3.9+, and -from `typing` on older Python versions. - -### `typing.Final` - -[PEP 591](https://peps.python.org/pep-0591/) Final attribute types (`Final[int]`) are supported and unstructured appropriately. - -```{versionadded} 23.1.0 - -``` - -```{seealso} [Structuring Final.](structuring.md#typingfinal) - -``` - -## `typing.Annotated` - -[PEP 593](https://www.python.org/dev/peps/pep-0593/) `typing.Annotated[type, ...]` are supported and are matched using the first type present in the annotated type. - -## Type Aliases - -[Type aliases](https://docs.python.org/3/library/typing.html#type-aliases) are supported on Python 3.12+ and are unstructured according to the rules for their underlying type. -Their hooks can also be overriden using {meth}`Converter.register_unstructure_hook() `. -(Since type aliases aren't proper classes they cannot be used with {meth}`Converter.register_unstructure_hook() `.) - -```{warning} -Type aliases using [`typing.TypeAlias`](https://docs.python.org/3/library/typing.html#typing.TypeAlias) aren't supported since there is no way at runtime to distinguish them from their underlying types. -``` - -```python ->>> from datetime import datetime, UTC - ->>> type IsoDate = datetime - ->>> converter = cattrs.Converter() ->>> converter.register_unstructure_hook_func( -... lambda t: t is IsoDate, -... lambda v: v.isoformat() -... ) - ->>> converter.unstructure(datetime.now(UTC), unstructure_as=IsoDate) -'2023-11-20T23:10:46.728394+00:00' -``` - -```{versionadded} 24.1.0 - -``` - -```{seealso} [Structuring Type Aliases.](structuring.md#type-aliases) - -``` - - -## `typing.NewType` - -[NewTypes](https://docs.python.org/3/library/typing.html#newtype) are supported and are unstructured according to the rules for their underlying type. -Their hooks can also be overriden using {meth}`Converter.register_unstructure_hook() `. - -```{versionadded} 22.2.0 - -``` - -```{seealso} [Structuring NewTypes.](structuring.md#typingnewtype) - -``` - -```{note} -NewTypes are not supported by the legacy {class}`BaseConverter `. -``` - -## _attrs_ Classes and Dataclasses - -_attrs_ classes and dataclasses are supported out of the box. -{class}`cattrs.Converters ` support two unstructuring strategies: - -- `UnstructureStrategy.AS_DICT` - similar to [`attrs.asdict()`](https://www.attrs.org/en/stable/api.html#attrs.asdict), unstructures _attrs_ and dataclass instances into dictionaries. This is the default. -- `UnstructureStrategy.AS_TUPLE` - similar to [`attrs.astuple()`](https://www.attrs.org/en/stable/api.html#attrs.astuple), unstructures _attrs_ and dataclass instances into tuples. - -```{doctest} - ->>> @define -... class C: -... a = field() -... b = field() - ->>> inst = C(1, 'a') - ->>> converter = cattrs.Converter(unstruct_strat=cattrs.UnstructureStrategy.AS_TUPLE) - ->>> converter.unstructure(inst) -(1, 'a') -``` - -## Mixing and Matching Strategies - -Converters publicly expose two helper methods, {meth}`Converter.unstructure_attrs_asdict() ` -and {meth}`Converter.unstructure_attrs_astuple() `. -These methods can be used with custom unstructuring hooks to selectively apply one strategy to instances of particular classes. - -Assume two nested _attrs_ classes, `Inner` and `Outer`; instances of `Outer` contain instances of `Inner`. -Instances of `Outer` should be unstructured as dictionaries, and instances of `Inner` as tuples. -Here's how to do this. - -```{doctest} - ->>> @define -... class Inner: -... a: int - ->>> @define -... class Outer: -... i: Inner - ->>> inst = Outer(i=Inner(a=1)) - ->>> converter = cattrs.Converter() ->>> converter.register_unstructure_hook(Inner, converter.unstructure_attrs_astuple) - ->>> converter.unstructure(inst) -{'i': (1,)} -``` - -Of course, these methods can be used directly as well, without changing the converter strategy. - -```{doctest} - ->>> @define -... class C: -... a: int -... b: str - ->>> inst = C(1, 'a') - ->>> converter = cattrs.Converter() - ->>> converter.unstructure_attrs_astuple(inst) # Default is AS_DICT. -(1, 'a') -``` - -## Unstructuring Hook Factories - -Hook factories operate one level higher than unstructuring hooks; unstructuring -hooks are functions registered to a class or predicate, and hook factories -are functions (registered via a predicate) that produce unstructuring hooks. - -Unstructuring hooks factories are registered using {meth}`Converter.register_unstructure_hook_factory() `. - -Here's a small example showing how to use factory hooks to skip unstructuring `init=False` attributes on all _attrs_ classes. - -```{doctest} - ->>> from attrs import define, has, field, fields ->>> from cattrs import override ->>> from cattrs.gen import make_dict_unstructure_fn - ->>> c = cattrs.Converter() ->>> c.register_unstructure_hook_factory( -... has, -... lambda cl: make_dict_unstructure_fn( -... cl, c, **{a.name: override(omit=True) for a in fields(cl) if not a.init} -... ) -... ) - ->>> @define -... class E: -... an_int: int -... another_int: int = field(init=False) - ->>> inst = E(1) ->>> inst.another_int = 5 ->>> c.unstructure(inst) -{'an_int': 1} -``` - -A complex use case for hook factories is described over at {ref}`usage:Using factory hooks`. diff --git a/docs/validation.md b/docs/validation.md index 385d12ab..72302a91 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,18 +1,19 @@ # Validation _cattrs_ has a detailed validation mode since version 22.1.0, and this mode is enabled by default. -When running under detailed validation, the un/structuring hooks are slightly slower but produce more precise and exhaustive error messages. +When running under detailed validation, the structuring hooks are slightly slower but produce richer and more precise error messages. +Unstructuring hooks are not affected. ## Detailed Validation ```{versionadded} 22.1.0 ``` -In detailed validation mode, any un/structuring errors will be grouped and raised together as a {class}`cattrs.BaseValidationError`, which is a [PEP 654 ExceptionGroup](https://www.python.org/dev/peps/pep-0654/). +In detailed validation mode, any structuring errors will be grouped and raised together as a {class}`cattrs.BaseValidationError`, which is a [PEP 654 ExceptionGroup](https://www.python.org/dev/peps/pep-0654/). ExceptionGroups are special exceptions which contain lists of other exceptions, which may themselves be other ExceptionGroups. In essence, ExceptionGroups are trees of exceptions. -When un/structuring a class, _cattrs_ will gather any exceptions on a field-by-field basis and raise them as a {class}`cattrs.ClassValidationError`, which is a subclass of {class}`BaseValidationError `. +When structuring a class, _cattrs_ will gather any exceptions on a field-by-field basis and raise them as a {class}`cattrs.ClassValidationError`, which is a subclass of {class}`BaseValidationError `. When structuring sequences and mappings, _cattrs_ will gather any exceptions on a key- or index-basis and raise them as a {class}`cattrs.IterableValidationError`, which is a subclass of {class}`BaseValidationError `. @@ -72,23 +73,27 @@ class Class: ``` -ExceptionGroup stack traces are great while you're developing, but sometimes a more compact representation of validation errors is better. +ExceptionGroup stack traces are useful while developing, but sometimes a more compact representation of validation errors is required. _cattrs_ provides a helper function, {func}`cattrs.transform_error`, which transforms validation errors into lists of error messages. The example from the previous paragraph produces the following error messages: -```python ->>> from cattrs import transform_error +```{testsetup} class +@define +class Class: + a_list: list[int] + a_dict: dict[str, int] +``` + +```{doctest} class + +>>> from cattrs import structure, transform_error >>> try: ... structure({"a_list": ["a"], "a_dict": {"str": "a"}}, Class) ... except Exception as exc: ... print(transform_error(exc)) - -[ - 'invalid value for type, expected int @ $.a_list[0]', - "invalid value for type, expected int @ $.a_dict['str']" -] +['invalid value for type, expected int @ $.a_list[0]', "invalid value for type, expected int @ $.a_dict['str']"] ``` A small number of built-in exceptions are converted into error messages automatically. diff --git a/src/cattrs/__init__.py b/src/cattrs/__init__.py index 6ed83139..db496363 100644 --- a/src/cattrs/__init__.py +++ b/src/cattrs/__init__.py @@ -13,38 +13,32 @@ from .gen import override from .v import transform_error -__all__ = ( - "AttributeValidationNote", +__all__ = [ + "structure", + "unstructure", + "get_structure_hook", + "get_unstructure_hook", + "register_structure_hook_func", + "register_structure_hook", + "register_unstructure_hook_func", + "register_unstructure_hook", + "structure_attrs_fromdict", + "structure_attrs_fromtuple", + "global_converter", "BaseConverter", + "Converter", + "AttributeValidationNote", "BaseValidationError", "ClassValidationError", - "Converter", - "converters", - "disambiguators", - "dispatch", - "errors", "ForbiddenExtraKeysError", - "gen", "GenConverter", - "global_converter", "IterableValidationError", "IterableValidationNote", "override", - "preconf", - "register_structure_hook_func", - "register_structure_hook", - "register_unstructure_hook_func", - "register_unstructure_hook", - "structure_attrs_fromdict", - "structure_attrs_fromtuple", - "structure", "StructureHandlerNotFoundError", "transform_error", - "unstructure", "UnstructureStrategy", - "get_structure_hook", - "get_unstructure_hook", -) +] #: The global converter. Prefer creating your own if customizations are required. global_converter: Final = Converter() diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 6a17902f..172a7584 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -171,6 +171,7 @@ def __init__( (lambda t: issubclass(t, Enum), self._unstructure_enum), (has, self._unstructure_attrs), (is_union_type, self._unstructure_union), + (lambda t: t is Any, self.unstructure), ] ) @@ -1001,8 +1002,7 @@ def gen_unstructure_optional(self, cl: type[T]) -> Callable[[T], Any]: union_params = cl.__args__ other = union_params[0] if union_params[1] is NoneType else union_params[1] - # TODO: Remove this special case when we make unstructuring Any consistent. - if other is Any or isinstance(other, TypeVar): + if isinstance(other, TypeVar): handler = self.unstructure else: handler = self._unstructure_func.dispatch(other) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 4d201f8f..02a676d7 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -6,6 +6,7 @@ from attrs import NOTHING, Factory, resolve_types from .._compat import ( + TypeAlias, adapted_fields, get_args, get_origin, @@ -34,6 +35,15 @@ from cattr.converters import BaseConverter +__all__ = [ + "make_dict_unstructure_fn", + "make_dict_structure_fn", + "make_iterable_unstructure_fn", + "make_hetero_tuple_unstructure_fn", + "make_mapping_unstructure_fn", + "make_mapping_structure_fn", +] + def override( omit_if_default: bool | None = None, @@ -682,7 +692,8 @@ def make_iterable_unstructure_fn( return globs[fn_name] -HeteroTupleUnstructureFn = Callable[[Tuple[Any, ...]], Any] +#: A type alias for heterogeneous tuple unstructure hooks. +HeteroTupleUnstructureFn: TypeAlias = Callable[[Tuple[Any, ...]], Any] def make_hetero_tuple_unstructure_fn( @@ -754,11 +765,9 @@ def make_mapping_unstructure_fn( if kh == identity: kh = None - if val_arg is not Any: - # TODO: Remove this once we have more consistent Any handling in place. - val_handler = converter._unstructure_func.dispatch(val_arg) - if val_handler == identity: - val_handler = None + val_handler = converter._unstructure_func.dispatch(val_arg) + if val_handler == identity: + val_handler = None globs = { "__cattr_mapping_cl": unstructure_to or cl, diff --git a/tests/test_any.py b/tests/test_any.py index 94fa0bd9..c580bfc5 100644 --- a/tests/test_any.py +++ b/tests/test_any.py @@ -1,5 +1,5 @@ """Tests for handling `typing.Any`.""" -from typing import Any, Dict +from typing import Any, Dict, Optional from attrs import define @@ -12,3 +12,15 @@ class A: def test_unstructuring_dict_of_any(converter): """Dicts with Any values should use runtime dispatch for their values.""" assert converter.unstructure({"a": A()}, Dict[str, Any]) == {"a": {}} + + +def test_unstructuring_any(converter): + """`Any` should use runtime dispatch.""" + + assert converter.unstructure(A(), Any) == {} + + +def test_unstructure_optional_any(converter): + """Unstructuring `Optional[Any]` should use the runtime value.""" + + assert converter.unstructure(A(), Optional[Any]) == {} diff --git a/tests/test_final.py b/tests/test_final.py index 5f6680e8..1d780f61 100644 --- a/tests/test_final.py +++ b/tests/test_final.py @@ -37,8 +37,8 @@ def test_unstructure_bare_final(genconverter: Converter) -> None: assert genconverter.unstructure(D(1)) == {"a": 1, "b": 5, "c": 3} genconverter.register_unstructure_hook(int, lambda i: str(i)) - # Bare finals don't work with factories. - assert genconverter.unstructure(D(1)) == {"a": "1", "b": "5", "c": 3} + # Bare finals resolve to `Final[Any]`, so the custom hook works. + assert genconverter.unstructure(D(1)) == {"a": "1", "b": "5", "c": "3"} def test_structure_bare_final(genconverter: Converter) -> None: diff --git a/tests/typed.py b/tests/typed.py index 6bed20d8..98a2ba82 100644 --- a/tests/typed.py +++ b/tests/typed.py @@ -125,7 +125,7 @@ def simple_typed_attrs( ) -> SearchStrategy[Tuple[_CountingAttr, SearchStrategy[PosArgs]]]: if not is_39_or_later: res = ( - bare_typed_attrs(defaults, kw_only) + any_typed_attrs(defaults, kw_only) | int_typed_attrs(defaults, kw_only) | str_typed_attrs(defaults, kw_only) | float_typed_attrs(defaults, kw_only) @@ -170,7 +170,7 @@ def simple_typed_attrs( ) else: res = ( - bare_typed_attrs(defaults, kw_only) + any_typed_attrs(defaults, kw_only) | int_typed_attrs(defaults, kw_only) | str_typed_attrs(defaults, kw_only) | float_typed_attrs(defaults, kw_only) @@ -316,11 +316,10 @@ def key(t): @composite -def bare_typed_attrs(draw, defaults=None, kw_only=None): - """ - Generate a tuple of an attribute and a strategy that yields values - appropriate for that attribute. - """ +def any_typed_attrs( + draw: DrawFn, defaults=None, kw_only=None +) -> Tuple[_CountingAttr, SearchStrategy[None]]: + """Attributes typed as `Any`, having values of `None`.""" default = NOTHING if defaults is True or (defaults is None and draw(booleans())): default = None