From ddbe3a55a264e5f560b0aebdbfdcd1cf1b01f706 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 21 Apr 2026 04:41:38 +0100 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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") From f3756b0fcc204ceebb46634c7557ca122f7e0e5f Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 28 Apr 2026 09:06:31 +0100 Subject: [PATCH 11/15] Tweak whatsnew and news entry for shlex.quote based on picnixz's review --- Doc/whatsnew/3.15.rst | 5 ++--- .../Library/2026-04-21-06-30-59.gh-issue-119670.pMWZfY.rst | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 70996b5425a792..ed71ac8e32222e 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1741,9 +1741,8 @@ New deprecations * :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. + * 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`.) 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 index a0fa6c726cb66c..fc1941be4dc792 100644 --- 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 @@ -1,3 +1,2 @@ -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. +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. From 2e128d5424dd084655a0dc32a758bcb5be4201f4 Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 28 Apr 2026 09:14:44 +0100 Subject: [PATCH 12/15] Tweak shlex.quote docs and doctring based on picnixz's review --- Doc/library/shlex.rst | 4 ++-- Lib/shlex.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Doc/library/shlex.rst b/Doc/library/shlex.rst index 24fe6e2ac18cd7..5c4728c9c2a814 100644 --- a/Doc/library/shlex.rst +++ b/Doc/library/shlex.rst @@ -50,8 +50,8 @@ The :mod:`!shlex` module defines the following functions: 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. + 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: diff --git a/Lib/shlex.py b/Lib/shlex.py index f847b33fbde9fd..952a529b974f5c 100644 --- a/Lib/shlex.py +++ b/Lib/shlex.py @@ -320,8 +320,8 @@ def join(split_command): 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 *force* is *True*, then *s* is unconditionally quoted, + even if it is already safe for a shell without being quoted. """ if not s: return "''" From 188a23968001f99de19509abe7883cf194d9d68b Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 28 Apr 2026 09:15:07 +0100 Subject: [PATCH 13/15] Tweak shlex.quote docs example of using `force` based on picnixz's review Show off the default behavior as `force=False` --- Doc/library/shlex.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/shlex.rst b/Doc/library/shlex.rst index 5c4728c9c2a814..2dfb0246d5d90c 100644 --- a/Doc/library/shlex.rst +++ b/Doc/library/shlex.rst @@ -99,7 +99,7 @@ The :mod:`!shlex` module defines the following functions: >>> 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 = [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] From cd5d9c28996a592feac7f143c78e0e271f9f49ac Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 28 Apr 2026 09:17:04 +0100 Subject: [PATCH 14/15] Tweak shlex.quote comments based on picnixz's review Try not to use don't and we're and should've etc apostrophes. --- Lib/shlex.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/shlex.py b/Lib/shlex.py index 952a529b974f5c..b89ac531d98041 100644 --- a/Lib/shlex.py +++ b/Lib/shlex.py @@ -335,8 +335,8 @@ def quote(s, *, force=False): b'abcdefghijklmnopqrstuvwxyz') 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` + # No quoting is needed if we are 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 e3d9b0568d8ea4a110e06e33911400ccdf87a16e Mon Sep 17 00:00:00 2001 From: jb2170 Date: Tue, 28 Apr 2026 09:32:10 +0100 Subject: [PATCH 15/15] Tweak shlex.quote tests based on picnixz's review Add test to ensure string with single quote inside it (without which the string wouldn't need quoting) is quoted even though `force=False`. --- Lib/test/test_shlex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_shlex.py b/Lib/test/test_shlex.py index 4a7edf59e180fd..2adaee81b06308 100644 --- a/Lib/test/test_shlex.py +++ b/Lib/test/test_shlex.py @@ -348,6 +348,7 @@ def testForceQuote(self): 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 [