Reputation: 1363
I've been reading up on Python 3.7's dataclass as an alternative to namedtuples (what I typically use when having to group data in a structure). I was wondering if dataclass is compatible with the property decorator to define getter and setter functions for the data elements of the dataclass. If so, is this described somewhere? Or are there examples available?
Upvotes: 104
Views: 153924
Reputation: 687
Both @mementum and @couteau proposed a descriptor based solution, which is great.
But both methods will define an underscored variable on the object -- that's undesirable.
To avoid that, you can store the value in the descriptor object and use default_factory
.
Upvotes: 0
Reputation: 158
This was going to be a comment on Shmee's answer, but there's far too much to talk about.
That answer has the key disadvantage that it exposes the private field name in the dataclass fields, which was pointed out by Rick as an antipattern. Milan pointed out a more fatal problem: the __init__
method generated by calling @dataclass
on Shmee's class does not invoke the property setter.
Both of these issues can be fixed by setting the default value of the dataclass field to the property object.
#!/usr/bin/env python
import dataclasses as d
@d.dataclass
class Foo():
_spam = "eggs"
def _getspam(self):
print("Getting spam")
return _spam
def _setspam(self, v):
print("Setting spam")
if isinstance(v, property):
# Prevents dataclass-generated __init__ from clobbering
# the default _spam when it attempts set it to the
# property object at Foo.spam.
return
self._spam = v
spam: str = property(_getspam, _setspam)
if __name__ == "__main__":
bar, baz = Foo(), Foo("ham")
print(bar, baz)
print(bar.spam, baz.spam)
print(d.fields(bar))
print(d.fields(baz))
You can see that the setters are run when the class is instantiated, and the field name matches the property name.
This code still has a (minor) outstanding problem: the actual default value (at _spam
) is not contained in the field object. The field object actually contains Foo.spam
as the default, so any code that inspects the fields to find defaults will receive an incorrect result for a property field.
If this is relevant to your use case, you could write something in __post_init__
to put Foo._spam
(possibly retrieved through Foo.name.fget(Foo)
?) in the right place in self.__dataclass_fields__
.
The aforementioned issue does not exist in all use cases. Many properties (toy example: the length of some other attribute) wouldn't be sensible if they had setters. In these cases, you can set spam
to d.field(init=False, default=property(_getspam))
, which eliminates the assignment in __init__
at the root of the problem. Then _setspam
can be omitted.
Upvotes: 0
Reputation: 319
I know this thread is getting old, but here is another method that does not rely on manipulating the inner workings of the dataclass, though it does rely on pulling values out of the class body's stack frame. This method avoids the need to create two dataclass fields (public and protected) for the same property as some of the other suggested methods do. The protected attribute stays protected and won't show up in, e.g., as_dict
representations of the instance.
First, define a new default_property
descriptor class that inherits from the builtin property
descriptor. The descriptor class reads the current value of the local variable in the class body's stack frame that matches the name of the wrapped getter method, and stores it as the default. Most of the rest of class just takes care that we don't trample that default value once we've grabbed it. Thanks to dataclass's descriptor handling, we just have to return the default from the __get__
method when the passed instance
argument is None
. This method also works when the default is a Field
object, in case you need to set other field parameters.
import inspect
from dataclasses import MISSING
class default_property(property):
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
super().__init__(fget, fset, fdel, doc)
self.default = MISSING
if fget:
name = fget.__qualname__.rsplit(".", 1)[-1]
f = inspect.currentframe().f_back.f_locals
if name in f and not isinstance(f[name], property):
self.default = f[name]
def __get__(self, instance, owner=None):
if instance is None:
return self.default
return super().__get__(instance, owner)
def getter(self, fget):
p = super().getter(fget)
p.default = self.default
return p
def setter(self, fset):
p = super().setter(fset)
p.default = self.default
return p
def deleter(self, fdel):
p = super().deleter(fdel)
p.default = self.default
return p
Then, in the dataclass, we can simply first declare a class property with a value, and then declare a default_property
with the same name.
from dataclasses import dataclass, field
@dataclass
class Model:
foo: str = "default"
@default_property
def foo(self):
return self._foo
@foo.setter
def foo(self, value):
self._foo = value
foobar: int = field(default=42, kw_only=True)
@default_property
def foobar(self):
return self._foobar
@foobar.setter
def foobar(self, value):
self._foobar = value
Constructing models works as expected.
if __name__ == "__main__":
m = Model()
print(m)
m = Model("bar")
print(m)
m = Model(foobar=1)
m.foo = "baz"
print(m)
m = Model("foobar", 5) # TypeError - foobar is kw_only
A couple caveats: 1) this method requires a property setter--otherwise, you'll get an error when the dataclass __init__
tries to set the instance property to the default value, 2) it doesn't work if the initial class attribute is set to a field
call without a default
(including when default_factory
is set instead)--this will cause the class attribute/descriptor to be deleted by dataclasses during processing, and 3) you shouldn't do this, because the stack frame logic depends on implementation details that could change (and may not work at all on non cpython implementations).
The first can probably be worked around using InitVar
s and a __post_init__
method.
Upvotes: 1
Reputation: 3203
The cleaner syntax I found ... is by re-implementing property
and which forfeits the need to define the underlying _name
attribute, as in:
(See full code below)
@dataclass
class myclass:
name: MyProperty[in] = MyProperty[int](5)
@name.getter
def name_get(self) -> int:
return self._name
@name.setter
def name_set(self, val: int) -> None
self._name = val
One can obviously do this too:
@dataclass
class myclass:
def name_get(self) -> int:
return self._name
name: MyProperty[in] = MyProperty[int](5, fget=name_get)
But it doesn't feel so clean.
To double down ... the new property
decorator is also a dataclass
itself
The deletion of the attribute can be controlled as in
AttributeError
will be raisedfulldel
is True
(default in the sample code) when instantiating the descriptor, after deletion AttributeError
will be raisedBoth mypy
and pyright
are happy with the typing
.
#!/usr/bin/env python
# -*- coding: utf-8; py-indent-offset:4 -*-
###############################################################################
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, ClassVar, Generic, Optional, overload, TypeVar, Union
from typing_extensions import TypeAlias # python 3.10
from typing_extensions import Self # python 3.11
# Type to be wrapped by the descriptor
T = TypeVar('T')
GETTER: TypeAlias = Callable[[Any], T]
SETTER: TypeAlias = Callable[[Any, T], None]
DELETER: TypeAlias = Callable[[Any], None]
@dataclass
class MyProperty(Generic[T]):
MISSING: ClassVar[object] = object()
val: Union[T, object] = MISSING
fget: Optional[GETTER] = None
fset: Optional[SETTER] = None
fdel: Optional[DELETER] = None
p_prefix: str = field(default='_', kw_only=True)
p_suffix: str = field(default='', kw_only=True)
fulldel: bool = field(default=True, kw_only=True)
name: str = field(default='', init=False) # property name
pname: str = field(init=False) # property underlying name
def __set_name__(self, owner: type[Any], name: str) -> None:
self.name = name
self.pname = pname = f'{self.p_prefix}{name}{self.p_suffix}'
setattr(owner, pname, self.val)
def __set__(self, instance: Any, val: T) -> None:
if self.fset is None:
raise AttributeError(f'{self.name} cannot be set (no setter)')
if val is self: # dataclass setting descriptor as default value
return
self.fset(instance, val)
# overloads allow typecheckers to discriminate actual return type
@overload
def __get__(self, instance: None, owner: type[Any]) -> Self:
...
@overload
def __get__(self, instance: Any, owner: type[Any]) -> T:
...
def __get__(self, instance: Optional[Any], owner: type[Any]) -> Union[Self, T]:
if self.fget is None:
raise AttributeError(f'{self.name} cannot be got (no getter)')
if instance is None: # class level access ... return descriptor
return self
if (val := self.fget(instance)) is self.MISSING:
raise AttributeError(f'{self.name} not set or deleted')
return val
def __delete__(self, instance: Optional[Any]) -> None:
if self.fdel is None:
raise AttributeError(f'{self.name} cannot be deleted (no deleter)')
if instance is None: # class level access ... return descriptor
return
self.fdel(instance)
if self.fulldel:
setattr(instance, self.pname, self.MISSING)
# descriptor attributes for method decoration
def getter(self, f: GETTER) -> None:
self.fget = f
def setter(self, f: SETTER) -> None:
self.fset = f
def deleter(self, f: DELETER) -> None:
self.fdel = f
Test Code
@dataclass
class test:
a_value: MyProperty[int] = MyProperty[int](5, fulldel=False)
b_value: MyProperty[int] = MyProperty[int](10, fulldel=True)
c_value: MyProperty[int] = MyProperty[int]()
@a_value.getter
def a_value_get(self) -> int:
return self._a_value
@a_value.setter
def a_value_set(self, val: int) -> None:
self._a_value = val
@a_value.deleter
def a_value_del(self) -> None:
delattr(self, '_a_value')
@b_value.getter
def b_value_get(self) -> int:
return self._b_value
@b_value.setter
def b_value_set(self, val: int) -> None:
self._b_value = val
@b_value.deleter
def b_value_del(self) -> None:
delattr(self, '_b_value')
@c_value.getter
def c_value_get(self) -> int:
return self._c_value
@c_value.setter
def c_value_set(self, val: int) -> None:
self._c_value = val
@c_value.deleter
def c_value_del(self) -> None:
delattr(self, '_c_value')
# -----------------------------------------------------------------------------
t = test()
print('-' * 10, 'a')
print(t.a_value)
t.a_value = 25
print(t.a_value)
delattr(t, 'a_value')
print(t.a_value) # default class value again
print('-' * 10, 'b')
print(t.b_value)
t.b_value = 35
print(t.b_value)
delattr(t, 'b_value')
try:
print(t.b_value) # default class value again
except AttributeError:
print('Got AttributeError after deletion')
print('-' * 10, 'c')
try:
print(t.c_value)
except AttributeError:
print('AttributeError ... because it has no default value')
t.c_value = 45
print(t.c_value)
delattr(t, 'c_value')
try:
print(t.c_value) # default class value again
except AttributeError:
print('Got AttributeError after deletion')
print('-' * 10, 'repr')
t.b_value = 10
t.c_value = 20
print(f'{t!r}')
Output
---------- a
5
25
5
---------- b
10
35
Got AttributeError after deletion
---------- c
AttributeError ... because it has no default value
45
Got AttributeError after deletion
---------- repr
test(a_value=5, b_value=10, c_value=20)
Upvotes: 2
Reputation: 21
I went through the previous comments, and although most of them answer thet need to tweak the dataclass itself. I came up with an approach using a decorator which I think is more concise:
from dataclasses import dataclass
import wrapt
def dataclass_properties(cls, property_starts='_'):
@wrapt.decorator
def wrapper(wrapped, instance, args, kwargs):
properties = [prop for prop in dir(cls) if isinstance(getattr(cls, prop), property)]
new_kwargs = {f"{property_starts}{k}" if k in properties else k: v for k, v in kwargs.items()}
return wrapped(*args, **new_kwargs)
return wrapt.FunctionWrapper(cls, wrapper)()
@dataclass_properties
@dataclass
class State:
_a: int
b: int
_c: int
@property
def a(self):
return self._a
@a.setter
def time(self, value):
self._a = value
if __name__=='__main__':
s = State(b=1,a=2,_c=1)
print(s) # returns: State(_a=2, b=1, _c=1)
print(s.a) # returns: 2
It can filter between properties and those variables that are not properties but start by "_". It also supports the instantiation providing the property true name. In this case "_a".
if __name__=='__main__':
s = State(b=1,_a=2,_c=1)
print(s) # returns: State(_a=2, b=1, _c=1)
I does not solve the problem of the representation though.
Upvotes: 0
Reputation: 1
I use this idiom to get around the default value during __init__
problem. Returning None
from __set__
if a property object is passed in (as is the case during __init__
) will keep the initial default value untouched. Defining the default value of the private attribute as that of the previously defined public attribute, ensures the private attribute is available. Type hints are shown with the correct default value, and the comments silence the pylint and mypy warnings:
from dataclasses import dataclass, field
from pprint import pprint
from typing import Any
class dataclass_property(property): # pylint: disable=invalid-name
def __set__(self, __obj: Any, __value: Any) -> None:
if isinstance(__value, self.__class__):
return None
return super().__set__(__obj, __value)
@dataclass
class Vehicle:
wheels: int = 1
_wheels: int = field(default=wheels, init=False, repr=False)
@dataclass_property # type: ignore
def wheels(self) -> int:
print("Get wheels")
return self._wheels
@wheels.setter # type: ignore
def wheels(self, val: int):
print("Set wheels to", val)
self._wheels = val
if __name__ == "__main__":
pprint(Vehicle())
pprint('#####')
pprint(Vehicle(wheels=4))
Output:
└─ $ python wheels.py
Get wheels
Vehicle(wheels=1)
'#####'
Set wheels to 4
Get wheels
Vehicle(wheels=4)
Type hint:
Type hint with correct default value
Upvotes: 0
Reputation: 11652
Ok, so this is my first attempt at having everything self-contained within the class.
I tried a couple different approaches, including having a class decorator right next to @dataclass
above the class definition. The issue with the decorator version is that my IDE complains if I decide to use it, and then I lose most of the type hints that the dataclass
decorator provides. For example, if I'm trying to pass a field name into the constructor method, it doesn't auto-complete anymore when I add a new class decorator. I suppose that makes sense since the IDE assumes a decorator overwrites the original definition in some important way, however that succeeded in convincing me not to try with the decorator approach.
I ended up adding a metaclass to update the properties associated with dataclass fields to check if the value passed to the setter
is a property object as mentioned by a few other solutions, and that seems to be working well enough now. Either of the two approaches below should work for testing (based on @Martin CR's solution)
from dataclasses import dataclass, field
@dataclass
class Test(metaclass=dataclass_property_support):
name: str = property
_name: str = field(default='baz', init=False, repr=False)
@name
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
self._name = value
# --- other properties like these should not be affected ---
@property
def other_prop(self) -> str:
return self._other_prop
@other_prop.setter
def other_prop(self, value):
self._other_prop = value
And here is an approach which (implicitly) maps the property _name
that begins with an underscore to the dataclass field name
:
@dataclass
class Test(metaclass=dataclass_property_support):
name: str = 'baz'
@property
def _name(self) -> str:
return self._name[::-1]
@_name.setter
def _name(self, value: str):
self._name = value[::-1]
I personally prefer the latter approach, because it looks a little cleaner in my opinion and also the field _name
doesn't show up when invoking the dataclass helper function asdict
for example.
The below should work for testing purposes with either of the approaches above. The best part is my IDE doesn't complain about any of the code either.
def main():
obj = Test(name='foo')
print(obj) # displays: Test(name='foo')
obj = Test()
obj.name = 'bar'
print(obj) # displays: Test(name='bar')
obj = Test()
print(obj) # displays: Test(name='baz')
if __name__ == '__main__':
main()
Finally, here is the definition for the metaclass dataclass_property_support
that now seems to be working:
from dataclasses import MISSING, Field
from functools import wraps
from typing import Dict, Any, get_type_hints
def dataclass_property_support(*args, **kwargs):
"""Adds support for using properties with default values in dataclasses."""
cls = type(*args, **kwargs)
# the args passed in to `type` will be a tuple of (name, bases, dict)
cls_dict: Dict[str, Any] = args[2]
# this accesses `__annotations__`, but should also work with sub-classes
annotations = get_type_hints(cls)
def get_default_from_annotation(field_: str):
"""Get the default value for the type annotated on a field"""
default_type = annotations.get(field_)
try:
return default_type()
except TypeError:
return None
for f, val in cls_dict.items():
if isinstance(val, property):
public_f = f.lstrip('_')
if val.fset is None:
# property is read-only, not settable
continue
if f not in annotations and public_f not in annotations:
# adding this to check if it's a regular property (not
# associated with a dataclass field)
continue
try:
# Get the value of the field named without a leading underscore
default = getattr(cls, public_f)
except AttributeError:
# The public field is probably type-annotated but not defined
# i.e. my_var: str
default = get_default_from_annotation(public_f)
else:
if isinstance(default, property):
# The public field is a property
# Check if the value of underscored field is a dataclass
# Field. If so, we can use the `default` if one is set.
f_val = getattr(cls, '_' + f, None)
if isinstance(f_val, Field) \
and f_val.default is not MISSING:
default = f_val.default
else:
default = get_default_from_annotation(public_f)
def wrapper(fset, initial_val):
"""
Wraps the property `setter` method to check if we are passed
in a property object itself, which will be true when no
initial value is specified (thanks to @Martin CR).
"""
@wraps(fset)
def new_fset(self, value):
if isinstance(value, property):
value = initial_val
fset(self, value)
return new_fset
# Wraps the `setter` for the property
val = val.setter(wrapper(val.fset, default))
# Replace the value of the field without a leading underscore
setattr(cls, public_f, val)
# Delete the property if the field name starts with an underscore
# This is technically not needed, but it supports cases where we
# define an attribute with the same name as the property, i.e.
# @property
# def _wheels(self)
# return self._wheels
if f.startswith('_'):
delattr(cls, f)
return cls
Update (10/2021):
I've managed to encapsulate the above logic - including support for additional edge cases - into the helper library dataclass-wizard
, in case this is of interest to anyone. You can find out more about using field properties in the linked documentation as well. Happy coding!
Update (11/2021):
A more performant approach is to use a metaclass to generate a __post_init__()
on the class that only runs once to fix field properties so it works with dataclasses. You can check out the gist here which I added. I was able to test it out and when creating multiple class instances, this approach is optimized as it sets everything up properly the first time __post_init__()
is run.
Upvotes: 2
Reputation: 1345
For the use case that brought me to this page, namely to have a dataclass that is immutable, there is a simple option to use @dataclass(frozen=True)
. This removes all the rather verbose explicit definition of getters and setters. The option eq=True
is helpful too.
Credit: a reply from joshorr to this post, linked in a comment to the accepted answer. Also a bit of a classical case of RTFM.
Upvotes: -1
Reputation: 2063
Just put the field definition after the property:
@dataclasses.dataclass
class Test:
@property
def driver(self):
print("In driver getter")
return self._driver
@driver.setter
def driver(self, value):
print("In driver setter")
self._driver = value
_driver: typing.Optional[str] =\
dataclasses.field(init=False, default=None, repr=False)
driver: typing.Optional[str] =\
dataclasses.field(init=False, default=driver)
>>> t = Test(1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: __init__() takes 1 positional argument but 2 were given
>>> t = Test()
>>> t._driver is None
True
>>> t.driver is None
In driver getter
True
>>> t.driver = "asdf"
In driver setter
>>> t._driver == "asdf"
True
>>> t
In driver getter
Test(driver='asdf')
I'm surprised this isn't already an answer but I question its wisdom. The only reason for this answer is to include the property in the representation - because the property's backing store (_driver
) is already included in comparison tests and equality tests and so on. For example, this is a common idiom:
class Test:
def __init__(self):
self._driver = "default"
@property
def driver(self):
if self._driver == "default":
self._driver = "new"
return self._driver
>>> t = Test()
>>> t
<__main__.Test object at 0x6fffffec11f0>
>>> t._driver
'default'
>>> t.driver
'new'
Here is the dataclass equivalent - except that it adds the property to the representation. In the standard class, the result of (t._driver,t.driver)
is ("default","new")
. Notice that the result from the dataclass is instead ("new","new")
. This is a very simple example but you must recognize that including properties with possible side effects in special methods may not be the best idea.
@dataclasses.dataclass
class Test:
@property
def driver(self):
print("In driver getter")
if self._driver == "default":
self._driver = "new"
return self._driver
_driver: typing.Optional[str] =\
dataclasses.field(init=False, default="default", repr=False)
driver: typing.Optional[str] =\
dataclasses.field(init=False, default=driver)
>>> t = Test()
>>> t
In driver getter
Test(driver='new')
>>> t._driver
'new'
>>> t.driver
In driver getter
'new'
So I would recommend just using:
@dataclasses.dataclass
class Test:
_driver: typing.Optional[str] =\
dataclasses.field(init=False, default="default", repr=False)
@property
def driver(self):
print("In driver getter")
if self._driver == "default":
self._driver = "new"
return self._driver
>>> t
Test()
>>> t._driver
'default'
>>> t.driver
In driver getter
'new'
And you can sidestep the entire issue, avoiding dataclasses
for initialization, by simply using hasattr
in the property getter.
@dataclasses.dataclass
class Test:
@property
def driver(self):
print("In driver getter")
if not hasattr(self, "_driver"):
self._driver = "new"
return self._driver
Or by using __post_init__
:
@dataclasses.dataclass
class Test:
def __post_init__(self):
self._driver = None
@property
def driver(self):
print("In driver getter")
if self._driver is None:
self._driver = "new"
return self._driver
Why do this? Because init=False
dataclass defaults are stored only on the class and not the instance.
Upvotes: 1
Reputation: 521
Some wrapping could be good:
# DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
# Version 2, December 2004
#
# Copyright (C) 2020 Xu Siyuan <[email protected]>
#
# Everyone is permitted to copy and distribute verbatim or modified
# copies of this license document, and changing it is allowed as long
# as the name is changed.
#
# DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
# TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
#
# 0. You just DO WHAT THE FUCK YOU WANT TO.
from dataclasses import dataclass, field
MISSING = object()
__all__ = ['property_field', 'property_dataclass']
class property_field:
def __init__(self, fget=None, fset=None, fdel=None, doc=None, **kwargs):
self.field = field(**kwargs)
self.property = property(fget, fset, fdel, doc)
def getter(self, fget):
self.property = self.property.getter(fget)
return self
def setter(self, fset):
self.property = self.property.setter(fset)
return self
def deleter(self, fdel):
self.property = self.property.deleter(fdel)
return self
def property_dataclass(cls=MISSING, / , **kwargs):
if cls is MISSING:
return lambda cls: property_dataclass(cls, **kwargs)
remembers = {}
for k in dir(cls):
if isinstance(getattr(cls, k), property_field):
remembers[k] = getattr(cls, k).property
setattr(cls, k, getattr(cls, k).field)
result = dataclass(**kwargs)(cls)
for k, p in remembers.items():
setattr(result, k, p)
return result
You can use it like this:
@property_dataclass
class B:
x: int = property_field(default_factory=int)
@x.getter
def x(self):
return self._x
@x.setter
def x(self, value):
self._x = value
Upvotes: 5
Reputation: 401
A solution with minimal additional code and no hidden variables is to override the __setattr__
method to do any checks on the field:
@dataclass
class Test:
x: int = 1
def __setattr__(self, prop, val):
if prop == "x":
self._check_x(val)
super().__setattr__(prop, val)
@staticmethod
def _check_x(x):
if x <= 0:
raise ValueError("x must be greater than or equal to zero")
Upvotes: 40
Reputation: 2682
This method of using properties in dataclasses also works with asdict
and is simpler too. Why? Fields that are typed with ClassVar
are ignored by the dataclass, but we can still use them in our properties.
@dataclass
def SomeData:
uid: str
_uid: ClassVar[str]
@property
def uid(self) -> str:
return self._uid
@uid.setter
def uid(self, uid: str) -> None:
self._uid = uid
Upvotes: 2
Reputation: 91
Here's another way which allows you to have fields without a leading underscore:
from dataclasses import dataclass
@dataclass
class Person:
name: str = property
@name
def name(self) -> str:
return self._name
@name.setter
def name(self, value) -> None:
self._name = value
def __post_init__(self) -> None:
if isinstance(self.name, property):
self.name = 'Default'
The result is:
print(Person().name) # Prints: 'Default'
print(Person('Joel').name) # Prints: 'Joel'
print(repr(Person('Jane'))) # Prints: Person(name='Jane')
Upvotes: 3
Reputation: 1049
After trying different suggestions from this thread I've come with a little modified version of @Samsara Apathika answer. In short: I removed the "underscore" field variable from the __init__
(so it is available for internal use, but not seen by asdict()
or by __dataclass_fields__
).
from dataclasses import dataclass, InitVar, field, asdict
@dataclass
class D:
a: float = 10. # Normal attribut with a default value
b: InitVar[float] = 20. # init-only attribute with a default value
c: float = field(init=False) # an attribute that will be defined in __post_init__
def __post_init__(self, b):
if not isinstance(getattr(D, "a", False), property):
print('setting `a` to property')
self._a = self.a
D.a = property(D._get_a, D._set_a)
print('setting `c`')
self.c = self.a + b
self.d = 50.
def _get_a(self):
print('in the getter')
return self._a
def _set_a(self, val):
print('in the setter')
self._a = val
if __name__ == "__main__":
d1 = D()
print(asdict(d1))
print('\n')
d2 = D()
print(asdict(d2))
Gives:
setting `a` to property
setting `c`
in the getter
in the getter
{'a': 10.0, 'c': 30.0}
in the setter
setting `c`
in the getter
in the getter
{'a': 10.0, 'c': 30.0}
Upvotes: 0
Reputation: 51
Here's what I did to define the field as a property in __post_init__
. This is a total hack, but it works with dataclasses
dict-based initialization and even with marshmallow_dataclasses.
from dataclasses import dataclass, field, asdict
@dataclass
class Test:
name: str = "schbell"
_name: str = field(init=False, repr=False)
def __post_init__(self):
# Just so that we don't create the property a second time.
if not isinstance(getattr(Test, "name", False), property):
self._name = self.name
Test.name = property(Test._get_name, Test._set_name)
def _get_name(self):
return self._name
def _set_name(self, val):
self._name = val
if __name__ == "__main__":
t1 = Test()
print(t1)
print(t1.name)
t1.name = "not-schbell"
print(asdict(t1))
t2 = Test("llebhcs")
print(t2)
print(t2.name)
print(asdict(t2))
This would print:
Test(name='schbell')
schbell
{'name': 'not-schbell', '_name': 'not-schbell'}
Test(name='llebhcs')
llebhcs
{'name': 'llebhcs', '_name': 'llebhcs'}
I actually started off from this blog post mentioned somewhere in this SO, but ran into the issue that the dataclass field was being set to type property
because the decorator is applied to the class. That is,
@dataclass
class Test:
name: str = field(default='something')
_name: str = field(init=False, repr=False)
@property
def name():
return self._name
@name.setter
def name(self, val):
self._name = val
would make name
to be of type property
and not str
. So, the setter will actually receive property
object as the argument instead of the field default.
Upvotes: 5
Reputation: 1420
TWO VERSIONS THAT SUPPORT DEFAULT VALUES
Most published approaches don't provide a readable way to set a default value for the property, which is quite an important part of dataclass. Here are two possible ways to do that.
The first way is based on the approach referenced by @JorenV. It defines the default value in _name = field()
and utilises the observation that if no initial value is specified, then the setter is passed the property object itself:
from dataclasses import dataclass, field
@dataclass
class Test:
name: str
_name: str = field(init=False, repr=False, default='baz')
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
if type(value) is property:
# initial value not specified, use default
value = Test._name
self._name = value
def main():
obj = Test(name='foo')
print(obj) # displays: Test(name='foo')
obj = Test()
obj.name = 'bar'
print(obj) # displays: Test(name='bar')
obj = Test()
print(obj) # displays: Test(name='baz')
if __name__ == '__main__':
main()
The second way is based on the same approach as @Conchylicultor: bypassing the dataclass machinery by overwriting the field outside the class definition.
Personally I think this way is cleaner and more readable than the first because it follows the normal dataclass idiom to define the default value and requires no 'magic' in the setter.
Even so I'd prefer everything to be self-contained... perhaps some clever person can find a way to incorporate the field update in dataclass.__post_init__()
or similar?
from dataclasses import dataclass
@dataclass
class Test:
name: str = 'foo'
@property
def _name(self):
return self._my_str_rev[::-1]
@_name.setter
def _name(self, value):
self._my_str_rev = value[::-1]
# --- has to be called at module level ---
Test.name = Test._name
def main():
obj = Test()
print(obj) # displays: Test(name='foo')
obj = Test()
obj.name = 'baz'
print(obj) # displays: Test(name='baz')
obj = Test(name='bar')
print(obj) # displays: Test(name='bar')
if __name__ == '__main__':
main()
Upvotes: 28
Reputation: 413
An @property
is typically used to store a seemingly public argument (e.g. name
) into a private attribute (e.g. _name
) through getters and setters, while dataclasses generate the __init__()
method for you.
The problem is that this generated __init__()
method should interface through the public argument name
, while internally setting the private attribute _name
.
This is not done automatically by dataclasses.
In order to have the same interface (through name
) for setting values and creation of the object, the following strategy can be used (Based on this blogpost, which also provides more explanation):
from dataclasses import dataclass, field
@dataclass
class Test:
name: str
_name: str = field(init=False, repr=False)
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, name: str) -> None:
self._name = name
This can now be used as one would expect from a dataclass with a data member name
:
my_test = Test(name='foo')
my_test.name = 'bar'
my_test.name('foobar')
print(my_test.name)
The above implementation does the following things:
name
class member will be used as the public interface, but it actually does not really store anything_name
class member stores the actual content. The assignment with field(init=False, repr=False)
makes sure that the @dataclass
decorator ignores it when constructing the __init__()
and __repr__()
methods.name
actually returns/sets the content of _name
@dataclass
will use the setter that we just defined. It will not initialize _name
explicitly, because we told it not to do so.Upvotes: 19
Reputation: 525
From the ideas from above, I created a class decorator function resolve_abc_prop
that creates a new class containing the getter and setter functions as suggested
by @shmee.
def resolve_abc_prop(cls):
def gen_abstract_properties():
""" search for abstract properties in super classes """
for class_obj in cls.__mro__:
for key, value in class_obj.__dict__.items():
if isinstance(value, property) and value.__isabstractmethod__:
yield key, value
abstract_prop = dict(gen_abstract_properties())
def gen_get_set_properties():
""" for each matching data and abstract property pair,
create a getter and setter method """
for class_obj in cls.__mro__:
if '__dataclass_fields__' in class_obj.__dict__:
for key, value in class_obj.__dict__['__dataclass_fields__'].items():
if key in abstract_prop:
def get_func(self, key=key):
return getattr(self, f'__{key}')
def set_func(self, val, key=key):
return setattr(self, f'__{key}', val)
yield key, property(get_func, set_func)
get_set_properties = dict(gen_get_set_properties())
new_cls = type(
cls.__name__,
cls.__mro__,
{**cls.__dict__, **get_set_properties},
)
return new_cls
Here we define a data class AData
and a mixin AOpMixin
implementing operations
on the data.
from dataclasses import dataclass, field, replace
from abc import ABC, abstractmethod
class AOpMixin(ABC):
@property
@abstractmethod
def x(self) -> int:
...
def __add__(self, val):
return replace(self, x=self.x + val)
Finally, the decorator resolve_abc_prop
is then used to create a new class
with the data from AData
and the operations from AOpMixin
.
@resolve_abc_prop
@dataclass
class A(AOpMixin):
x: int
A(x=4) + 2 # A(x=6)
EDIT #1: I created a python package that makes it possible to overwrite abstract properties with a dataclass: dataclass-abc
Upvotes: 0
Reputation: 12627
Following a very thorough post about data classes and properties that can be found here the TL;DR version which solves some very ugly cases where you have to call MyClass(_my_var=2)
and strange __repr__
outputs:
from dataclasses import field, dataclass
@dataclass
class Vehicle:
wheels: int
_wheels: int = field(init=False, repr=False)
def __init__(self, wheels: int):
self._wheels = wheels
@property
def wheels(self) -> int:
return self._wheels
@wheels.setter
def wheels(self, wheels: int):
self._wheels = wheels
Upvotes: 1
Reputation: 5729
Currently, the best way I found was to overwrite the dataclass fields by property in a separate child class.
from dataclasses import dataclass, field
@dataclass
class _A:
x: int = 0
class A(_A):
@property
def x(self) -> int:
return self._x
@x.setter
def x(self, value: int):
self._x = value
The class behave like a regular dataclass. And will correctly define the __repr__
and __init__
field (A(x=4)
instead of A(_x=4)
. The drawback is that the properties cannot be read-only.
This blog post, tries to overwrite the wheels dataclass attribute by the property
of the same name.
However, the @property
overwrite the default field
, which leads to unexpected behavior.
from dataclasses import dataclass, field
@dataclass
class A:
x: int
# same as: `x = property(x) # Overwrite any field() info`
@property
def x(self) -> int:
return self._x
@x.setter
def x(self, value: int):
self._x = value
A() # `A(x=<property object at 0x7f0cf64e5fb0>)` Oups
print(A.__dataclass_fields__) # {'x': Field(name='x',type=<class 'int'>,default=<property object at 0x>,init=True,repr=True}
One way solve this, while avoiding inheritance would be to overwrite the field outside the class definition, after the dataclass metaclass has been called.
@dataclass
class A:
x: int
def x_getter(self):
return self._x
def x_setter(self, value):
self._x = value
A.x = property(x_getter)
A.x = A.x.setter(x_setter)
print(A(x=1))
print(A()) # missing 1 required positional argument: 'x'
It should probably possible to overwrite this automatically by creating some custom metaclass and setting some field(metadata={'setter': _x_setter, 'getter': _x_getter})
.
Upvotes: 7
Reputation: 5101
It sure does work:
from dataclasses import dataclass
@dataclass
class Test:
_name: str="schbell"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, v: str) -> None:
self._name = v
t = Test()
print(t.name) # schbell
t.name = "flirp"
print(t.name) # flirp
print(t) # Test(_name='flirp')
In fact, why should it not? In the end, what you get is just a good old class, derived from type:
print(type(t)) # <class '__main__.Test'>
print(type(Test)) # <class 'type'>
Maybe that's why properties are nowhere mentioned specifically. However, the PEP-557's Abstract mentions the general usability of well-known Python class features:
Because Data Classes use normal class definition syntax, you are free to use inheritance, metaclasses, docstrings, user-defined methods, class factories, and other Python class features.
Upvotes: 90