17

How can I upgrade values from a base dataclass to one that inherits from it?

Example (Python 3.7.2)

from dataclasses import dataclass

@dataclass
class Person:
    name: str 
    smell: str = "good"    

@dataclass
class Friend(Person):

    # ... more fields

    def say_hi(self):        
        print(f'Hi {self.name}')

friend = Friend(name='Alex')
f1.say_hi()

prints "Hi Alex"

random_stranger = Person(name = 'Bob', smell='OK')

return for random_stranger "Person(name='Bob', smell='OK')"

How do I turn the random_stranger into a friend?

Friend(random_stranger)

returns "Friend(name=Person(name='Bob', smell='OK'), smell='good')"

I'd like to get "Friend(name='Bob', smell='OK')" as a result.

Friend(random_stranger.name, random_stranger.smell)

works, but how do I avoid having to copy all fields?

Or is it possible that I can't use the @dataclass decorator on classes that inherit from dataclasses?

daniel451
  • 10,626
  • 19
  • 67
  • 125
576i
  • 7,579
  • 12
  • 55
  • 92
  • 2
    Maybe it's simplier to create a method `def become_friend(self): return Friend(self.name, self.smell, other_parameters)` of `class Person`? – sanyassh Feb 22 '19 at 10:38
  • not yet familiar with dataclasses but have you tried mutating the class? rs.__class__ = Friend. works well (StateMachine pattern) on class designed for equivalence. – JL Peyret Feb 22 '19 at 15:53
  • 1
    @JLPeyret That sounds like a good recipe for disaster. – Arne Mar 08 '19 at 14:35

4 Answers4

18

What you are asking for is realized by the factory method pattern, and can be implemented in python classes straight forwardly using the @classmethod keyword.

Just include a dataclass factory method in your base class definition, like this:

import dataclasses

@dataclasses.dataclass
class Person:
    name: str
    smell: str = "good"

    @classmethod
    def from_instance(cls, instance):
        return cls(**dataclasses.asdict(instance))

Any new dataclass that inherit from this baseclass can now create instances of each other[1] like this:

@dataclasses.dataclass
class Friend(Person):
    def say_hi(self):        
        print(f'Hi {self.name}')

random_stranger = Person(name = 'Bob', smell='OK')
friend = Friend.from_instance(random_stranger)
print(friend.say_hi())
# "Hi Bob"

[1] It won't work if your child classes introduce new fields with no default values, you try to create parent class instances from child class instances, or your parent class has init-only arguments.

Arne
  • 17,706
  • 5
  • 83
  • 99
  • Also see https://stackoverflow.com/a/74184041/1546600 for why this answer breaks when fields themselves are dataclasses – Ethereal Feb 07 '23 at 22:35
3

You probably do not want to have the class itself be a mutable property, and instead use something such as an enum to indicate a status such as this. Depending on the requirements, you may consider one of a few patterns:

class RelationshipStatus(Enum):
    STRANGER = 0
    FRIEND = 1
    PARTNER = 2

@dataclass
class Person(metaclass=ABCMeta):
    full_name: str
    smell: str = "good"
    status: RelationshipStatus = RelationshipStatus.STRANGER

@dataclass
class GreetablePerson(Person):
    nickname: str = ""

    @property
    def greet_name(self):
        if self.status == RelationshipStatus.STRANGER:
            return self.full_name
        else:
            return self.nickname

    def say_hi(self):
        print(f"Hi {self.greet_name}")

if __name__ == '__main__':
    random_stranger = GreetablePerson(full_name="Robert Thirstwilder",
                                      nickname="Bobby")
    random_stranger.status = RelationshipStatus.STRANGER
    random_stranger.say_hi()
    random_stranger.status = RelationshipStatus.FRIEND
    random_stranger.say_hi()

You may want, also, to implement this in a trait/mixin style. Instead of creating a GreetablePerson, instead make a class Greetable, also abstract, and make your concrete class inherit both of those.

You may also consider using the excellent, backported, much more flexible attrs package. This would also enable you to create a fresh object with the evolve() function:

friend = attr.evolve(random_stranger, status=RelationshipStatus.FRIEND)
Bob Zimmermann
  • 938
  • 7
  • 11
  • 1
    What is the purpose of setting in `Person` the `metaclass=ABCMeta`? The class `Person` is still directly instantiable. – Yu Chen Aug 07 '20 at 02:19
  • You are right. In order to make it uninstantiable, one must add an `abstractmethod`, and then an exception is thrown upon attempt at instantiation. I don't know if I agree with requiring an `abstractmethod`, and I might see it as an omission. But python tends to be non-agressive with respect to type errors, so it could be a matter of the python philosophy. I would leave it in for the clarity of intention, and probably also not add an abstractmethod unless it represents part of the universal interface of `Person`. In this case, maybe `say_hi()` would be appropriate. – Bob Zimmermann Aug 09 '20 at 16:06
3

dataclasses.asdict is recursive (see doc), so if fields themselves are dataclasses, dataclasses.asdict(instance) appearing in other answers breaks. Instead, define:

from dataclasses import fields

def shallow_asdict(instance):
  return {field.name: getattr(instance, field.name) for field in fields(instance)}

and use it to initialize a Friend object from the Person object's fields:

friend = Friend(**shallow_asdict(random_stranger))

assert friend == Friend(name="Bob", smell="OK")
amka66
  • 699
  • 7
  • 11
0

vars(stranger) gives you a dict of all attributes of the dataclass instance stranger. As the default __init__() method of dataclasses takes keyword arguments, twin_stranger = Person(**vars(stranger)) creates a new instance with a copy of the values. That also works for derived classes if you supply the additional arguments like stranger_got_friend = Friend(**vars(stranger), city='Rome'):

from dataclasses import dataclass


@dataclass
class Person:
    name: str
    smell: str


@dataclass
class Friend(Person):
    city: str

    def say_hi(self):
        print(f'Hi {self.name}')


friend = Friend(name='Alex', smell='good', city='Berlin')
friend.say_hi()  # Hi Alex
stranger = Person(name='Bob', smell='OK')
stranger_got_friend = Friend(**vars(stranger), city='Rome')
stranger_got_friend.say_hi()  # Hi Bob
phispi
  • 604
  • 7
  • 15
  • 2
    you shouldn't use `vars` to get to attributes of a dataclass, `dataclass.asdict` is better. `vars` will include init-only and class variables, and doesn't work if `__slots__` are used to store the attributes, all of which is bad to not support. Which is why `dataclass.asdict` exists. – Arne May 16 '20 at 06:53
  • `dataclass.asdict` does not work in my use cases as it converts the child dataclasses recursively (doc: "dataclasses, dicts, lists, and tuples are recursed into."). My dataclasses usually have child dataclasses that should stay dataclasses. – phispi May 17 '20 at 21:12
  • True, I can see how it wouldn't work for your case then. – Arne May 20 '20 at 20:20