diff --git a/Lib/gettext.py b/Lib/gettext.py index 2f77f0e849e9aeb..f681f69b9827cbf 100644 --- a/Lib/gettext.py +++ b/Lib/gettext.py @@ -136,6 +136,9 @@ def _parse(tokens, priority=-1): except ValueError: raise _error(nexttok) from None result = '%s%d' % (result, value) + # Unary '!' binds tighter than binary operators in C, unlike 'not'. + if result.startswith('not '): + result = '(%s)' % result nexttok = next(tokens) j = 100 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/Lib/test/test_gettext.py b/Lib/test/test_gettext.py index 9ad37909a8ec4e0..b18489f34ae4806 100644 --- a/Lib/test/test_gettext.py +++ b/Lib/test/test_gettext.py @@ -613,6 +613,23 @@ def test_negation(self): self.assertEqual(f(1), 0) self.assertEqual(f(2), 0) + def test_negation_precedence(self): + # gh-157451: in C the unary '!' binds tighter than any binary + # operator, so '!n + 1' is '(!n) + 1', not '!(n + 1)'. + f = gettext.c2py('!n + 1') + self.assertEqual(f(0), 2) + self.assertEqual(f(1), 1) + self.assertEqual(gettext.c2py('!n < 3')(0), 1) + self.assertEqual(gettext.c2py('!n * 2')(0), 2) + self.assertEqual(gettext.c2py('!n * 2')(1), 0) + # Double negation still normalises to 0/1 (C semantics). + self.assertEqual(gettext.c2py('!!n')(5), 1) + self.assertEqual(gettext.c2py('!!n')(0), 0) + # '!' as a right operand also binds tighter than the binary operator, + # so '2 * !n + 1' is '2 * (!n) + 1' rather than a SyntaxError. + self.assertEqual(gettext.c2py('2 * !n + 1')(0), 3) + self.assertEqual(gettext.c2py('2 * !n + 1')(1), 1) + def test_nested_condition_operator(self): self.assertEqual(gettext.c2py('n?1?2:3:4')(0), 4) self.assertEqual(gettext.c2py('n?1?2:3:4')(1), 2) 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. diff --git a/Misc/NEWS.d/next/Library/2026-09-14-00-30-00.gh-issue-157451.k7Qm2x.rst b/Misc/NEWS.d/next/Library/2026-09-14-00-30-00.gh-issue-157451.k7Qm2x.rst new file mode 100644 index 000000000000000..45e3fedf4ca3278 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-14-00-30-00.gh-issue-157451.k7Qm2x.rst @@ -0,0 +1,3 @@ +Fix :mod:`gettext` plural form selection when the ``Plural-Forms`` expression +combines the unary ``!`` operator with a binary operator, for example +``!n + 1`` or ``2 * !n``. The latter raised :exc:`SyntaxError`.