diff --git a/HISTORY.md b/HISTORY.md index ec96fd6f..7bcef0a4 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,10 +4,18 @@ ## 24.1.0 (UNRELEASED) +- 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. + ([#472](https://github.com/python-attrs/cattrs/pull/472)) - Add support for [PEP 695](https://peps.python.org/pep-0695/) type aliases. ([#452](https://github.com/python-attrs/cattrs/pull/452)) +- The `include_subclasses` strategy now fetches the member hooks from the converter (making use of converter defaults) if overrides are not provided, instead of generating new hooks with no overrides. + ([#429](https://github.com/python-attrs/cattrs/issues/429) [#472](https://github.com/python-attrs/cattrs/pull/472)) - The {class}`orjson preconf converter ` now passes through dates and datetimes to orjson while unstructuring, greatly improving speed. ([#463](https://github.com/python-attrs/cattrs/pull/463)) +- `cattrs.gen` generators now attach metadata to the generated functions, making them introspectable. + ([#472](https://github.com/python-attrs/cattrs/pull/472)) - More robust support for `Annotated` and `NotRequired` in TypedDicts. ([#450](https://github.com/python-attrs/cattrs/pull/450)) - `typing_extensions.Literal` is now automatically structured, just like `typing.Literal`. @@ -16,6 +24,8 @@ ([#452](https://github.com/python-attrs/cattrs/pull/452)) - Imports are now sorted using Ruff. - 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 docs now use the Inter font. ## 23.2.3 (2023-11-30) diff --git a/docs/basics.md b/docs/basics.md new file mode 100644 index 00000000..bbcd0721 --- /dev/null +++ b/docs/basics.md @@ -0,0 +1,112 @@ +# The Basics +```{currentmodule} cattrs +``` + +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. + + +## Converters + +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_. + +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. + +```python +>>> from cattrs import structure, unstructure +>>> from attrs import define + +>>> @define +... class Model: +... a: int + +>>> unstructure(Model(1)) +{"a": 1} +>>> structure({"a": 1}, Model) +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. + +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 mechanisms of Python function composition. + +```python +>>> from cattrs import get_structure_hook + +>>> base_hook = get_structure_hook(Model) + +>>> def my_hook(value, type): +... # Apply any preprocessing to the value. +... result = base_hook(value, type) +... # Apply any postprocessing to the value. +... 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. + +```python +>>> def my_int_hook(value, type): +... if not isinstance(value, int): +... raise ValueError('not an int!') +... return value +``` + +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`. + +```python +>>> base_hook = c.get_structure_hook(Model) +>>> base_hook({"a": "1"}, Model) + + Exception Group Traceback (most recent call last): + | File "...", line 22, in + | base_hook({"a": "1"}, Model) + | File "", line 9, in structure_Model + | cattrs.errors.ClassValidationError: While structuring Model (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | File "", line 5, in structure_Model + | File "...", line 15, in my_int_hook + | raise ValueError("not an int!") + | ValueError: not an int! + | Structuring class Model @ attribute a + +------------------------------------ +``` + +To continue reading about customizing _cattrs_, see [](customizing.md). +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 `. +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: + +- {meth}`cattrs.structure` +- {meth}`cattrs.unstructure` +- {meth}`cattrs.get_structure_hook` +- {meth}`cattrs.get_unstructure_hook` +- {meth}`cattrs.structure_attrs_fromtuple` +- {meth}`cattrs.structure_attrs_fromdict` + +Changes made to the global converter will affect the behavior of these functions. + +Larger applications are strongly encouraged to create and customize different, private instances of {class}`cattrs.Converter`. diff --git a/docs/converters.md b/docs/converters.md deleted file mode 100644 index db17c520..00000000 --- a/docs/converters.md +++ /dev/null @@ -1,95 +0,0 @@ -# Converters - -All _cattrs_ functionality is exposed through a {class}`cattrs.Converter` object. -Global _cattrs_ functions, such as {meth}`cattrs.unstructure`, use a single global converter. -Changes done to this global converter, such as registering new structure and unstructure hooks, affect all code using the global functions. - -## Global Converter - -A global converter is provided for convenience as `cattrs.global_converter`. -The following functions implicitly use this global converter: - -- {meth}`cattrs.structure` -- {meth}`cattrs.unstructure` -- {meth}`cattrs.structure_attrs_fromtuple` -- {meth}`cattrs.structure_attrs_fromdict` - -Changes made to the global converter will affect the behavior of these functions. - -Larger applications are strongly encouraged to create and customize a different, private instance of {class}`cattrs.Converter`. - -## Converter Objects - -To create a private converter, simply instantiate a {class}`cattrs.Converter`. - -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_. - -Currently, a converter contains the following state: - -- a registry of unstructure hooks, backed by a [singledispatch](https://docs.python.org/3/library/functools.html#functools.singledispatch) and a `function_dispatch`. -- a registry of structure hooks, backed by a different singledispatch and `function_dispatch`. -- a LRU cache of union disambiguation functions. -- a reference to an unstructuring strategy (either AS_DICT or AS_TUPLE). -- a `dict_factory` callable, used for creating `dicts` when dumping _attrs_ classes using `AS_DICT`. - -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 - -By default, when a {class}`converter ` cannot handle a type it will: - -* when unstructuring, pass the value through unchanged -* when structuring, raise a {class}`cattrs.errors.StructureHandlerNotFoundError` asking the user to add configuration - -These behaviors can be customized by providing custom [hook factories](usage.md#using-factory-hooks) when creating the converter. - -```python ->>> from pickle import dumps - ->>> class Unsupported: -... """An artisinal (non-attrs) class, unsupported by default.""" - ->>> converter = Converter(unstructure_fallback_factory=lambda _: dumps) ->>> instance = Unsupported() ->>> converter.unstructure(instance) -b'\x80\x04\x95\x18\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94.' -``` - -This also enables converters to be chained. - -```python ->>> parent = Converter() - ->>> child = Converter( -... unstructure_fallback_factory=parent._unstructure_func.dispatch, -... structure_fallback_factory=parent._structure_func.dispatch, -... ) -``` - -```{note} -`Converter._structure_func.dispatch` and `Converter._unstructure_func.dispatch` are slated to become public APIs in a future release. -``` - -```{versionadded} 23.2.0 - -``` - -## `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. - -`Converter` differs from the {class}`cattrs.BaseConverter` in the following ways: - -- structuring and unstructuring of _attrs_ classes is slower the first time, but faster every subsequent time -- structuring and unstructuring can be customized -- support for _attrs_ classes with PEP563 (postponed) annotations -- support for generic _attrs_ classes -- support for easy overriding collection unstructuring - -The `Converter` used to be called `GenConverter`, and that alias is still present for backwards compatibility reasons. - -## `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/customizing.md b/docs/customizing.md index c54bc2ec..43c43220 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -1,54 +1,127 @@ -# Customizing Class Un/structuring +# Customizing Un/structuring -This section deals with customizing the unstructuring and structuring processes in _cattrs_. +This section describes customizing the unstructuring and structuring processes in _cattrs_. -## Using `cattrs.Converter` +## Manual Un/structuring Hooks -The default {class}`Converter `, upon first encountering an _attrs_ class, will use the generation functions mentioned here to generate the specialized hooks for it, register the hooks and use them. +You can write your own structuring and unstructuring functions and register them for types using {meth}`Converter.register_structure_hook() ` and {meth}`Converter.register_unstructure_hook() `. +This approach is the most flexible but also requires the most amount of boilerplate. -## Manual Un/structuring Hooks +{meth}`register_structure_hook() ` and {meth}`register_unstructure_hook() ` use a Python [_singledispatch_](https://docs.python.org/3/library/functools.html#functools.singledispatch) under the hood. +_singledispatch_ is powerful and fast but comes with some limitations; namely that it performs checks using `issubclass()` which doesn't work with many Python types. +Some examples of this are: + +* various generic collections (`list[int]` is not a _subclass_ of `list`) +* literals (`Literal[1]` is not a _subclass_ of `Literal[1]`) +* generics (`MyClass[int]` is not a _subclass_ of `MyClass`) +* protocols, unless they are `runtime_checkable` +* various modifiers, such as `Final` and `NotRequired` +* newtypes and 3.12 type aliases + +... and many others. In these cases, predicate functions should be used instead. + +### Predicate Hooks + +A predicate is a function that takes a type and returns true or false, depending on whether the associated hook can handle the given type. + +The {meth}`register_unstructure_hook_func() ` and {meth}`register_structure_hook_func() ` are used +to link un/structuring hooks to arbitrary types. These hooks are then called _predicate hooks_, and are very powerful. + +Predicate hooks are evaluated after the _singledispatch_ hooks. +In the case where both a _singledispatch_ hook and a predicate hook are present, the _singledispatch_ hook will be used. +Predicate hooks are checked in reverse order of registration, one-by-one, until a match is found. + +The following example demonstrates a predicate that checks for the presence of an attribute on a class (`custom`), and then overrides the structuring logic. + +```{doctest} + +>>> class D: +... custom = True +... def __init__(self, a): +... self.a = a +... def __repr__(self): +... return f'D(a={self.a})' +... @classmethod +... def deserialize(cls, data): +... return cls(data["a"]) + +>>> cattrs.register_structure_hook_func( +... lambda cls: getattr(cls, "custom", False), lambda d, t: t.deserialize(d) +... ) + +>>> cattrs.structure({'a': 2}, D) +D(a=2) +``` + +### Hook Factories + +Hook factories are higher-order predicate hooks: they are functions that *produce* hooks. +Hook factories are commonly used to create very optimized hooks by offloading part of the work into a separate, earlier step. + +Hook factories are registered using {meth}`Converter.register_unstructure_hook_factory() ` and {meth}`Converter.register_structure_hook_factory() `. + +Here's an example showing how to use hook factories to apply the `forbid_extra_keys` to all attrs classes: + +```{doctest} + +>>> from attrs import define, has +>>> from cattrs.gen import make_dict_structure_fn + +>>> c = cattrs.Converter() +>>> c.register_structure_hook_factory( +... has, +... lambda cl: make_dict_structure_fn(cl, c, _cattrs_forbid_extra_keys=True) +... ) + +>>> @define +... class E: +... an_int: int + +>>> c.structure({"an_int": 1, "else": 2}, E) +Traceback (most recent call last): +... +cattrs.errors.ForbiddenExtraKeysError: Extra fields in constructor for E: else +``` + +A complex use case for hook factories is described over at {ref}`usage:Using factory hooks`. -You can write your own structuring and unstructuring functions and register -them for types using {meth}`Converter.register_structure_hook() ` and -{meth}`Converter.register_unstructure_hook() `. This approach is the most -flexible but also requires the most amount of boilerplate. ## Using `cattrs.gen` Generators -_cattrs_ includes a module, {mod}`cattrs.gen`, which allows for generating and compiling specialized functions for unstructuring _attrs_ classes. +The {mod}`cattrs.gen` module allows for generating and compiling specialized hooks for unstructuring _attrs_ classes, dataclasses and typed dicts. +The default {class}`Converter `, upon first encountering one of these types, will use the generation functions mentioned here to generate specialized hooks for it, register the hooks and use them. -One reason for generating these functions in advance is that they can bypass a lot of _cattrs_ machinery and be significantly faster than normal _cattrs_. +One reason for generating these hooks in advance is that they can bypass a lot of _cattrs_ machinery and be significantly faster than normal _cattrs_. +The hooks are also good building blocks for more complex customizations. -Another reason is that it's possible to override behavior on a per-attribute basis. +Another reason is overriding behavior on a per-attribute basis. -Currently, the overrides only support generating dictionary un/structuring functions (as opposed to tuples), and support `omit_if_default`, `forbid_extra_keys`, `rename` and `omit`. +Currently, the overrides only support generating dictionary un/structuring hooks (as opposed to tuples), and support `omit_if_default`, `forbid_extra_keys`, `rename` and `omit`. ### `omit_if_default` This override can be applied on a per-class or per-attribute basis. -The generated unstructuring function will skip unstructuring values that are equal to their default or factory values. +The generated unstructuring hook will skip unstructuring values that are equal to their default or factory values. ```{doctest} >>> from cattrs.gen import make_dict_unstructure_fn, override ->>> + >>> @define ... class WithDefault: ... a: int ... b: dict = Factory(dict) ->>> + >>> c = cattrs.Converter() >>> c.register_unstructure_hook(WithDefault, make_dict_unstructure_fn(WithDefault, c, b=override(omit_if_default=True))) >>> c.unstructure(WithDefault(1)) {'a': 1} ``` -Note that the per-attribute value overrides the per-class value. A side-effect -of this is the ability to force the presence of a subset of fields. -For example, consider a class with a `DateTime` field and a factory for it: -skipping the unstructuring of the `DateTime` field would be inconsistent and -based on the current time. So we apply the `omit_if_default` rule to the class, -but not to the `DateTime` field. +Note that the per-attribute value overrides the per-class value. +A side-effect of this is the ability to force the presence of a subset of fields. +For example, consider a class with a `dateTime` field and a factory for it: skipping the unstructuring of the `dateTime` field would be inconsistent and based on the current time. +So we apply the `omit_if_default` rule to the class, but not to the `dateTime` field. ```{note} The parameter to `make_dict_unstructure_function` is named ``_cattrs_omit_if_default`` instead of just ``omit_if_default`` to avoid potential collisions with an override for a field named ``omit_if_default``. @@ -56,14 +129,14 @@ but not to the `DateTime` field. ```{doctest} ->>> from pendulum import DateTime +>>> from datetime import datetime >>> from cattrs.gen import make_dict_unstructure_fn, override ->>> + >>> @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)) >>> c.register_unstructure_hook(TestClass, hook) @@ -78,7 +151,7 @@ This override has no effect when generating structuring functions. By default _cattrs_ is lenient in accepting unstructured input. If extra keys are present in a dictionary, they will be ignored when generating a structured object. Sometimes it may be desirable to enforce a stricter contract, and to raise an error when unknown keys are present - in particular when fields have default values this may help with catching typos. -`forbid_extra_keys` can also be enabled (or disabled) on a per-class basis when creating structure hooks with {py:func}`make_dict_structure_fn() `. +`forbid_extra_keys` can also be enabled (or disabled) on a per-class basis when creating structure hooks with {meth}`make_dict_structure_fn() `. ```{doctest} :options: +SKIP @@ -109,19 +182,18 @@ The value for the `make_dict_structure_fn._cattrs_forbid_extra_keys` parameter i ### `rename` -Using the rename override makes `cattrs` simply use the provided name instead -of the real attribute name. This is useful if an attribute name is a reserved -keyword in Python. +Using the rename override makes `cattrs` use the provided name instead of the real attribute name. +This is useful if an attribute name is a reserved keyword in Python. ```{doctest} >>> from pendulum import DateTime >>> from cattrs.gen import make_dict_unstructure_fn, make_dict_structure_fn, override ->>> + >>> @define ... class ExampleClass: ... klass: Optional[int] ->>> + >>> c = cattrs.Converter() >>> unst_hook = make_dict_unstructure_fn(ExampleClass, c, klass=override(rename="class")) >>> st_hook = make_dict_structure_fn(ExampleClass, c, klass=override(rename="class")) @@ -135,7 +207,7 @@ ExampleClass(klass=1) ### `omit` -This override can only be applied to individual attributes. +This override can only be applied to individual attributes. Using the `omit` override will simply skip the attribute completely when generating a structuring or unstructuring function. ```{doctest} @@ -157,7 +229,7 @@ Using the `omit` override will simply skip the attribute completely when generat By default, the generators will determine the right un/structure hook for each attribute of a class at time of generation according to the type of each individual attribute. -This process can be overriden by passing in the desired un/structure manually. +This process can be overriden by passing in the desired un/structure hook manually. ```{doctest} @@ -180,7 +252,7 @@ ExampleClass(an_int=2) ### `use_alias` By default, fields are un/structured to and from dictionary keys exactly matching the field names. -_attrs_ classes support field aliases, which override the `__init__` parameter name for a given field. +_attrs_ classes support _attrs_ field aliases, which override the `__init__` parameter name for a given field. By generating your un/structure function with `_cattrs_use_alias=True`, _cattrs_ will use the field alias instead of the field name as the un/structured dictionary key. ```{doctest} diff --git a/docs/indepth.md b/docs/indepth.md new file mode 100644 index 00000000..0d7802e2 --- /dev/null +++ b/docs/indepth.md @@ -0,0 +1,74 @@ +# Converters In-Depth +```{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: + +- a registry of unstructure hooks, backed by a [singledispatch](https://docs.python.org/3/library/functools.html#functools.singledispatch) and a {class}`FunctionDispatch `, wrapped in a [cache](https://docs.python.org/3/library/functools.html#functools.cache). +- a registry of structure hooks, backed by a different singledispatch and `FunctionDispatch`, and a different cache. +- a `detailed_validation` flag (defaulting to true), determining whether the converter uses [detailed validation](validation.md#detailed-validation). +- a reference to {class}`an unstructuring strategy ` (either AS_DICT or AS_TUPLE). +- a `prefer_attrib_converters` flag (defaulting to false), determining whether to favor _attrs_ converters over normal _cattrs_ machinery when structuring _attrs_ classes +- a `dict_factory` callable, a legacy parameter used for creating `dicts` when dumping _attrs_ classes using `AS_DICT`. + +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 + +By default, when a {class}`converter ` cannot handle a type it will: + +* when unstructuring, pass the value through unchanged +* when structuring, raise a {class}`cattrs.errors.StructureHandlerNotFoundError` asking the user to add configuration + +These behaviors can be customized by providing custom [hook factories](usage.md#using-factory-hooks) when creating the converter. + +```python +>>> from pickle import dumps + +>>> class Unsupported: +... """An artisinal (non-attrs) class, unsupported by default.""" + +>>> converter = Converter(unstructure_fallback_factory=lambda _: dumps) +>>> instance = Unsupported() +>>> converter.unstructure(instance) +b'\x80\x04\x95\x18\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94.' +``` + +This also enables converters to be chained. + +```python +>>> parent = Converter() + +>>> child = Converter( +... unstructure_fallback_factory=parent.get_unstructure_hook, +... structure_fallback_factory=parent.get_structure_hook, +... ) +``` + +```{versionadded} 23.2.0 + +``` + +### `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. + +`Converter` differs from the {class}`cattrs.BaseConverter` in the following ways: + +- structuring and unstructuring of _attrs_ classes is slower the first time, but faster every subsequent time +- structuring and unstructuring can be customized +- support for _attrs_ classes with PEP563 (postponed) annotations +- support for generic _attrs_ classes +- support for easy overriding collection unstructuring + +The {class}`Converter` used to be called `GenConverter`, and that alias is still present for backwards compatibility. + +### `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 e6a06b01..691836e9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,7 @@ hidden: true --- self -converters -usage +basics structuring unstructuring customizing @@ -14,6 +13,8 @@ strategies validation preconf unions +usage +indepth history benchmarking contributing diff --git a/docs/structuring.md b/docs/structuring.md index 2c97a182..8ca19a81 100644 --- a/docs/structuring.md +++ b/docs/structuring.md @@ -1,12 +1,10 @@ # 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. +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. +All structuring conversions are composable, where applicable. +This is demonstrated further in the examples. ## Primitive Values @@ -602,64 +600,4 @@ The structuring hooks are callables that take two arguments: the object to conve (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. - -In some situations, it is not possible to decide on the converter using typing mechanisms alone (such as with _attrs_ classes). In these situations, -_cattrs_ provides a {meth}`register_unstructure_hook_func() ` hook instead, which accepts a predicate function to determine whether that type can be handled instead. - -The function-based hooks are evaluated after the class-based hooks. In the case where both a class-based hook and a function-based hook are present, the class-based hook will be used. - -```{doctest} - ->>> class D: -... custom = True -... def __init__(self, a): -... self.a = a -... def __repr__(self): -... return f'D(a={self.a})' -... @classmethod -... def deserialize(cls, data): -... return cls(data["a"]) - ->>> cattrs.register_structure_hook_func( -... lambda cls: getattr(cls, "custom", False), lambda d, t: t.deserialize(d) -... ) - ->>> cattrs.structure({'a': 2}, D) -D(a=2) -``` - -## Structuring Hook Factories - -Hook factories operate one level higher than structuring hooks; structuring -hooks are functions registered to a class or predicate, and hook factories -are functions (registered via a predicate) that produce structuring hooks. - -Structuring hooks factories are registered using {meth}`Converter.register_structure_hook_factory() `. - -Here's a small example showing how to use factory hooks to apply the `forbid_extra_keys` to all attrs classes: - -```{doctest} - ->>> from attrs import define, has ->>> from cattrs.gen import make_dict_structure_fn - ->>> c = cattrs.Converter() ->>> c.register_structure_hook_factory( -... has, -... lambda cl: make_dict_structure_fn( -... cl, c, _cattrs_forbid_extra_keys=True, _cattrs_detailed_validation=False -... ) -... ) - ->>> @define -... class E: -... an_int: int - ->>> c.structure({"an_int": 1, "else": 2}, E) -Traceback (most recent call last): -... -cattrs.errors.ForbiddenExtraKeysError: Extra fields in constructor for E: else -``` - -A complex use case for hook factories is described over at {ref}`usage:Using factory hooks`. +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/usage.md b/docs/usage.md index f0536201..7eab1870 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,97 +1,6 @@ -# Common Usage Examples +# Advanced Examples -This section covers common use examples of _cattrs_ features. - -## Using Pendulum for Dates and Time - -To use the [Pendulum](https://pendulum.eustace.io/) library for datetimes, we need to register structuring and unstructuring hooks for it. - -First, we need to decide on the unstructured representation of a datetime instance. -Since all our datetimes will use the UTC time zone, we decide to use the UNIX epoch timestamp as our unstructured representation. - -Define a class using Pendulum's `DateTime`: - -```python ->>> import pendulum ->>> from pendulum import DateTime - ->>> @define -... class MyRecord: -... a_string: str -... a_datetime: DateTime -``` - -Next, we register hooks for the `DateTime` class on a new {class}`Converter ` instance. - -```python ->>> from cattrs import Converter - ->>> converter = Converter() - ->>> converter.register_unstructure_hook(DateTime, lambda dt: dt.timestamp()) ->>> converter.register_structure_hook(DateTime, lambda ts, _: pendulum.from_timestamp(ts)) -``` - -And we can proceed with unstructuring and structuring instances of `MyRecord`. - -```{testsetup} pendulum - -import pendulum -from pendulum import DateTime - -@define -class MyRecord: - a_string: str - a_datetime: DateTime - -converter = cattrs.Converter() -converter.register_unstructure_hook(DateTime, lambda dt: dt.timestamp()) -converter.register_structure_hook(DateTime, lambda ts, _: pendulum.from_timestamp(ts)) -``` - -```{doctest} pendulum - ->>> my_record = MyRecord('test', pendulum.datetime(2018, 7, 28, 18, 24)) ->>> my_record -MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('UTC'))) - ->>> converter.unstructure(my_record) -{'a_string': 'test', 'a_datetime': 1532802240.0} - ->>> converter.structure({'a_string': 'test', 'a_datetime': 1532802240.0}, MyRecord) -MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('UTC'))) -``` - -After a while, we realize we _will_ need our datetimes to have timezone information. -We decide to switch to using the ISO 8601 format for our unstructured datetime instances. - -```{testsetup} pendulum-iso8601 - -import pendulum -from pendulum import DateTime - -@define -class MyRecord: - a_string: str - a_datetime: DateTime -``` - -```{doctest} pendulum-iso8601 - ->>> converter = cattrs.Converter() ->>> converter.register_unstructure_hook(DateTime, lambda dt: dt.to_iso8601_string()) ->>> converter.register_structure_hook(DateTime, lambda isostring, _: pendulum.parse(isostring)) - ->>> my_record = MyRecord('test', pendulum.datetime(2018, 7, 28, 18, 24, tz='Europe/Paris')) ->>> my_record -MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('Europe/Paris'))) - ->>> converter.unstructure(my_record) -{'a_string': 'test', 'a_datetime': '2018-07-28T18:24:00+02:00'} - ->>> converter.structure({'a_string': 'test', 'a_datetime': '2018-07-28T18:24:00+02:00'}, MyRecord) -MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('+02:00'))) -``` +This section covers advanced use examples of _cattrs_ features. ## Using Factory Hooks @@ -99,7 +8,7 @@ For this example, let's assume you have some attrs classes with snake case attri ```{warning} A simpler and better approach to this problem is to simply make your class attributes camel case. -However, this is a good example of the power of hook factories and _cattrs'_ component-based design. +However, this is a good example of the power of hook factories and _cattrs'_ composition-based design. ``` Here's our simple data model: @@ -254,6 +163,7 @@ converter.register_structure_hook_factory( The `converter` instance will now un/structure every attrs class to camel case. Nothing has been omitted from this final example; it's complete. + ## Using Fallback Key Names Sometimes when structuring data, the input data may be in multiple formats that need to be converted into a common attribute. diff --git a/src/cattrs/__init__.py b/src/cattrs/__init__.py index e243d881..6ed83139 100644 --- a/src/cattrs/__init__.py +++ b/src/cattrs/__init__.py @@ -1,3 +1,5 @@ +from typing import Final + from .converters import BaseConverter, Converter, GenConverter, UnstructureStrategy from .errors import ( AttributeValidationNote, @@ -40,10 +42,12 @@ "transform_error", "unstructure", "UnstructureStrategy", + "get_structure_hook", + "get_unstructure_hook", ) - -global_converter = Converter() +#: The global converter. Prefer creating your own if customizations are required. +global_converter: Final = Converter() unstructure = global_converter.unstructure structure = global_converter.structure @@ -53,3 +57,5 @@ register_structure_hook_func = global_converter.register_structure_hook_func register_unstructure_hook = global_converter.register_unstructure_hook register_unstructure_hook_func = global_converter.register_unstructure_hook_func +get_structure_hook: Final = global_converter.get_structure_hook +get_unstructure_hook: Final = global_converter.get_unstructure_hook diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index bd3ed9e0..8221f62f 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -17,6 +17,7 @@ Optional, Protocol, Tuple, + Type, get_args, get_origin, get_type_hints, @@ -73,6 +74,12 @@ except ImportError: # pragma: no cover pass +NoneType = type(None) + + +def is_optional(typ: Type) -> bool: + return is_union_type(typ) and NoneType in typ.__args__ and len(typ.__args__) == 2 + def is_typeddict(cls): """Thin wrapper around typing(_extensions).is_typeddict""" diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 4d960bdd..6a17902f 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1,22 +1,11 @@ +from __future__ import annotations + from collections import Counter, deque from collections.abc import MutableSet as AbcMutableSet from dataclasses import Field from enum import Enum -from functools import lru_cache from pathlib import Path -from typing import ( - Any, - Callable, - Deque, - Dict, - Iterable, - List, - Optional, - Tuple, - Type, - TypeVar, - Union, -) +from typing import Any, Callable, Iterable, Optional, Tuple, TypeVar from attrs import Attribute, resolve_types from attrs import has as attrs_has @@ -26,6 +15,7 @@ Mapping, MutableMapping, MutableSequence, + NoneType, OriginAbstractSet, OriginMutableSet, Sequence, @@ -48,6 +38,7 @@ is_literal, is_mapping, is_mutable_set, + is_optional, is_protocol, is_sequence, is_tuple, @@ -89,7 +80,6 @@ __all__ = ["UnstructureStrategy", "BaseConverter", "Converter", "GenConverter"] -NoneType = type(None) T = TypeVar("T") V = TypeVar("V") @@ -101,16 +91,7 @@ class UnstructureStrategy(Enum): AS_TUPLE = "astuple" -def _subclass(typ: Type) -> Callable[[Type], bool]: - """a shortcut""" - return lambda cls: issubclass(cls, typ) - - -def is_optional(typ: Type) -> bool: - return is_union_type(typ) and NoneType in typ.__args__ and len(typ.__args__) == 2 - - -def is_literal_containing_enums(typ: Type) -> bool: +def is_literal_containing_enums(typ: type) -> bool: return is_literal(typ) and any(isinstance(val, Enum) for val in typ.__args__) @@ -118,7 +99,6 @@ class BaseConverter: """Converts between structured and unstructured data.""" __slots__ = ( - "_dis_func_cache", "_unstructure_func", "_unstructure_attrs", "_structure_attrs", @@ -164,8 +144,6 @@ def __init__( self._unstructure_attrs = self.unstructure_attrs_astuple self._structure_attrs = self.structure_attrs_fromtuple - self._dis_func_cache = lru_cache()(self._get_dis_func) - self._unstructure_func = MultiStrategyDispatch(unstructure_fallback_factory) self._unstructure_func.register_cls_list( [(bytes, identity), (str, identity), (Path, str)] @@ -190,7 +168,7 @@ def __init__( (is_sequence, self._unstructure_seq), (is_mutable_set, self._unstructure_seq), (is_frozenset, self._unstructure_seq), - (_subclass(Enum), self._unstructure_enum), + (lambda t: issubclass(t, Enum), self._unstructure_enum), (has, self._unstructure_attrs), (is_union_type, self._unstructure_union), ] @@ -243,7 +221,7 @@ def __init__( self._dict_factory = dict_factory # Unions are instances now, not classes. We use different registries. - self._union_struct_registry: Dict[Any, Callable[[Any, Type[T]], T]] = {} + self._union_struct_registry: dict[Any, Callable[[Any, type[T]], T]] = {} self._unstruct_copy_skip = self._unstructure_func.get_num_fns() self._struct_copy_skip = self._structure_func.get_num_fns() @@ -299,6 +277,27 @@ def register_unstructure_hook_factory( """ self._unstructure_func.register_func_list([(predicate, factory, True)]) + def get_unstructure_hook( + self, type: Any, cache_result: bool = True + ) -> UnstructureHook: + """Get the unstructure hook for the given type. + + This hook can be manually called, or composed with other functions + and re-registered. + + If no hook is registered, the converter unstructure fallback factory + will be used to produce one. + + :param cache: Whether to cache the returned hook. + + .. versionadded:: 24.1.0 + """ + return ( + self._unstructure_func.dispatch(type) + if cache_result + else self._unstructure_func.dispatch_without_caching(type) + ) + def register_structure_hook(self, cl: Any, func: StructureHook) -> None: """Register a primitive-to-class converter function for a type. @@ -321,7 +320,7 @@ def register_structure_hook(self, cl: Any, func: StructureHook) -> None: self._structure_func.register_cls_list([(cl, func)]) def register_structure_hook_func( - self, check_func: Callable[[Type[T]], bool], func: StructureHook + self, check_func: Callable[[type[T]], bool], func: StructureHook ) -> None: """Register a class-to-primitive converter function for a class, using a function to check if it's a match. @@ -341,12 +340,31 @@ def register_structure_hook_factory( """ self._structure_func.register_func_list([(predicate, factory, True)]) - def structure(self, obj: UnstructuredValue, cl: Type[T]) -> T: + def structure(self, obj: UnstructuredValue, cl: type[T]) -> T: """Convert unstructured Python data structures to structured data.""" return self._structure_func.dispatch(cl)(obj, cl) + def get_structure_hook(self, type: Any, cache_result: bool = True) -> StructureHook: + """Get the structure hook for the given type. + + This hook can be manually called, or composed with other functions + and re-registered. + + If no hook is registered, the converter structure fallback factory + will be used to produce one. + + :param cache: Whether to cache the returned hook. + + .. versionadded:: 24.1.0 + """ + return ( + self._structure_func.dispatch(type) + if cache_result + else self._structure_func.dispatch_without_caching(type) + ) + # Classes to Python primitives. - def unstructure_attrs_asdict(self, obj: Any) -> Dict[str, Any]: + def unstructure_attrs_asdict(self, obj: Any) -> dict[str, Any]: """Our version of `attrs.asdict`, so we can call back to us.""" attrs = fields(obj.__class__) dispatch = self._unstructure_func.dispatch @@ -357,7 +375,7 @@ def unstructure_attrs_asdict(self, obj: Any) -> Dict[str, Any]: rv[name] = dispatch(a.type or v.__class__)(v) return rv - def unstructure_attrs_astuple(self, obj: Any) -> Tuple[Any, ...]: + def unstructure_attrs_astuple(self, obj: Any) -> tuple[Any, ...]: """Our version of `attrs.astuple`, so we can call back to us.""" attrs = fields(obj.__class__) dispatch = self._unstructure_func.dispatch @@ -402,7 +420,7 @@ def _unstructure_union(self, obj: Any) -> Any: # Python primitives to classes. - def _gen_structure_generic(self, cl: Type[T]) -> DictStructureFn[T]: + def _gen_structure_generic(self, cl: type[T]) -> DictStructureFn[T]: """Create and return a hook for structuring generics.""" return make_dict_structure_fn( cl, self, _cattrs_prefer_attrib_converters=self._prefer_attrib_converters @@ -410,7 +428,7 @@ def _gen_structure_generic(self, cl: Type[T]) -> DictStructureFn[T]: def _gen_attrs_union_structure( self, cl: Any, use_literals: bool = True - ) -> Callable[[Any, Type[T]], Optional[Type[T]]]: + ) -> Callable[[Any, type[T]], type[T] | None]: """ Generate a structuring function for a union of attrs classes (and maybe None). @@ -434,7 +452,7 @@ def structure_attrs_union(obj, _): return structure_attrs_union @staticmethod - def _structure_call(obj: Any, cl: Type[T]) -> Any: + def _structure_call(obj: Any, cl: type[T]) -> Any: """Just call ``cl`` with the given ``obj``. This is just an optimization on the ``_structure_default`` case, when @@ -459,11 +477,11 @@ def _structure_enum_literal(val, type): def _structure_newtype(self, val: UnstructuredValue, type) -> StructuredValue: base = get_newtype_base(type) - return self._structure_func.dispatch(base)(val, base) + return self.get_structure_hook(base)(val, base) def _find_type_alias_structure_hook(self, type: Any) -> StructureHook: base = get_type_alias_base(type) - res = self._structure_func.dispatch(base) + res = self.get_structure_hook(base) if res == self._structure_call: # we need to replace the type arg of `structure_call` return lambda v, _, __base=base: self._structure_call(v, __base) @@ -471,12 +489,12 @@ def _find_type_alias_structure_hook(self, type: Any) -> StructureHook: def _structure_final_factory(self, type): base = get_final_base(type) - res = self._structure_func.dispatch(base) + res = self.get_structure_hook(base) return lambda v, _, __base=base: res(v, __base) # Attrs classes. - def structure_attrs_fromtuple(self, obj: Tuple[Any, ...], cl: Type[T]) -> T: + def structure_attrs_fromtuple(self, obj: tuple[Any, ...], cl: type[T]) -> T: """Load an attrs class from a sequence (tuple).""" conv_obj = [] # A list of converter parameters. for a, value in zip(fields(cl), obj): @@ -486,7 +504,7 @@ def structure_attrs_fromtuple(self, obj: Tuple[Any, ...], cl: Type[T]) -> T: return cl(*conv_obj) - def _structure_attribute(self, a: Union[Attribute, Field], value: Any) -> Any: + def _structure_attribute(self, a: Attribute | Field, value: Any) -> Any: """Handle an individual attrs attribute.""" type_ = a.type attrib_converter = getattr(a, "converter", None) @@ -508,7 +526,7 @@ def _structure_attribute(self, a: Union[Attribute, Field], value: Any) -> Any: return value raise - def structure_attrs_fromdict(self, obj: Mapping[str, Any], cl: Type[T]) -> T: + def structure_attrs_fromdict(self, obj: Mapping[str, Any], cl: type[T]) -> T: """Instantiate an attrs class from a mapping (dict).""" # For public use. @@ -524,7 +542,7 @@ def structure_attrs_fromdict(self, obj: Mapping[str, Any], cl: Type[T]) -> T: return cl(**conv_obj) - def _structure_list(self, obj: Iterable[T], cl: Any) -> List[T]: + def _structure_list(self, obj: Iterable[T], cl: Any) -> list[T]: """Convert an iterable to a potentially generic list.""" if is_bare(cl) or cl.__args__[0] is Any: res = list(obj) @@ -554,7 +572,7 @@ def _structure_list(self, obj: Iterable[T], cl: Any) -> List[T]: res = [handler(e, elem_type) for e in obj] return res - def _structure_deque(self, obj: Iterable[T], cl: Any) -> Deque[T]: + def _structure_deque(self, obj: Iterable[T], cl: Any) -> deque[T]: """Convert an iterable to a potentially generic deque.""" if is_bare(cl) or cl.__args__[0] is Any: res = deque(e for e in obj) @@ -622,7 +640,7 @@ def _structure_frozenset( """Convert an iterable into a potentially generic frozenset.""" return self._structure_set(obj, cl, structure_to=frozenset) - def _structure_dict(self, obj: Mapping[T, V], cl: Any) -> Dict[T, V]: + def _structure_dict(self, obj: Mapping[T, V], cl: Any) -> dict[T, V]: """Convert a mapping into a potentially generic dict.""" if is_bare(cl) or cl.__args__ == (Any, Any): return dict(obj) @@ -650,7 +668,7 @@ def _structure_union(self, obj, union): handler = self._union_struct_registry[union] return handler(obj, union) - def _structure_tuple(self, obj: Any, tup: Type[T]) -> T: + def _structure_tuple(self, obj: Any, tup: type[T]) -> T: """Deal with structuring into a tuple.""" tup_params = None if tup in (Tuple, tuple) else tup.__args__ has_ellipsis = tup_params and tup_params[-1] is Ellipsis @@ -723,8 +741,12 @@ def _structure_tuple(self, obj: Any, tup: Type[T]) -> T: raise ValueError(f"{problem} values in {obj!r} to structure as {tup!r}") return res - @staticmethod - def _get_dis_func(union: Any, use_literals: bool = True) -> Callable[[Any], Type]: + def _get_dis_func( + self, + union: Any, + use_literals: bool = True, + overrides: dict[str, AttributeOverride] | None = None, + ) -> Callable[[Any], type]: """Fetch or try creating a disambiguation function for a union.""" union_types = union.__args__ if NoneType in union_types: # type: ignore @@ -739,22 +761,27 @@ def _get_dis_func(union: Any, use_literals: bool = True) -> Callable[[Any], Type if not all(has(get_origin(e) or e) for e in union_types): raise StructureHandlerNotFoundError( "Only unions of attrs classes supported " - "currently. Register a loads hook manually.", + "currently. Register a structure hook manually.", type_=union, ) - return create_default_dis_func(*union_types, use_literals=use_literals) + return create_default_dis_func( + self, + *union_types, + use_literals=use_literals, + overrides=overrides if overrides is not None else "from_converter", + ) - def __deepcopy__(self, _) -> "BaseConverter": + def __deepcopy__(self, _) -> BaseConverter: return self.copy() def copy( self, - dict_factory: Optional[Callable[[], Any]] = None, - unstruct_strat: Optional[UnstructureStrategy] = None, - prefer_attrib_converters: Optional[bool] = None, - detailed_validation: Optional[bool] = None, - ) -> "BaseConverter": + dict_factory: Callable[[], Any] | None = None, + unstruct_strat: UnstructureStrategy | None = None, + prefer_attrib_converters: bool | None = None, + detailed_validation: bool | None = None, + ) -> BaseConverter: """Create a copy of the converter, keeping all existing custom hooks. :param detailed_validation: Whether to use a slightly slower mode for detailed @@ -799,8 +826,8 @@ def __init__( unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT, omit_if_default: bool = False, forbid_extra_keys: bool = False, - type_overrides: Mapping[Type, AttributeOverride] = {}, - unstruct_collection_overrides: Mapping[Type, Callable] = {}, + type_overrides: Mapping[type, AttributeOverride] = {}, + unstruct_collection_overrides: Mapping[type, Callable] = {}, prefer_attrib_converters: bool = False, detailed_validation: bool = True, unstructure_fallback_factory: HookFactory[UnstructureHook] = lambda _: identity, @@ -929,9 +956,9 @@ def __init__( self._struct_copy_skip = self._structure_func.get_num_fns() self._unstruct_copy_skip = self._unstructure_func.get_num_fns() - def get_structure_newtype(self, type: Type[T]) -> Callable[[Any, Any], T]: + def get_structure_newtype(self, type: type[T]) -> Callable[[Any, Any], T]: base = get_newtype_base(type) - handler = self._structure_func.dispatch(base) + handler = self.get_structure_hook(base) return lambda v, _: handler(v, base) def gen_unstructure_annotated(self, type): @@ -941,10 +968,10 @@ def gen_unstructure_annotated(self, type): def gen_structure_annotated(self, type) -> Callable: """A hook factory for annotated types.""" origin = type.__origin__ - hook = self._structure_func.dispatch(origin) + hook = self.get_structure_hook(origin) return lambda v, _: hook(v, origin) - def gen_unstructure_typeddict(self, cl: Any) -> Callable[[Dict], Dict]: + def gen_unstructure_typeddict(self, cl: Any) -> Callable[[dict], dict]: """Generate a TypedDict unstructure function. Also apply converter-scored modifications. @@ -952,8 +979,8 @@ def gen_unstructure_typeddict(self, cl: Any) -> Callable[[Dict], Dict]: return make_typeddict_dict_unstruct_fn(cl, self) def gen_unstructure_attrs_fromdict( - self, cl: Type[T] - ) -> Callable[[T], Dict[str, Any]]: + self, cl: type[T] + ) -> Callable[[T], dict[str, Any]]: origin = get_origin(cl) attribs = fields(origin or cl) if attrs_has(cl) and any(isinstance(a.type, str) for a in attribs): @@ -969,7 +996,7 @@ def gen_unstructure_attrs_fromdict( cl, self, _cattrs_omit_if_default=self.omit_if_default, **attrib_overrides ) - def gen_unstructure_optional(self, cl: Type[T]) -> Callable[[T], Any]: + def gen_unstructure_optional(self, cl: type[T]) -> Callable[[T], Any]: """Generate an unstructuring hook for optional types.""" union_params = cl.__args__ other = union_params[0] if union_params[1] is NoneType else union_params[1] @@ -985,7 +1012,7 @@ def unstructure_optional(val, _handler=handler): return unstructure_optional - def gen_structure_typeddict(self, cl: Any) -> Callable[[Dict], Dict]: + def gen_structure_typeddict(self, cl: Any) -> Callable[[dict], dict]: """Generate a TypedDict structure function. Also apply converter-scored modifications. @@ -995,7 +1022,7 @@ def gen_structure_typeddict(self, cl: Any) -> Callable[[Dict], Dict]: ) def gen_structure_attrs_fromdict( - self, cl: Type[T] + self, cl: type[T] ) -> Callable[[Mapping[str, Any], Any], T]: attribs = fields(get_origin(cl) or cl if is_generic(cl) else cl) if attrs_has(cl) and any(isinstance(a.type, str) for a in attribs): @@ -1039,7 +1066,7 @@ def gen_unstructure_mapping( self, cl: Any, unstructure_to: Any = None, - key_handler: Optional[Callable[[Any, Optional[Any]], Any]] = None, + key_handler: Callable[[Any, Any | None], Any] | None = None, ) -> MappingUnstructureFn: unstructure_to = self._unstruct_collection_overrides.get( get_origin(cl) or cl, unstructure_to or dict @@ -1070,15 +1097,15 @@ def gen_structure_mapping(self, cl: Any) -> MappingStructureFn[T]: def copy( self, - dict_factory: Optional[Callable[[], Any]] = None, - unstruct_strat: Optional[UnstructureStrategy] = None, - omit_if_default: Optional[bool] = None, - forbid_extra_keys: Optional[bool] = None, - type_overrides: Optional[Mapping[Type, AttributeOverride]] = None, - unstruct_collection_overrides: Optional[Mapping[Type, Callable]] = None, - prefer_attrib_converters: Optional[bool] = None, - detailed_validation: Optional[bool] = None, - ) -> "Converter": + dict_factory: Callable[[], Any] | None = None, + unstruct_strat: UnstructureStrategy | None = None, + omit_if_default: bool | None = None, + forbid_extra_keys: bool | None = None, + type_overrides: Mapping[type, AttributeOverride] | None = None, + unstruct_collection_overrides: Mapping[type, Callable] | None = None, + prefer_attrib_converters: bool | None = None, + detailed_validation: bool | None = None, + ) -> Converter: """Create a copy of the converter, keeping all existing custom hooks. :param detailed_validation: Whether to use a slightly slower mode for detailed diff --git a/src/cattrs/disambiguators.py b/src/cattrs/disambiguators.py index 281954e1..ad145f65 100644 --- a/src/cattrs/disambiguators.py +++ b/src/cattrs/disambiguators.py @@ -1,19 +1,23 @@ """Utilities for union (sum type) disambiguation.""" -from collections import OrderedDict, defaultdict +from __future__ import annotations + +from collections import defaultdict from functools import reduce from operator import or_ -from typing import Any, Callable, Dict, Mapping, Optional, Set, Type, Union +from typing import TYPE_CHECKING, Any, Callable, Literal, Mapping, Union -from attrs import NOTHING, fields, fields_dict +from attrs import NOTHING, Attribute, AttrsInstance, fields, fields_dict -from ._compat import get_args, get_origin, has, is_literal, is_union_type +from ._compat import NoneType, get_args, get_origin, has, is_literal, is_union_type +from .gen import AttributeOverride -__all__ = ("is_supported_union", "create_default_dis_func") +if TYPE_CHECKING: + from .converters import BaseConverter -NoneType = type(None) +__all__ = ["is_supported_union", "create_default_dis_func"] -def is_supported_union(typ: Type) -> bool: +def is_supported_union(typ: Any) -> bool: """Whether the type is a union of attrs classes.""" return is_union_type(typ) and all( e is NoneType or has(get_origin(e) or e) for e in typ.__args__ @@ -21,18 +25,30 @@ def is_supported_union(typ: Type) -> bool: def create_default_dis_func( - *classes: Type[Any], use_literals: bool = True -) -> Callable[[Mapping[Any, Any]], Optional[Type[Any]]]: + converter: BaseConverter, + *classes: type[AttrsInstance], + use_literals: bool = True, + overrides: dict[str, AttributeOverride] + | Literal["from_converter"] = "from_converter", +) -> Callable[[Mapping[Any, Any]], type[Any] | None]: """Given attrs classes, generate a disambiguation function. - The function is based on unique fields or unique values. + The function is based on unique fields without defaults or unique values. :param use_literals: Whether to try using fields annotated as literals for disambiguation. + :param overrides: Attribute overrides to apply. """ if len(classes) < 2: raise ValueError("At least two classes required.") + if overrides == "from_converter": + overrides = [ + getattr(converter.get_structure_hook(c), "overrides", {}) for c in classes + ] + else: + overrides = [overrides for _ in classes] + # first, attempt for unique values if use_literals: # requirements for a discriminator field: @@ -44,7 +60,7 @@ def create_default_dis_func( ] # literal field names common to all members - discriminators: Set[str] = cls_candidates[0] + discriminators: set[str] = cls_candidates[0] for possible_discriminators in cls_candidates: discriminators &= possible_discriminators @@ -76,7 +92,7 @@ def create_default_dis_func( for k, v in best_result.items() } - def dis_func(data: Mapping[Any, Any]) -> Optional[Type]: + def dis_func(data: Mapping[Any, Any]) -> type | None: if not isinstance(data, Mapping): raise ValueError("Only input mappings are supported.") return final_mapping[data[best_discriminator]] @@ -88,45 +104,83 @@ def dis_func(data: Mapping[Any, Any]) -> Optional[Type]: # NOTE: This could just as well work with just field availability and not # uniqueness, returning Unions ... it doesn't do that right now. cls_and_attrs = [ - (cl, {at.name for at in fields(get_origin(cl) or cl)}) for cl in classes + (cl, *_usable_attribute_names(cl, override)) + for cl, override in zip(classes, overrides) ] - if len([attrs for _, attrs in cls_and_attrs if len(attrs) == 0]) > 1: - raise ValueError("At least two classes have no attributes.") - # TODO: Deal with a single class having no required attrs. # For each class, attempt to generate a single unique required field. - uniq_attrs_dict: Dict[str, Type] = OrderedDict() - cls_and_attrs.sort(key=lambda c_a: -len(c_a[1])) + uniq_attrs_dict: dict[str, type] = {} + + # We start from classes with the largest number of unique fields + # so we can do easy picks first, making later picks easier. + cls_and_attrs.sort(key=lambda c_a: len(c_a[1]), reverse=True) fallback = None # If none match, try this. - for i, (cl, cl_reqs) in enumerate(cls_and_attrs): - other_classes = cls_and_attrs[i + 1 :] - if other_classes: - other_reqs = reduce(or_, (c_a[1] for c_a in other_classes)) - uniq = cl_reqs - other_reqs - if not uniq: - m = f"{cl} has no usable unique attributes." - raise ValueError(m) - # We need a unique attribute with no default. - cl_fields = fields(get_origin(cl) or cl) - for attr_name in uniq: - if getattr(cl_fields, attr_name).default is NOTHING: - break - else: - raise ValueError(f"{cl} has no usable non-default attributes.") - uniq_attrs_dict[attr_name] = cl + for cl, cl_reqs, back_map in cls_and_attrs: + # We do not have to consider classes we've already processed, since + # they will have been eliminated by the match dictionary already. + other_classes = [ + c_and_a + for c_and_a in cls_and_attrs + if c_and_a[0] is not cl and c_and_a[0] not in uniq_attrs_dict.values() + ] + other_reqs = reduce(or_, (c_a[1] for c_a in other_classes), set()) + uniq = cl_reqs - other_reqs + + # We want a unique attribute with no default. + cl_fields = fields(get_origin(cl) or cl) + for maybe_renamed_attr_name in uniq: + orig_name = back_map[maybe_renamed_attr_name] + if getattr(cl_fields, orig_name).default is NOTHING: + break else: - fallback = cl - - def dis_func(data: Mapping[Any, Any]) -> Optional[Type]: - if not isinstance(data, Mapping): - raise ValueError("Only input mappings are supported.") - for k, v in uniq_attrs_dict.items(): - if k in data: - return v - return fallback + if fallback is None: + fallback = cl + continue + raise TypeError(f"{cl} has no usable non-default attributes") + uniq_attrs_dict[maybe_renamed_attr_name] = cl + + if fallback is None: + + def dis_func(data: Mapping[Any, Any]) -> type[AttrsInstance] | None: + if not isinstance(data, Mapping): + raise ValueError("Only input mappings are supported") + for k, v in uniq_attrs_dict.items(): + if k in data: + return v + raise ValueError("Couldn't disambiguate") + + else: + + def dis_func(data: Mapping[Any, Any]) -> type[AttrsInstance] | None: + if not isinstance(data, Mapping): + raise ValueError("Only input mappings are supported") + for k, v in uniq_attrs_dict.items(): + if k in data: + return v + return fallback return dis_func create_uniq_field_dis_func = create_default_dis_func + + +def _overriden_name(at: Attribute, override: AttributeOverride | None) -> str: + if override is None or override.rename is None: + return at.name + return override.rename + + +def _usable_attribute_names( + cl: type[AttrsInstance], overrides: dict[str, AttributeOverride] +) -> tuple[set[str], dict[str, str]]: + """Return renamed fields and a mapping to original field names.""" + res = set() + mapping = {} + + for at in fields(get_origin(cl) or cl): + res.add(n := _overriden_name(at, overrides.get(at.name))) + mapping[n] = at.name + + return res, mapping diff --git a/src/cattrs/dispatch.py b/src/cattrs/dispatch.py index 2aa525a8..fe3ceba8 100644 --- a/src/cattrs/dispatch.py +++ b/src/cattrs/dispatch.py @@ -85,7 +85,7 @@ class MultiStrategyDispatch(Generic[Hook]): """ _fallback_factory: HookFactory[Hook] - _direct_dispatch: Dict = field(init=False, factory=dict) + _direct_dispatch: Dict[TargetType, Hook] = field(init=False, factory=dict) _function_dispatch: FunctionDispatch = field(init=False, factory=FunctionDispatch) _single_dispatch: Any = field( init=False, factory=partial(singledispatch, _DispatchNotFound) @@ -93,11 +93,13 @@ class MultiStrategyDispatch(Generic[Hook]): dispatch: Callable[[TargetType], Hook] = field( init=False, default=Factory( - lambda self: lru_cache(maxsize=None)(self._dispatch), takes_self=True + lambda self: lru_cache(maxsize=None)(self.dispatch_without_caching), + takes_self=True, ), ) - def _dispatch(self, typ: TargetType) -> Hook: + def dispatch_without_caching(self, typ: TargetType) -> Hook: + """Dispatch on the type but without caching the result.""" try: dispatch = self._single_dispatch.dispatch(typ) if dispatch is not _DispatchNotFound: diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 74b4b23c..4d201f8f 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -65,6 +65,9 @@ def make_dict_unstructure_fn( Generate a specialized dict unstructuring function for an attrs class or a dataclass. + Any provided overrides are attached to the generated function under the + `overrides` attribute. + :param _cattrs_omit_if_default: if true, attributes equal to their default values will be omitted in the result dictionary. :param _cattrs_use_alias: If true, the attribute alias will be used as the @@ -221,7 +224,10 @@ def make_dict_unstructure_fn( if not working_set: del already_generating.working_set - return globs[fn_name] + res = globs[fn_name] + res.overrides = kwargs + + return res DictStructureFn = Callable[[Mapping[str, Any], Any], T] @@ -242,8 +248,15 @@ def make_dict_structure_fn( Generate a specialized dict structuring function for an attrs class or dataclass. + Any provided overrides are attached to the generated function under the + `overrides` attribute. + :param _cattrs_forbid_extra_keys: Whether the structuring function should raise a `ForbiddenExtraKeysError` if unknown keys are encountered. + :param _cattrs_use_linecache: Whether to store the source code in the Python + linecache. + :param _cattrs_prefer_attrib_converters: If an _attrs_ converter is present on a + field, use it instead of processing the field normally. :param _cattrs_detailed_validation: Whether to use a slower mode that produces more detailed errors. :param _cattrs_use_alias: If true, the attribute alias will be used as the @@ -629,7 +642,10 @@ def make_dict_structure_fn( eval(compile(script, fname, "exec"), globs) - return globs[fn_name] + res = globs[fn_name] + res.overrides = kwargs + + return res IterableUnstructureFn = Callable[[Iterable[Any]], Any] @@ -808,11 +824,11 @@ def make_mapping_structure_fn( is_bare_dict = val_type is Any and key_type is Any if not is_bare_dict: # We can do the dispatch here and now. - key_handler = converter._structure_func.dispatch(key_type) + key_handler = converter.get_structure_hook(key_type) if key_handler == converter._structure_call: key_handler = key_type - val_handler = converter._structure_func.dispatch(val_type) + val_handler = converter.get_structure_hook(val_type) if val_handler == converter._structure_call: val_handler = val_type diff --git a/src/cattrs/gen/_shared.py b/src/cattrs/gen/_shared.py index bbade22e..2bd1007f 100644 --- a/src/cattrs/gen/_shared.py +++ b/src/cattrs/gen/_shared.py @@ -23,7 +23,7 @@ def find_structure_handler( # so it falls back to that. handler = None elif a.converter is not None and not prefer_attrs_converters and type is not None: - handler = c._structure_func.dispatch(type) + handler = c.get_structure_hook(type) if handler == raise_error: handler = None elif type is not None: @@ -35,7 +35,7 @@ def find_structure_handler( # This is a special case where we can use the # type of the default to dispatch on. type = a.default.__class__ - handler = c._structure_func.dispatch(type) + handler = c.get_structure_hook(type) if handler == c._structure_call: # Finals can't really be used with _structure_call, so # we wrap it so the rest of the toolchain doesn't get @@ -45,7 +45,7 @@ def handler(v, _, _h=handler): return _h(v, type) else: - handler = c._structure_func.dispatch(type) + handler = c.get_structure_hook(type) else: handler = c.structure return handler diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index 8a12ddf8..f77c0a86 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -425,7 +425,7 @@ def make_dict_structure_fn( # For each attribute, we try resolving the type here and now. # If a type is manually overwritten, this function should be # regenerated. - handler = converter._structure_func.dispatch(t) + handler = converter.get_structure_hook(t) kn = an if override.rename is None else override.rename allowed_fields.add(kn) @@ -468,7 +468,7 @@ def make_dict_structure_fn( # For each attribute, we try resolving the type here and now. # If a type is manually overwritten, this function should be # regenerated. - handler = converter._structure_func.dispatch(t) + handler = converter.get_structure_hook(t) struct_handler_name = f"__c_structure_{ix}" internal_arg_parts[struct_handler_name] = handler diff --git a/src/cattrs/preconf/bson.py b/src/cattrs/preconf/bson.py index c7a6a4e1..6fc6d72a 100644 --- a/src/cattrs/preconf/bson.py +++ b/src/cattrs/preconf/bson.py @@ -9,6 +9,7 @@ from cattrs.gen import make_mapping_structure_fn from ..converters import BaseConverter, Converter +from ..dispatch import StructureHook from ..strategies import configure_union_passthrough from . import validate_datetime @@ -67,7 +68,7 @@ def key_handler(k): cl, unstructure_to=unstructure_to, key_handler=key_handler ) - def gen_structure_mapping(cl: Any): + def gen_structure_mapping(cl: Any) -> StructureHook: args = getattr(cl, "__args__", None) if args and issubclass(args[0], bytes): h = make_mapping_structure_fn(cl, converter, key_type=Base85Bytes) @@ -76,12 +77,8 @@ def gen_structure_mapping(cl: Any): return h converter.register_structure_hook(Base85Bytes, lambda v, _: b85decode(v)) - converter._unstructure_func.register_func_list( - [(is_mapping, gen_unstructure_mapping, True)] - ) - converter._structure_func.register_func_list( - [(is_mapping, gen_structure_mapping, True)] - ) + converter.register_unstructure_hook_factory(is_mapping, gen_unstructure_mapping) + converter.register_structure_hook_factory(is_mapping, gen_structure_mapping) converter.register_structure_hook(ObjectId, lambda v, _: ObjectId(v)) configure_union_passthrough( diff --git a/src/cattrs/strategies/_subclasses.py b/src/cattrs/strategies/_subclasses.py index cb2be697..68396089 100644 --- a/src/cattrs/strategies/_subclasses.py +++ b/src/cattrs/strategies/_subclasses.py @@ -1,37 +1,39 @@ """Strategies for customizing subclass behaviors.""" +from __future__ import annotations + from gc import collect -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union +from typing import Any, Callable, Union from ..converters import BaseConverter, Converter from ..gen import AttributeOverride, make_dict_structure_fn, make_dict_unstructure_fn from ..gen._consts import already_generating -def _make_subclasses_tree(cl: Type) -> List[Type]: +def _make_subclasses_tree(cl: type) -> list[type]: return [cl] + [ sscl for scl in cl.__subclasses__() for sscl in _make_subclasses_tree(scl) ] -def _has_subclasses(cl: Type, given_subclasses: Tuple[Type, ...]) -> bool: +def _has_subclasses(cl: type, given_subclasses: tuple[type, ...]) -> bool: """Whether the given class has subclasses from `given_subclasses`.""" actual = set(cl.__subclasses__()) given = set(given_subclasses) return bool(actual & given) -def _get_union_type(cl: Type, given_subclasses_tree: Tuple[Type]) -> Optional[Type]: +def _get_union_type(cl: type, given_subclasses_tree: tuple[type]) -> type | None: actual_subclass_tree = tuple(_make_subclasses_tree(cl)) class_tree = tuple(set(actual_subclass_tree) & set(given_subclasses_tree)) return Union[class_tree] if len(class_tree) >= 2 else None def include_subclasses( - cl: Type, + cl: type, converter: Converter, - subclasses: Optional[Tuple[Type, ...]] = None, - union_strategy: Optional[Callable[[Any, BaseConverter], Any]] = None, - overrides: Optional[Dict[str, AttributeOverride]] = None, + subclasses: tuple[type, ...] | None = None, + union_strategy: Callable[[Any, BaseConverter], Any] | None = None, + overrides: dict[str, AttributeOverride] | None = None, ) -> None: """ Configure the converter so that the attrs/dataclass `cl` is un/structured as if it @@ -54,6 +56,9 @@ def include_subclasses( :func:`cattrs.gen.override`) to customize un/structuring. .. versionadded:: 23.1.0 + .. versionchanged:: 24.1.0 + When overrides are not provided, hooks for individual classes are retrieved from + the converter instead of generated with no overrides, using converter defaults. """ # Due to https://github.com/python-attrs/attrs/issues/1047 collect() @@ -62,9 +67,6 @@ def include_subclasses( else: parent_subclass_tree = tuple(_make_subclasses_tree(cl)) - if overrides is None: - overrides = {} - if union_strategy is None: _include_subclasses_without_union_strategy( cl, converter, parent_subclass_tree, overrides @@ -78,8 +80,8 @@ def include_subclasses( def _include_subclasses_without_union_strategy( cl, converter: Converter, - parent_subclass_tree: Tuple[Type], - overrides: Dict[str, AttributeOverride], + parent_subclass_tree: tuple[type], + overrides: dict[str, AttributeOverride] | None, ): # The iteration approach is required if subclasses are more than one level deep: for cl in parent_subclass_tree: @@ -95,8 +97,12 @@ def _include_subclasses_without_union_strategy( def cls_is_cl(cls, _cl=cl): return cls is _cl - base_struct_hook = make_dict_structure_fn(cl, converter, **overrides) - base_unstruct_hook = make_dict_unstructure_fn(cl, converter, **overrides) + if overrides is not None: + base_struct_hook = make_dict_structure_fn(cl, converter, **overrides) + base_unstruct_hook = make_dict_unstructure_fn(cl, converter, **overrides) + else: + base_struct_hook = converter.get_structure_hook(cl) + base_unstruct_hook = converter.get_unstructure_hook(cl) if subclass_union is None: @@ -104,7 +110,7 @@ def struct_hook(val: dict, _, _cl=cl, _base_hook=base_struct_hook) -> cl: return _base_hook(val, _cl) else: - dis_fn = converter._get_dis_func(subclass_union) + dis_fn = converter._get_dis_func(subclass_union, overrides=overrides) def struct_hook( val: dict, @@ -130,7 +136,7 @@ def unstruct_hook( _c=converter, _cl=cl, _base_hook=base_unstruct_hook, - ) -> Dict: + ) -> dict: """ If val is an instance of the class `cl`, use the hook. @@ -148,9 +154,9 @@ def unstruct_hook( def _include_subclasses_with_union_strategy( converter: Converter, - union_classes: Tuple[Type, ...], + union_classes: tuple[type, ...], union_strategy: Callable[[Any, BaseConverter], Any], - overrides: Dict[str, AttributeOverride], + overrides: dict[str, AttributeOverride] | None, ): """ This function is tricky because we're dealing with what is essentially a circular @@ -176,8 +182,12 @@ def _include_subclasses_with_union_strategy( # manipulate the _already_generating set to force runtime dispatch. already_generating.working_set = set(union_classes) - {cl} try: - unstruct_hook = make_dict_unstructure_fn(cl, converter, **overrides) - struct_hook = make_dict_structure_fn(cl, converter, **overrides) + if overrides is not None: + unstruct_hook = make_dict_unstructure_fn(cl, converter, **overrides) + struct_hook = make_dict_structure_fn(cl, converter, **overrides) + else: + unstruct_hook = converter.get_unstructure_hook(cl, cache_result=False) + struct_hook = converter.get_structure_hook(cl, cache_result=False) finally: already_generating.working_set = set() original_unstruct_hooks[cl] = unstruct_hook @@ -202,8 +212,8 @@ def cls_is_cl(cls, _cl=cl): converter.register_structure_hook_func(cls_is_cl, hook) union_strategy(final_union, converter) - unstruct_hook = converter._unstructure_func.dispatch(final_union) - struct_hook = converter._structure_func.dispatch(final_union) + unstruct_hook = converter.get_unstructure_hook(final_union) + struct_hook = converter.get_structure_hook(final_union) for cl in union_classes: # In the second pass, we overwrite the hooks with the union hook. @@ -216,7 +226,7 @@ def cls_is_cl(cls, _cl=cl): if len(subclasses) > 1: u = Union[subclasses] # type: ignore union_strategy(u, converter) - struct_hook = converter._structure_func.dispatch(u) + struct_hook = converter.get_structure_hook(u) def sh(payload: dict, _, _u=u, _s=struct_hook) -> cl: return _s(payload, _u) diff --git a/src/cattrs/strategies/_unions.py b/src/cattrs/strategies/_unions.py index 8a3eb13f..1e63744d 100644 --- a/src/cattrs/strategies/_unions.py +++ b/src/cattrs/strategies/_unions.py @@ -50,8 +50,8 @@ def configure_tagged_union( exact_cl_unstruct_hooks = {} for cl in args: tag = tag_generator(cl) - struct_handler = converter._structure_func.dispatch(cl) - unstruct_handler = converter._unstructure_func.dispatch(cl) + struct_handler = converter.get_structure_hook(cl) + unstruct_handler = converter.get_unstructure_hook(cl) def structure_union_member(val: dict, _cl=cl, _h=struct_handler) -> cl: return _h(val, _cl) @@ -65,7 +65,7 @@ def unstructure_union_member(val: union, _h=unstruct_handler) -> dict: cl_to_tag = {cl: tag_generator(cl) for cl in args} if default is not NOTHING: - default_handler = converter._structure_func.dispatch(default) + default_handler = converter.get_structure_hook(default) def structure_default(val: dict, _cl=default, _h=default_handler): return _h(val, _cl) diff --git a/tests/strategies/test_include_subclasses.py b/tests/strategies/test_include_subclasses.py index 421ff705..0b29910f 100644 --- a/tests/strategies/test_include_subclasses.py +++ b/tests/strategies/test_include_subclasses.py @@ -3,61 +3,61 @@ from functools import partial from typing import Tuple -import attr import pytest +from attrs import define from cattrs import Converter, override from cattrs.errors import ClassValidationError from cattrs.strategies import configure_tagged_union, include_subclasses -@attr.define +@define class Parent: p: int -@attr.define +@define class Child1(Parent): c1: int -@attr.define +@define class GrandChild(Child1): g: int -@attr.define +@define class Child2(Parent): c2: int -@attr.define +@define class UnionCompose: a: typing.Union[Parent, Child1, Child2, GrandChild] -@attr.define +@define class NonUnionCompose: a: Parent -@attr.define +@define class UnionContainer: a: typing.List[typing.Union[Parent, Child1, Child2, GrandChild]] -@attr.define +@define class NonUnionContainer: a: typing.List[Parent] -@attr.define +@define class CircularA: a: int other: "typing.List[CircularA]" -@attr.define +@define class CircularB(CircularA): b: int @@ -244,11 +244,11 @@ def test_unstructuring_with_inheritance( def test_structuring_unstructuring_unknown_subclass(): - @attr.define + @define class A: a: int - @attr.define + @define class A1(A): a1: int @@ -256,7 +256,7 @@ class A1(A): include_subclasses(A, converter) # We define A2 after having created the custom un/structuring functions for A and A1 - @attr.define + @define class A2(A1): a2: int diff --git a/tests/test_converter.py b/tests/test_converter.py index 6e0563b7..65ee8496 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -762,8 +762,24 @@ class Test: child.structure(child.unstructure(Test()), Test) child = converter_cls( - unstructure_fallback_factory=parent._unstructure_func.dispatch, - structure_fallback_factory=parent._structure_func.dispatch, + unstructure_fallback_factory=parent.get_unstructure_hook, + structure_fallback_factory=parent.get_structure_hook, ) assert isinstance(child.structure(child.unstructure(Test()), Test), Test) + + +def test_hook_getting(converter: BaseConverter): + """Converters can produce their hooks.""" + + @define + class Test: + a: int + + hook = converter.get_unstructure_hook(Test) + + assert hook(Test(1)) == {"a": 1} + + structure = converter.get_structure_hook(Test) + + assert structure({"a": 1}, Test) == Test(1) diff --git a/tests/test_disambiguators.py b/tests/test_disambiguators.py index 5ec9ad7c..508586cf 100644 --- a/tests/test_disambiguators.py +++ b/tests/test_disambiguators.py @@ -1,4 +1,5 @@ """Tests for auto-disambiguators.""" +from functools import partial from typing import Literal, Union import pytest @@ -11,12 +12,14 @@ create_uniq_field_dis_func, is_supported_union, ) +from cattrs.gen import make_dict_structure_fn, override from .untyped import simple_classes def test_edge_errors(): """Edge input cases cause errors.""" + c = Converter() @define class A: @@ -24,21 +27,18 @@ class A: with pytest.raises(ValueError): # Can't generate for only one class. - create_uniq_field_dis_func(A) + create_uniq_field_dis_func(c, A) with pytest.raises(ValueError): - create_default_dis_func(A) + create_default_dis_func(c, A) @define class B: pass - with pytest.raises(ValueError): + with pytest.raises(TypeError): # No fields on either class. - create_uniq_field_dis_func(A, B) - - with pytest.raises(ValueError): - create_default_dis_func(A, B) + create_uniq_field_dis_func(c, A, B) @define class C: @@ -48,13 +48,13 @@ class C: class D: a = field() - with pytest.raises(ValueError): + with pytest.raises(TypeError): # No unique fields on either class. - create_uniq_field_dis_func(C, D) + create_uniq_field_dis_func(c, C, D) - with pytest.raises(ValueError): + with pytest.raises(TypeError): # No discriminator candidates - create_default_dis_func(C, D) + create_default_dis_func(c, C, D) @define class E: @@ -64,9 +64,9 @@ class E: class F: b = None - with pytest.raises(ValueError): + with pytest.raises(TypeError): # no usable non-default attributes - create_uniq_field_dis_func(E, F) + create_uniq_field_dis_func(c, E, F) @define class G: @@ -76,15 +76,16 @@ class G: class H: x: Literal[1] - with pytest.raises(ValueError): + with pytest.raises(TypeError): # The discriminator chosen does not actually help - create_default_dis_func(C, D) + create_default_dis_func(c, C, D) @given(simple_classes(defaults=False)) def test_fallback(cl_and_vals): """The fallback case works.""" cl, vals, kwargs = cl_and_vals + c = Converter() assume(fields(cl)) # At least one field. @@ -92,7 +93,7 @@ def test_fallback(cl_and_vals): class A: pass - fn = create_uniq_field_dis_func(A, cl) + fn = create_uniq_field_dis_func(c, A, cl) assert fn({}) is A assert fn(asdict(cl(*vals, **kwargs))) is cl @@ -109,6 +110,7 @@ def test_disambiguation(cl_and_vals_a, cl_and_vals_b): """Disambiguation should work when there are unique required fields.""" cl_a, vals_a, kwargs_a = cl_and_vals_a cl_b, vals_b, kwargs_b = cl_and_vals_b + c = Converter() req_a = {a.name for a in fields(cl_a)} req_b = {a.name for a in fields(cl_b)} @@ -122,13 +124,15 @@ def test_disambiguation(cl_and_vals_a, cl_and_vals_b): for attr_name in req_b - req_a: assume(getattr(fields(cl_b), attr_name).default is NOTHING) - fn = create_uniq_field_dis_func(cl_a, cl_b) + fn = create_uniq_field_dis_func(c, cl_a, cl_b) assert fn(asdict(cl_a(*vals_a, **kwargs_a))) is cl_a # not too sure of properties of `create_default_dis_func` def test_disambiguate_from_discriminated_enum(): + c = Converter() + # can it find any discriminator? @define class A: @@ -138,7 +142,7 @@ class A: class B: a: Literal[1] - fn = create_default_dis_func(A, B) + fn = create_default_dis_func(c, A, B) assert fn({"a": 0}) is A assert fn({"a": 1}) is B @@ -153,7 +157,7 @@ class D: a: Literal[0] b: Literal[0] - fn = create_default_dis_func(C, D) + fn = create_default_dis_func(c, C, D) assert fn({"a": 0, "b": 1}) is C assert fn({"a": 0, "b": 0}) is D @@ -173,7 +177,7 @@ class G: op: Literal[0] t: Literal["MESSAGE_UPDATE"] - fn = create_default_dis_func(E, F, G) + fn = create_default_dis_func(c, E, F, G) assert fn({"op": 1}) is E assert fn({"op": 0, "t": "MESSAGE_CREATE"}) is Union[F, G] @@ -190,13 +194,14 @@ class J: class K: a: Literal[0] - fn = create_default_dis_func(H, J, K) + fn = create_default_dis_func(c, H, J, K) assert fn({"a": 1}) is Union[H, J] assert fn({"a": 0}) is Union[J, K] def test_default_no_literals(): """The default disambiguator can skip literals.""" + c = Converter() @define class A: @@ -206,11 +211,11 @@ class A: class B: a: Literal["b"] = "b" - default = create_default_dis_func(A, B) # Should work. + default = create_default_dis_func(c, A, B) # Should work. assert default({"a": "a"}) is A - with pytest.raises(ValueError): - create_default_dis_func(A, B, use_literals=False) + with pytest.raises(TypeError): + create_default_dis_func(c, A, B, use_literals=False) @define class C: @@ -221,17 +226,16 @@ class C: class D: a: Literal["b"] = "b" - default = create_default_dis_func(C, D) # Should work. + default = create_default_dis_func(c, C, D) # Should work. assert default({"a": "a"}) is C - no_lits = create_default_dis_func(C, D, use_literals=False) + no_lits = create_default_dis_func(c, C, D, use_literals=False) assert no_lits({"a": "a", "b": 1}) is C assert no_lits({"a": "a"}) is D def test_converter_no_literals(converter: Converter): """A converter can be configured to skip literals.""" - from functools import partial converter.register_structure_hook_factory( is_supported_union, @@ -248,3 +252,22 @@ class D: a: Literal["b"] = "b" assert converter.structure({}, Union[C, D]) == D() + + +def test_field_renaming(converter: Converter): + """A renamed field properly disambiguates.""" + + @define + class A: + a: int + + @define + class B: + a: int + + converter.register_structure_hook( + B, make_dict_structure_fn(B, converter, a=override(rename="b")) + ) + + assert converter.structure({"a": 1}, Union[A, B]) == A(1) + assert converter.structure({"b": 1}, Union[A, B]) == B(1) diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index 5960a7c6..18aca3ec 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -172,6 +172,7 @@ def test_unmodified_generated_structuring(cl_and_vals, dv: bool): converter = Converter(detailed_validation=dv) cl, vals, kwargs = cl_and_vals fn = make_dict_structure_fn(cl, converter, _cattrs_detailed_validation=dv) + assert fn.overrides == {} inst = cl(*vals, **kwargs) @@ -202,6 +203,7 @@ def test_renaming(cl_and_vals, data): s_fn = make_dict_structure_fn( cl, converter, **{to_replace.name: override(rename="class")} ) + assert s_fn.overrides == {to_replace.name: override(rename="class")} converter.register_structure_hook(cl, s_fn) converter.register_unstructure_hook(cl, u_fn)