Source code for mytk.bindable

"""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)
[docs] def bind_property_to_widget_value( self, property_name: str, control_widget: "Base" # noqa: F821 ): """Binds an object property to the `value_variable` of a widget-like object. The `control_widget` must define a `value_variable` attribute (typically a Tkinter Variable) which will remain synchronized with the given property of this object. This allows direct model-view bindings in Tkinter. """ self.bind_properties( property_name, control_widget, other_property_name="value_variable" )