cattrs icon indicating copy to clipboard operation
cattrs copied to clipboard

Cattrs serializer is not round-trip safe for type B | X (B is a built-in type) when value is not type B

Open ngchihuan opened this issue 6 months ago • 3 comments

cattrs version: 24.1.3

Hi cattrs team, The round-trip serialization of a union type in the following case is not guaranteed. It is clearly stated in the manual that built-in types are passed through during the serialization. However, the structuring with the same type hint fails. It seems to contradict the intuition that round-trip serialization of the same type should be invertible.

I am aware of the workaround of customizing either the restructuring or structuring for the built-in types. But it also seems to me that these can be handled internally by cattrs.

Thanks a lot.

@attrs.define
class B:
    b: int

c = make_converter()
c.structure(c.unstructure(1, float|  B), float | B)
# where float can be substituted by any built-in types.

Error:

  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "<cattrs generated structure __main__.B>", line 5, in structure_B
    |     res['b'] = __c_structure_b(o['b'])
    | TypeError: 'int' object is not subscriptable
    | Structuring class B @ attribute b
    +------------------------------------

ngchihuan avatar Jun 11 '25 13:06 ngchihuan

Hello, which converter are you using exactly?

Tinche avatar Jun 11 '25 14:06 Tinche

@Tinche Hi Tinche, thanks for looking into this. I used the orjson converter out of the box.

ngchihuan avatar Jun 11 '25 14:06 ngchihuan

This is actually an interesting case.

You're using the value 1, which is of class int. The target type is float | B.

As far as type checkers are concerned, int is a subclass of float - see https://typing.python.org/en/latest/spec/special-types.html#special-cases-for-float-and-complex. So the type system is fine with this.

At runtime though,

>>> isinstance(1, float)
False

So at runtime, 1 is not an instance of float, hence cattrs failing to validate it.

If you want a quick fix, you can change your type to float | int | B and it will work.

We could probably paper over this Python wart directly in the strategy itself, but it would require special treatment of exactly this case. It probably makes sense to do though, although I can't commit to a timeline (apart from next version).

Tinche avatar Jun 12 '25 14:06 Tinche

Fixed it by special-casing the int/float behavior in the next version!

Tinche avatar Jul 06 '25 20:07 Tinche

Awesome! Thanks a lot for the nice fix @Tinche.

ngchihuan avatar Jul 07 '25 05:07 ngchihuan