Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Lib/idlelib/idle_test/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"')
Expand Down Expand Up @@ -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()


Expand Down
35 changes: 34 additions & 1 deletion Lib/idlelib/idle_test/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
11 changes: 6 additions & 5 deletions Lib/idlelib/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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):
Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions Lib/idlelib/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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.
Loading