From 3afa68bf1d014e52dc22b855228101dbec611ec1 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 29 Jun 2026 13:46:19 +0300 Subject: [PATCH] gh-40440: Add a timeout parameter to tkinter wait_variable() Without a timeout wait_variable() blocks forever if the variable is never set, for example because the window that would set it was destroyed. The new keyword-only timeout parameter bounds the wait and returns whether the variable was modified. Co-Authored-By: Claude Opus 4.8 --- Doc/library/tkinter.rst | 11 +++- Doc/whatsnew/3.16.rst | 5 ++ Lib/test/test_tkinter/test_misc.py | 18 +++++- Lib/tkinter/__init__.py | 56 ++++++++++++++++++- ...6-06-29-10-41-13.gh-issue-40440.Kt9rXm.rst | 3 + 5 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-29-10-41-13.gh-issue-40440.Kt9rXm.rst diff --git a/Doc/library/tkinter.rst b/Doc/library/tkinter.rst index 8c4678b4b23c77..3893c89c0dac02 100644 --- a/Doc/library/tkinter.rst +++ b/Doc/library/tkinter.rst @@ -1524,18 +1524,25 @@ Base and mixin classes This updates the display of windows, for example after geometry changes, but does not process events caused by the user. - .. method:: waitvar(name) + .. method:: waitvar(name, *, timeout=None) :no-typesetting: - .. method:: wait_variable(name) + .. method:: wait_variable(name, *, timeout=None) Wait until the Tcl variable *name* is modified, continuing to process events in the meantime so that the application stays responsive. *name* is usually a :class:`Variable` instance, such as an :class:`IntVar` or :class:`StringVar`. + If *timeout* is given, it is the maximum time to wait in seconds. + Return ``True`` if the variable was modified, or ``False`` if the timeout elapsed before that (or the application was destroyed while waiting). + Without a *timeout* the call blocks until the variable is modified and always returns ``True``. + :meth:`waitvar` is an alias of :meth:`!wait_variable`. + .. versionchanged:: next + Added the *timeout* parameter and the return value. + .. method:: wait_window(window=None) Wait until *window* is destroyed, continuing to process events in the diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 8407d0df325619..92b1d668040b2f 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -371,6 +371,11 @@ tkinter ttk version, and accepts mappings of button options as *buttons* entries. (Contributed by Serhiy Storchaka in :gh:`59396`.) +* The :meth:`~tkinter.Misc.wait_variable` method now accepts an optional + keyword-only *timeout* argument that bounds how long it waits, instead of + blocking indefinitely. + (Contributed by Serhiy Storchaka in :gh:`40440`.) + xml --- diff --git a/Lib/test/test_tkinter/test_misc.py b/Lib/test/test_tkinter/test_misc.py index e6221f7089704d..da5c65cefa3420 100644 --- a/Lib/test/test_tkinter/test_misc.py +++ b/Lib/test/test_tkinter/test_misc.py @@ -3,6 +3,7 @@ import platform import sys import textwrap +import time import unittest import weakref import tkinter @@ -600,11 +601,26 @@ def test_wait_variable(self): var = tkinter.StringVar(self.root) self.assertEqual(self.root.waitvar, self.root.wait_variable) self.root.after(1, var.set, 'done') - self.root.wait_variable(var) # Returns once the variable is set. + self.assertIs(self.root.wait_variable(var), True) # Returns once set. self.assertEqual(var.get(), 'done') # The name is required (gh-152587). self.assertRaises(TypeError, self.root.wait_variable) + def test_wait_variable_timeout(self): + var = tkinter.StringVar(self.root) + # The variable is set before the timeout elapses. + self.root.after(1, var.set, 'done') + self.assertIs( + self.root.wait_variable(var, timeout=support.SHORT_TIMEOUT), True) + self.assertEqual(var.get(), 'done') + # The variable is never set: give up when the timeout elapses instead + # of blocking forever (gh-40440). + var.set('') + start = time.monotonic() + self.assertIs(self.root.wait_variable(var, timeout=0.2), False) + self.assertGreaterEqual(time.monotonic() - start, 0.2) + self.assertEqual(var.get(), '') + def test_wait_window(self): top = tkinter.Toplevel(self.root) self.root.after(1, top.destroy) diff --git a/Lib/tkinter/__init__.py b/Lib/tkinter/__init__.py index 83d11a7695ffbe..f6921274ab4bb4 100644 --- a/Lib/tkinter/__init__.py +++ b/Lib/tkinter/__init__.py @@ -818,12 +818,62 @@ def tk_inactive(self, reset=False, *, displayof=0): else: return self.tk.getint(self.tk.call(args)) - def wait_variable(self, name): + def wait_variable(self, name, *, timeout=None): """Wait until the variable is modified. A parameter of type IntVar, StringVar, DoubleVar or - BooleanVar must be given.""" - self.tk.call('tkwait', 'variable', name) + BooleanVar must be given. + + If timeout is given, it specifies the maximum time to wait in + seconds. Return True if the variable was modified, or False if + the timeout elapsed before that (or the application was destroyed + while waiting). Without a timeout the call blocks until the + variable is modified and always returns True.""" + if timeout is None: + self.tk.call('tkwait', 'variable', name) + return True + + name = str(name) + done = [] + timed_out = [] + cb = self.register(lambda *args: done.append(True)) + try: + self.tk.call('trace', 'add', 'variable', name, + ('write', 'unset'), (cb,)) + try: + # A one-shot timer guarantees that dooneevent() wakes up at the + # deadline even if no other event arrives. + timer = self.after(max(int(timeout * 1000), 1), + lambda: timed_out.append(True)) + try: + while not done and not timed_out: + self.tk.dooneevent(_tkinter.ALL_EVENTS) + # Stop instead of blocking forever if the application was + # destroyed (which also cancels our timer) while waiting. + if not self.tk.getboolean( + self.tk.call('winfo', 'exists', '.')): + break + return bool(done) + except TclError: + # the interpreter or application was torn down + return bool(done) + finally: + # Cleanup may fail if the application was torn down. + try: + self.after_cancel(timer) + except TclError: + pass + finally: + try: + self.tk.call('trace', 'remove', 'variable', name, + ('write', 'unset'), cb) + except TclError: + pass + finally: + try: + self.deletecommand(cb) + except TclError: + pass waitvar = wait_variable # XXX b/w compat def wait_window(self, window=None): diff --git a/Misc/NEWS.d/next/Library/2026-06-29-10-41-13.gh-issue-40440.Kt9rXm.rst b/Misc/NEWS.d/next/Library/2026-06-29-10-41-13.gh-issue-40440.Kt9rXm.rst new file mode 100644 index 00000000000000..faba3fa4df642d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-29-10-41-13.gh-issue-40440.Kt9rXm.rst @@ -0,0 +1,3 @@ +Add an optional *timeout* parameter to :meth:`tkinter.Misc.wait_variable`. +If the variable is not modified within the given time, the call returns +``False`` instead of blocking indefinitely.