python-attrs / cattrs

Composable custom class converters for attrs, dataclasses and friends.
https://catt.rs
MIT License
829 stars 113 forks source link

Use default factory if attribute is None or treat None as missing #570

Closed danielnelson closed 3 months ago

danielnelson commented 3 months ago

Description

This is an issue I can workaround but I would like to know if there is a better way to do it.

I have a type that should have a field with a list type and I want it to default to an empty list. I'm converting from a JSON API that sets the field as null if it is not set and I want cattrs to use the default factory for the field if it is None.

I used attrs.converters.default_if_none, though I'm not particularly fond of it. If there is a way in the converter to make this go away I would prefer it.

What I Did

import attrs
import cattrs

@attrs.define
class A:
    x: list[str] = attrs.field(
        converter=attrs.converters.default_if_none(factory=list),
        factory=list,
    )

print(A())

print(A(x=None))

a = cattrs.structure({}, A)
print(a)

a = cattrs.structure({"x": None}, A)
print(a)

Output:

A(x=[])
A(x=[])
A(x=[])
  + Exception Group Traceback (most recent call last):
  |   File "/home/dbn/src/rockfish/cuttlefish/bar.py", line 21, in <module>
  |     a = cattrs.structure({"x": None}, A)
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/home/dbn/usr/py-3.11/lib/python3.11/site-packages/cattrs/converters.py", line 332, in structure
  |     return self._structure_func.dispatch(cl)(obj, cl)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "<cattrs generated structure __main__.A>", line 10, in structure_A
  | cattrs.errors.ClassValidationError: While structuring A (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "<cattrs generated structure __main__.A>", line 6, in structure_A
    |   File "/home/dbn/usr/py-3.11/lib/python3.11/site-packages/cattrs/converters.py", line 519, in _structure_list
    |     for e in obj:
    | TypeError: 'NoneType' object is not iterable
    | Structuring class A @ attribute x
    +------------------------------------

My desired output would be 4 lines of A(x=[]), no exception.

Tinche commented 3 months ago

Hi,

I'm on paternity leave right now so my availability is limited ;)

Here's how I would approach this: instead of installing a converter on the class itself, I would customize how cattrs deals with the x field when loading the data. I would do this my writing my custom hook, and registering it on the x field. Here's the code:

import attrs

import cattrs

@attrs.define
class A:
    x: list[str] = attrs.Factory(list)

c = cattrs.Converter()

def none_aware_list_hook(val, type):
    """A structure hook that can also handle None."""
    if val is None:
        return []
    return c.structure(val, type)

c.register_structure_hook(
    A,
    cattrs.gen.make_dict_structure_fn(
        A, c, x=cattrs.override(struct_hook=none_aware_list_hook)
    ),
)

print(A())

print(A(x=None))

a = c.structure({}, A)
print(a)

a = c.structure({"x": None}, A)
print(a)

This will print:

A(x=[])
A(x=None)
A(x=[])
A(x=[])

Personally I would be OK with this since it isolates the special case None handling to cattrs - when dealing with the class directly, there's no special behavior. That's one of the benefits of using cattrs - weirdness can be isolated to exactly where it's needed ;)

This isn't the only way of handling cases like this, but it's what I'd use first. Let me know if you have any other questions, will close this now ;)

danielnelson commented 3 months ago

This is perfect, thank you.