https://github.com/python/cpython/commit/0fff6bd86cf0224152c509e295d3cbbd209098f3
commit: 0fff6bd86cf0224152c509e295d3cbbd209098f3
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-06-28T20:11:47+03:00
summary:

gh-69134: Harden tkinter GUI tests that depend on a mapped widget (GH-152499)

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.

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 4b7a6acfad0b92..689acd1c321211 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 (
@@ -786,10 +787,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):
@@ -2156,9 +2158,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')
 
 
@@ -2278,10 +2282,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 bffcfcff526c66..9379afb6aa47ee 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]

Reply via email to