From ddbe3a55a264e5f560b0aebdbfdcd1cf1b01f706 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 21 Apr 2026 04:41:38 +0100 Subject: [PATCH 01/10] shlex: Implement `force` parameter behavior for `shlex.quote` --- Lib/shlex.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Lib/shlex.py b/Lib/shlex.py index 5959f52dd12639..47abd496ccc160 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 From 0e722d6818f30bf5c411e4a1af601d5aca04ce9f Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 21 Apr 2026 04:43:22 +0100 Subject: [PATCH 02/10] shlex: Make `force` a keyword only argument in `shlex.quote` 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. --- Lib/shlex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/shlex.py b/Lib/shlex.py index 47abd496ccc160..6df6ae5819c860 100644 --- a/Lib/shlex.py +++ b/Lib/shlex.py @@ -317,7 +317,7 @@ def join(split_command): return ' '.join(quote(arg) for arg in split_command) -def quote(s, force=False): +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 From fd4af184f3254a1137c832fb85ed4aa71fe83ba9 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 21 Apr 2026 04:48:53 +0100 Subject: [PATCH 03/10] shlex tests: Add testForceQuote Test special cases of strings that don't need quoting, do need quoting, do use `force`, don't use `force` etc. I've tried to be exhaustive. --- Lib/test/test_shlex.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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 [ From 78b6f3b7d6bb2ad3a4fbd5807f083b464b916890 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 21 Apr 2026 06:26:41 +0100 Subject: [PATCH 04/10] shlex: Update documentation to mention `shlex.quote`'s `force` kwarg --- Doc/library/shlex.rst | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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: From 762999d9e35f13f12f596c976fd4961aec5d88f1 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 21 Apr 2026 06:31:13 +0100 Subject: [PATCH 05/10] Add blurb entry for gh-119670: Add force keyword only argument to `shlex.force` --- .../Library/2026-04-21-06-30-59.gh-issue-119670.pMWZfY.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-04-21-06-30-59.gh-issue-119670.pMWZfY.rst 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. From 2a301a5b3535bde40a94b5c956541d55a96af36c Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 21 Apr 2026 19:38:02 +0100 Subject: [PATCH 06/10] Add whatsnew entry for `shlex.quote` gaining `force` kwarg --- Doc/whatsnew/3.15.rst | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From f470aa14b057b99134374136f3556de55b18e025 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Wed, 22 Apr 2026 18:59:34 +0100 Subject: [PATCH 07/10] shlex.quote: improve formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartosz Sławecki --- Lib/shlex.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/shlex.py b/Lib/shlex.py index 6df6ae5819c860..f847b33fbde9fd 100644 --- a/Lib/shlex.py +++ b/Lib/shlex.py @@ -334,8 +334,7 @@ def quote(s, *, force=False): b'ABCDEFGHIJKLMNOPQRSTUVWXYZ_' b'abcdefghijklmnopqrstuvwxyz') if (not force - and s.isascii() and not s.encode().translate(None, delete=safe_chars) - ): + 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 From 5450159e6bcbcc4749448784315a6be3e696860a Mon Sep 17 00:00:00 2001 From: jb2170 Date: Wed, 22 Apr 2026 19:01:53 +0100 Subject: [PATCH 08/10] shlex.quote tests: Remove comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments for why these tests exist can always be found in the git history Co-authored-by: Bartosz Sławecki --- Lib/test/test_shlex.py | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/Lib/test/test_shlex.py b/Lib/test/test_shlex.py index 2089206a0ec1a4..127aa561af08d0 100644 --- a/Lib/test/test_shlex.py +++ b/Lib/test/test_shlex.py @@ -345,24 +345,11 @@ def testQuote(self): 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'") + 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'") def testJoin(self): for split_command, command in [ From bcd8b63b8ab261d507285a00ba27a5cebe21b626 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Wed, 22 Apr 2026 19:04:36 +0100 Subject: [PATCH 09/10] shlex.quote tests: Remove another comment Missed from last commit --- Lib/test/test_shlex.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_shlex.py b/Lib/test/test_shlex.py index 127aa561af08d0..80701001793aaa 100644 --- a/Lib/test/test_shlex.py +++ b/Lib/test/test_shlex.py @@ -344,7 +344,6 @@ def testQuote(self): # self.assertRaises(TypeError, shlex.quote, None) def testForceQuote(self): - # ensure default `force` behavior does not unnecessarily quote strings self.assertEqual(shlex.quote("spam"), "spam") self.assertEqual(shlex.quote("spam", force=False), "spam") self.assertEqual(shlex.quote("spam", force=True), "'spam'") From d000ccd17276c04ca533d683b1f2bbad86aa0b91 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Wed, 22 Apr 2026 19:15:40 +0100 Subject: [PATCH 10/10] shlex tests: Remove misplaced comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intended for another branch shlex-quote-typeerror Co-authored-by: Bartosz Sławecki --- Lib/test/test_shlex.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_shlex.py b/Lib/test/test_shlex.py index 80701001793aaa..4a7edf59e180fd 100644 --- a/Lib/test/test_shlex.py +++ b/Lib/test/test_shlex.py @@ -341,7 +341,6 @@ 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): self.assertEqual(shlex.quote("spam"), "spam")