diff --git a/Lib/idlelib/idle_test/test_query.py b/Lib/idlelib/idle_test/test_query.py index 58c173723a5ada..3b0a3388f64e33 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 d90a0784075d3e..adf871fe4399cb 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 5f9bdc031e544b..6217b8123de76d 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 f408daf728d4a9..68391d5eef2b9f 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 00000000000000..a9fe00a72fe87c --- /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.