翻译
Python Subprocess: Run External Commands
尽管 PyPI 上有很多库,但有时你需要在 Python 代码中运行一个外部命令。内置的 Python subprocess 模块使之相对容易。在这篇文章中,你将学习一些关于进程和子进程的基本知识。
我们将使用 Python subprocess 模块来安全地执行外部命令,获取输出,并有选择地向它们提供来自标准输入的输入。 如果你熟悉进程和子进程的理论,你可以跳过第一部分。
进程和子进程
一个在计算机上执行的程序也被称为一个进程。但究竟什么是进程?让我们更正式地定义它。
进程
进程是一个计算机程序的实例,由一个或多个线程执行。
一个进程可以有多个线程,这被称为多线程。反过来,一台计算机可以同时运行多个进程。这些进程可以是不同的程序,但它们也可以是同一个程序的多个实例。在我们关于 Python并发性 的文章中,对此有非常详细的解释。下面的图片也来自那篇文章:
如果你想运行一个外部命令,这意味着你需要从你的 Python 进程中创建一个新的进程。这样的进程通常被称为子进程或 sub-process 。从视觉上看,这就是一个进程产生两个子进程的情况:
在内部(操作系统内核内部)发生的是所谓的 fork。进程自己 fork,意味着该进程的一个新副本被创建和启动。如果你想使你的代码并行化,并利用你机器上的多个CPU,这可能是有用的。这就是我们所说的多进程。
不过,我们可以利用相同的技术来启动另一个进程。首先,进程 fork 自己,创建一个副本。该副本将自身替换为另一个进程:你希望执行的进程。
我们可以采用低级别的方式,使用 Python subprocess 模块来完成这些工作,但幸运的是,Python 还提供了一个包装器,可以处理所有细节,并且这样做也很安全。多亏了包装器,运行外部命令只需要调用一个函数。这个封装器就是 subprocess 库中的函数 run(),这就是我们将在本文中使用的。
我认为让你知道内部发生了什么会很好,但如果你感到困惑,请放心,你不需要这些知识就能做到你想要的:用 Python subprocess 模块运行外部命令。
使用 subprocess.run 创建一个 Python subprocess
理论讲得够多了,现在是时候动手写一些代码来执行外部命令了。
首先,您需要导入 subprocess 库。 由于它是 Python 3 的一部分,因此你无需单独安装它。 在这个库中,我们将使用 run 命令。 这个命令是在 Python 3.5 中添加的。 确保你至少有这个 Python 版本,但最好是运行最新版本。 如果你需要帮助,请查看我们详细的 Python 安装说明。
让我们从对 ls 的简单调用开始,列出当前目录和文件:
>>> import subprocess
>>> subprocess.run(['ls', '-al'])
(a list of your directories will be printed)
事实上,我们可以从我们的 Python 代码中调用 Python 二进制文件 。 接下来让我们获取系统上默认安装的 python 3 版本:
>>> import subprocess
>>> result = subprocess.run(['python3', '--version'])
Python 3.8.5
>>> result
CompletedProcess(args=['python3', '--version'], returncode=0)
逐行解释:
- 我们导入 subprocess 库
- 运行一个 subprocess ,在这里是 python3 二进制文件,有一个参数:--version
- 查看 result 变量,它的类型是 CompletedProcess
该进程返回代码 0,表示它执行成功。 任何其他返回码都意味着存在某种错误。 这取决于你调用的进程定义的不同返回代码的含义。
正如你在输出中看到的,Python 二进制文件将其版本号打印在标准输出上,这通常是你的终端。你的结果可能不同,因为你的 Python 版本可能不同。也许,你甚至会得到一个看起来像这样的错误。FileNotFoundError: [Errno 2] No such file or directory: 'python3'。在这种情况下,请确保 python3 的 Python 二进制文件在你的系统上,并且也在PATH中。
捕获 Python subprocess 的输出
如果你运行一个外部命令,你很可能想捕获该命令的输出。我们可以通过 capture_output=True 选项实现这一目的:
>>> import subprocess
>>> result = subprocess.run(['python3', '--version'], capture_output=True, encoding='UTF-8')
>>> result
CompletedProcess(args=['python3', '--version'], returncode=0, stdout='Python 3.8.5\n', stderr='')
正如你所看到的,Python 这次没有把它的版本打印到我们的终端。subprocess.run 命令重定向了标准输出和标准错误流,所以可以捕获它们并为我们存储在 result 中 。查看 result 变量,我们看到 Python 的版本是从标准输出中捕获的。由于没有错误,stderr是空的。
我还添加了 encoding='UTF-8' 选项。如果你不这样做,subprocess.run 会认为输出是一个字节流,因为它没有这个信息。你可以试试。结果是,stdout 和 stderr 将是字节数组。因此,如果你知道输出将是 ASCII文本或 UTF-8 文本,你最好指定它,以便运行函数对捕获的输出也进行相应编码。
另外,你也可以使用选项 text=True 而不指定编码。Python 将把输出作为文本捕获。如果你知道编码,我建议明确指定它。
从标准输入输入数据
如果外部命令期望在标准输入上获得数据,我们也可以通过 Python 的 subprocess.run 函数的 input 选项来轻松实现。请注意,我不会在这里讨论流数据。在这里我们将建立在前面的例子上:
>>> import subprocess
>>> code = """
... for i in range(1, 3):
... print(f"Hello world {i}")
... """
>>> result = subprocess.run(['python3'], input=code, capture_output=True, encoding='UTF-8')
>>> print(result.stdout)
>>> print(result.stdout)
Hello world 1
Hello world 2
我们只是用 Python3 二进制文件来执行一些 Python 代码。完全无用,但 (希望) 非常有指导意义!
code 变量是一个多行的 Python 字符串,我们用 input 选项将其作为输入分配给 subprocess.run 命令。
运行 shell 命令
如果你想在类 Unix 系统上执行 shell 命令,我指的是你通常会在类似 Bash 的 shell 中输入的任何命令,你需要意识到,这些命令通常不是执行的外部二进制文件。例如,像 for 和 while 循环这样的表达式,或者管道和其它操作符,是由 shell 本身解释的。
Python 常常以内置库的形式提供替代方案,你应该更喜欢这些方案。但是如果你需要执行一个 shell 命令,不管是什么原因,当你使用 shell=True 选项时,subprocess.run 会很乐意这样做。它允许你输入命令,就像你在一个与 Bash 兼容的 shell 中输入一样:
>>> import subprocess
>>> result = subprocess.run(['ls -al | head -n 1'], shell=True)
total 396
>>> result
CompletedProcess(args=['ls -al | head -n 1'], returncode=0)
但有一个警告:使用这种方法容易受到命令注入攻击(见:注意事项)。
需要注意的事项
运行外部命令并非没有风险。请非常仔细地阅读本节。
os.system vs subprocess.run
你可能会看到 os.system() 用于执行命令的代码示例。 不过,subprocess 模块更加强大,官方 Python 文档推荐使用它而不是 os.system()。os.system 的另一个问题是,它更容易被注入命令。
命令注入
一种常见的攻击或漏洞,是注入额外的命令来获得对计算机系统的控制。 例如,如果你要求你的用户输入并在调用 os.system() 或调用 subprocess.run(...., shell=True) 时使用这些输入,你就有可能受到命令注入攻击。
为了演示,下面的代码允许我们运行任何 shell 命令。
import subprocess
thedir = input()
result = subprocess.run([f'ls -al {thedir}'], shell=True)
因为我们直接使用了用户的输入,用户只需在其后面加上分号,就可以运行任何命令。例如,下面的输入将列出/目录并回显一个文本。自己试试吧。
/; echo "command injection worked!";
解决方案不是尝试清理用户的输入。你可能很想开始寻找分号,并在发现分号时拒绝输入。不要这样做;黑客们在这种情况下至少能想到5种其他的追加命令的方法。这是一场艰苦的战斗。
更好的解决办法是不使用shell=True,而是像我们在前面的例子中那样在一个列表中输入命令。像这样的输入在这种情况下会失败,因为 subprocess 模块会确定输入是你正在执行的程序的参数,而不是一个新的命令。
使用同样的输入,但 shell=False,你会得到下面的结果。
import subprocess
thedir = input()
>>> result = subprocess.run([f'ls -al {thedir}'], shell=False)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.8/subprocess.py", line 489, in run
with Popen(*popenargs, **kwargs) as process:
File "/usr/lib/python3.8/subprocess.py", line 854, in __init__
self._execute_child(args, executable, preexec_fn, close_fds,
File "/usr/lib/python3.8/subprocess.py", line 1702, in _execute_child
raise child_exception_type(errno_num, err_msg, err_filename)
FileNotFoundError: [Errno 2] No such file or directory: 'ls -al /; echo "command injection worked!";'
该命令被当作 ls 的一个参数,而 ls 则告诉我们,它找不到那个文件或目录。
用户输入总是危险的
事实上,使用用户输入总是危险的,不仅仅是因为命令注入。例如,假设你允许用户输入一个文件名。之后,我们读取该文件并将其显示给用户。虽然这看起来无害,但用户可以输入这样的内容:.../.../.../configuration/settings.yaml。
其中 settings.yaml 可能包含你的数据库密码......哎呀! 你总是需要对用户输入进行适当的清理和检查。不过,如何正确地做到这一点,已经超出了本文的范围。
继续学习
以下相关资源将帮助你更深入地研究这个主题:
- 官方文档 中有关于 subprocess 库的所有细节
- 我们关于 Python 并发 的文章解释了关于进程和线程的更多信息
- 我们关于 使用 Unix shell 的部分可能会派上用场
- 学习一些 基本的 Unix 命令
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。