_tkinter.TclError: can't delete Tcl command – customtkinter – custom prompt

Question:

What do I need

I am trying to implement a custom Yes / No prompt box with help of tkinter. However I don’t want to use the default messagebox, because I require the following two functionalites:

  • a default value
  • a countdown after which the widget destroys itself and takes the default value as answer

What are the unpredictable errors

I’ve managed to implement these requirements with the code below, however I get some really unpredictable behaviour when using the widgets in the following sense:

  • Sometimes everything works as expected. When I press the buttons, the correct answer is stored, or if I let the countdown time out, the default answer is stored, or if I click the close-window it correctly applies the default value as answer.
  • But then, at times when I click the buttons, I get some wierd errors _tkinter.TclError: invalid command name ".!ctkframe2.!ctkcanvas" (see execution log below for whole stacktrace)

I suspect it has something to do with the timer, since the errors do not always apper when the buttons are pressed. It is really driving me crazy…

example code

# util_gui_classes.py
# -*- coding: utf-8 -*-

"""
Classes which serve for gui applications.
"""

from typing import Any

import tkinter
import tkinter.messagebox
import customtkinter


# ____________________________________________________________________________________________


customtkinter.set_appearance_mode('System')  # Modes: 'System' (standard), 'Dark', 'Light'
customtkinter.set_default_color_theme('blue')  # Themes: 'blue' (standard), 'green', 'dark-blue'


# ____________________________________________________________________________________________


class GuiPromptYesNo(customtkinter.CTk):
    """
    Creates a yes / no gui based prompt with default value and countdown functionality.
    The user input will be stored in:
    > instance.answer
    """
    WIDTH = 500
    HEIGHT = 200

    def __init__(self, question: str, default_value: str = 'no', countdown_seconds: int = 0):
        super().__init__()

        self.title('input required')
        self.geometry(f'{self.__class__.WIDTH}x{self.__class__.HEIGHT}')
        self.protocol('WM_DELETE_WINDOW', self.on_closing)  # call .on_closing() when app gets closed
        self.resizable(False, False)

        self.question = question
        self.answer = None
        self.default_value = default_value
        self.countdown_seconds = countdown_seconds
        self.remaining_seconds = countdown_seconds

        # ============ create top-level-frames ============

        # configure grid layout (4x1)
        self.equal_weighted_grid(self, 4, 1)
        self.grid_rowconfigure(0, minsize=10)
        self.grid_rowconfigure(3, minsize=10)

        self.frame_label = customtkinter.CTkFrame(master=self, corner_radius=10)
        self.frame_label.grid(row=1, column=0)

        self.frame_buttons = customtkinter.CTkFrame(master=self, corner_radius=0, fg_color=None)
        self.frame_buttons.grid(row=2, column=0)

        # ============ design frame_label ============

        # configure grid layout (5x4)
        self.equal_weighted_grid(self.frame_label, 5, 4)
        self.frame_label.grid_rowconfigure(0, minsize=10)
        self.frame_label.grid_rowconfigure(2, minsize=10)
        self.frame_label.grid_rowconfigure(5, minsize=10)

        self.label_question = customtkinter.CTkLabel(
            master=self.frame_label,
            text=self.question,
            text_font=('Consolas',),
        )
        self.label_question.grid(row=1, column=0, columnspan=4, pady=5, padx=10)

        self.label_default_value = customtkinter.CTkLabel(
            master=self.frame_label,
            text='default value: ',
            text_font=('Consolas',),
        )
        self.label_default_value.grid(row=3, column=0, pady=5, padx=10)

        self.entry_default_value = customtkinter.CTkEntry(
            master=self.frame_label,
            width=40,
            justify='center',
            placeholder_text=self.default_value,
            state='disabled',
            textvariable=tkinter.StringVar(value=self.default_value),
            text_font=('Consolas',),
        )
        self.entry_default_value.grid(row=3, column=1, pady=5, padx=10)

        if countdown_seconds > 0:
            self.label_timer = customtkinter.CTkLabel(
                master=self.frame_label,
                text='timer [s]: ',
                text_font=('Consolas',),
            )
            self.label_timer.grid(row=3, column=2, pady=5, padx=10)

            self.entry_timer = customtkinter.CTkEntry(
                master=self.frame_label,
                width=40,
                justify='center',
                state='disabled',
                textvariable=tkinter.StringVar(value=str(self.remaining_seconds)),
                placeholder_text=str(self.remaining_seconds),
                text_font=('Consolas',),
            )
            self.entry_timer.grid(row=3, column=3, pady=5, padx=10)

        # ============ design frame_buttons ============

        # configure grid layout (3x2)
        self.equal_weighted_grid(self.frame_buttons, 3, 2)
        self.frame_buttons.grid_rowconfigure(0, minsize=10)
        self.frame_buttons.grid_rowconfigure(2, minsize=10)

        self.button_yes = customtkinter.CTkButton(
            master=self.frame_buttons,
            text='yes',
            text_font=('Consolas',),
            command=lambda: self.button_event('yes'),
        )
        self.button_yes.grid(row=1, column=0, pady=5, padx=20)

        self.button_no = customtkinter.CTkButton(
            master=self.frame_buttons,
            text='no',
            text_font=('Consolas',),
            command=lambda: self.button_event('no'),
        )
        self.button_no.grid(row=1, column=1, pady=5, padx=20)

        if self.countdown_seconds > 0:
            self.countdown()

        self.attributes('-topmost', True)
        self.mainloop()

    # __________________________________________________________
    # methods

    @staticmethod
    def equal_weighted_grid(obj: Any, rows: int, cols: int):
        """configures the grid to be of equal cell sizes for rows and columns."""
        for row in range(rows):
            obj.grid_rowconfigure(row, weight=1)
        for col in range(cols):
            obj.grid_columnconfigure(col, weight=1)

    def button_event(self, answer):
        """Stores the user input as instance attribute `answer`."""
        self.answer = answer
        self.terminate()

    def countdown(self):
        """Sets the timer for the question."""
        if self.answer is not None:
            self.terminate()
        elif self.remaining_seconds < 0:
            self.answer = self.default_value
            self.terminate()
        else:
            self.entry_timer.configure(textvariable=tkinter.StringVar(value=str(self.remaining_seconds)))
            self.remaining_seconds -= 1
            self.after(1000, self.countdown)

    def stop_after_callbacks(self):
        """Stops all after callbacks on the root."""
        for after_id in self.tk.eval('after info').split():
            self.after_cancel(after_id)

    def on_closing(self, event=0):
        """If the user presses the window x button without providing input"""
        if self.answer is None and self.default_value is not None:
            self.answer = self.default_value
        self.terminate()

    def terminate(self):
        """Properly terminates the gui."""
        # stop all .after callbacks to avoid error message "Invalid command ..." after destruction
        self.stop_after_callbacks()

        self.destroy()


# ____________________________________________________________________________________________


if __name__ == '__main__':
    print('n', 'do some python stuff before', 'n', sep='')

    q1 = GuiPromptYesNo(question='1. do you want to proceed?', countdown_seconds=5)
    print(f'>>>{q1.answer=}')

    print('n', 'do some python stuff in between', 'n', sep='')

    q2 = GuiPromptYesNo(question='2. do you want to proceed?', countdown_seconds=5)
    print(f'>>>{q2.answer=}')

    print('n', 'do some python stuff at the end', 'n', sep='')


# ____________________________________________________________________________________________

execution log with errors

The first three tests where successful (clicking buttons included), after that the errors appeared.

(py311) C:UsersuserPycharmProjectsSandboxgui_tools>python util_guis.py

 do some python stuff before 

q1.answer='yes'

 do some python stuff in between 

q2.answer='no'

 do some python stuff at the end 


(py311) C:UsersuserPycharmProjectsSandboxgui_tools>python util_guis.py

 do some python stuff before

q1.answer='yes'

 do some python stuff in between

q2.answer='yes'

 do some python stuff at the end


(py311) C:UsersuserPycharmProjectsSandboxgui_tools>python util_guis.py

 do some python stuff before

q1.answer='no'

 do some python stuff in between

q2.answer='no'

 do some python stuff at the end


(py311) C:UsersuserPycharmProjectsSandboxgui_tools>python util_guis.py

do some python stuff before

>>>q1.answer='yes'

do some python stuff in between

Exception in Tkinter callback
Traceback (most recent call last):
  File "C:Program FilesPython311Libtkinter__init__.py", line 1948, in __call__
    return self.func(*args)
           ^^^^^^^^^^^^^^^^
  File "C:Program FilesPython311Libtkinter__init__.py", line 861, in callit
    func(*args)
  File "C:UsersuserPycharmProjectsSandboxgui_toolsutil_guis.py", line 197, in countdown
    self.terminate()
  File "C:UsersuserPycharmProjectsSandboxgui_toolsutil_guis.py", line 224, in terminate
    child.destroy()
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwidgetswidget_base_class.py", line 85, in destroy
    super().destroy()
  File "C:Program FilesPython311Libtkinter__init__.py", line 2635, in destroy
    for c in list(self.children.values()): c.destroy()
                                           ^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwidgetswidget_base_class.py", line 85, in destroy
    super().destroy()
  File "C:Program FilesPython311Libtkinter__init__.py", line 2639, in destroy
    Misc.destroy(self)
  File "C:Program FilesPython311Libtkinter__init__.py", line 687, in destroy
    self.tk.deletecommand(name)
_tkinter.TclError: can't delete Tcl command
>>>q2.answer='no'

do some python stuff at the end


(py311) C:UsersuserPycharmProjectsSandboxgui_tools>python util_guis.py

do some python stuff before

Exception in Tkinter callback
Traceback (most recent call last):
  File "C:Program FilesPython311Libtkinter__init__.py", line 1948, in __call__
    return self.func(*args)
           ^^^^^^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwidgetsctk_button.py", line 377, in clicked
    self.command()
  File "C:UsersuserPycharmProjectsSandboxgui_toolsutil_guis.py", line 124, in <lambda>
    command=lambda: self.button_event('yes'),
                    ^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:UsersuserPycharmProjectsSandboxgui_toolsutil_guis.py", line 156, in button_event
    self.terminate()
  File "C:UsersuserPycharmProjectsSandboxgui_toolsutil_guis.py", line 186, in terminate
    self.destroy()
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwindowsctk_tk.py", line 108, in destroy
    super().destroy()
  File "C:Program FilesPython311Libtkinter__init__.py", line 2367, in destroy
    for c in list(self.children.values()): c.destroy()
                                           ^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwidgetswidget_base_class.py", line 85, in destroy
    super().destroy()
  File "C:Program FilesPython311Libtkinter__init__.py", line 2635, in destroy
    for c in list(self.children.values()): c.destroy()
                                           ^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwidgetswidget_base_class.py", line 85, in destroy
    super().destroy()
  File "C:Program FilesPython311Libtkinter__init__.py", line 2639, in destroy
    Misc.destroy(self)
  File "C:Program FilesPython311Libtkinter__init__.py", line 687, in destroy
    self.tk.deletecommand(name)
_tkinter.TclError: can't delete Tcl command
Exception in Tkinter callback
Traceback (most recent call last):
  File "C:Program FilesPython311Libtkinter__init__.py", line 1948, in __call__
    return self.func(*args)
           ^^^^^^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwidgetswidget_base_class.py", line 142, in update_dimensions_event
    self.draw(no_color_updates=True)  # faster drawing without color changes
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwidgetsctk_frame.py", line 80, in draw
    requires_recoloring = self.draw_engine.draw_rounded_rect_with_border(self.apply_widget_scaling(self._current_width),
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterdraw_engine.py", line 88, in draw_rounded_rect_with_border
    return self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, ())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterdraw_engine.py", line 207, in __draw_rounded_rect_with_border_font_shapes
    self._canvas.delete("border_parts")
  File "C:Program FilesPython311Libtkinter__init__.py", line 2879, in delete
    self.tk.call((self._w, 'delete') + args)
_tkinter.TclError: invalid command name ".!ctkframe2.!ctkcanvas"
>>>q1.answer='yes'

do some python stuff in between

Exception in Tkinter callback
Traceback (most recent call last):
  File "C:Program FilesPython311Libtkinter__init__.py", line 1948, in __call__
    return self.func(*args)
           ^^^^^^^^^^^^^^^^
  File "C:Usersuserpythonshared_venvspy311Libsite-packagescustomtkinterwidgetsctk_button.py", line 377, in clicked
    self.command()
    super().destroy()
  File "C:Program FilesPython311Libtkinter__init__.py", line 2639, in destroy
    Misc.destroy(self)
  File "C:Program FilesPython311Libtkinter__init__.py", line 687, in destroy
    self.tk.deletecommand(name)
_tkinter.TclError: can't delete Tcl command
>>>q2.answer='no'

do some python stuff at the end


(py311) C:UsersuserPycharmProjectsSandboxgui_tools>

requirements

I am using Windows 11 as os and have a virtual python 3.11 environment with customtkinter installed.

EDIT:

With the help of @Thingamabobs answer I managed to achive the expected behaviour without getting the errors. Here is the final code:

# util_gui_classes.py
# -*- coding: utf-8 -*-

"""
Classes which serve for gui applications.
"""

from typing import Any

import tkinter
import tkinter.messagebox
import customtkinter
from _tkinter import TclError


# _______________________________________________________________________


customtkinter.set_appearance_mode('System')  # Modes: 'System' (standard), 'Dark', 'Light'
customtkinter.set_default_color_theme('blue')  # Themes: 'blue' (standard), 'green', 'dark-blue'


# _______________________________________________________________________


class GuiPromptYesNo(customtkinter.CTk):
    """
    Creates a yes / no gui based prompt with default value and countdown functionality.
    The user input will be stored in:
    >>> instance.answer
    """
    WIDTH = 500
    HEIGHT = 200

    def __init__(self, question: str, default_value: str = 'no', countdown_seconds: int = 0):
        super().__init__()
        self.terminated = False

        self.title('input required')
        self.geometry(f'{self.__class__.WIDTH}x{self.__class__.HEIGHT}')
        self.protocol('WM_DELETE_WINDOW', self.on_closing)  # call .on_closing() when app gets closed
        self.resizable(False, False)

        self.question = question
        self.answer = None
        self.default_value = default_value
        self.countdown_seconds = countdown_seconds
        self.remaining_seconds = countdown_seconds

        # ============ create top-level-frames ============

        # configure grid layout (4x1)
        self.equal_weighted_grid(self, 4, 1)
        self.grid_rowconfigure(0, minsize=10)
        self.grid_rowconfigure(3, minsize=10)

        self.frame_label = customtkinter.CTkFrame(master=self, corner_radius=10)
        self.frame_label.grid(row=1, column=0)

        self.frame_buttons = customtkinter.CTkFrame(master=self, corner_radius=0, fg_color=None)
        self.frame_buttons.grid(row=2, column=0)

        # ============ design frame_label ============

        # configure grid layout (5x4)
        self.equal_weighted_grid(self.frame_label, 5, 4)
        self.frame_label.grid_rowconfigure(0, minsize=10)
        self.frame_label.grid_rowconfigure(2, minsize=10)
        self.frame_label.grid_rowconfigure(5, minsize=10)

        self.label_question = customtkinter.CTkLabel(
            master=self.frame_label,
            text=self.question,
            text_font=('Consolas',),
        )
        self.label_question.grid(row=1, column=0, columnspan=4, pady=5, padx=10)

        self.label_default_value = customtkinter.CTkLabel(
            master=self.frame_label,
            text='default value: ',
            text_font=('Consolas',),
        )
        self.label_default_value.grid(row=3, column=0, pady=5, padx=10)

        self.entry_default_value = customtkinter.CTkEntry(
            master=self.frame_label,
            width=40,
            justify='center',
            placeholder_text=self.default_value,
            state='disabled',
            textvariable=tkinter.StringVar(value=self.default_value),
            text_font=('Consolas',),
        )
        self.entry_default_value.grid(row=3, column=1, pady=5, padx=10)

        if countdown_seconds > 0:
            self.label_timer = customtkinter.CTkLabel(
                master=self.frame_label,
                text='timer [s]: ',
                text_font=('Consolas',),
            )
            self.label_timer.grid(row=3, column=2, pady=5, padx=10)

            self.entry_timer = customtkinter.CTkEntry(
                master=self.frame_label,
                width=40,
                justify='center',
                state='disabled',
                textvariable=tkinter.StringVar(value=str(self.remaining_seconds)),
                placeholder_text=str(self.remaining_seconds),
                text_font=('Consolas',),
            )
            self.entry_timer.grid(row=3, column=3, pady=5, padx=10)

        # ============ design frame_buttons ============

        # configure grid layout (3x2)
        self.equal_weighted_grid(self.frame_buttons, 3, 2)
        self.frame_buttons.grid_rowconfigure(0, minsize=10)
        self.frame_buttons.grid_rowconfigure(2, minsize=10)

        self.button_yes = customtkinter.CTkButton(
            master=self.frame_buttons,
            text='yes',
            text_font=('Consolas',),
            command=lambda: self.button_event('yes'),
        )
        self.button_yes.grid(row=1, column=0, pady=5, padx=20)

        self.button_no = customtkinter.CTkButton(
            master=self.frame_buttons,
            text='no',
            text_font=('Consolas',),
            command=lambda: self.button_event('no'),
        )
        self.button_no.grid(row=1, column=1, pady=5, padx=20)

        if self.countdown_seconds > 0:
            self.countdown()

        self.attributes('-topmost', True)
        self.mainloop()

    # __________________________________________________________
    # methods

    @staticmethod
    def equal_weighted_grid(obj: Any, rows: int, cols: int):
        """configures the grid to be of equal cell sizes for rows and columns."""
        for row in range(rows):
            obj.grid_rowconfigure(row, weight=1)
        for col in range(cols):
            obj.grid_columnconfigure(col, weight=1)

    def button_event(self, answer):
        """Stores the user input as instance attribute `answer`."""
        self.answer = answer
        self.terminate()

    def countdown(self):
        """Sets the timer for the question."""
        if self.answer is not None:
            self.terminate()
        elif self.remaining_seconds < 0:
            self.answer = self.default_value
            self.terminate()
        else:
            self.entry_timer.configure(textvariable=tkinter.StringVar(value=str(self.remaining_seconds)))
            self.remaining_seconds -= 1
            self.after(1000, self.countdown)

    def stop_after_callbacks(self):
        """Stops all after callbacks on the root."""
        for after_id in self.tk.eval('after info').split():
            self.after_cancel(after_id)

    def on_closing(self, event=0):
        """If the user presses the window x button without providing input"""
        if self.answer is None and self.default_value is not None:
            self.answer = self.default_value
        self.terminate()

    def terminate(self):
        """Properly terminates the gui."""
        # stop all .after callbacks to avoid error message "Invalid command ..." after destruction
        self.stop_after_callbacks()

        if not self.terminated:
            self.terminated = True
            try:
                self.destroy()
            except TclError:
                self.destroy()


# _______________________________________________________________________


if __name__ == '__main__':
    print('before')

    q1 = GuiPromptYesNo(question='1. do you want to proceed?', countdown_seconds=5)
    print(f'>>>{q1.answer=}')

    print('between')

    q2 = GuiPromptYesNo(question='2. do you want to proceed?', countdown_seconds=5)
    print(f'>>>{q2.answer=}')

    print('after')


# _______________________________________________________________________

BTW: the class can also be found in my package utils_nm inside the module util_gui_classes.

Asked By: N. Maks

||

Answers:

While I don’t have Ctk to give you the exact code. I can tell you exactly what is wrong and how you need to solve it.

You have self repeating function via after here:

def countdown(self):
        """Sets the timer for the question."""
        if self.answer is not None:
            self.terminate()
        elif self.remaining_seconds < 0:
            self.answer = self.default_value
            self.terminate()
        else:
            self.entry_timer.configure(textvariable=tkinter.StringVar(value=str(self.remaining_seconds)))
            self.remaining_seconds -= 1
            self.after(1000, self.countdown)

The problem that you are facing is that you try to delete the window that has already been destroyed in an after call. So depending on which event is quicker you are running into an error or not.

Why does this happen?

When ever you give an instruction regardless of what it is (with a few exceptions e.g update), it is placed in a event queue and scheduled in some sort of FIFO (first in, first out). So the oldest event gets processed. That means you can try to cancel an alarm but running the alarm before you actual cancel it.

How to solve?

There are different approaches available. The easiest and cleanest solution, in my opinion, is to set a flag and avoid trying to destroy an already destroyed Window.
Something like:

    def __init__(self, question: str, default_value: str = 'no', countdown_seconds: int = 0):
        super().__init__()
        self.terminated = False

and set it like:

def terminate(self):
    """Properly terminates the gui."""
    # stop all .after callbacks to avoid error message "Invalid command ..." after destruction
    #self.stop_after_callbacks() shouldn't be needed
    if not self.terminated:
        self.terminated = True
        self.destroy()

In addition to the above proposal I suggest you to set up a protocol handler for WM_DELETE_WINDOW and set the flag, since it probably also occur by destroying the window via the window manager.


A different approach is a try and except block in terminate where you catch except _tkinter.TclError:. But note the underscore, the module is not intended to be used and it can change in future and might break your app again, even if this seems unlikely.

Answered By: Thingamabobs
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.