https://github.com/python/cpython/commit/7d6fc2bb9ab6c3f2a8bc7e623b281d48e954a741 commit: 7d6fc2bb9ab6c3f2a8bc7e623b281d48e954a741 branch: 3.14 author: Miss Islington (bot) <[email protected]> committer: serhiy-storchaka <[email protected]> date: 2026-06-28T17:38:01Z summary:
[3.14] gh-69134: Harden tkinter GUI tests that depend on a mapped widget (GH-152499) (GH-152517) Add wait_until_mapped() and AbstractTkTest.require_mapped() to test_tkinter.support and use them to guard the assertions that need a widget to be actually mapped (winfo_width(), identify(), coords(), ...). This avoids intermittent failures under window managers that do not map the widget promptly, without skipping the unrelated checks. (cherry picked from commit 0fff6bd86cf0224152c509e295d3cbbd209098f3) Co-authored-by: Serhiy Storchaka <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]> files: M Lib/test/test_tkinter/support.py M Lib/test/test_tkinter/test_widgets.py M Lib/test/test_ttk/test_extensions.py M Lib/test/test_ttk/test_widgets.py diff --git a/Lib/test/test_tkinter/support.py b/Lib/test/test_tkinter/support.py index 7fb0b217fd24ec..df0cca95a33a18 100644 --- a/Lib/test/test_tkinter/support.py +++ b/Lib/test/test_tkinter/support.py @@ -1,4 +1,5 @@ import functools +import time import tkinter import unittest from test import support @@ -45,6 +46,20 @@ def tearDown(self): w.destroy() self.root.withdraw() + def require_mapped(self, widget, timeout=None): + """Realize *widget*, or skip the test if the window manager will + not map it (e.g. a tiling WM or a headless/contended display). + + Use this instead of a bare update() before querying realized + geometry (winfo_width(), identify(), coords(), place_info(), ...). + See gh-69134, gh-74941 and bpo-40722. + """ + if timeout is None: + timeout = support.LOOPBACK_TIMEOUT + if not wait_until_mapped(widget, timeout): + self.skipTest('widget was not mapped by the window manager ' + f'(timed out after {timeout:g}s)') + class AbstractDefaultRootTest: @@ -78,6 +93,32 @@ def destroy_default_root(): tkinter._default_root.destroy() tkinter._default_root = None +def wait_until_mapped(widget, timeout=None): + """Wait until *widget* is actually mapped and laid out by the window + manager, so that realized-geometry queries (winfo_width(), identify(), + coords(), ...) return meaningful values. + + Return True once the widget is mapped with a non-trivial size, or False + if that has not happened within *timeout* seconds (default: + ``support.LOOPBACK_TIMEOUT``). Unlike Misc.wait_visibility(), this + never blocks indefinitely, so it is safe under a window manager that + never maps the window (see gh-69134, gh-74941, bpo-40722). + """ + if timeout is None: + timeout = support.LOOPBACK_TIMEOUT + deadline = time.monotonic() + timeout + widget.update_idletasks() + while True: + widget.update() # drain pending Map/Configure events + if (widget.winfo_ismapped() + and widget.winfo_width() > 1 + and widget.winfo_height() > 1): + return True + if time.monotonic() >= deadline: + return False + time.sleep(0.01) + + def simulate_mouse_click(widget, x, y): """Generate proper events to click at the x, y position (tries to act like an X server).""" diff --git a/Lib/test/test_tkinter/test_widgets.py b/Lib/test/test_tkinter/test_widgets.py index 4b51d219d87e5b..f5481e7712dad4 100644 --- a/Lib/test/test_tkinter/test_widgets.py +++ b/Lib/test/test_tkinter/test_widgets.py @@ -7,6 +7,7 @@ from test.test_tkinter.support import setUpModule # noqa: F401 from test.test_tkinter.support import (requires_tk, tk_version, get_tk_patchlevel, widget_eq, + wait_until_mapped, AbstractDefaultRootTest) from test.test_tkinter.widget_tests import ( @@ -754,10 +755,11 @@ def test_invoke(self): def test_identify(self): widget = self.create() widget.pack() - widget.update_idletasks() - # The empty string is returned for a point over no element. - self.assertIn(widget.identify(5, 5), - ('entry', 'buttonup', 'buttondown', 'none', '')) + # Identifying the element under a point requires the widget to be + # mapped with a real size. + if wait_until_mapped(widget): + self.assertIn(widget.identify(5, 5), + ('entry', 'buttonup', 'buttondown', 'none')) self.assertRaises(TclError, widget.identify, 'a', 'b') def test_scan(self): @@ -2096,9 +2098,11 @@ def test_delta(self): def test_identify(self): sb = self.create() sb.pack(fill='y', expand=True) - sb.update_idletasks() - self.assertIn(sb.identify(5, 5), - ('arrow1', 'arrow2', 'slider', 'trough1', 'trough2', '')) + # Identifying the element under a point requires the widget to be + # mapped with a real size. + if wait_until_mapped(sb): + self.assertIn(sb.identify(5, 5), + ('arrow1', 'arrow2', 'slider', 'trough1', 'trough2')) self.assertRaises(TclError, sb.identify, 'a', 'b') @@ -2218,10 +2222,12 @@ def test_identify(self): p, b, c = self.create2() p.configure(width=200, height=50) p.pack() - p.update() - x, y = p.sash_coord(0) - # A point over the sash reports the sash. - self.assertIn('sash', p.identify(x + 1, y + 5)) + # Locating the sash requires the widget to be mapped with a real + # size; the rest of the checks do not. + if wait_until_mapped(p): + x, y = p.sash_coord(0) + # A point over the sash reports the sash. + self.assertIn('sash', p.identify(x + 1, y + 5)) # A point over a pane reports nothing. self.assertFalse(p.identify(2, 2)) self.assertRaises(TclError, p.identify, 'a', 'b') diff --git a/Lib/test/test_ttk/test_extensions.py b/Lib/test/test_ttk/test_extensions.py index fb6bd24b4b9147..f6ca5e976109fc 100644 --- a/Lib/test/test_ttk/test_extensions.py +++ b/Lib/test/test_ttk/test_extensions.py @@ -109,7 +109,7 @@ def check_positions(scale, scale_pos, label, label_pos): def test_horizontal_range(self): lscale = ttk.LabeledScale(self.root, from_=0, to=10) lscale.pack() - lscale.update() + self.require_mapped(lscale) linfo_1 = lscale.label.place_info() prev_xcoord = lscale.scale.coords()[0] @@ -138,7 +138,7 @@ def test_horizontal_range(self): def test_variable_change(self): x = ttk.LabeledScale(self.root) x.pack() - x.update() + self.require_mapped(x) curr_xcoord = x.scale.coords()[0] newval = x.value + 1 @@ -181,7 +181,7 @@ def test_resize(self): x = ttk.LabeledScale(self.root) x.pack(expand=True, fill='both') gc_collect() # For PyPy or other GCs. - x.update() + self.require_mapped(x) width, height = x.master.winfo_width(), x.master.winfo_height() width_new, height_new = width * 2, height * 2 diff --git a/Lib/test/test_ttk/test_widgets.py b/Lib/test/test_ttk/test_widgets.py index aef15a7137a06e..5c50fb659d47de 100644 --- a/Lib/test/test_ttk/test_widgets.py +++ b/Lib/test/test_ttk/test_widgets.py @@ -8,7 +8,7 @@ from test.test_tkinter.support import setUpModule # noqa: F401 from test.test_tkinter.support import ( AbstractTkTest, requires_tk, tk_version, get_tk_patchlevel, - simulate_mouse_click, AbstractDefaultRootTest) + simulate_mouse_click, wait_until_mapped, AbstractDefaultRootTest) from test.test_tkinter.widget_tests import (add_configure_tests, AbstractWidgetTest, StandardOptionsTests, IntegerSizeTests, PixelSizeTests) @@ -78,11 +78,13 @@ def setUp(self): self.widget.pack() def test_identify(self): - self.widget.update() - self.assertEqual(self.widget.identify( - int(self.widget.winfo_width() / 2), - int(self.widget.winfo_height() / 2) - ), "label") + # Identifying the element under a point requires the widget to be + # mapped with a real size; the rest of the checks do not. + if wait_until_mapped(self.widget): + self.assertEqual(self.widget.identify( + int(self.widget.winfo_width() / 2), + int(self.widget.winfo_height() / 2) + ), "label") self.assertEqual(self.widget.identify(-1, -1), "") self.assertRaises(tkinter.TclError, self.widget.identify, None, 5) @@ -385,9 +387,11 @@ def test_identify(self): self.skipTest('Test does not work on macOS Tk 9.') # https://core.tcl-lang.org/tk/tktview/8b49e9cfa6 self.entry.pack() - self.root.update() - self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS) + # Identifying the element under a point requires the widget to be + # mapped with a real size; the rest of the checks do not. + if wait_until_mapped(self.entry): + self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS) self.assertEqual(self.entry.identify(-1, -1), "") self.assertRaises(tkinter.TclError, self.entry.identify, None, 5) @@ -506,7 +510,7 @@ def test_virtual_event(self): self.combo.bind('<<ComboboxSelected>>', lambda evt: success.append(True)) self.combo.pack() - self.combo.update() + self.require_mapped(self.combo) height = self.combo.winfo_height() self._show_drop_down_listbox() @@ -525,7 +529,7 @@ def test_configure_postcommand(self): self.combo['postcommand'] = lambda: success.append(True) self.combo.pack() - self.combo.update() + self.require_mapped(self.combo) self._show_drop_down_listbox() self.assertTrue(success) @@ -875,8 +879,10 @@ def test_get(self): else: conv = float - scale_width = self.scale.winfo_width() - self.assertEqual(self.scale.get(scale_width, 0), self.scale['to']) + # Reading the value at the far edge needs the realized width. + if wait_until_mapped(self.scale): + scale_width = self.scale.winfo_width() + self.assertEqual(self.scale.get(scale_width, 0), self.scale['to']) self.assertEqual(conv(self.scale.get(0, 0)), conv(self.scale['from'])) self.assertEqual(self.scale.get(), self.scale['value']) @@ -918,7 +924,10 @@ def test_set(self): # nevertheless, note that the max/min values we can get specifying # x, y coords are the ones according to the current range self.assertEqual(conv(self.scale.get(0, 0)), min) - self.assertEqual(conv(self.scale.get(self.scale.winfo_width(), 0)), max) + # Reading the value at the far edge needs the realized width. + if wait_until_mapped(self.scale): + self.assertEqual( + conv(self.scale.get(self.scale.winfo_width(), 0)), max) self.assertRaises(tkinter.TclError, self.scale.set, None) @@ -1269,6 +1278,7 @@ def create(self, **kwargs): return ttk.Spinbox(self.root, **kwargs) def _click_increment_arrow(self): + self.require_mapped(self.spin) width = self.spin.winfo_width() height = self.spin.winfo_height() x = width - 5 @@ -1279,6 +1289,7 @@ def _click_increment_arrow(self): self.spin.update_idletasks() def _click_decrement_arrow(self): + self.require_mapped(self.spin) width = self.spin.winfo_width() height = self.spin.winfo_height() x = width - 5 _______________________________________________ Python-checkins mailing list -- [email protected] To unsubscribe send an email to [email protected] https://mail.python.org/mailman3//lists/python-checkins.python.org Member address: [email protected]
