python-attrs / cattrs

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

tagged union disambiguation seems to ignore omit_if_default setting #429

Closed aha79 closed 8 months ago

aha79 commented 11 months ago

Description

The tagged union disambiguation seems to ignore omit_if_default=True

What I Did

@dataclass(kw_only=True,frozen=True,eq=True)
class A:
    a: int
    b: Optional[int] = None

@dataclass(kw_only=True,frozen=True,eq=True)
class Derived(A):
    d: int
    c: Optional[int] = None

  def ustrat(a: Any, conv) -> Any:
      return cattrs.strategies.configure_tagged_union(a, conv, tag_name="type")

  converter = cattrs.Converter(omit_if_default=True)
  cattrs.strategies.include_subclasses(A, converter, union_strategy=ustrat)

  #data =  [ Derived(a=1, d=2) ]
  data =  [ A(a=1) ]
  dic = converter.unstructure(data, unstructure_as=List[A] )

  # >> dic = {'a': 1, 'b': None, 'type': 'a'}

Maybe this is related https://github.com/python-attrs/cattrs/issues/402

aha79 commented 11 months ago

I think the culprit lies here: https://github.com/python-attrs/cattrs/blob/c447dfe4de2978ef1bb2974b53562e413fe28a1a/src/cattrs/strategies/_subclasses.py#L98

No additional arguments are passed as for instance in the 'gen_xx' function:

However, I am not sure if it is a good idea at all to call 'makedict(un)structure_fn' directly there (even when the missing arguments are added), as it will also prevent user-defined hooks from working.

Tinche commented 11 months ago

You're right, I can reproduce the issue. Took me a while to understand what's going on. Two separate issues here: existing hooks aren't honored, and converter.omit_if_default isn't honored when new hooks are generated.

We could make the strategy fish out hooks out of the converter instead of generating them; that way existing hooks and converter flags would be automatically honored. The problem is the strategy was written to take overrides, implying it would generate hooks for you with these overrides, so this would be a backwards-incompatible API change. So we need to be careful here.

We could make it so the hooks are not generated if there are no overrides; I think this would work but it just complicates the strategy with a separate behavior path. Maybe it's ok?

The simplest change would be to just let the strategy pass converter.omit_if_default to gen_unstructure_hook. This would solve your particular issue.

aha79 commented 11 months ago

Personally I like the idea of using the existing generator hook if there are no overrides and regenerate the hook if there are overrides. It doesnt seem very unclean, but I do not know if there are any not yet seen pitfalls.

But after looking at the code and thinking about what you wrote, I see another problem:

As a solution maybe one can augment 'gen_unstructure_attrs_fromdict' and 'gen_structure_attrs_fromdict' to accept another 'overrides ' as arguments and then call these functions from 'include_subclasses' if there are call level overrides for re-generation (and use existing hooks if there are no overrides). Just an idea

Tinche commented 11 months ago

Yeah I'm warming up to the idea of fishing the hook out if no overrides. Maybe that's the way to go.

The type_overrides are mostly used for customizing collections so I don't think those will be problematic, unless I'm misunderstanding your concern.

Your second bullet is valid, and would also be fixed by fishing the hook out if no overrides. You can apply your overrides prior to applying the strategy and let the strat fish em out.

aha79 commented 11 months ago

Regarding the typeoverrides: I was thinking of renaming fields. Sometimes there is no way around, e.g. if the dict key is a python reserved keyword ('class', 'if'), and the dataclass has to have altered field names ('class', 'if_). And that should also work if the dataclass is in a union.

Tinche commented 8 months ago

I've changed it so the strategy uses the converter to get the base hooks when no overrides are provided, so converter-level customizations should propagate now.