From c9177fcb9ac452dc33c05b10ee41f9b05312796e Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 16 Sep 2026 17:30:46 +0300 Subject: [PATCH] gh-93016: Fix parsing of arguments in the IDLE "Run... Customized" dialog On Windows, split the command line as the Python executable does instead of using the POSIX rules, so that backslashes are not escape characters. Display previous arguments quoted and joined, not as a Tcl list. --- Lib/idlelib/idle_test/test_query.py | 15 +++++ Lib/idlelib/idle_test/test_util.py | 35 +++++++++++- Lib/idlelib/query.py | 11 ++-- Lib/idlelib/util.py | 55 +++++++++++++++++++ ...6-09-16-20-00-00.gh-issue-93016.cliarg.rst | 3 + 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2026-09-16-20-00-00.gh-issue-93016.cliarg.rst 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/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/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.