2025, Dec 03 23:00
Avoid breaking Python properties: use per-instance callbacks for safe aggregator updates
Learn why wrapping Python properties breaks descriptors and how per-instance callbacks trigger instance-level notifications while avoiding cross-group updates.
When you need a group of objects to react to state changes in one of their peers, the obvious idea is to decorate a property and inject a side effect. In Python, doing this on instance-bound properties can bite back: the descriptor machinery is easy to disrupt if you treat it like a regular attribute. Below is a compact walk-through of the issue and a clean way to wire instance-level notifications without breaking properties or coupling unrelated groups.
Problem setup
Consider a simple data holder with a property and a separate object that aggregates several of these holders. The intent is to trigger the aggregator’s update whenever any individual value changes.
class Signal():
def __init__(self):
self._metric = None
@property
def metric(self):
return self._metric
@metric.setter
def metric(self, val):
self._metric = val
class Collector():
def __init__(self, sensors):
self.sensors = sensors
def notifier(func):
def proxy(*args):
result = func(*args) # wrap the property accessor/mutator
self.refresh() # attempt to trigger the aggregator
return result
return proxy
for s in sensors:
s.metric = notifier(s.metric) # breaks: s.metric here is a value access, not a function
s.__class__.__dict__['metric'].fset = notifier(
s.__class__.__dict__['metric'].fset
) # breaks: AttributeError: readonly attribute
def refresh(self):
self.total = sum([s.metric for s in self.sensors])
What goes wrong and why
The first assignment replaces an instance’s property access with the result of calling a wrapper, so the property is no longer used to get or set the value. The second line tries to mutate the setter function stored in the class-level descriptor, which raises AttributeError: readonly attribute. In short, treating a property as a regular attribute or trying to update its internals in place at runtime leads to errors and breaks the descriptor semantics.
There is another subtlety when decorating at the class level: any global patch applied to the property would affect every instance and every group, triggering updates in all aggregators instead of only the ones that include a specific object. That defeats the idea of isolated groups.
Working approach
A straightforward, reliable fix is to keep the property intact and add an instance-level callback that an aggregator can attach. The property’s setter calls this callback if it exists and is callable. Each aggregator wires only its own members to its own update method, so cross-group interference is avoided.
class Signal():
def __init__(self):
self._metric = None
@property
def metric(self):
return self._metric
@metric.setter
def metric(self, val):
self._metric = val
if hasattr(self, 'collector_sync') and callable(self.collector_sync):
self.collector_sync()
class Collector():
def __init__(self, sensors):
self.sensors = sensors
for s in self.sensors:
s.collector_sync = self.refresh
def refresh(self):
self.total = sum([s.metric for s in self.sensors])
This keeps the descriptor untouched and clearly scopes the side effect to the owning collector.
Why this matters
Decorating or patching properties at the wrong level can accidentally sever descriptor behavior or fan out updates far beyond the intended boundary. The instance-level hook pattern avoids both pitfalls: the property remains a property, and only the right aggregator gets notified. This is especially useful when the same data holder type participates in multiple, disjoint groups that must not observe each other.
Takeaways
Don’t wrap or reassign instance properties as if they were plain callables. Avoid mutating class-level descriptor internals at runtime. If you need side effects on set, add a lightweight, per-instance callable and let the aggregator attach its update method to the members it owns. The resulting design is explicit, local, and won’t surprise you with cross-group updates.