"""Property-Observer and Binding Mechanism for Python/Tkinter.
This class implements a Property-Value-Observer pattern, which allows objects
to observe changes in specific attributes of other objects. It supports both
traditional Python attributes and Tkinter `Variable` instances, making it
suitable for reactive GUI programming in Tkinter.
It enables:
1. Observing changes in object properties, with optional context.
2. Automatically synchronizing (binding) two properties so that changing one updates the other.
This is inspired by macOS’s Key-Value-Observing (KVO) pattern, and can be used
to build reactive MVC-style architectures in Python/Tkinter applications.
Classes:
- Bindable: Base class that supports observing and binding properties.
Usage Example:
class Model(Bindable):
def __init__(self):
super().__init__()
self.name = tk.StringVar()
def observed_property_changed(self, observed, property_name, new_value, context):
print(f"{property_name} changed to {new_value}")
model = Model()
view = SomeWidget(...)
model.bind_property_to_widget_value("name", view)
"""
from collections import namedtuple
from contextlib import suppress
from tkinter import Variable
ObserverInfo = namedtuple(
"ObserverInfo", ["observer", "observed_property_name", "context"]
)
[docs]
class Bindable:
"""A class to observe changes in variables and optionally bind variables together.
In general, this is called a "one-to-one" pattern because an observer
registers specifically for a change in a specific object. To notify
one-to-many, use the NotificationCenter.
It is also called a Property-Value-Observer pattern, identical to the
Key-Value-Observer pattern on macOS.
It implements two functionalities:
1. a callback method to notify a specific object that a change occurred in
another object.
2. a binding mechanism so that two properties are always
synchronized, regardless of which one changed
"""
def __init__(self, *args, **kwargs):
"""Assign observing_me before super().__init__().
The overridden __setattr__ will be active for subclasses
in case this is part of a multiple inheritance (it is).
"""
self.observing_me = []
super().__init__() # cooperative!
[docs]
def add_observer(self, observer, my_property_name, context=None):
"""Register an observer for changes to a named property of this object.
When the property "my_property_name" of object "self" changes, the
method "observed_property_changed" of object "observer" will be
called. You must implement the following method signature
in "observer":
observer.observed_property_changed
(observed_object, observed_property_name, new_value, context)
Based on either parameters, you can determine what to do:
observed_property_name may be sufficient if you have only registered
the observer for one thing, but you can also provide "context" when
with anything you want and can be an indicator of what to do
(e.g., context="object_needs_refresh").
We treat Tk.Variable() differently. We do not observe for a change in
the actual value_variable (i.e. the Variable()): we observe if the
Variable() changes its value. We really need this to integrate this
observer pattern with Tk and generalize it to any property. To do so,
we register à-la-TkVariable with trace_add and redirect the call with
our observed_property_changed mechanism.
"""
try:
var = getattr(self, my_property_name)
observer_info = ObserverInfo(observer, my_property_name, context)
self.observing_me.append(observer_info)
"""
If the property is a regular object property, then __setattr__
will catch the change and call property_did_change. This is done
automatically. On the other hand, if the property is a
Tk.Variable, then we must register using Tk's mechanism (trace_add) to
observe not the variable itself but when its value is modified.
"""
if isinstance(var, Variable):
var.trace_add("write", self.traced_tk_variable_changed)
except AttributeError as err:
raise AttributeError(
f"Attempting to observe inexistent property '{my_property_name}' in Bindable object {self}"
) from err
def __setattr__(self, property_name, new_value):
"""Assigns a value to a property and notifies observers if it changed.
This method overrides the default setattr to automatically trigger
observer callbacks when a property is modified. If the property is
a Tkinter Variable, care is taken to avoid accidentally overwriting
it with a non-variable object, which is typically a mistake.
We always set the property regardless of the value. It is possible
that the property does not exist yet (which is not an error, it
happens in __init__) so we need to cover that case by ignoring
AttributeError(then the property will be managed in __setattr__ right
after). Also, we warn if the user is overwriting a Tk Variable with
something other than a Variable or None, because it is highly likely a mistake.
"""
with suppress(AttributeError):
observed_property = getattr(self, property_name)
if isinstance(observed_property, Variable) and new_value is not None and not isinstance(
new_value, Variable
):
raise TypeError(
f"You are overwriting the Tk Variable '{property_name}' with a non-tk Variable value '{new_value}'"
)
super().__setattr__(property_name, new_value)
self.property_value_did_change(property_name)
# pylint: disable=unused-argument
[docs]
def traced_tk_variable_changed(self, var, index, mode):
"""This function is called by tk when a Tk.Variable value is changed.
This is a hook function into our Property-Value-Observing mechanism.
We do not observe for a change in the actual value_variable (i.e. the
Variable()): we observe if the tk.Variable() changes its value. We
need this to integrate this Property-Observer Pattern with Tk and
generalize it to any property. To do so, we register à-la-TkVariable
with trace_add (see above) and call our property_value_did_change
mechanism.
"""
for _, property_name, _ in self.observing_me:
observed_property = getattr(self, property_name)
# pylint: disable=protected-access
if isinstance(observed_property, Variable) and observed_property._name == var:
self.property_value_did_change(property_name)
[docs]
def property_value_did_change(self, property_name):
"""Notify all observers that a property value has changed.
Recovers all the parameters of the observer (who is observing and
what is the context that was provided when registering) before calling
the observer callback. Tk.Variables need special treatment because we
are looking at their values, not the Tk.Variable object itself.
"""
new_value = getattr(self, property_name) # Assume python property
if isinstance(new_value, Variable): # If tk Variable, get its value
new_value = new_value.get()
if hasattr(self, "observing_me"):
for observer_info in self.observing_me:
observer, observed_property_name, context = observer_info
if observed_property_name == property_name:
observer.observed_property_changed(
self, observed_property_name, new_value, context
)
[docs]
def observed_property_changed(
self, observed_object, observed_property_name, new_value, context
):
"""Handle a property change notification in the observer.
But the binding mechanism uses the same Property-Value-Observing
mechanism, and we can treat it without having the user do anything.
Therefore, by default, we check to see if it is a binding, and if it
is, we treat it, the subclass will not have anything to do. The
context will be a dictionary and will have the key "binding". If it
does not, then it's not a binding and that's it. But if it is, we set
the property (stored in context{'binding':variable_name}) of self to
the new_value. Again, we treat Tk.Variable differently: we do not
change the property that holds the Tk.Variable, we change its
value.
If you are using the basic Property-Value-Observing pattern to be
notified of a change in a property, then your class *needs* to
override this observed_property_changed() and should perform whatever
it wants to do based on the context, and then call
super().observed_property_changed to benefit of property binding management.
"""
if isinstance(context, dict):
bound_variable = context.get("binding")
if bound_variable is not None:
old_value = getattr(self, bound_variable)
var = None
if isinstance(old_value, Variable):
var = old_value
old_value = var.get()
if old_value != new_value:
if var is not None:
var.set(new_value)
else:
setattr(self, bound_variable, new_value)
[docs]
def bind_properties(
self, this_property_name, other_object, other_property_name
):
"""Bind two properties for two-way synchronization between objects.
Makes use of the Property-Value-Observing mechanism and uses the
context to indicate that it is actually "a binding". Changing one
property will notify the other, which will be changed, and vice-versa.
"""
other_object.add_observer(
self, other_property_name, context={"binding": this_property_name}
)
self.add_observer(
other_object,
this_property_name,
context={"binding": other_property_name},
)
self.property_value_did_change(this_property_name)