https://github.com/python/cpython/commit/2a38a3dcb2c1f5a3639de006652603d9477722d7
commit: 2a38a3dcb2c1f5a3639de006652603d9477722d7
branch: 3.13
author: Miss Islington (bot) <[email protected]>
committer: terryjreedy <[email protected]>
date: 2026-09-23T23:08:21-04:00
summary:

[3.13] gh-93016: Fix parsing of arguments in the IDLE "Run... Customized" 
dialog (GH-157635) (#158050)

gh-93016: Fix parsing of arguments in the IDLE "Run... Customized" dialog 
(GH-157635)

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.
(cherry picked from commit db2d1a5a159b8e7862648750f80cde0b30d02f47)

Co-authored-by: Serhiy Storchaka <[email protected]>

files:
A Misc/NEWS.d/next/IDLE/2026-09-16-20-00-00.gh-issue-93016.cliarg.rst
M Lib/idlelib/idle_test/test_query.py
M Lib/idlelib/idle_test/test_util.py
M Lib/idlelib/query.py
M Lib/idlelib/util.py

diff --git a/Lib/idlelib/idle_test/test_query.py 
b/Lib/idlelib/idle_test/test_query.py
index 5d0176af42f03e..cb33a6a224ae3c 100644
--- a/Lib/idlelib/idle_test/test_query.py
+++ b/Lib/idlelib/idle_test/test_query.py
@@ -281,11 +281,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"')
@@ -445,6 +454,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 0acf4f0fe5a2c0..84825107c3f895 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
@@ -163,5 +163,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 22b7b0763f9b10..636ca95ba92892 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<backslashes>\\*)(?P<quotes>"+)
+  | (?P<literal>\\+|[^ \t"\\]+)  # backslashes not followed by a quote
+  | (?P<space>[ \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.

_______________________________________________
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