https://github.com/python/cpython/commit/1dd8aacd414e0728ae1e5b24527a643516b4dcfa
commit: 1dd8aacd414e0728ae1e5b24527a643516b4dcfa
branch: 3.13
author: Miss Islington (bot) <[email protected]>
committer: terryjreedy <[email protected]>
date: 2026-09-17T21:46:57-04:00
summary:

[3.13] gh-69365: Show search pattern errors in the IDLE search dialogs 
(GH-157598) (#157669)

gh-69365: Show search pattern errors in the IDLE search dialogs (GH-157598)

Show the error in red below the entry and move the cursor to the
offending character in the pattern, instead of a message box. Find
Again without a dialog still uses the message box.

Check the pattern as typed, so that the position is right. This also
fixes the "Whole word" option with a regular expression: the pattern
is now grouped, so that "a|b" is anchored on both sides, and a pattern
ending with a backslash is an error instead of matching "\b".
(cherry picked from commit 9ab004d41ec18e71fd8afe992a831e6156b84b03)

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

files:
A Misc/NEWS.d/next/IDLE/2026-09-15-22-34-33.gh-issue-69365.KehjZN.rst
M Lib/idlelib/idle_test/test_searchbase.py
M Lib/idlelib/idle_test/test_searchengine.py
M Lib/idlelib/searchbase.py
M Lib/idlelib/searchengine.py

diff --git a/Lib/idlelib/idle_test/test_searchbase.py 
b/Lib/idlelib/idle_test/test_searchbase.py
index 1780cab6527dd94..aeb85dc3ebf226e 100644
--- a/Lib/idlelib/idle_test/test_searchbase.py
+++ b/Lib/idlelib/idle_test/test_searchbase.py
@@ -56,6 +56,16 @@ def test_open_and_close(self):
 
         self.dialog.open(text, searchphrase="hello")
         self.assertEqual(self.dialog.ent.get(), 'hello')
+
+        # While open, the dialog shows pattern errors (gh-69365).
+        self.assertEqual(self.engine.error_handler, self.dialog.show_error)
+        self.dialog.show_error('Error: spam', 1)
+        self.assertEqual(self.dialog.error_label['text'], 'Error: spam')
+        self.assertEqual(self.dialog.ent.index('insert'), 1)
+        self.engine.patvar.set('eggs')  # Editing the pattern clears it.
+        self.assertEqual(self.dialog.error_label['text'], '')
+        self.dialog.close()
+        self.assertIsNone(self.engine.error_handler)
         toplevel.update_idletasks()
         toplevel.destroy()
 
diff --git a/Lib/idlelib/idle_test/test_searchengine.py 
b/Lib/idlelib/idle_test/test_searchengine.py
index 9d9798394195860..5207d2a6b4a9369 100644
--- a/Lib/idlelib/idle_test/test_searchengine.py
+++ b/Lib/idlelib/idle_test/test_searchengine.py
@@ -154,7 +154,7 @@ def test_getcookedpat(self):
         engine.setpat('hello')
         Equal(engine.getcookedpat(), 'hello')
         engine.wordvar.set(True)
-        Equal(engine.getcookedpat(), r'\bhello\b')
+        Equal(engine.getcookedpat(), r'\b(?:hello)\b')
         engine.wordvar.set(False)
 
         engine.setpat(r'\s')
@@ -182,6 +182,24 @@ def test_getprog(self):
         Equal(engine.getprog(), None)
         Equal(Mbox.showerror.message,
               'Error: nothing to repeat\nPattern: +\nOffset: 0')
+        # Errors are reported for the pattern as typed, not as cooked.
+        engine.wordvar.set(True)
+        engine.setpat('a\\')
+        Equal(engine.getprog(), None)
+        Equal(Mbox.showerror.message,
+              'Error: bad escape (end of pattern)\nPattern: a\\\nOffset: 1')
+        engine.setpat('a|b')
+        Equal(engine.getprog().pattern, r'\b(?:a|b)\b')
+        engine.setpat(')(')
+        Equal(engine.getprog(), None)
+        Equal(Mbox.showerror.message,
+              'Error: unbalanced parenthesis\nPattern: )(\nOffset: 0')
+        engine.setpat('(?i)x')
+        Equal(engine.getprog(), None)
+        Equal(Mbox.showerror.message,
+              'Error: global flags like (?i) cannot be used with the '
+              '"Whole word" option\nPattern: (?i)x')
+        engine.wordvar.set(False)
 
     def test_report_error(self):
         showerror = Mbox.showerror
@@ -199,6 +217,17 @@ def test_report_error(self):
         expected_message += "\nOffset: 5"
         Equal(showerror.message, expected_message)
 
+        # An open dialog shows the message itself (gh-69365).
+        messages = []
+        self.engine.error_handler = lambda msg, pos: messages.append((msg, 
pos))
+        self.addCleanup(setattr, self.engine, 'error_handler', None)
+        showerror.message = None
+        Equal(self.engine.report_error(pat, msg, 3), None)
+        Equal(messages, [("Error: " + msg + " at position 3", 3)])
+        Equal(showerror.message, None)
+        Equal(self.engine.report_error(pat, "Empty"), None)
+        Equal(messages[-1], ("Error: Empty", None))
+
 
 class SearchTest(unittest.TestCase):
     # Test that search_text makes right call to right method.
diff --git a/Lib/idlelib/searchbase.py b/Lib/idlelib/searchbase.py
index c68a6ca339af045..da96a047348094e 100644
--- a/Lib/idlelib/searchbase.py
+++ b/Lib/idlelib/searchbase.py
@@ -64,14 +64,30 @@ def open(self, text, searchphrase=None):
         self.ent.selection_range(0, "end")
         self.ent.icursor(0)
         self.top.grab_set()
+        self.show_error('')
+        self.engine.error_handler = self.show_error
 
     def close(self, event=None):
         "Put dialog away for later use."
         if self.top:
+            self.engine.error_handler = None
             self.top.grab_release()
             self.top.transient('')
             self.top.withdraw()
 
+    def show_error(self, message, pos=None):
+        """Show message (or nothing) in red below the entries.
+
+        If pos is given, move the cursor to that position of the pattern.
+        """
+        self.error_label['text'] = message
+        if message:
+            self.bell()
+        if pos is not None:
+            self.ent.focus_set()
+            self.ent.selection_clear()
+            self.ent.icursor(pos)
+
     def create_widgets(self):
         '''Create basic 3 row x 3 col search (find) dialog.
 
@@ -96,6 +112,7 @@ def create_widgets(self):
         self.frame.grid_columnconfigure(1, pad=2, minsize=100, weight=100)
 
         self.create_entries()  # row 0 (and maybe 1), cols 0, 1
+        self.create_error_label()  # next row, col 1
         self.create_option_buttons()  # next row, cols 0, 1
         self.create_other_buttons()  # next row, cols 0, 1
         self.create_command_buttons()  # col 2, all rows
@@ -117,6 +134,13 @@ def create_entries(self):
         "Create one or more entry lines with make_entry."
         self.ent = self.make_entry("Find:", self.engine.patvar)[0]
 
+    def create_error_label(self):
+        "Create the label for a pattern error, cleared when the pattern 
changes."
+        self.error_label = Label(self.frame, text=' ', foreground='red')
+        self.error_label.grid(row=self.row, column=1, sticky="nw")
+        self.row = self.row + 1
+        self.engine.patvar.trace_add('write', lambda *args: 
self.show_error(''))
+
     def make_frame(self,labeltext=None):
         '''Return (frame, label).
 
diff --git a/Lib/idlelib/searchengine.py b/Lib/idlelib/searchengine.py
index ceb38cfaef900ba..54c5399541900a1 100644
--- a/Lib/idlelib/searchengine.py
+++ b/Lib/idlelib/searchengine.py
@@ -31,6 +31,7 @@ def __init__(self, root):
         self.wordvar = BooleanVar(root, False)   # match whole word?
         self.wrapvar = BooleanVar(root, True)   # wrap around buffer?
         self.backvar = BooleanVar(root, False)   # search backwards?
+        self.error_handler = None  # Set by an open dialog, see report_error.
 
     # Access methods
 
@@ -69,7 +70,7 @@ def getcookedpat(self):
         if not self.isre():  # if True, see setcookedpat
             pat = re.escape(pat)
         if self.isword():
-            pat = r"\b%s\b" % pat
+            pat = r"\b(?:%s)\b" % pat
         return pat
 
     def getprog(self):
@@ -78,19 +79,34 @@ def getprog(self):
         if not pat:
             self.report_error(pat, "Empty regular expression")
             return None
-        pat = self.getcookedpat()
         flags = 0
         if not self.iscase():
             flags = flags | re.IGNORECASE
+        if self.isre():
+            # Check the pattern as typed, so that an error is reported
+            # at the right position.
+            try:
+                re.compile(pat, flags)
+            except re.PatternError as e:
+                self.report_error(pat, e.msg, e.pos)
+                return None
         try:
-            prog = re.compile(pat, flags)
+            return re.compile(self.getcookedpat(), flags)
         except re.PatternError as e:
-            self.report_error(pat, e.msg, e.pos)
+            msg = e.msg
+            if msg.startswith('global flags not at the start'):
+                msg = ('global flags like (?i) cannot be used '
+                       'with the "Whole word" option')
+            self.report_error(pat, msg)
             return None
-        return prog
 
     def report_error(self, pat, msg, col=None):
-        # Derived class could override this with something fancier
+        "Show msg in the open dialog, if any, else in a message box."
+        if self.error_handler is not None:
+            if col is not None:
+                msg = f"{msg} at position {col}"
+            self.error_handler("Error: " + str(msg), col)
+            return
         msg = "Error: " + str(msg)
         if pat:
             msg = msg + "\nPattern: " + str(pat)
diff --git 
a/Misc/NEWS.d/next/IDLE/2026-09-15-22-34-33.gh-issue-69365.KehjZN.rst 
b/Misc/NEWS.d/next/IDLE/2026-09-15-22-34-33.gh-issue-69365.KehjZN.rst
new file mode 100644
index 000000000000000..d29c87aed78e1a0
--- /dev/null
+++ b/Misc/NEWS.d/next/IDLE/2026-09-15-22-34-33.gh-issue-69365.KehjZN.rst
@@ -0,0 +1,2 @@
+The IDLE search dialogs now show a regular expression error below the entry
+instead of in a message box.

_______________________________________________
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