I'm writing an application with two basic class types:
- DocumentObject
- Property (of document object).
Property
instances are attributes of the DocumentObject
class and The Property
class has several simple attributes of its own (value, unit, etc.) and some methods.
In trying to make the scripting as user friendly as possible, I would like
objectName.propertyName
to return the value attribute of the Property instance, not the Property instance itself. Of course, it is possible to write objectName.propertyName.value
but most of the time, the user will be interacting with the value, not the Property instance.
It seems it should be possible to implement this behaviour using modified __getattr__
and __setattr__
methods in DocumentObject
like in the following example:
Input
class Property():
def __init__(self, name, value, unit='mm'):
self._name = name
self.value = value
self._unit = unit
self._isVisible = True
@property
def name(self):
return self._name
@property
def unit(self):
return self._unit
@property
def isVisible(self):
return self._isVisible
@isVisible.setter
def isVisible(self, value):
self._isVisible = bool(value)
class DocumentObject():
def __init__(self, properties):
object.__setattr__(self, 'properties', dict())
self.properties = properties
def __getattr__(self, name):
if "properties" in vars(self):
if name in self.properties:
return self.properties[name].value
else: raise AttributeError
else: raise AttributeError
def __setattr__(self, key, value):
if key in self.properties:
self.properties[key].value = value
else:
object.__setattr__(self, key, value)
brick = DocumentObject({'length': Property('length',10), 'width': Property('width',5)})
print(brick.properties["length"].name)
print(brick.length)
Output
length
10
Questions:
- Is is good practice to do this?
- Are there likely to be some negative consequences of this decision?
- Is there a more elegant solution that I have missed?
2 Answers 2
Are there likely to be some negative consequences of this decision?
Absolutely.
- Calling
help(DocumentObject)
will not tell you whatProperty
attributes exist in your class. - An IDE won't have any information for autocomplete. Eg) Typing
brick.
and pressing the<TAB>
key won't offerlength
andwidth
as possible completions. - Callers can add, remove and change elements of
brick.properties
.
We can get around all of this by defining your own data descriptors.
Reworked Code
Data descriptor
First, let's create a data descriptor: a class with a __get__
and __set__
methods. This will allow us to defined a Property
on the the DocumentObject
class, and control the way things are read from or written to instances of DocumentObject
class instances through those properties.
The name, default value and units of the property can be stored in the property descriptor, since they are read-only values.
We'll also create a Property.Instance
class to hold the data in an instance of the the property in the DocumentObject
. Instances of the Property.Instance
will have the read-write attributes of value
, and visible
, as well as a link to the property descriptor for the read-only values.
__slots__
is used to prevent additional fields from being set on a property.
class Property:
__slots__ = ('name', 'default', 'units', '__doc__',)
def __init__(self, default, units, doc):
self.default = default
self.units = units
self.__doc__ = doc
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
prop = instance._get_property(self.name)
return prop.value
def __set__(self, instance, value):
prop = instance._get_property(self.name)
prop.value = value
class Instance:
__slots__ = ('value', '_visible', '_property')
def __init__(self, prop):
self._property = prop
self.value = prop.default
self._visible = True
@property
def name(self):
return self._property.name
@property
def units(self):
return self._property.units
@property
def visible(self):
return self._visible
@visible.setter
def visible(self, value):
self._visible = bool(value)
def __repr__(self):
return f"Prop[{self.name} {self.value} {self.units} {self.visible}]"
Properties
For each DocumentObject
, we'll want a container for all of the properties. The _get_property()
method we used, above, exacts a named property instance from the container.
Since we want this container to be a fixed size, with only the named properties defined on the class, we'll create a named tuple with those property instances.
To make life easy, we'll create the namedtuple of property instances automatically, when the subclass is defined.
from collections import namedtuple
class Property:
...
class Properties:
def __init_subclass__(cls):
cls._property_list = [attr for attr in vars(cls).values()
if isinstance(attr, Property)]
names = [prop.name for prop in cls._property_list]
properties_type = namedtuple(cls.__name__ + "Properties", names)
cls._properties_type = properties_type
def __init__(self):
property_list = self.__class__._property_list
properties_type = self.__class__._properties_type
properties = [Property.Instance(prop) for prop in property_list]
self._properties = properties_type._make(properties)
def _get_property(self, name):
return getattr(self._properties, name)
@property
def properties(self):
return self._properties
Creating the DocumentObject
Deriving the DocumentObject
from the Properties
class will automatically call the __init_subclass__
of the parent class. At this point, it collects all of the Property
descriptors, and constructs the namedtuple
type for the property container. During the actual super().__init__()
, all of the property instances get created, and stored in an instance of the namedtuple
type created for this purpose.
...
class DocumentObject(Properties):
def __init__(self):
super().__init__()
length = Property(10, "mm", "length of brick, in mm")
width = Property(5, "mm", "width of brick, in mm")
brick = DocumentObject()
print(brick.properties.length.name)
print(brick.length)
Seatbelts
Autocompletion
Typing brick.
and pressing TAB can autocomplete with length
or width
(or properties), because those are now named attributes of the DocumentObject
class.
Typing brick.properties.
and pressing TAB will also suggest length
and width
as autocompletions for the property container.
Immutability
The caller cannot add or change brick.properties
because it is an immutable named tuple.
Of course, the property instances are not immutable, so the the following are all allowed:
brick.properties.length.visible = False
brick.properties.length.value = 20
brick.length = 30
Help
Typing help(DocumentObject)
now produces:
Help on class DocumentObject in module __main__:
class DocumentObject(Properties)
| ...
|
| Data descriptors defined here:
|
| length
| length of brick, in mm
|
| width
| width of brick, in mm
|
| ...
- Do not write trivial getters/setters; this is not Java/C++/etc. It's more Pythonic to simply have everything public under most circumstances. Private-ness is not enforced anyway and is more of a suggestion.
- Consider using
@dataclass
- You can drop empty parens after your class definition
- Under what circumstances would it be possible for
properties
to be missing fromDocumentObject
? I'm not sure why you check for this. Won't it always be invars
? - Use type hints.
- Use snake_case, not camelCase for variables
- Do not allow arbitrary
__setattr__
on yourDocumentObject
other than to registered properties - Do not accept a dict with redundant keys; just accept an iterable of properties
- IMO it's less surprising to have the document's actual
properties
dict override any request to a property named "properties" than the other way around
Suggested:
from dataclasses import dataclass
from typing import Dict, Iterable, Union
@dataclass
class Property:
name: str
value: float # ?? or maybe Any
unit: str = 'mm'
is_visible: bool = True
PropDict = Dict[str, Property]
class DocumentObject:
def __init__(self, properties: Iterable[Property]):
self.properties: PropDict = {p.name: p for p in properties}
self.__setattr__ = self._setattr
def __getattr__(self, name: str) -> Union[PropDict, float]:
if name == 'properties':
return self.properties
return self.properties[name].value
def _setattr(self, key: str, value: float):
self.properties[key].value = value
brick = DocumentObject((
Property('length', 10),
Property('width', 5),
))
print(brick.properties['length'].name)
print(brick.length)
brick.width = 7
print(brick.width)
"length"
; is the length attributes unit a fixed unit (such asmm
), or can it be changed by the user (ie,brick.property["length"].units = "feet"
)? \$\endgroup\$unit
will probably be constant. But another attribute of Property would beisVisible
which would be a boolean settable by the user. I would implement this using a @property descriptors for isVisible. \$\endgroup\$