5
\$\begingroup\$

I'm writing an application with two basic class types:

  1. DocumentObject
  2. 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:

  1. Is is good practice to do this?
  2. Are there likely to be some negative consequences of this decision?
  3. Is there a more elegant solution that I have missed?
asked Jan 14, 2021 at 10:59
\$\endgroup\$
5
  • \$\begingroup\$ Is a property's unit intended to be constant? For example, the length attributes name should always be the constant "length"; is the length attributes unit a fixed unit (such as mm), or can it be changed by the user (ie, brick.property["length"].units = "feet")? \$\endgroup\$ Commented Jan 14, 2021 at 16:30
  • \$\begingroup\$ unit will probably be constant. But another attribute of Property would be isVisible which would be a boolean settable by the user. I would implement this using a @property descriptors for isVisible. \$\endgroup\$ Commented Jan 15, 2021 at 9:18
  • \$\begingroup\$ I've now implemented this change. \$\endgroup\$ Commented Jan 15, 2021 at 9:26
  • \$\begingroup\$ Good first question! It's fine that you've shown an implemented change, but keep in mind that we discourage editing your question once an answer appears. \$\endgroup\$ Commented Jan 15, 2021 at 17:30
  • \$\begingroup\$ Thanks, I'll bear that in mind! \$\endgroup\$ Commented Jan 15, 2021 at 17:41

2 Answers 2

4
\$\begingroup\$

Are there likely to be some negative consequences of this decision?

Absolutely.

  1. Calling help(DocumentObject) will not tell you what Property attributes exist in your class.
  2. An IDE won't have any information for autocomplete. Eg) Typing brick. and pressing the <TAB> key won't offer length and width as possible completions.
  3. 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
 | 
 | ...
answered Jan 15, 2021 at 19:17
\$\endgroup\$
3
\$\begingroup\$
  • 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 from DocumentObject? I'm not sure why you check for this. Won't it always be in vars?
  • Use type hints.
  • Use snake_case, not camelCase for variables
  • Do not allow arbitrary __setattr__ on your DocumentObject 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)
answered Jan 15, 2021 at 17:49
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.