pythontkintercallback

Retrieve/get back command callback function from TKinter widget


I am (for some elaborate setup reasons) trying to retrieve the actual command callback function from tkinter widgets, for example setting up a callback for a button b

import tkinter as tk
root = tk.Tk()
b = tk.Button(root, text='btn', command=lambda:print('foo'))

both

b['command']
b.cget('command')

which I think both are equivalent to

b.tk.call(b._w, 'cget', '-command')

will only return a string like "2277504761920<lambda\>" and not the actual command function. Is there a way to get the actual callback function?


Solution

  • Looking at tkinter.__init__.py:

    class BaseWidget:
        ...
        def _register(self, func, subst=None, needcleanup=1):
            """Return a newly created Tcl function. If this
            function is called, the Python function FUNC will
            be executed. An optional function SUBST can
            be given which will be executed before FUNC."""
            f = CallWrapper(func, subst, self).__call__
            name = repr(id(f))
            try:
                func = func.__func__
            except AttributeError:
                pass
            try:
                name = name + func.__name__
            except AttributeError:
                pass
            self.tk.createcommand(name, f)
            if needcleanup:
                if self._tclCommands is None:
                    self._tclCommands = []
                self._tclCommands.append(name)
            return name
    

    and

    class CallWrapper:
        """Internal class. Stores function to call when some user
        defined Tcl function is called e.g. after an event occurred."""
        def __init__(self, func, subst, widget):
            """Store FUNC, SUBST and WIDGET as members."""
            self.func = func
            self.subst = subst
            self.widget = widget
        def __call__(self, *args):
            """Apply first function SUBST to arguments, than FUNC."""
            try:
                if self.subst:
                    args = self.subst(*args)
                return self.func(*args)
            except SystemExit:
                raise
            except:
                self.widget._report_exception()
    

    We get that tkinter wraps the function in the CallWrapper class. That means that if we get all of the CallWrapper objects we can recover the function. Using @hussic's suggestion of monkey patching the CallWrapper class with a class that is easier to work with, we can easily get all of the CallWrapper objects.

    This is my solution implemented with @hussic's suggestion:

    import tkinter as tk
    
    tk.call_wappers = [] # A list of all of the `MyCallWrapper` objects
    
    class MyCallWrapper:
        __slots__ = ("func", "subst", "__call__")
    
        def __init__(self, func, subst, widget):
            # We aren't going to use `widget` because that can take space
            # and we have a memory leak problem
            self.func = func
            self.subst = subst
            # These are the 2 lines I added:
            # First one appends this object to the list defined up there
            # the second one uses lambda because python can be tricky if you
            # use `id(<object>.<function>)`.
            tk.call_wappers.append(self)
            self.__call__ = lambda *args: self.call(*args)
    
        def call(self, *args):
            """Apply first function SUBST to arguments, than FUNC."""
            try:
                if self.subst:
                    args = self.subst(*args)
                return self.func(*args)
            except SystemExit:
                raise
            except:
                if tk._default_root is None:
                    raise
                else:
                    tk._default_root._report_exception()
    
    tk.CallWrapper = MyCallWrapper # Monkey patch tkinter
    
    # If we are going to monkey patch `tk.CallWrapper` why not also `tk.getcommand`?
    def getcommand(name):
        for call_wapper in tk.call_wappers:
            candidate_name = repr(id(call_wapper.__call__))
            if name.startswith(candidate_name):
                return call_wapper.func
        return None
    
    tk.getcommand = getcommand
    
    
    # This is the testing code:
    def myfunction():
        print("Hi")
    
    root = tk.Tk()
    
    button = tk.Button(root, text="Click me", command=myfunction)
    button.pack()
    
    commandname = button.cget("command")
    # This is how we are going to get the function into our variable:
    myfunction_from_button = tk.getcommand(commandname)
    print(myfunction_from_button)
    
    root.mainloop()
    

    As @hussic said in the comments there is a problem that the list (tk.call_wappers) is only being appended to. THe problem will be apparent if you have a .after tkinter loop as each time .after is called an object will be added to the list. To fix this you might want to manually clear the list using tk.call_wappers.clear(). I changed it to use the __slots__ feature to make sure that it doesn't take a lot of space but that doesn't solve the problem.