https://github.com/python/cpython/commit/ae507e3b14a133b62b3aae9cd531a7dbfdaa8f24
commit: ae507e3b14a133b62b3aae9cd531a7dbfdaa8f24
branch: main
author: jb2170 <[email protected]>
committer: vstinner <[email protected]>
date: 2026-06-04T11:06:02+02:00
summary:

gh-119670: Add `force` keyword only argument to `shlex.quote` (#148846)

There are propositions to add a single-quote-double-quote switch
(gh-90630), so to avoid hiccups of people passing `force` as a
positional and it being used for the single-double switch, we make
kwargs kwargs-only.

Co-authored-by: Bartosz Sławecki <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-04-21-06-30-59.gh-issue-119670.pMWZfY.rst
M Doc/library/shlex.rst
M Doc/whatsnew/3.16.rst
M Lib/shlex.py
M Lib/test/test_shlex.py

diff --git a/Doc/library/shlex.rst b/Doc/library/shlex.rst
index 2ab12f2f6f9169e..2dfb0246d5d90c0 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* is unconditionally 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) 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.16.rst b/Doc/whatsnew/3.16.rst
index 0aff48dba61449c..a055113dec0494c 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -109,6 +109,13 @@ os
   process via a pidfd.  Available on Linux 5.6+.
   (Contributed by Maurycy Pawłowski-Wieroński in :gh:`149464`.)
 
+shlex
+-----
+
+* Add keyword-only parameter *force* to :func:`shlex.quote` to force quoting
+  a string, even if it is already safe for a shell without being quoted.
+  (Contributed by Jay Berry in :gh:`148846`.)
+
 xml
 ---
 
diff --git a/Lib/shlex.py b/Lib/shlex.py
index 5959f52dd12639d..c7ffc918d53961c 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* is unconditionally quoted,
+    even if it is already safe for a shell without being quoted.
+    """
     if not s:
         return "''"
 
@@ -329,8 +333,10 @@ 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):
+    # No quoting is needed if we are not forcing quoting
+    # and `s` is an ASCII string consisting only of `safe_chars`.
+    if (not force
+        and s.isascii() and not s.encode().translate(None, delete=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 2a355abdeeb30fb..2adaee81b063085 100644
--- a/Lib/test/test_shlex.py
+++ b/Lib/test/test_shlex.py
@@ -342,6 +342,14 @@ def testQuote(self):
         self.assertRaises(TypeError, shlex.quote, 42)
         self.assertRaises(TypeError, shlex.quote, b"abc")
 
+    def testForceQuote(self):
+        self.assertEqual(shlex.quote("spam"), "spam")
+        self.assertEqual(shlex.quote("spam", force=False), "spam")
+        self.assertEqual(shlex.quote("spam", force=True), "'spam'")
+        self.assertEqual(shlex.quote("spam eggs", force=False), "'spam eggs'")
+        self.assertEqual(shlex.quote("spam eggs", force=True), "'spam eggs'")
+        self.assertEqual(shlex.quote("two's-complement", force=False), 
"'two'\"'\"'s-complement'")
+
     def testJoin(self):
         for split_command, command in [
             (['a ', 'b'], "'a ' b"),
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 000000000000000..fc1941be4dc7925
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-04-21-06-30-59.gh-issue-119670.pMWZfY.rst
@@ -0,0 +1,2 @@
+Add keyword-only parameter *force* to :func:`shlex.quote` to force quoting
+a string, even if it is already safe for a shell without being quoted.

_______________________________________________
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