Skip to content
Advertisement

Extending frozen dataclass and take all data from base class instance

Suppose we have a class coming from a library,

@dataclass(frozen=True)
class Dog:
    name: str
    blabla : int
    # lot of parameters
    # ...
    whatever: InitVar[Sequence[str]]

I have a dog constructor coming from an external library.

pluto = dog_factory() # returns a Dog object

I would like this dog to have a new member, let’s say ‘bite‘. Obviously pluto['bite'] = True will fail, since dataclass is frozen.

So my idea is to make a subclass from Dog and get all the data from the ‘pluto’ instance.

class AngryDog(Dog):
    # what will come here ?

Is there a way to avoid manually put all the class Dog parameters in init ? Something like a copy constructor.

ideally:

class AngryDog(Dog):
    def __init__(self, dog, bite = True):
        copy_construct(dog)

Advertisement

Answer

If you want to use inheritance to solve your problem, you need to start off with writing a proper AngryDog subclass that you can use to build sane instances from.

The next step would be to add a from_dog classmethod, something like this maybe:

from dataclasses import dataclass, asdict

@dataclass(frozen=True)
class AngryDog(Dog):
    bite: bool = True

    @classmethod
    def from_dog(cls, dog: Dog, **kwargs):
        return cls(**asdict(dog), **kwargs)

But following this pattern, you’ll face a specific edge case, which you yourself already pointed out through the whatever parameter. When re-calling the Dog constructor, any InitVar will be missing in an asdict call, since they are not a proper member of the class. In fact, anything that takes place in a dataclass’ __post_init__, which is where InitVars go, might lead to bugs or unexpected behavior.

If it’s only minor stuff like filtering or deleting known parameters from the cls call and the parent class is not expected to change, you can just try to handle it in from_dog. But there is conceptually no way to provide a general solution for this kind of from_instance problem.


Composition would work bug-free from a data-integrity perspective, but might be unidiomatic or clunky given the exact matter at hand. Such a dog-extension wouldn’t be usable in-place of a proper dog-instance, but we could duck-type it into the right shape in case it’s necessary:

class AngryDogExtension:
    def __init__(self, dog, bite=True):
        self.dog = dog
        self.bite = bite

    def __getattr__(self, item):
        """Will make instances of this class bark like a dog."""
        return getattr(self.dog, item)

Usage:

# starting with a basic dog instance
>>> dog = Dog(name='pluto', blabla=1, whatever=['a', 'b'])

>>> dog_e = AngryDogExtension(d)
>>> dog_e.bite  # no surprise here, just a regular member
True
>>> dog_e.name  # this class proxies its dog member, so no need to run `dog_e.dog.name` 
pluto

But ultimately, the point remains that isinstance(dog_e, Dog) will return False. If you’re committed to make that call return True, there is some advanced trickery to help you out, and make anyone who inherits your code hate you:

class AngryDogDoppelganger(Dog):
    def __init__(self, bite, **kwargs):
        if "__dog" in kwargs:
            object.__setattr__(self, "__dog", kwargs["__dog"])
        else:
            object.__setattr__(self, "__dog", Dog(**kwargs))
        object.__setattr__(self, "bite", bite)

    @classmethod
    def from_dog(cls, dog, bite=True):
        return cls(bite, __dog=dog)

    def __getattribute__(self, name):
        """Will make instances of this class bark like a dog.

        Can't use __getattr__, since it will see its own instance
        attributes. To have __dog work as a proxy, it needs to be
        checked before basic attribute lookup. 
        """
        try:
            return getattr(object.__getattribute__(self, "__dog"), name)
        except AttributeError:
            pass
        return object.__getattribute__(self, name)

Usage:

# starting with a basic dog instance
>>> dog = Dog(name='pluto', blabla=1, whatever=['a', 'b'])

# the doppelganger offers a from_instance method, as well as 
# a constructor that works as expected of a subclass
>>> angry_1 = AngryDogDoppelganger.from_dog(dog)
>>> angry_2 = AngryDogDoppelganger(name='pluto', blabla=1, whatever=['a', 'b'], bite=True)

# instances also bark like at dog, and now even think they're a dog
>>> angry_1.bite  # from subclass
True
>>> angry_1.name  # looks like inherited from parent class, is actually proxied from __dog
pluto
>>> isinstance(angry_1, Dog)  # 🎉
True

Most of the dataclass-added methods, like __repr__, will be broken though, including plugging doppelganger instances in things like dataclass.asdict or even just vars – so use at own risk.

User contributions licensed under: CC BY-SA
10 People found this is helpful
Advertisement