https://github.com/python/cpython/commit/3b564385e4c966de7f2da9ff8bfbc5e50296dc3d
commit: 3b564385e4c966de7f2da9ff8bfbc5e50296dc3d
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-09-07T14:21:20+03:00
summary:

gh-154357: Fix tkinter, ttk and IDLE tests depending on the window manager 
(GH-154370)

The window manager can take the focus from the application, ignore
lift() and resize a toplevel on its own.

* Hide the root window in the dialog tests, so that it does not compete
  for the focus.
* Take the focus right before generating a key event.
* Tolerate additional focus events.
* Do not check focus_get() and focus_displayof() without the focus.
* Use override-redirect toplevels on X11 in test_wm_stackorder.
* Resize the toplevel to fit its content in wait_until_mapped().

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>

files:
M Lib/idlelib/idle_test/test_configdialog.py
M Lib/test/test_tkinter/support.py
M Lib/test/test_tkinter/test_filedialog.py
M Lib/test/test_tkinter/test_misc.py
M Lib/test/test_tkinter/test_simpledialog.py
M Lib/test/test_ttk/test_widgets.py

diff --git a/Lib/idlelib/idle_test/test_configdialog.py 
b/Lib/idlelib/idle_test/test_configdialog.py
index f3e1c785a92674c..24d47854dddd9e1 100644
--- a/Lib/idlelib/idle_test/test_configdialog.py
+++ b/Lib/idlelib/idle_test/test_configdialog.py
@@ -150,8 +150,8 @@ def test_fontlist_key(self):
         font = d.fontlist.get('active')
 
         # Test Down key.
-        fontlist.focus_force()
         fontlist.update()
+        fontlist.focus_force()
         fontlist.event_generate('<Key-Down>')
         fontlist.event_generate('<KeyRelease-Down>')
 
@@ -160,8 +160,8 @@ def test_fontlist_key(self):
         self.assertIn(d.font_name.get(), down_font.lower())
 
         # Test Up key.
-        fontlist.focus_force()
         fontlist.update()
+        fontlist.focus_force()
         fontlist.event_generate('<Key-Up>')
         fontlist.event_generate('<KeyRelease-Up>')
 
diff --git a/Lib/test/test_tkinter/support.py b/Lib/test/test_tkinter/support.py
index 31feee2b8a40ee6..8d4ad2345a16510 100644
--- a/Lib/test/test_tkinter/support.py
+++ b/Lib/test/test_tkinter/support.py
@@ -61,6 +61,16 @@ def require_mapped(self, widget, timeout=None):
                           f'(timed out after {timeout:g}s)')
 
 
+class AbstractDialogTest(AbstractTkTest):
+    # Tk delivers generated keyboard events to the focused window.  Hide the
+    # root window, otherwise the window manager can take the focus back from
+    # the dialog (gh-154357).
+
+    def setUp(self):
+        super().setUp()
+        self.root.withdraw()
+
+
 class AbstractDefaultRootTest:
 
     def setUp(self):
@@ -112,6 +122,7 @@ def wait_until_mapped(widget, timeout=None, *, 
full_size=False):
         timeout = support.LOOPBACK_TIMEOUT
     deadline = time.monotonic() + timeout
     widget.update_idletasks()
+    reset = False
     while True:
         widget.update()  # drain pending Map/Configure events
         if widget.winfo_ismapped():
@@ -123,6 +134,11 @@ def wait_until_mapped(widget, timeout=None, *, 
full_size=False):
                 h_ok = widget.winfo_height() > 1
             if w_ok and h_ok:
                 return True
+            if full_size and not reset:
+                # Tk no longer resizes the toplevel to fit its content if
+                # the window manager has resized it.  Undo this.
+                widget.winfo_toplevel().wm_geometry('')
+                reset = True
         if time.monotonic() >= deadline:
             return False
         time.sleep(0.01)
diff --git a/Lib/test/test_tkinter/test_filedialog.py 
b/Lib/test/test_tkinter/test_filedialog.py
index f65ed19895a1ed4..2df77c0e23e92d8 100644
--- a/Lib/test/test_tkinter/test_filedialog.py
+++ b/Lib/test/test_tkinter/test_filedialog.py
@@ -6,7 +6,7 @@
 from tkinter.commondialog import Dialog
 from test.support import requires, swap_attr
 from test.test_tkinter.support import setUpModule  # noqa: F401
-from test.test_tkinter.support import AbstractTkTest
+from test.test_tkinter.support import AbstractDialogTest, AbstractTkTest
 
 requires('gui')
 
@@ -72,7 +72,7 @@ def test_results_preserved(self):
                          ('/a', '/b'))
 
 
-class FileDialogTest(AbstractTkTest, unittest.TestCase):
+class FileDialogTest(AbstractDialogTest, unittest.TestCase):
     # The pure-Python FileDialog runs its own modal loop in go(); its logic is
     # exercised here without entering the loop.
 
@@ -164,8 +164,8 @@ def test_alt_key(self):
         d = self.open()
         invoked = []
         d.cancel_button.configure(command=lambda: invoked.append(True))
-        d.top.focus_force()
         d.top.update()
+        d.top.focus_force()
         d.top.event_generate('<Alt-c>')  # "&Cancel"
         d.top.update()
         self.assertTrue(invoked)
@@ -174,8 +174,8 @@ def test_escape_cancels(self):
         # The Escape key cancels the dialog.
         d = self.open()
         d.how = 'spam'
-        d.top.focus_force()
         d.top.update()
+        d.top.focus_force()
         d.top.event_generate('<Escape>')
         d.top.update()
         self.assertIsNone(d.how)
@@ -195,8 +195,10 @@ def test_type_ahead(self):
         d.files.delete(0, 'end')
         for name in ('alpha', 'bravo', 'charlie'):
             d.files.insert('end', name)
-        d.files.focus_force()
         d.top.update()
+        # Force the focus right before generating the event: the window
+        # manager can take it back.
+        d.files.focus_force()
         d.files.event_generate('<Key>', keysym='c')
         d.top.update()
         sel = d.files.curselection()
diff --git a/Lib/test/test_tkinter/test_misc.py 
b/Lib/test/test_tkinter/test_misc.py
index a225079dd99091b..c9d402e11a8826f 100644
--- a/Lib/test/test_tkinter/test_misc.py
+++ b/Lib/test/test_tkinter/test_misc.py
@@ -17,7 +17,8 @@
 from test.test_tkinter.support import setUpModule  # noqa: F401
 from test.test_tkinter.support import (AbstractTkTest, AbstractDefaultRootTest,
                                        requires_tk, get_tk_patchlevel,
-                                       tcl_version, tk_version)
+                                       tcl_version, tk_version,
+                                       wait_until_mapped)
 
 support.requires('gui')
 
@@ -508,15 +509,20 @@ def test_focus_methods(self):
         self.root.update_idletasks()
         f.focus_force()
         self.root.update()
-        self.assertIs(self.root.focus_get(), f)
-        self.assertIs(self.root.focus_displayof(), f)
+        # The window manager can take the focus away, and then focus_get()
+        # and focus_displayof() return None.
+        if self.root.focus_displayof() is not None:
+            self.assertIs(self.root.focus_get(), f)
+            self.assertIs(self.root.focus_displayof(), f)
         self.assertIs(f.focus_lastfor(), f)
         b = tkinter.Button(f)
         b.pack()
         self.root.update()
         b.focus_set()
         self.root.update()
-        self.assertIs(self.root.focus_get(), b)
+        if self.root.focus_displayof() is not None:
+            self.assertIs(self.root.focus_get(), b)
+        self.assertIs(f.focus_lastfor(), b)
 
     def test_focus_methods_unresolvable(self):
         # The focus may be on a widget that tkinter did not create and so
@@ -1319,9 +1325,15 @@ def test_wm_transient(self):
     def test_wm_stackorder(self):
         t1 = tkinter.Toplevel(self.root)
         t2 = tkinter.Toplevel(self.root)
+        if self.root._windowingsystem == 'x11':
+            # Bypass the window manager, which may ignore lift() or reorder
+            # the windows while they are being mapped.
+            t1.overrideredirect(True)
+            t2.overrideredirect(True)
         t1.deiconify()
         t2.deiconify()
-        self.root.update()
+        wait_until_mapped(t1)
+        wait_until_mapped(t2)
         t1.lift(t2)  # Raise t1 above t2.
         self.root.update()
         order = self.root.wm_stackorder()
@@ -1361,7 +1373,9 @@ def test_focus(self):
 
         f.focus_force()
         self.root.update()
-        self.assertEqual(len(events), 1, events)
+        # The window manager can take the focus away and give it back,
+        # which makes Tk generate additional focus events.
+        self.assertGreaterEqual(len(events), 1, events)
         e = events[0]
         self.assertIs(e.type, tkinter.EventType.FocusIn)
         self.assertIs(e.widget, f)
diff --git a/Lib/test/test_tkinter/test_simpledialog.py 
b/Lib/test/test_tkinter/test_simpledialog.py
index be0be8d3f546311..943d6e113ed34cd 100644
--- a/Lib/test/test_tkinter/test_simpledialog.py
+++ b/Lib/test/test_tkinter/test_simpledialog.py
@@ -3,7 +3,7 @@
 from tkinter import messagebox, ttk
 from test.support import requires, swap_attr
 from test.test_tkinter.support import setUpModule  # noqa: F401
-from test.test_tkinter.support import AbstractDefaultRootTest, AbstractTkTest
+from test.test_tkinter.support import AbstractDefaultRootTest, 
AbstractDialogTest
 from tkinter.simpledialog import (Dialog, SimpleDialog,
                                   askinteger, askfloat, askstring,
                                   _QueryInteger, _QueryFloat, _QueryString,
@@ -12,7 +12,7 @@
 requires('gui')
 
 
-class SimpleDialogTest(AbstractTkTest, unittest.TestCase):
+class SimpleDialogTest(AbstractDialogTest, unittest.TestCase):
     # SimpleDialog's modal loop is in go(); its bindings are exercised here by
     # generating events on the constructed dialog, without entering the loop.
 
@@ -45,8 +45,8 @@ def test_use_ttk(self):
                          ttk.Style(d.root).lookup('.', 'background'))
         # The bindings work with the themed buttons too.
         self.require_mapped(d.root)
-        d._buttons[0].focus_force()
         d.root.update()
+        d._buttons[0].focus_force()
         d.root.event_generate('<Return>')
         d.root.update()
         self.assertEqual(d.num, 0)
@@ -144,8 +144,8 @@ def test_alt_key(self):
         # the matching button (cf. tk::AmpWidget in tk::MessageBox).
         d = self.create(buttons=['Yes', {'text': 'No', 'underline': 0}])
         self.require_mapped(d.root)
-        d._buttons[0].focus_force()
         d.root.update()
+        d._buttons[0].focus_force()
         d.root.event_generate('<Alt-n>')  # "No" -> underline 0 -> "N"
         d.root.update()
         self.assertEqual(d.num, 1)
@@ -155,8 +155,8 @@ def test_return_invokes_focused_button(self):
         # default and the focus was not moved by keyboard traversal.
         d = self.create(buttons=['Yes', 'No'])  # default 0
         self.require_mapped(d.root)
-        d._buttons[1].focus_force()
         d.root.update()
+        d._buttons[1].focus_force()
         d.root.event_generate('<Return>')
         d.root.update()
         self.assertEqual(d.num, 1)
@@ -165,8 +165,8 @@ def test_focus_next_then_return(self):
         # <Tab> moves the focus to the next button; <Return> invokes it.
         d = self.create(buttons=['Yes', 'No'])
         self.require_mapped(d.root)
-        d._buttons[0].focus_force()
         d.root.update()
+        d._buttons[0].focus_force()
         d._buttons[0].event_generate('<Tab>')
         d.root.update()
         d.root.event_generate('<Return>')
@@ -177,8 +177,8 @@ def test_focus_prev_then_return(self):
         # <Shift-Tab> moves the focus to the previous button.
         d = self.create(buttons=['Yes', 'No'])
         self.require_mapped(d.root)
-        d._buttons[1].focus_force()
         d.root.update()
+        d._buttons[1].focus_force()
         d._buttons[1].event_generate('<Shift-Tab>')
         d.root.update()
         d.root.event_generate('<Return>')
@@ -189,8 +189,8 @@ def test_return_activates_default(self):
         # <Return> with the focus off the buttons invokes the default button.
         d = self.create()  # default 0
         self.require_mapped(d.root)
-        d.root.focus_force()  # the dialog, not a button, has the focus
         d.root.update()
+        d.root.focus_force()  # the dialog, not a button, has the focus
         d.root.event_generate('<Return>')
         d.root.update()
         self.assertEqual(d.num, 0)
@@ -246,7 +246,7 @@ def test_go(self):
         self.assertEqual(d.go(), 0)
 
 
-class DialogTest(AbstractTkTest, unittest.TestCase):
+class DialogTest(AbstractDialogTest, unittest.TestCase):
     # Dialog's button box is modelled on tk::MessageBox.
 
     def open(self, **kw):
@@ -279,8 +279,8 @@ def test_use_classic(self):
         invoked = []
         cancel = d.children['cancel']
         cancel.configure(command=lambda: invoked.append(True))
-        cancel.focus_force()
         d.update()
+        cancel.focus_force()
         d.event_generate('<Return>')
         d.update()
         self.assertTrue(invoked)
@@ -359,8 +359,8 @@ def test_alt_key(self):
         invoked = []
         cancel = d.children['cancel']  # "&Cancel"
         cancel.configure(command=lambda: invoked.append(True))
-        d.focus_force()
         d.update()
+        d.focus_force()
         d.event_generate('<Alt-c>')
         d.update()
         self.assertTrue(invoked)
@@ -371,8 +371,8 @@ def test_return_invokes_focused_button(self):
         invoked = []
         cancel = d.children['cancel']
         cancel.configure(command=lambda: invoked.append(True))
-        cancel.focus_force()
         d.update()
+        cancel.focus_force()
         d.event_generate('<Return>')
         d.update()
         self.assertEqual(invoked, [True])
@@ -384,8 +384,8 @@ def test_focus_next_then_return(self):
         for name in ('ok', 'cancel'):
             d.children[name].configure(command=lambda name=name: 
invoked.append(name))
         ok = d.children['ok']
-        ok.focus_force()
         d.update()
+        ok.focus_force()
         ok.event_generate('<Tab>')  # OK -> Cancel
         d.update()
         d.event_generate('<Return>')
@@ -399,8 +399,8 @@ def test_focus_prev_then_return(self):
         for name in ('ok', 'cancel'):
             d.children[name].configure(command=lambda name=name: 
invoked.append(name))
         cancel = d.children['cancel']
-        cancel.focus_force()
         d.update()
+        cancel.focus_force()
         cancel.event_generate('<Shift-Tab>')  # Cancel -> OK
         d.update()
         d.event_generate('<Return>')
@@ -432,7 +432,7 @@ def mock_wait_window(w):
             self.assertRaises(RuntimeError, askinteger, "Go To Line", "Line 
number")
 
 
-class QueryDialogTest(AbstractTkTest, unittest.TestCase):
+class QueryDialogTest(AbstractDialogTest, unittest.TestCase):
     # The query dialogs are modal: their __init__ blocks in wait_window().
     # Mock that out so the dialog stays alive and can be driven with generated
     # events, exercising the <Return>/<Escape> bindings and the validation.
diff --git a/Lib/test/test_ttk/test_widgets.py 
b/Lib/test/test_ttk/test_widgets.py
index 4fa6a032e97c5f8..6ed593e3e43bb07 100644
--- a/Lib/test/test_ttk/test_widgets.py
+++ b/Lib/test/test_ttk/test_widgets.py
@@ -2024,19 +2024,23 @@ def test_virtual_events(self):
                      lambda e: selects.append(self.tv.selection()))
         self.tv.bind('<<TreeviewOpen>>', lambda e: 
opens.append(self.tv.focus()))
         self.tv.bind('<<TreeviewClose>>', lambda e: 
closes.append(self.tv.focus()))
-        self.tv.focus_force()
         self.tv.focus(parent)
         self.tv.selection_set(parent)
         self.tv.update()
 
+        # Force the focus right before generating the event: the window
+        # manager can take it back.
+        self.tv.focus_force()
         self.tv.event_generate('<Right>')  # Open the focused parent.
         self.tv.update()
         self.assertEqual(opens, [parent])
 
+        self.tv.focus_force()
         self.tv.event_generate('<Left>')  # Close it again.
         self.tv.update()
         self.assertEqual(closes, [parent])
 
+        self.tv.focus_force()
         self.tv.event_generate('<Down>')  # Move the selection.
         self.tv.update()
         self.assertEqual(self.tv.selection(), (item2,))

_______________________________________________
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