How do I make a custom class that's serializable with dataclasses.asdict()?

197 Views Asked by At

I'm trying to use a dataclass as a (more strongly typed) dictionary in my application, and found this strange behavior when using a custom type subclassing list within the dataclass. I'm using Python 3.11.3 on Windows.

from dataclasses import dataclass, asdict

class CustomFloatList(list):
    def __init__(self, args):
        for i, arg in enumerate(args):
            assert isinstance(arg, float), f"Expected index {i} to be a float, but it's a {type(arg).__name__}"

        super().__init__(args)

    @classmethod
    def from_list(cls, l: list[float]):
        return cls(l)

@dataclass
class Poc:
    x: CustomFloatList

p = Poc(x=CustomFloatList.from_list([3.0]))
print(p)  # Prints Poc(x=[3.0])
print(p.x)  # Prints [3.0]
print(asdict(p))  # Prints {'x': []}

This does not occur if I use a regular list[float], but I'm using a custom class here to enforce some runtime constraints.

How do I do this correctly?

I'm open to just using .__dict__ directly, but I thought asdict() was the more "official" way to handle this

A simple modification makes the code behave as expected, but is slightly less efficient:

from dataclasses import dataclass, asdict

class CustomFloatList(list):
    def __init__(self, args):
        dup_args = list(args)
        for i, arg in enumerate(dup_args):
            assert isinstance(arg, float), f"Expected index {i} to be a float, but it's a {type(arg).__name__}"

        super().__init__(dup_args)

    @classmethod
    def from_list(cls, l: list[float]):
        return cls(l)

@dataclass
class Poc:
    x: CustomFloatList

p = Poc(x=CustomFloatList.from_list([3.0]))
print(p)
print(p.x)
print(asdict(p))
2

There are 2 best solutions below

12
juanpa.arrivillaga On BEST ANSWER

If you look at the source code of asdict, you'll see that passes a generator expression that recursively calls itself on the elements of a list when it encounters a list:

    elif isinstance(obj, (list, tuple)):
        # Assume we can create an object of this type by passing in a
        # generator (which is not true for namedtuples, handled
        # above).
        return type(obj)(_asdict_inner(v, dict_factory) for v in obj)

But your implementation depletes any iterator it gets in __init__ before the super call.

Don't do that. You'll have to "cache" the values if you want to use the superclass constructor. Something like:

class CustomFloatList(list):
    def __init__(self, args):
        args = list(args)
        for i, arg in enumerate(args):
            assert isinstance(arg, float), f"Expected index {i} to be a float, but it's a {type(arg).__name__}"

        super().__init__(args)

Or perhaps:

class CustomFloatList(list):
    def __init__(self, args):
        super().__init__(args)
        for i, arg in enumerate(self):
            if not isinstance(arg, float):
                raise TypeError(f"Expected index {i} to be a float, but it's a {type(arg).__name__}")
0
Barmar On

This is not specific to asdict(). The problem is that your subclass is not compatible with the real list class, because the __init__() method doesn't work correctly when the input is a generator. You can see the problem more simply with

print(CustomFloatList(float(x) for x in range(10)))

This will print an empty list.

You consume the generator in the loop that checks that all the elements are floats, then pass the empty generator when calling super().__init__().

Change it so it extracts the contents once.

class CustomFloatList(list):
    def __init__(self, args):
        temp = list(args)
        for i, arg in enumerate(temp):
            assert isinstance(arg, float), f"Expected index {i} to be a float, but it's a {type(arg).__name__}"

        super().__init__(temp)

    @classmethod
    def from_list(cls, l: list[float]):
        return cls(l)