Skip to content

Should unstruct_hook not unstruct? #432

@aljungberg

Description

@aljungberg
  • cattrs version: 23.1.2
  • Python version: 3.11.5
  • Operating System: macOS 13.4.1

Description

The new unstruct_hook override feature in cattrs provides a way to customise unstructuring per attribute but it appears a blunt instrument: you now seem to be responsible for the totality of unstructuring from that attribute down.

What I Did

import attrs, cattrs
from cattrs.gen import make_dict_unstructure_fn, override

# Let's say we have a class Person which, as an implementation detail, stores its children in a dict, 
# so we can retrieve them by name.

@attrs.define
class Person:
    name: str = attrs.field()
    _children_by_name: dict[str, "Person"] = attrs.field(factory=dict)

    def add_child(self, child: "Person"):
        self._children_by_name[child.name] = child


attrs.resolve_types(Person)

alice = Person(name="Alice")
alice.add_child(Person(name="Bob"))


# Let's make our external representation a little more presentable by renaming _children_by_name to children.
c = cattrs.Converter()
c.register_unstructure_hook(Person, make_dict_unstructure_fn(Person, c, 
    _children_by_name=override(rename="children")))
# Note that just because we use an `override` with a `rename` we don't lose recursive unstructuring.
assert repr(c.unstructure(alice)) == "{'name': 'Alice', 'children': {'Bob': {'name': 'Bob', 'children': {}}}}"


# Let's use a list instead of a dict, since the name is redundant. A case for an attribute unstruct hook?
c = cattrs.Converter()
c.register_unstructure_hook(Person, make_dict_unstructure_fn(Person, c, 
    _children_by_name=override(rename="children", unstruct_hook=lambda x: list(x.values()))))
# Oh, but this is unexpected. Now Bob is not unstructured anymore.
assert repr(c.unstructure(alice)) == "{'name': 'Alice', 'children': [Person(name='Bob', _children_by_name={})]}"


# This works: calling back into the converter explicitly. But it seems surprising.
c = cattrs.Converter()
c.register_unstructure_hook(Person, make_dict_unstructure_fn(Person, c, 
    _children_by_name=override(rename="children", unstruct_hook=lambda x: c.unstructure(list(x.values())))))
assert repr(c.unstructure(alice)) == "{'name': 'Alice', 'children': [{'name': 'Bob', 'children': []}]}"

Discussion: An Intuition Gap?

I'm a new user and might just be doing things sideways. All of the following might be wrong, just my quick thoughts.

It seems unstruct_hook is intended to be the ultimate escape hatch that enables wholesale replacement of the unstructuring behaviour for specific attributes. But I do wonder if this is usually what one wants with override. If I wanted to just write my own asdict method for Person, I could already just write a function and use it directly with register_unstructure_hook. The reason I'm using cattrs.gen is to tweak the built-in behaviour rather than reimplementing it from scratch.

There's also an intuition gap here. Specifically, it feels counterintuitive that customising Person unstructuring causes a non-unstructured Person to pop out. I recognise this is in truth a misunderstanding: I think I'm customising but I'm actually replacing. Still, I'm probably not the last person who'll make the same mistake.

To me it would make sense to do one of the following:

  • override should wrap its default handler around the unstructure_hook rather than replacing it
  • ...or pass the default handler as an argument to the hook so the hook can intuitively opt to pass further work back up the chain
  • ...or there should be another kind of override which is a preprocessor of the input
  • ...or a postprocessor of the output

Obviously you could argue that just calling c.unstructure recursively like I do in the final example is fine and I'm just holding it wrong.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions