TLDR version: Use MRO and a stub class to set sensible default class attribute that guarantees abstractness is removed for the desired properties. Use dataclasses.MISSING as suggested by Roy, so no default values are actually created by dataclass.
Original post example (adapted):
import abc
import dataclasses
from typing import cast
@dataclasses.dataclass
class Parent(abc.ABC):
@property
@abc.abstractmethod
def inherited_attribute(self) -> int:
pass
class _ParentImpl:
# this hides the abstract method in the final class
# so it cannot "zombie" back if there is no default value for field
inherited_attribute: int = cast(int, dataclasses.MISSING)
@dataclasses.dataclass
class Child1(_ParentImpl, Parent):
inherited_attribute: int
@dataclasses.dataclass
class Child2(_ParentImpl, Parent):
inherited_attribute: int = dataclasses.field()
@dataclasses.dataclass
class Child3(_ParentImpl, Parent):
inherited_attribute: int = None
def test_1():
Child1(42)
def test_2():
Child2(42)
def test_3():
Child3(42)
Keep reading to understand details if you wish...
It took me some digging, but I found out why Roy's answer with dataclasses.MISSING
seems to work. It has to do with the fact that dataclasses store field default values (not default_factory) in the class attributes. As an illustrative example:
@dataclass
class MyClass:
foo: float
bar: float = field(default=0.0)
baz: float = 0.0
In the final class (after dataclass does its machinery), both bar and baz will be class attributes with value 0.0. (The field metadata disappears into __dataclass_fields__
). The field foo does not show up as a class attribute, since its only an annotation (and not in __dict__
).
Now, consider this modified example
@dataclass
class MyBaseClass:
foo: float = field()
bar: float = field(default=1.0)
@dataclass
class MyClass(MyBaseClass):
baz: float = 0.0
Now, foo is not just an annotation in the base class, but is an actual class attribute (as written). But, since there is no default, dataclass actually deletes this attribute, since it is not useful to any derived classes (it does not hold a default value). The key to Roy's answer is that the missing default is delineated by the special value dataclasses.MISSING
.
In contrast, bar holds an actual field default. This is transferred to the class attribute as MyBaseClass.bar = 1.0
.
So, we have the following rules:
- If a field is specified by annotation only, there is no resulting class attribute (in that concrete class)
- If a field is specified with annotation and direct default value (non-
field
object), then this value remains as the final value of the class attribute. This is true even if the value is dataclasses.MISSING
.
- If a field is specified as a
dataclasses.field
object, then if it has no default (default=dataclasses.MISSING
), the class attribute is deleted. Otherwise, the default value is transferred to the class attribute during the dataclass construction process.
Now, we must examine the interaction of this mechanism with abstract properties. Given
class MyBaseClass(ABC):
@property
@abstractmethod
def foo(self) -> float:
...
@dataclass
class MyClass(MyBaseClass):
foo: float
What happens in this particular case is that the dataclass parser looks at the base class and finds the property MyBaseClass.foo. It then interprets this as the default value of dataclass field foo. As you might imagine, this will not end well. So, we have to prevent this from happening by providing a new default value. Using Roy's solution
@dataclass
class MyClass(MyBaseClass):
foo: float = dataclasses.MISSING
Now, there is a new default for foo, so MyBaseClass.foo is not read. Furthermore, when abc.update_abstractmethods
is called at the end of the dataclass process, it will not detect that foo is an abstract method class attribute, so it will be properly removed from the abstract method list. However, due to dataclasses.MISSING
being a special sentinal value, no actual defaults for foo are added to the __init__
method, or anywhere else.
However, if we do instead:
@dataclass
class MyClass(MyBaseClass):
foo: float = field()
The default value of foo is still dataclasses.MISSING
, but this is not transferred to the final class attribute according to rule 3 above. Rather, the attribute is deleted (on the concrete class) and the abstract property persists as the exposed foo according to the MRO.
Now, why don't we use the former (Roy's) solution rather than the latter? We could imagine we need a field object to specify something like default_factory, repr behavior, etc. The bare default value solution is rather limiting. So, here is a workaround that lets you use field() in this construct.
class MyBaseClass(ABC):
@property
@abstractmethod
def foo(self) -> float:
...
@dataclass
class MyClass(MyBaseClass):
foo: float = field(repr=False, default_factory=lambda: 1.0) # contrived need for field()
# replace the MISSING default value and fix registration of abstractness of foo
MyClass.foo = dataclasses.MISSING
abc.update_abstractmethods(MyClass)
Result:
x = MyClass(foo=2.0)
print(x.foo)
2.0
y = MyClass()
print(y.foo)
1.0
This is rather ugly to make use of as a general practice, so I recommend to wrap up in some kind of decorator. I rolled my own pre and post decorators that fix annotation-only fields and applied the dataclasses.MISSING resolution after dataclass creation.
Note that this is only an issues if you do not provide a default value for the field (default_factory does not count).
Edit: you can also abuse MRO to fix this by creating a trivial base class which lists the fields to be used as overrides of the abstract property as a class attribute equal to dataclasses.MISSING
. This class should be listed first in the MRO before any abstract classes so that the "default" is resolved correctly. This is perhaps more straightforward because you can list all fields you are overriding rather than having to pay attention to which have defaults. (Any actual defaults will be overridden in concrete dataclass).
Here is a working example to demonstrate this (with appropriate typing cast fixes to pass mypy and other type checking). Note it works well with multiple levels of inheritance. Note also that stub classes are not processed with @dataclass
.
import dataclasses
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import cast
class MyBaseClass(ABC):
@property
@abstractmethod
def foo(self) -> float:
...
@property
@abstractmethod
def bar(self) -> float:
...
class _MyClass:
foo: float = cast(float, dataclasses.MISSING)
@dataclass
class MyClass(_MyClass, MyBaseClass, ABC):
foo: float = field(hash=False)
class _MyChildClass:
bar: float = cast(float, dataclasses.MISSING)
@dataclass
class MyChildClass(_MyChildClass, MyClass):
bar: float
if __name__ == '__main__':
x = MyChildClass(1.0, 2.0)
print(x)