diff --git a/Grammar/python.gram b/Grammar/python.gram index 315f12fe00282b2..28f5ed3a6e61d4a 100644 --- a/Grammar/python.gram +++ b/Grammar/python.gram @@ -555,12 +555,12 @@ complex_number[expr_ty]: signed_number[expr_ty]: | NUMBER - | '+' number=NUMBER { number } + | '+' number=NUMBER { _PyAST_UnaryOp(UAdd, number, EXTRA) } | '-' number=NUMBER { _PyAST_UnaryOp(USub, number, EXTRA) } signed_real_number[expr_ty]: | real_number - | '+' real=real_number { real } + | '+' real=real_number { _PyAST_UnaryOp(UAdd, real, EXTRA) } | '-' real=real_number { _PyAST_UnaryOp(USub, real, EXTRA) } real_number[expr_ty]: diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py index 90a23620354f313..3ef9fcbc5bb569b 100644 --- a/Lib/idlelib/idle_test/test_iomenu.py +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -1,11 +1,13 @@ "Test , coverage 17%." from idlelib import iomenu +import os import unittest +from unittest import mock from test.support import requires -from tkinter import Tk +from tkinter import Tk, TclVersion from idlelib.editor import EditorWindow -from idlelib import util +from idlelib import macosx, util from idlelib.idle_test.mock_idle import Func # Fail if either tokenize.open and t.detect_encoding does not exist. @@ -38,6 +40,17 @@ def tearDownClass(cls): def test_init(self): self.assertIs(self.io.editwin, self.editwin) + def test_defaultfilename_tilde(self): + # gh-59568: escape a leading tilde for Tcl older than 9. + io = self.io + with mock.patch.object(io, 'filename', os.path.join('dir', '~file.py')): + dirname, base = io.defaultfilename() + self.assertEqual(dirname, 'dir') + if TclVersion < 9 and not macosx.isAquaTk(): + self.assertEqual(base, os.path.join(os.curdir, '~file.py')) + else: + self.assertEqual(base, '~file.py') + def test_fixnewlines_end(self): eq = self.assertEqual io = self.io diff --git a/Lib/idlelib/idle_test/test_query.py b/Lib/idlelib/idle_test/test_query.py index 58c173723a5adac..3b0a3388f64e330 100644 --- a/Lib/idlelib/idle_test/test_query.py +++ b/Lib/idlelib/idle_test/test_query.py @@ -280,11 +280,20 @@ def test_blank_args(self): dialog = self.Dummy_CustomRun(' ') self.assertEqual(dialog.cli_args_ok(), []) + @unittest.skipIf(sys.platform == 'win32', 'not an error on Windows') def test_invalid_args(self): dialog = self.Dummy_CustomRun("'no-closing-quote") self.assertEqual(dialog.cli_args_ok(), None) self.assertIn('No closing', dialog.entry_error['text']) + @unittest.skipUnless(sys.platform == 'win32', 'Windows only') + def test_windows_args(self): + # gh-93016: backslashes are not escapes on Windows. + dialog = self.Dummy_CustomRun(r'c:\Users "c:\Program Files"') + self.assertEqual(dialog.cli_args_ok(), + [r'c:\Users', r'c:\Program Files']) + self.assertEqual(dialog.entry_error['text'], '') + def test_good_args(self): args = ['-n', '10', '--verbose', '-p', '/path', '--name'] dialog = self.Dummy_CustomRun(' '.join(args) + ' "my name"') @@ -444,6 +453,12 @@ def test_click_args(self): dialog.entry.insert(END, ' c') dialog.button_ok.invoke() self.assertEqual(dialog.result, (['a', 'b=1', 'c'], True)) + # gh-93016: arguments with spaces and backslashes round-trip. + args = ['a b', r'c:\dir\x'] + dialog = query.CustomRun(root, 'Title', cli_args=args, _utest=True) + self.assertNotIn('{', dialog.entry.get()) + dialog.button_ok.invoke() + self.assertEqual(dialog.result, (args, True)) root.destroy() diff --git a/Lib/idlelib/idle_test/test_util.py b/Lib/idlelib/idle_test/test_util.py index d90a0784075d3e0..adf871fe4399cb3 100644 --- a/Lib/idlelib/idle_test/test_util.py +++ b/Lib/idlelib/idle_test/test_util.py @@ -3,7 +3,7 @@ import sys import unittest from unittest import mock -from test.support import requires +from test.support import requires, subTests from test.support.isolation import runInSubprocess import tkinter from tkinter import EventType @@ -162,5 +162,38 @@ def test_fix_x11_paste(self): self.assertEqual(after, before[cls]) +class CLIargsTest(unittest.TestCase): + "Test the command line splitting and joining functions (gh-93016)." + + # Expected results were verified against sys.argv of python.exe. + @subTests('cli_string,args', [ + (r'c:\Users', [r'c:\Users']), + (r'\\server\share', [r'\\server\share']), + (r'"c:\Program Files\x" 1 2', [r'c:\Program Files\x', '1', '2']), + (r' x y ', ['x', 'y']), + ('a\tb', ['a', 'b']), + (r'"a b"c', ['a bc']), + (r'a"b c"d', ['ab cd']), + (r'"a""b"', ['a"b']), + (r'a\"b', ['a"b']), + (r'a\\"b c" d', ['a\\b c', 'd']), + (r'a\\\"b', ['a\\"b']), + (r'"x\\"', ['x\\']), + ('"c:\\Users\\"', ['c:\\Users"']), + ('x\\', ['x\\']), + (r'""', ['']), + (r'"" a', ['', 'a']), + (r'"', ['']), + ('', []), + ]) + def test_split_windows(self, cli_string, args): + self.assertEqual(util._split_windows(cli_string), args) + + @subTests('args', [['a'], ['a b'], [r'c:\x'], ['q"q'], ["s's"], [''], + ['\\'], ['\\"'], ['a b', r'c:\x', '', 'q"q']]) + def test_split_join(self, args): + self.assertEqual(util.split_cli_args(util.join_cli_args(args)), args) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Lib/idlelib/iomenu.py b/Lib/idlelib/iomenu.py index 9560aaa2da0c424..96bf6078f12c3ec 100644 --- a/Lib/idlelib/iomenu.py +++ b/Lib/idlelib/iomenu.py @@ -5,11 +5,13 @@ import tempfile import tokenize +from tkinter import TclVersion from tkinter import filedialog from tkinter import messagebox from tkinter.simpledialog import askstring # loadfile encoding. from idlelib.config import idleConf +from idlelib import macosx from idlelib.util import py_extensions py_extensions = ' '.join("*"+ext for ext in py_extensions) @@ -405,7 +407,12 @@ def askopenfile(self): def defaultfilename(self, mode="open"): if self.filename: - return os.path.split(self.filename) + dirname, base = os.path.split(self.filename) + if base[:1] == '~' and TclVersion < 9 and not macosx.isAquaTk(): + # Tcl before version 9 substitutes a leading tilde in + # a file name (gh-59568). The macOS dialog does not. + base = os.path.join(os.curdir, base) + return dirname, base elif self.dirname: return self.dirname, "" else: diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py index 19a6569ac61182e..1aeabba0a728feb 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -705,6 +705,8 @@ def execfile(self, filename, source=None): def runsource(self, source): "Extend base class method: Stuff the source in the line cache first" + # Remove the highlighting of a previous syntax error (gh-93966). + self.tkconsole.text.tag_remove("ERROR", "1.0", "end") filename = self.stuffsource(source) # at the moment, InteractiveInterpreter expects str assert isinstance(source, str) @@ -741,7 +743,6 @@ def showsyntaxerror(self, filename=None, **kwargs): """ tkconsole = self.tkconsole text = tkconsole.text - text.tag_remove("ERROR", "1.0", "end") type, value, tb = sys.exc_info() if not issubclass(type, SyntaxError): tkconsole.resetoutput() diff --git a/Lib/idlelib/query.py b/Lib/idlelib/query.py index 5f9bdc031e544b7..6217b8123de76d4 100644 --- a/Lib/idlelib/query.py +++ b/Lib/idlelib/query.py @@ -21,7 +21,6 @@ import importlib.util, importlib.abc import os -import shlex from sys import executable, platform # Platform is set for one test. from tkinter import Toplevel, StringVar, BooleanVar, W, E, S @@ -30,6 +29,8 @@ from tkinter.font import Font from tkinter.simpledialog import _setup_dialog +from idlelib.util import split_cli_args, join_cli_args + class Query(Toplevel): """Base class for getting verified answer from a user. @@ -332,6 +333,7 @@ def entry_ok(self): path = self.path_ok() return None if name is None or path is None else (name, path) + class CustomRun(Query): """Get settings for custom run of module. @@ -344,12 +346,11 @@ def __init__(self, parent, title, *, cli_args=[], _htest=False, _utest=False): """cli_args is a list of strings. - The list is assigned to the default Entry StringVar. - The strings are displayed joined by ' ' for display. + The strings are quoted and joined for display in the Entry. """ message = 'Command Line Arguments for sys.argv:' super().__init__( - parent, title, message, text0=cli_args, + parent, title, message, text0=join_cli_args(cli_args), _htest=_htest, _utest=_utest) def create_extra(self): @@ -369,7 +370,7 @@ def cli_args_ok(self): "Return command line arg list or None if error." cli_string = self.entry.get().strip() try: - cli_args = shlex.split(cli_string, posix=True) + cli_args = split_cli_args(cli_string) except ValueError as err: self.showerror(str(err)) return None diff --git a/Lib/idlelib/util.py b/Lib/idlelib/util.py index f408daf728d4a97..68391d5eef2b9f8 100644 --- a/Lib/idlelib/util.py +++ b/Lib/idlelib/util.py @@ -12,6 +12,7 @@ * std streams (pyshell, run), * warning stuff (pyshell, run). """ +import re import sys # .pyw is for Windows; .pyi is for typing stub files. @@ -114,6 +115,60 @@ def wheel_event(event, widget=None): return 'break' +_cli_token_re = re.compile(r""" + (?P\\*)(?P"+) + | (?P\\+|[^ \t"\\]+) # backslashes not followed by a quote + | (?P[ \t]+) +""", re.VERBOSE) + + +def _split_windows(cli_string): + """Split a command line into arguments as the C runtime does. + + See https://learn.microsoft.com/cpp/c-language/parsing-c-command-line-arguments + """ + args = [] + arg = None # None when not in an argument. + quoted = False + for m in _cli_token_re.finditer(cli_string): + match m.lastgroup: + case 'space' if not quoted: + if arg is not None: + args.append(arg) + arg = None + case 'space' | 'literal': + arg = (arg or '') + m[0] + case _: # Backslashes followed by quotes. + count = len(m['backslashes']) + escaped = count % 2 # Odd backslashes escape a quote. + bare = len(m['quotes']) - escaped + # In a quoted part every two quotes give a literal quote; + # if not quoted, the first quote opens a quoted part. + literal = escaped + ((bare + quoted - 1) // 2 if bare else 0) + arg = (arg or '') + '\\' * (count // 2) + '"' * literal + quoted ^= bare % 2 + if arg is not None: + args.append(arg) + return args + + +def split_cli_args(cli_string): # Called in query. + "Split a command line as the Python executable does (gh-93016)." + if sys.platform == 'win32': + return _split_windows(cli_string) + import shlex + return shlex.split(cli_string) + + +def join_cli_args(cli_args): # Called in query. + "Join arguments into a command line which split_cli_args() splits back." + if sys.platform == 'win32': + import subprocess + return subprocess.list2cmdline(cli_args) + import shlex + return shlex.join(cli_args) + + if __name__ == '__main__': from unittest import main main('idlelib.idle_test.test_util', verbosity=2) diff --git a/Lib/test/test_fstring.py b/Lib/test/test_fstring.py index 2fe959f6c14f77a..63006266bcd039e 100644 --- a/Lib/test/test_fstring.py +++ b/Lib/test/test_fstring.py @@ -617,6 +617,34 @@ def test_unterminated_string(self): r"""f'{("x}'""", ]) + def test_unclosed_multiline_replacement_field(self): + for prefix in ('f', 't', 'rf', 'rt'): + for quote in ('"', "'"): + triple = quote * 3 + cases = ( + # The apparent closing quotes open a string in the field. + ('var = "abc"\na = PREFIXQUOTE {var} extern "C" { QUOTE\n' + 'b = QUOTE string QUOTE', 2), + # Parentheses and dictionaries inside the field must not + # change which opening brace the diagnostic identifies. + ('a = PREFIXQUOTE{\n(QUOTE', 1), + ('a = PREFIXQUOTE{\n{0: QUOTE', 1), + # Use the innermost format field or formatted string. + ('a = PREFIXQUOTE{0:\n{1\nQUOTE', 2), + ('a = f"{\nPREFIXQUOTE{1\nQUOTE', 2), + # Account for parentheses outside the formatted string. + ('a = (PREFIXQUOTE{1\nQUOTE', 1), + ) + for source, lineno in cases: + source = source.replace('PREFIX', prefix).replace('QUOTE', triple) + with self.subTest(source=source): + with self.assertRaises(SyntaxError) as cm: + compile(source, '', 'exec') + self.assertEqual( + cm.exception.msg, + f"{prefix[-1]}-string: expecting '}}' to close '{{' " + f"on line {lineno}") + @unittest.skipIf(support.is_wasi, "exhausts limited stack on WASI") def test_mismatched_parens(self): self.assertAllRaise(SyntaxError, r"closing parenthesis '\}' " diff --git a/Lib/test/test_unparse.py b/Lib/test/test_unparse.py index 28faede2e2a1a5c..9bc04fc8e0affb9 100644 --- a/Lib/test/test_unparse.py +++ b/Lib/test/test_unparse.py @@ -693,6 +693,24 @@ def test_unparse_interactive_integrity_3(self): self.check_src_roundtrip(src, out, mode='single') + @test.support.subTests('case', [ + "case 'a string':", + "case True:", + "case _:", + "case [*_]:", + "case tuple():", + "case cls(arg, more=arg2):", + "case tuple() | list():", + "case [tuple() as obj, _, {'a': a, **more}]:", + "case -2:", + "case +2:", + "case 2 + 3j:", + "case +2 - 3j:", + ]) + def test_unparse_match(self, case): + src = 'match x:\n ' + case + '\n pass' + self.check_src_roundtrip(src) + class CosmeticTestCase(ASTTestCase): """Test if there are cosmetic issues caused by unnecessary additions""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-30-14-50-00.gh-issue-152708.aBcDeF.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-30-14-50-00.gh-issue-152708.aBcDeF.rst new file mode 100644 index 000000000000000..edcf99f56ff9071 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-30-14-50-00.gh-issue-152708.aBcDeF.rst @@ -0,0 +1 @@ +Fix AST generation asymmetry where unary positive ``+`` was implicitly dropped in pattern match expressions, restoring parsing parity with unary negative ``-``. diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-19-12-00-00.gh-issue-157778.fstring-brace.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-19-12-00-00.gh-issue-157778.fstring-brace.rst new file mode 100644 index 000000000000000..cb1bc8942d1b1b4 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-19-12-00-00.gh-issue-157778.fstring-brace.rst @@ -0,0 +1,3 @@ +Include the line number of the opening brace in the :exc:`SyntaxError` for +an unclosed f-string or t-string replacement field when a matching quote +on a later line is interpreted as the start of an unterminated string. diff --git a/Misc/NEWS.d/next/IDLE/2026-09-15-22-10-17.gh-issue-93966.ch4fTB.rst b/Misc/NEWS.d/next/IDLE/2026-09-15-22-10-17.gh-issue-93966.ch4fTB.rst new file mode 100644 index 000000000000000..3260b42211b780b --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-09-15-22-10-17.gh-issue-93966.ch4fTB.rst @@ -0,0 +1,2 @@ +The highlighting of a syntax error in the IDLE Shell is now removed when the +next statement is entered, not only when the next syntax error occurs. diff --git a/Misc/NEWS.d/next/IDLE/2026-09-16-18-00-00.gh-issue-59568.tilde1.rst b/Misc/NEWS.d/next/IDLE/2026-09-16-18-00-00.gh-issue-59568.tilde1.rst new file mode 100644 index 000000000000000..3eabd4e176ad8a6 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-09-16-18-00-00.gh-issue-59568.tilde1.rst @@ -0,0 +1,2 @@ +Fix "Save As" and "Open" dialogs in IDLE for a file whose name starts with +``~`` on Windows and X11 with Tcl/Tk older than 9. diff --git a/Misc/NEWS.d/next/IDLE/2026-09-16-20-00-00.gh-issue-93016.cliarg.rst b/Misc/NEWS.d/next/IDLE/2026-09-16-20-00-00.gh-issue-93016.cliarg.rst new file mode 100644 index 000000000000000..a9fe00a72fe87c3 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-09-16-20-00-00.gh-issue-93016.cliarg.rst @@ -0,0 +1,3 @@ +Fix parsing of command line arguments in the IDLE "Run... Customized" dialog +on Windows: backslashes are no longer treated as escape characters. Fix also +display of the previous arguments in the dialog. diff --git a/Parser/lexer/string.c b/Parser/lexer/string.c index 945480ef86f7789..1fb684e337de972 100644 --- a/Parser/lexer/string.c +++ b/Parser/lexer/string.c @@ -355,9 +355,19 @@ _PyLexer_scan_string(struct tok_state *tok, struct token *token, int c) const ftstring_state *state = _PyLexer_CurrentFTString(tok); if (state != NULL) { - /* A matching quote belongs to the surrounding formatted - * string, so the expression is missing its closing brace. */ + /* A matching quote may have been intended to close the + * surrounding formatted string instead of opening a string + * inside a replacement field. */ if (state->quote == quote && state->quote_size == quote_size) { + int level = state->paren_level + state->replacement_depth - 1; + assert(level >= 0 && level < tok->level); + assert(tok->parenstack[level] == '{'); + int lineno = tok->parenlinenostack[level]; + if (lineno != tok->lineno) { + return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok, + "%c-string: expecting '}' to close '{' on line %d", + _PyLexer_StringPrefix(state->kind), lineno)); + } return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok, "%c-string: expecting '}'", _PyLexer_StringPrefix(state->kind))); diff --git a/Parser/parser.c b/Parser/parser.c index bb8a43caf9bd353..b3d73dd4fafb762 100644 --- a/Parser/parser.c +++ b/Parser/parser.c @@ -9116,7 +9116,16 @@ signed_number_rule(Parser *p) ) { D(fprintf(stderr, "%*c+ signed_number[%d-%d]: %s succeeded!\n", p->level, ' ', _mark, p->mark, "'+' NUMBER")); - _res = number; + Token *_token = _PyPegen_get_last_nonnwhitespace_token(p); + if (_token == NULL) { + p->level--; + return NULL; + } + int _end_lineno = _token->end_lineno; + UNUSED(_end_lineno); // Only used by EXTRA macro + int _end_col_offset = _token->end_col_offset; + UNUSED(_end_col_offset); // Only used by EXTRA macro + _res = _PyAST_UnaryOp ( UAdd , number , EXTRA ); if ((_res == NULL || p->error_indicator) && PyErr_Occurred()) { p->error_indicator = 1; p->level--; @@ -9226,7 +9235,16 @@ signed_real_number_rule(Parser *p) ) { D(fprintf(stderr, "%*c+ signed_real_number[%d-%d]: %s succeeded!\n", p->level, ' ', _mark, p->mark, "'+' real_number")); - _res = real; + Token *_token = _PyPegen_get_last_nonnwhitespace_token(p); + if (_token == NULL) { + p->level--; + return NULL; + } + int _end_lineno = _token->end_lineno; + UNUSED(_end_lineno); // Only used by EXTRA macro + int _end_col_offset = _token->end_col_offset; + UNUSED(_end_col_offset); // Only used by EXTRA macro + _res = _PyAST_UnaryOp ( UAdd , real , EXTRA ); if ((_res == NULL || p->error_indicator) && PyErr_Occurred()) { p->error_indicator = 1; p->level--; diff --git a/Python/ast.c b/Python/ast.c index f625c59fe5bffc3..1befd1778114927 100644 --- a/Python/ast.c +++ b/Python/ast.c @@ -426,11 +426,11 @@ ensure_literal_number(expr_ty exp, bool allow_real, bool allow_imaginary) } static int -ensure_literal_negative(expr_ty exp, bool allow_real, bool allow_imaginary) +ensure_literal_signed(expr_ty exp, bool allow_real, bool allow_imaginary) { assert(exp->kind == UnaryOp_kind); - // Must be negation ... - if (exp->v.UnaryOp.op != USub) { + // Must be negation or positive ... + if (exp->v.UnaryOp.op != USub && exp->v.UnaryOp.op != UAdd) { return 0; } // ... of a constant ... @@ -461,7 +461,7 @@ ensure_literal_complex(expr_ty exp) } break; case UnaryOp_kind: - if (!ensure_literal_negative(left, /*real=*/true, /*imaginary=*/false)) { + if (!ensure_literal_signed(left, /*real=*/true, /*imaginary=*/false)) { return 0; } break; @@ -512,9 +512,9 @@ validate_pattern_match_value(expr_ty exp) // Constants and attribute lookups are always permitted return 1; case UnaryOp_kind: - // Negated numbers are permitted (whether real or imaginary) + // Signed numbers are permitted (whether real or imaginary) // Compiler will complain if AST folding doesn't create a constant - if (ensure_literal_negative(exp, /*real=*/true, /*imaginary=*/true)) { + if (ensure_literal_signed(exp, /*real=*/true, /*imaginary=*/true)) { return 1; } break; diff --git a/Python/ast_preprocess.c b/Python/ast_preprocess.c index 54dec3dfe042686..951b3f71522b254 100644 --- a/Python/ast_preprocess.c +++ b/Python/ast_preprocess.c @@ -867,11 +867,11 @@ fold_const_match_patterns(expr_ty node, PyArena *ctx_, _PyASTPreprocessState *st { case UnaryOp_kind: { - if (node->v.UnaryOp.op == USub && + if ((node->v.UnaryOp.op == USub || node->v.UnaryOp.op == UAdd) && node->v.UnaryOp.operand->kind == Constant_kind) { PyObject *operand = node->v.UnaryOp.operand->v.Constant.value; - PyObject *folded = PyNumber_Negative(operand); + PyObject *folded = node->v.UnaryOp.op == USub ? PyNumber_Negative(operand) : PyNumber_Positive(operand); return make_const(node, folded, ctx_); } break;