diff --git a/Doc/library/shlex.rst b/Doc/library/shlex.rst index 2ab12f2f6f9169..24fe6e2ac18cd7 100644 --- a/Doc/library/shlex.rst +++ b/Doc/library/shlex.rst @@ -44,12 +44,15 @@ The :mod:`!shlex` module defines the following functions: .. versionadded:: 3.8 -.. function:: quote(s) +.. function:: quote(s, *, force=False) Return a shell-escaped version of the string *s*. The returned value is a string that can safely be used as one token in a shell command line, for cases where you cannot use a list. + If *force* is :const:`True` then *s* will be quoted even if it is already + safe for a shell without being quoted. + .. _shlex-quote-warning: .. warning:: @@ -91,8 +94,23 @@ The :mod:`!shlex` module defines the following functions: >>> command ['ls', '-l', 'somefile; rm -rf ~'] + The *force* keyword can be used to produce consistent behavior when + escaping multiple strings: + + >>> from shlex import quote + >>> filenames = ['my first file', 'file2', 'file 3'] + >>> filenames_some_escaped = [quote(f, force=False) for f in filenames] + >>> filenames_some_escaped + ["'my first file'", 'file2', "'file 3'"] + >>> filenames_all_escaped = [quote(f, force=True) for f in filenames] + >>> filenames_all_escaped + ["'my first file'", "'file2'", "'file 3'"] + .. versionadded:: 3.3 + .. versionchanged:: next + The *force* keyword was added. + The :mod:`!shlex` module defines the following class: diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index c4dac339be66af..70996b5425a792 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1739,6 +1739,15 @@ New deprecations Hugo van Kemenade in :gh:`148100`.) +* :mod:`shlex`: + + * :func:`shlex.quote` has a new keyword-only parameter *force* that ensures + a string will always be quoted, even if it is already safe for a shell + without being quoted. + + (Contributed by Jay Berry in :gh:`148846`.) + + * :mod:`struct`: * Calling the ``Struct.__new__()`` without required argument now is diff --git a/Lib/shlex.py b/Lib/shlex.py index 5959f52dd12639..6df6ae5819c860 100644 --- a/Lib/shlex.py +++ b/Lib/shlex.py @@ -317,8 +317,12 @@ def join(split_command): return ' '.join(quote(arg) for arg in split_command) -def quote(s): - """Return a shell-escaped version of the string *s*.""" +def quote(s, *, force=False): + """Return a shell-escaped version of the string *s*. + + If *force* is *True* then *s* will be quoted even if it is + already safe for a shell without being quoted. + """ if not s: return "''" @@ -329,8 +333,11 @@ def quote(s): safe_chars = (b'%+,-./0123456789:=@' b'ABCDEFGHIJKLMNOPQRSTUVWXYZ_' b'abcdefghijklmnopqrstuvwxyz') - # No quoting is needed if `s` is an ASCII string consisting only of `safe_chars` - if s.isascii() and not s.encode().translate(None, delete=safe_chars): + if (not force + and s.isascii() and not s.encode().translate(None, delete=safe_chars) + ): + # No quoting is needed if we're not forcing quoting + # and `s` is an ASCII string consisting only of `safe_chars` return s # use single quotes, and put single quotes into double quotes diff --git a/Lib/test/test_shlex.py b/Lib/test/test_shlex.py index 2a355abdeeb30f..2089206a0ec1a4 100644 --- a/Lib/test/test_shlex.py +++ b/Lib/test/test_shlex.py @@ -341,6 +341,28 @@ def testQuote(self): "'test%s'\"'\"'name'\"'\"''" % u) self.assertRaises(TypeError, shlex.quote, 42) self.assertRaises(TypeError, shlex.quote, b"abc") + # self.assertRaises(TypeError, shlex.quote, None) + + def testForceQuote(self): + # ensure default `force` behavior does not unnecessarily quote strings + self.assertEqual(shlex.quote("no-quotes-needed"), + "no-quotes-needed") + + # ensure `force=False` does not unnecessarily quote strings + self.assertEqual(shlex.quote("no-quotes-needed", force=False), + "no-quotes-needed") + + # ensure `force=True` does quote strings that + # would not be quoted if using `force=False` + self.assertEqual(shlex.quote("no-quotes-needed", force=True), + "'no-quotes-needed'") + + # ensure `force` does not affect outcome for strings that + # need quoting anyways + self.assertEqual(shlex.quote("quotes needed", force=False), + "'quotes needed'") + self.assertEqual(shlex.quote("quotes needed", force=True), + "'quotes needed'") def testJoin(self): for split_command, command in [ diff --git a/Misc/NEWS.d/next/Library/2026-04-21-06-30-59.gh-issue-119670.pMWZfY.rst b/Misc/NEWS.d/next/Library/2026-04-21-06-30-59.gh-issue-119670.pMWZfY.rst new file mode 100644 index 00000000000000..a0fa6c726cb66c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-04-21-06-30-59.gh-issue-119670.pMWZfY.rst @@ -0,0 +1,3 @@ +Add *force* keyword only argument to :func:`shlex.quote` to always quote the +string passed to it, even if it is already safe for a shell without being +quoted.