Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dataclass-style object with mutable and immutable properties?

I have been playing around with dataclasses dynamically loaded with property names from a file and I am unable to find a way to create both 'frozen' and 'non-frozen' properties. I believe dataclasses only allow you to set all properites to frozen or non-frozen.

As of now, I create a frozen dataclass and add a mutable class as one of the properties which I can change as I go but I am not very happy with the readability of this approach.

Is there another pythonic dataclass people would recommend without needing to implement a class with the ability to set mutable/immutable properties?

import dataclasses

class ModifiableConfig:
    """There is stuff in here but you get the picture."""
    ...

config_dataclass = dataclasses.make_dataclass(
    'c',
    [(x, type(x), v) for x, v in config.items()] + [('var', object, ModifiableConfig())],
    frozen=True
)

However I would prefer the ability to choose which attributes are frozen and which are not. Making the need of adding an additional class to the dataclass obsolete. It may look like this:

config_dataclass_modifiable = dataclasses.make_dataclass(
            'c', [(x, type(x), v, True if 'modifiable' in x else False) for x, v in config.items()])

Notice the "True if 'modifiable' in x else False", I'm not saying this is how I would do it in the end but hopefully this helps understand my question better.

like image 634
JMB Avatar asked Oct 23 '19 23:10

JMB


2 Answers

The normal approach to tuning attribute handling is writing a custom __setattr__ method which allows you to override the default behavior for attribute assignments. Unfortunately, that method is also what dataclasses hooks into to enforce the frozen logic, which effectively locks the function from being altered any further by throwing TypeError: Cannot overwrite attribute __setattr__ in class ModifiableConfig as soon as you try to touch it.

As a consequence, there is no straight forward and simple solution to your problem that I can see. Your approach of delegating the mutable parts of a class to an inner object or dictionary is, in my opinion, not bad or un-pythonic at all, but if you're fine with dropping frozen from your requirements list and only want a partly-mutable dataclass, you can try using this bootleg-semi-frozen recipe here that updates the dataclass decorator with a flag semi that you can switch on to get the behavior you described:

from dataclasses import dataclass as dc
from traceback import format_stack

def dataclass(_cls=None, *, init=True, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False, semi=False):

    def wrap(cls):
        # sanity checks for new kw
        if semi:
            if frozen:
                raise AttributeError("Either semi or frozen, not both.")
            if cls.__setattr__ != cls.mro()[1].__setattr__:
                raise AttributeError("No touching setattr when using semi!")

        # run original dataclass decorator
        dc(cls, init=init, repr=repr, eq=eq, order=order,
           unsafe_hash=unsafe_hash, frozen=frozen)

        # add semi-frozen logic
        if semi:
            def __setattr__(self, key, value):
                if key in self.__slots__:
                    caller = format_stack()[-2].rsplit('in ', 1)[1].strip()
                    if caller != '__init__':
                        raise TypeError(f"Attribute '{key}' is immutable!")
                object.__setattr__(self, key, value)
            cls.__setattr__ = __setattr__

        return cls

    # Handle being called with or without parens
    if _cls is None:
        return wrap
    return wrap(_cls)

I'm being brief here and don't address some potential edge-cases here. There are better ways to handle the wrapping so that the internals are more consistent, but it would blow this already complicated snippet up even more.

Given this new dataclass decorator, you can use it like this to define a dataclass with some immutable attributes and some mutable ones:

>>> @dataclass(semi=True)
... class Foo:
...     # put immutable attributes and __dict__ into slots 
...     __slots__ = ('__dict__', 'x', 'y')
...     x: int
...     y: int
...     z: int
...
>>> f = Foo(1, 2, 3)
>>> f        # prints Foo(x=1, y=2, z=3)
>>> f.z = 4  # will work
>>> f.x = 4  # raises TypeError: attribute 'x' is immutable!

You don't have to use __slots__ to separate the mutable from the immutable part, but it is convenient for a few reasons (such as being a meta-attribute that isn't part of the default dataclass repr) and felt intuitive to me.

like image 160
Arne Avatar answered Sep 22 '22 15:09

Arne


In the top answer above, the code breaks if Foo is a subclass of another class. To fix this, the line:

super(type(self), self).__setattr__(key, value)

should read:

super(type(cls), cls).__setattr__(key, value)

That way, super actually traverses upward instead of going into an infinite self reference.

like image 34
Coert van Gemeren Avatar answered Sep 22 '22 15:09

Coert van Gemeren