Skip to content

Commit a591f70

Browse files
committed
PEP 787: Introducing t-string support in subprosess module
1 parent 7a093da commit a591f70

File tree

2 files changed

+281
-0
lines changed

2 files changed

+281
-0
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,8 @@ peps/pep-0782.rst @vstinner
664664
peps/pep-0783.rst @hoodmane @ambv
665665
peps/pep-0784.rst @gpshead
666666
# ...
667+
peps/pep-0787.rst @ncoghlan @nhumrich
668+
# ...
667669
peps/pep-0789.rst @njsmith
668670
# ...
669671
peps/pep-0801.rst @warsaw

peps/pep-0787.rst

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
PEP: 787
2+
Title: Safer subprocess usage using t-strings
3+
Author: Alyssa Coghlan <ncoghlan@gmail.com>, Nick Humrich <nick@humrich.us>
4+
Discussions-To: Pending
5+
Status: Draft
6+
Type: Standards Track
7+
Requires: 750
8+
Created: 11-Apr-2025
9+
Python-Version: 3.14
10+
11+
Abstract
12+
========
13+
14+
PEP 750 introduced template strings (t-strings) as a generalization of f-strings,
15+
providing a way to safely handle string interpolation in various contexts. This PEP
16+
proposes extending the :mod:`subprocess` and :mod:`shlex` modules to natively support t-strings, enabling
17+
safer and more ergonomic shell command execution with interpolated values, acting as a
18+
reference implementation for the t-string feature.
19+
20+
Motivation
21+
==========
22+
23+
Despite the safety benefits and flexibility that template strings offer in PEP 750,
24+
they lack a concrete reference implementation in the standard library that demonstrates
25+
their practical application. One of the most compelling use cases for t-strings is safe
26+
shell command execution, as noted in the withdrawn PEP 501:
27+
28+
.. code-block:: python
29+
30+
# Unsafe with f-strings:
31+
os.system(f"echo {message_from_user}")
32+
33+
# Safe with t-strings:
34+
subprocess.run(t"echo {message_from_user}", shell=True)
35+
36+
Currently, developers must choose between convenience (using f-strings with potential
37+
security risks) and safety (using more verbose, list-based APIs). By adding native t-string
38+
support to the :mod:`subprocess` module, we provide a practical reference implementation that
39+
demonstrates the value of t-strings while addressing a common security concern.
40+
41+
Rationale
42+
=========
43+
44+
The subprocess module is an ideal candidate for t-string support for several reasons:
45+
46+
1. Command injection vulnerabilities in shell commands are a well-known security risk.
47+
2. The subprocess module already supports both string and list-based command specifications.
48+
3. There's a natural mapping between t-strings and proper shell escaping that provides both convenience and safety.
49+
4. It serves as a practical showcase for t-strings that developers can immediately understand and appreciate.
50+
51+
By extending subprocess to handle t-strings natively, we make it easier to write secure code without sacrificing
52+
the convenience that led many developers to use potentially unsafe f-strings.
53+
54+
Specification
55+
=============
56+
57+
This PEP proposes two main additions to the standard library:
58+
59+
1. A new ``sh()`` renderer function in the :mod:`shlex` module for safe shell command construction
60+
2. Adding t-string support to the :mod:`subprocess` module's core functions,
61+
particularly :class:`subprocess.Popen`, :func:`subprocess.run`, and other related functions
62+
that accept a command argument
63+
64+
65+
Renderer for shell escaping added to :mod:`shlex`
66+
-------------------------------------------------
67+
68+
As a reference implementation, a renderer for safe POSIX shell escaping will be added to
69+
the :mod:`shlex` module. This renderer would be called ``sh`` and would be equivalent to
70+
calling ``shlex.quote`` on each field value in the template literal.
71+
72+
Thus:
73+
74+
.. code-block:: python
75+
76+
os.system(shlex.sh(t"cat {myfile}"))
77+
78+
would have the same behavior as:
79+
80+
.. code-block:: python
81+
82+
os.system("cat " + shlex.quote(myfile)))
83+
84+
The implementation would be:
85+
86+
.. code-block:: python
87+
88+
def sh(template: TemplateLiteral):
89+
def render_field(value, format_spec, conversion_spec)
90+
field_text = format(value, format_spec, conversion_spec)
91+
return quote(field_text)
92+
return template.render(render_field=render_field)
93+
94+
The addition of ``shlex.sh`` will NOT change the existing admonishments in the
95+
:mod:`subprocess` documentation that passing ``shell=True`` is best avoided, nor the
96+
reference from the :func:`os.system` documentation the higher level ``subprocess`` APIs.
97+
98+
Then implementation would look like:
99+
100+
.. code-block:: python
101+
102+
from string.templatelib import Template, Interpolation
103+
104+
def sh(template: Template) -> str:
105+
parts: list[str] = []
106+
for item in template:
107+
if isinstance(item, Interpolation):
108+
parts.append(quote(str(item.value)))
109+
else:
110+
parts.append(item)
111+
112+
return "".join(parts)
113+
114+
This allows for explicit escaping of t-strings for shell usage:
115+
116+
.. code-block:: python
117+
118+
import shlex
119+
# Safe shell command construction
120+
command = shlex.sh(t"cat {filename}")
121+
os.system(command)
122+
123+
Changes to subprocess module
124+
----------------------------
125+
126+
With the additional renderer in the shlex module, and the addition of template strings,
127+
the :mod:`subprocess` module can be changed to handle accepting template strings
128+
as an additional input type to ``Popen``, as it already accepts a sequence, or a string,
129+
with different behavior for each. In return, all :class:`subprocess.Popen` higher level
130+
functions such as :func:`subprocess.run` could accept strings in a safe way
131+
(at least on :ref:`POSIX systems <pep-9999-defer-non-posix-shells>`).
132+
133+
For example:
134+
135+
.. code-block:: python
136+
137+
subprocess.run(t"cat {myfile}", shell=True)
138+
139+
would automatically use the ``shlex.sh`` renderer provided in this PEP. Therefore, using
140+
``shlex`` inside a ``subprocess.run`` call like so:
141+
142+
.. code-block:: python
143+
144+
subprocess.run(shlex.sh(t"cat {myfile}"), shell=True)
145+
146+
would be redundant, as ``run`` would automatically render any template literals
147+
through ``shlex.sh``
148+
149+
Alternatively, when ``subprocess.Popen`` is run without ``shell=True``, it could still
150+
provide subprocess with a more ergonomic syntax. For example:
151+
152+
.. code-block:: python
153+
154+
subprocess.run(t"cat {myfile} --flag {value}")
155+
156+
would be equivalent to:
157+
158+
.. code-block:: python
159+
160+
subprocess.run(["cat", myfile, "--flag", value])
161+
162+
or, more accurately:
163+
164+
.. code-block:: python
165+
166+
subprocess.run(shlex.split(f"cat {shlex.quote(myfile)} --flag {shlex.quote(value)}"))
167+
168+
It would do this by first using the ``shlex.sh`` renderer, as above, then using
169+
``shlex.split`` on the result.
170+
171+
The implementation inside ``subprocess.Popen._execute_child`` would check for t-strings:
172+
173+
.. code-block:: python
174+
175+
from string.templatelib import Template, Interpolation
176+
177+
if isinstance(args, Template):
178+
import shlex
179+
if shell:
180+
args = shlex.sh(args)
181+
else:
182+
args = shlex.split(shlex.sh(args))
183+
184+
Backwards Compatibility
185+
=======================
186+
187+
This change is fully backwards compatible as it only adds new functionality without altering existing behavior.
188+
The subprocess module will continue to handle strings and lists in the same way it currently does.
189+
190+
Security Implications
191+
=====================
192+
193+
This PEP is explicitly designed to improve security by providing a safer alternative to using
194+
f-strings with shell commands. By automatically applying appropriate escaping based on context
195+
(shell or non-shell), it helps prevent command injection vulnerabilities.
196+
197+
However, it's worth noting that when ``shell=True`` is used, the safety is limited to
198+
POSIX-compliant shells. On Windows systems where cmd.exe or PowerShell may be used as the shell,
199+
the escaping mechanism provided by :func:`shlex.quote` may not be sufficient to prevent all forms
200+
of command injection.
201+
202+
How to Teach This
203+
=================
204+
205+
This feature can be taught as a natural extension of t-strings that demonstrates their practical value:
206+
207+
1. Introduce the problem of command injection and why f-strings are dangerous with shell commands
208+
2. Show the traditional solutions (list-based commands, manual escaping)
209+
3. Introduce the ``shlex.sh`` renderer for explicit shell escaping:
210+
211+
.. code-block:: python
212+
213+
# Unsafe:
214+
os.system(f"cat {filename}") # Potential command injection!
215+
216+
# Safe using shlex.sh:
217+
os.system(shlex.sh(t"cat {filename}")) # Explicitly escaping for shell
218+
219+
4. Introduce the subprocess module's native t-string support:
220+
221+
.. code-block:: python
222+
223+
# Unsafe:
224+
subprocess.run(f"cat {filename}", shell=True) # Potential command injection!
225+
226+
# Safe but verbose:
227+
subprocess.run(["cat", filename])
228+
229+
# Safe and readable with t-strings:
230+
subprocess.run(t"cat {filename}", shell=True) # Automatically escapes filename
231+
subprocess.run(t"cat {filename}") # Automatically converts to list form
232+
233+
The implementation should be added to both the shlex and subprocess module documentation with clear
234+
examples and security advisories.
235+
236+
.. _pep-9999-defer-non-posix-shells:
237+
238+
Deferring escaped rendering support for non-POSIX shells
239+
--------------------------------------------------------
240+
241+
:func:`shlex.quote` works by classifying the regex character set ``[\w@%+=:,./-]`` to be
242+
safe, deeming all other characters to be unsafe, and hence requiring quoting of the string
243+
containing them. The quoting mechanism used is then specific to the way that string quoting
244+
works in POSIX shells, so it cannot be trusted when running a shell that doesn't follow
245+
POSIX shell string quoting rules.
246+
247+
For example, running ``subprocess.run(f"echo {shlex.quote(sys.argv[1])}", shell=True)`` is
248+
safe when using a shell that follows POSIX quoting rules::
249+
250+
$ cat > run_quoted.py
251+
import sys, shlex, subprocess
252+
subprocess.run(f"echo {shlex.quote(sys.argv[1])}", shell=True)
253+
$ python3 run_quoted.py pwd
254+
pwd
255+
$ python3 run_quoted.py '; pwd'
256+
; pwd
257+
$ python3 run_quoted.py "'pwd'"
258+
'pwd'
259+
260+
but remains unsafe when running a shell from Python invokes ``cmd.exe`` (or Powershell)::
261+
262+
S:\> echo import sys, shlex, subprocess > run_quoted.py
263+
S:\> echo subprocess.run(f"echo {shlex.quote(sys.argv[1])}", shell=True) >> run_quoted.py
264+
S:\> type run_quoted.py
265+
import sys, shlex, subprocess
266+
subprocess.run(f"echo {shlex.quote(sys.argv[1])}", shell=True)
267+
S:\> python3 run_quoted.py "echo OK"
268+
'echo OK'
269+
S:\> python3 run_quoted.py "'& echo Oh no!"
270+
''"'"'
271+
Oh no!'
272+
273+
Resolving this standard library limitation is beyond the scope of this PEP.
274+
275+
Copyright
276+
=========
277+
278+
This document is placed in the public domain or under the
279+
CC0-1.0-Universal license, whichever is more permissive.

0 commit comments

Comments
 (0)