pythondictionarytkinterattributesttk

Erratic widget behavior when using a tk.Variable as a Widget attribute


I am currently having a problem with Tkinter.Variable initialization and naming.

Setup:

I have a window, with a frame (window_frame) in it and menubar at the top (tk.Menu). Each command on the menu opens a new app, which clears window_frame and re-fills it with all new widgets. Some of the widgets have a tk.Variable attached to them (ex. tk.Variable, tk.StringVar, etc).

Problem:

I am using customized widget classes (for global configuration), and originally, I had the variables initialized inside the widget with a name based on the widget name, for example:

<-SNIP->

# In the widgets module:
class CustomWidget(tk.Entry):
    def __init__(self, master=None, cnf={}, **kw):
        value = kw.pop('value', None)
        tk.Entry.__init__(self, master, cnf, **kw)
        self.variable = tk.Variable(self, name=self._name + '_var', value=value)

def destroy_widgets(master):
    for child in master.winfo_children():
        destroy_children(child)
        child.destroy()

# In each app module:
def main(window_frame):
    # Destroy the widgets in the window_frame:
    destroy_widgets(window_frame)
    # Add new widgets to the window_frame:
    widgets = dict(
        child1 = dict(widget = CustomWidget(name='child1', master=master)),
        child2 = dict(widget = CustomWidget(name='child2', master=master)),
        # etc
        )
    # Keep track of the variable in the dict as well:
    for key in widgets:
        widgets[key]['variable'] = widgets[key]['widget'].variable

    # Notes:
    # window_frame is populated with widgets & a button to re-run main().
    # At root level, window_frame also has a menu to run main() 
    # from various apps.

<-SNIP->

The expected behavior was that after the Widget (and its Variable) were destroyed, each tk.Variable would be re-initialized, and have the new value.

This appeared to be working for all of the widgets except for ones based on ttk.Checkbutton, which had the checkbutton going into an "alternate" state on app change/reload.

However upon further inspection, all the Widgets were affected.

My understanding is that after destroying the Widgets in window_frame, the named tk.Variables would still be "set" in Tcl when the widgets (and their embedded tk.Variables) were initialized. Therefore tkinter would re-use the Tcl variables with those names instead of initializing new tk.Variable instances. However, by the time the code attempted to get() the values from the tk.Variables (for example, to display a checkbutton in "selected" or "!selected" state) the tk.Variable was no longer "set" in Tcl and the widget would behave erratically, since the tk.Variable was not accessible via tk's setvar() or getvar() methods.

Proposed solution:

Someone recommended I keep track of the tk.Variable names at the root level, for example in a dict that would have the variable names passed to it. Then reuse variables as I go instead of creating new Variables with each Widget. (Note: I quickly discovered the tk.Variable instances would have to be kept track of in this dict, not just the name of the Variable!)

However, before I move forward, is there something else I am missing that is causing the tk.Variable instances to behave erratically?

For example, since I am keeping all the widget information, including a pointer to the widget, in a dict that is initialized on each main() call in each app module. Perhaps these dicts are not being properly deleted by the garbage collector when I run a new app's main()? Should I propagate the dictionaries upward, and update a dict at the root level, rather than initializing a new dict inside the main() of each app?

Initial fix:

My initial fix was to remove all the names I assigned to all of my tk.Variable instances. This allowed tkinter to initialize a new Variable upon each Widget initialization ("PYVAR_1", "PYVAR_2", etc.).

However, when I added a print statement in an app to track the number of Tcl variables, the number rapidly increased with each app change/refresh. The number of variables does not appear to decrease at any point, whether I wait several seconds and then change/refresh the app, or force window_frame.update() after destroying the child widgets.

Example app module:

<-SNIP->

# App window definition:
def main(window_frame, **kw):
    print(root.tk.call("info", "vars")) # This number always increases on main() call
    destroy_children(window_frame)
    # Add new widgets here

<-SNIP->


Solution

  • After quite a bit of investigation, I've determined that the issue was in lingering pointers in Python that were preventing Tcl from deleting tk.Variables.

    The solution was to:

    1. Propagate all the children definitions from individual app-level dicts into a root level dict.
    2. In each master key of the root dict, keep a list of tuples (child_key, child_widget).
    3. Refactor destroy_children to use the master keys' child widget tuples, in order to pop() the variables from the widget __dict__s and del the variables explicity.

    Refactored code snippets (not including unchanged methods):

    <-SNIP->

    # In the widgets module:
    def destroy_widgets(master_dict, master):
        # 'master' is now a key in 'master_dict' rather than a widget!
        # Use (child, child_widget) tuples instead of master.winfo_children()
        # to cycle through the children of master:
        for child, widget in master_dict[master].children:
            # Pop the child key from the master_dict:
            master_dict.pop(child)
    
            # Remove the Variable pointer from the Widget:
            variable = widget.__dict__.pop('variable')
    
            # Destroy the widget:
            widget.destroy()
    
            # Delete the Variable:
            del variable
    
        # Clear the master's children:
        master_dict[master].children = [] 
    
        # At this point, no pointers to widgets or Variables exist in Python,
        # so Tcl can "unset" the named Variables without issue
    
    # In each app module:
    def main(window_dict): # Note change to window_dict from window_frame
        # The number of Tcl variables number no longer increases on main() call:
        print(root.tk.call("info", "vars"))
    
        # Destroy the widgets in the window_frame:
        destroy_widgets(window_dict, 'window_frame')
    
        # Add new widgets to the window_frame:
        master = window_dict['window_frame']['widget']
        widgets = dict(
            child1 = dict(widget = CustomWidget(name='child1', master=master)),
            child2 = dict(widget = CustomWidget(name='child2', master=master)),
            # etc
            )
        # Propagate upwards:
        window_dict.update(**widgets)
    
        # Add the children to the window_frame key:
        if window_dict['window_frame'].get('children') is None:
            window_dict['window_frame']['children'] = []    
        for key in widgets.keys():
            # Change: Don't need to keep track of the variable in the dict:
            # window_dict[key]['variable'] = window_dict[key]['widget'].variable
    
            # Add the (child, child_widget) tuples to the master key:
            window_dict['window_frame']['children'].append(
                (key, window_dict[key]['widget']))
    

    <-SNIP->

    With this, I am considering this issue closed. The Variables are being "unset" properly in Tcl, allowing the same variable names to be reused without the erratic behavior seen before.