0

If I use a regular dataclass I can get a dict with

@dataclass
class Cat:
   fur: bool
   meow: int

Cat(True, 3).__dict__

But it doesn't work when using __slots__. What is the most efficient solution to use instead?

@dataclass
class Cat:
   __slots___ = ['fur', 'meow']
   fur: bool
   meow: int

# Doesn't work:
Cat(True, 3).__dict__
v1z3
  • 137
  • 2
  • 9
  • Does this answer your question? [How can dataclasses be made to work better with \_\_slots\_\_?](https://stackoverflow.com/questions/50180735/how-can-dataclasses-be-made-to-work-better-with-slots) – Brian Destura Jul 02 '21 at 00:15
  • No. I'm trying to make a dictionary/json string from the dataclass with __slots__ – v1z3 Jul 02 '21 at 00:18
  • Why not just create a method to do it `return {'fur'self.fur, 'meow': self meow}` – juanpa.arrivillaga Jul 02 '21 at 00:26
  • 4
    Also note, `__dict__` doesn't create a dict, it *is the actual namespace of the instance* – juanpa.arrivillaga Jul 02 '21 at 00:27
  • 1
    Note, there is the `dataclasses.asdict` function already in the module – juanpa.arrivillaga Jul 02 '21 at 00:29
  • I don't see anything wrong with this code--I believe it should work as written. Can you paste the error message you are seeing? – bpgeck Jul 02 '21 at 00:32
  • You're right, it somehow does work... It's strange that I had an error message when I first tried it, but I was wrong, you can use __dict__ even if the dataclass has slots and it works. We should close this question – v1z3 Jul 02 '21 at 01:21

2 Answers2

1

But it doesn't work when using __slots__. What is the most efficient solution to use instead?

Assuming the goal as listed above, I would personally advise against dataclasses.asdict(), as specific sections of the code might slow down processing of dataclass field values. For instance, it calls copy.deepcopy() on any complex types that it encounters (such as datetime) which as I understand, might not be desirable behavior; it is also rather inefficient to do this, as I demonstrate below.

In any case, here is the simplest (and most efficient) approach to resolve it.

The first step would be to create a helper Mixin class, named as SerializableMixin or anything else. This would then access a class's __slots__ namespace, and generate the dict() and json() methods specifically for the given subclass. Note that this approach extends from my other answer.

Assuming your class is relatively simple, and doesn't contain any complex types or nested dataclasses:

from __future__ import annotations

import json
from typing import Any


class SerializableMixin:
    """
    Mixin class to add a `dict()` and `json()` method on classes that define
    a __slots__ attribute.
    """

    def __init_subclass__(cls):
        cls_slots = getattr(cls, '__slots__', None)

        # only if/when slots have been declared on the class
        if not cls_slots:
            return

        body_lines = ','.join(f"'{f}':self.{f}" for f in cls_slots)
        # Compute the text of the entire function(s).
        txt = (f'def dict(self):\n'
               f' return {{{body_lines}}}\n'
               f'def json(self):\n'
               f' return dumps({{{body_lines}}})')

        ns = {}
        fn_locals = {'dumps': json.dumps}
        exec(txt, fn_locals, ns)

        cls.dict = ns['dict']
        cls.json = ns['json']

    # added for IDE auto-completion purposes
    def dict(self) -> dict[str, Any]: ...
    def json(self) -> str: ...

To handle more complex types such as datetime, and nested dataclasses:

from __future__ import annotations

import json

from datetime import datetime
from typing import Any
from uuid import UUID


_defaults = {UUID: str,
             datetime: datetime.isoformat}


def _default_fn(o: Any):  # `default` argument to `json.dumps`
    _dict = getattr(o, 'dict', None)
    return _dict() if _dict else _defaults.get(type(o), str)(o)


class SerializableMixin:
    """
    Mixin class to add a `dict()` and `json()` method on classes that define
    a __slots__ attribute.
    """

    def __init_subclass__(cls):
        cls_slots = getattr(cls, '__slots__', None)

        # only if/when slots have been declared on the class
        if not cls_slots:
            return

        body_lines = ','.join(f"'{f}':self.{f}" for f in cls_slots)
        # Compute the text of the entire function(s).
        txt = (f'def dict(self):\n'
               f' return {{{body_lines}}}\n'
               f'def json(self):\n'
               f' return dumps({{{body_lines}}}, default=default)')

        ns = {}
        fn_locals = {'dumps': json.dumps, 'default': _default_fn}
        exec(txt, fn_locals, ns)

        cls.dict = ns['dict']
        cls.json = ns['json']

    # added for IDE auto-completion purposes
    def dict(self) -> dict[str, Any]: ...
    def json(self) -> str: ...

In any case, usage would be as follows:

from dataclasses import dataclass


@dataclass(slots=True)
class Cat(SerializableMixin):
    fur: bool
    meow: int


kitty = Cat(True, 3)

print(kitty.dict())
print(kitty.json())

Output:

{'fur': True, 'meow': 3}
{"fur": true, "meow": 3}

To prove that this is indeed more efficient, I use the timeit module to compare against a similar approach with dataclasses.asdict:

from __future__ import annotations

import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from timeit import timeit
from typing import Any
from uuid import UUID, uuid4


_defaults = {UUID: str,
             datetime: datetime.isoformat}


def _default_fn(o: Any):  # `default` argument to `json.dumps`
    _dict = getattr(o, 'dict', None)
    return _dict() if _dict else _defaults.get(type(o), str)(o)


class SerializableMixin:
    """
    Mixin class to add a `dict()` and `json()` method on classes that define
    a __slots__ attribute.
    """

    def __init_subclass__(cls):
        # only if/when slots have been declared on the class
        if hasattr(cls, '__slots__'):
            cls._generate_helper_methods()

    @classmethod
    def _generate_helper_methods(cls):
        body_lines = ','.join(f"'{f}':self.{f}" for f in cls.__slots__)
        # Compute the text of the entire function(s).
        txt = (f'def dict(self):\n'
               f' return {{{body_lines}}}\n'
               f'def json(self):\n'
               f' return dumps({{{body_lines}}}, default=default)')

        ns = {}
        fn_locals = {'dumps': json.dumps, 'default': _default_fn}
        exec(txt, fn_locals, ns)

        cls.dict = ns['dict']
        cls.json = ns['json']

    # added for IDE auto-completion purposes
    def dict(self) -> dict[str, Any]: ...
    def json(self) -> str: ...


@dataclass(slots=True)
class Inner(SerializableMixin):
    # for Python version < 3.10, remove `slots=True` above and uncomment the below
    # __slots__ = ('string', )
    string: str = 'hello world'


@dataclass(slots=True)
class Cat(SerializableMixin):
    # for Python version < 3.10, remove `slots=True` above and uncomment the below
    # __slots___ = ('fur', 'meow', 'id', 'dt', 'inner')
    fur: bool
    meow: int
    id: UUID = field(default_factory=uuid4)
    dt: datetime = datetime.min
    inner: Inner = field(default_factory=Inner)


@dataclass
class Cat2:
    __slots___ = ('fur', 'meow', 'id', 'dt', 'inner')
    fur: bool
    meow: int
    id: UUID = field(default_factory=uuid4)
    dt: datetime = datetime.min
    inner: Inner = field(default_factory=Inner)

    def as_dict(self):
        return dict(asdict(self).items())

    # this approach is faster, since `asdict` already calls `copy.deepcopy()` for us
    as_dict_alt = asdict

    def as_json(self):
        return json.dumps(self.as_dict(), default=_default_fn)


_id = uuid4()

kitty = Cat(True, 3, id=_id)
kitty2 = Cat2(True, 3, id=_id)

print('dict:            ', timeit('kitty.dict()', globals=globals()))
print('asdict:          ', timeit('kitty2.as_dict()', globals=globals()))
print('asdict (alt):    ', timeit('kitty2.as_dict_alt()', globals=globals()))
print('dict -> json:    ', timeit('kitty.json()', globals=globals()))
print('asdict -> json:  ', timeit('kitty2.as_json()', globals=globals()))

# `kitty.dict()` is not equal because `inner` value is still an `Inner` object
assert kitty.dict() != kitty2.as_dict() == kitty2.as_dict_alt()
# `json()` string values should be equal
assert kitty.json() == kitty2.as_json()

print()
print('dict:\n ', kitty.dict())
print('json:\n ', kitty.json())

Results for Python 3.10, on Mac M1:

dict:             0.1584392080003454
asdict:           8.291836708000119
asdict (alt):     8.119467332999193
dict -> json:     3.4996820830001525
asdict -> json:   12.136880125000971
rv.kvetch
  • 9,940
  • 3
  • 24
  • 53
0

Using python 3.10 here

I agree with juanpa.arrivillaga. __dict__ is the actual namespace of the instance and you can use dataclasses.asdict() to create a dict and JSON string.

Here's how you can do it:

import json
from dataclasses import dataclass, asdict


@dataclass
class Cat:
    __slots___ = ('fur', 'meow')
    fur: bool
    meow: int

    def as_dict(self):
        return dict(asdict(self).items())

    def as_json(self):
        return json.dumps(self.as_dict())


kitty = Cat(True, 3)
print(kitty.as_dict())
print(kitty.as_json())

inspired and referenced from this answer.

Luv_Python
  • 194
  • 6