python subprocess-更优雅的创建子进程
简介
在任何编程语言中,启动进程都是非常常见的任务,python也是如此,而不正确的启动进程方式会给程序带来很大安全风险。Subprocess模块开发之前,标准库已有大量用于进程创建的接口函数(如
os.system
、
os.spawn*
),但是略显混乱使开发者难以抉择,因此Subprocess的目的是打造一个“统一”模块来提供之前进程创建相关函数的功能实现。与之前的相关接口相比,提供了以下增强功能:
- 一个“统一”的模块来提供以前进程创建相关函数的所有功能;
- 跨进程异常优化:子进程中的异常会在父进程再次抛出,以便检测子进程执行情况;
-
提供用于在
fork和exec之间执行自定义代码的钩子; -
没有隐式调用
/bin/sh,这意味着不需要对危险的shell meta characters进行转义; - 支持文件描述符重定向的所有组合;
- 使用subprocess模块,可以控制在执行新程序之前是否应关闭所有打开的文件描述符;
- 支持连接多个子进程 ;
- 支持universal newline;
-
支持
communication()方法,它使发送stdin数据以及读取stdout和stderr数据变得容易,而没有死锁的风险;
subprocess 基础
subprocess.run
subprocess
推荐使用
run
函数来处理它所能够处理的一切cases, 如果需要更高级灵活的定制化使用,则可以使用其底层的
popen
接口来实现。
run
函数signature为:
def subprocess.run(args, *, stdin=None, input=None, stdout=None, stderr=None, capture_output=False, shell=False, cwd=None, timeout=None, check=False, encoding=None, errors=None, text=None, env=None, universal_newlines=None, *other_popen_kwargs)->subprocess.CompletedProcess:
pass
上面写的参数只是最常见的参数,完整的函数列表在很大程度上与
popen
函数的参数相同,即这个函数的大多数参数都被传递到该接口。(
timeout
、
input
、
check
和
capture_output
除外)。该函数常用参数如下:
-
args, 必选参数,数据类型应为一个string 或则 一个sequence(list, tuple等等)。通常最好传递一个sequence,因为它允许模块处理任何必需的参数转义和引用; 如果传递的是字符串,则shell必须为True,否则该字符串必须简单地为要执行的程序的名字,而不能指定任何参数。
在复杂情况下,构建一个sequence-like的参数可以借助shlex.split()来实现
>>> import shlex, subprocess
>>> command_line = input()
/bin/vikings -input eggs.txt -output "spam spam.txt" -cmd "echo '$MONEY'"
>>> args = shlex.split(command_line)
>>> print(args)
['/bin/vikings', '-input', 'eggs.txt', '-output', 'spam spam.txt', '-cmd', "echo '$MONEY'"]
>>> p = subprocess.Popen(args) # Success!
shell模式执行等同于:
Popen(['/bin/sh', '-c', args[0], args[1], ...])
## 以下两句代码等价,都是通过shell模式执行`ls -l`
subprocess.run('ls -l', shell=True)
subprocess.run(['/bin/sh', '-c', 'ls -l'], shell=False)
## 下面代码通过非shell模式执行`ls -l`
subprocess.run(['ls', '-l'], shell=False)
# 下面代码实际执行的是`ls`
subprocess.run(['/bin/sh', '-c', 'ls', '-l'], shell=False)
当使用
shell=True
时,要注意可能潜在的安全问题,需要确保所有空格和元字符都被适当地引用,以避免shell注入漏洞。如下面的例子:
from shlex import quote
>>> filename = 'somefile; rm -rf ~' # 有这么一个奇怪的文件名
>>> command = 'ls -l {}'.format(filename)
>>> print(command) # executed by a shell: boom!
ls -l somefile; rm -rf ~
>>> subprocess.run(command, shell=True) # 这时就会有极大的安全隐患
>>> command = 'ls -l {}'.format(quote(filename)) # 使用shlex.quote对文件名进行正确的转义
>>> print(command)
ls -l 'somefile; rm -rf ~'
>>> subprocess.run(command, shell=True)
-
capture_output, 如果capture_output=True,则将捕获stdout和stderr,调用时内部的Popen对象将自动使用stdout=PIPE和stderr = PIPE创建标准输出和标准错误对象;传递stdout和stderr参数时不能同时传递capture_output参数。如果希望捕获并将两个stream合并为一个,使用stdout=PIPE和stderr = STDOUT。 -
check,如果check=True,并且进程以非零退出代码退出,则将抛出CalledProcessError异常。 -
input,该参数传递给Popen.communicate(),然后传递给子进程的stdin。该参数数据类型应为字节序列(bytes);但如果指定了encoding,errors参数或则text=True,参数则必须为字符串。使用该参数时,内部Popen对象,将使用stdin = PIPE自动创建该对象,不能同时使用stdin参数。 -
timeout,该参数传递给Popen.communicate(),如果指定时间之后子进程仍未结束,子进程将被kill,并抛出TimeoutExpired异常。 -
stdin,stdout和stderr分别指定执行程序的标准输入,标准输出和标准错误文件的file handles。如subprocess.PIPE,subprocess.DEVNULL, 或者None。此外,stderr可以设定为subprocess.STDOUT,这表示来自子进程的stderr数据应重定向到与stdout相同的file handle中。默认情况下,stdin,stdout和stderr对应的file handle都是以binary的方式打开。 -
encoding,errors,text。当传递encoding,errors参数 或text=True时,stdin,stdout和stderr对应的file handle以text的模式打开。universal_newlines和text同义,为了保持向下兼容而保留。默认情况下,文件对象以二进制的方式打开。 -
env,通过传递mappings对象,给子进程提供环境变量,该参数直接传递给Popen函数。 -
shell, 如果shell=True,则将通过Shell执行指定的命令。当使用shell=True时,shlex.quote()函数可用于正确地转义字符串中的空格和Shell元字符。 -
函数返回数据类型为
subprocess.CompletedProcess, 该对象包含以下属性或方法:
-
args, 调用该进程的参数,同subprocess.run(args,***)中的args; -
returncode,当值为0时,代表子进程执行成功;负值-N指示进程被signalN所终止 (POSIX only);None代表未终止; -
stdout,stderr,代表子进程的标准输出和标准错误; -
check_returncode(), check子进程是否执行成功,若执行失败将抛出异常;
old high level interfaces
run
函数在 Python 3.5 新增,之前使用该模块的high level interface包括三个函数:
call()
,
check_call()
,
check_output()
。这三个函数参数和
subprocess.run()
的函数参数含义相同。但需要注意的是,这三个函数的参数列表略微不同,函数signature如下:
-
subprocess.call(args, *, stdin=None, stdout=None, stderr=None, shell=False, cwd=None, timeout=None, **other_popen_kwargs)
执行
args
参数所指定的程序并等待其完成。当
shell=True
, 无论子进程执行成功与否,返回值为return code;当
shell=False
,子进程如果执行失败,将会抛出异常;
该函数旨在对
os.system()
进行功能增强,同时易于使用
。
-
subprocess.check_call(args, *, stdin=None, stdout=None, stderr=None, shell=False, cwd=None, timeout=None, **other_popen_kwargs)
执行args参数所指定的程序并等待其完成,如果子进程返回0,则函数返回;若子进程失败,则抛出异常; -
subprocess.check_output(args, *, stdin=None, stderr=None, shell=False, cwd=None, encoding=None, errors=None, universal_newlines=None, timeout=None, text=None, **other_popen_kwargs)
执行args参数所指定的程序并返回其输出,如果子进程执行失败将抛出异常; 该函数的返回值默认为bytes
注意: 请勿在subprocess.call及``subprocess.check_call中使用stdout=PIPE或stderr=PIPE。如果子进程输出信息过大将会耗尽OS管道缓冲区的缓冲,该子进程将阻塞; 要禁止这两个函数的stdout或stderr,可以通过subprocess.DEVNULL`设置。
subprocess.Popen
Popen构造函数
上面四个high level interfaces 底层的进程创建及进程管理实际上都是基于
subprocess.Popen
类来实现,当需要定制化更灵活的进程调用时,这个函数会是一个更好的选择。首先看该类的构造函数如下:
class subprocess.Popen(args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, shell=False, cwd=None, env=None, universal_newlines=None, startupinfo=None, creationflags=0,start_new_session=False,restore_signals=True,close_fds=True,pass_fds=(), *, encoding=None, errors=None, text=None):
pass
此时,你会发现很多熟悉的参数。因为high level function本身大部分参数也确实传递给了
Popen
类。该类的作用即是创建 (fork) 并执行 (exec) 子进程。在POSIX,该类使用类似
os.execvp()
的方式执行子进程;在windows上,则使用windows系统的
CreateProcess()
函数执行子进程。
Popen
构造函数参数十分丰富,除了上面介绍的还有一大堆参数需要注意。
close_fds
,
pass_fds
与file handle相关;
restore_signals
与POSIX信号相关;
start_new_session
,
startupinfo
,
creationflags
则和子进程的创建相关;另外,以下参数可能需要特别关注:
-
bufsize, 在创建stdin / stdout / stderr管道文件对象时,bufsize将作为open()函数的相应参数 - 0 代表unbuffered
- 1 代表 line buffered
- 其他正数代表buffer size
-
负数代表使用系统默认的buffer size (
io.DEFAULT_BUFFER_SIZE) -
excutable,这个参数不常见,当shell=True时, 在POSIX上,可使用该参数指定不同于默认的/bin/shshell来执行子进程;而当shell=False时,这个用法似乎不太常见,我能想到的一个例子可能如下:
## 以下两行命令等价
>>> subprocess.run(['bedtools','intersect', '--help'])
>>> subprocess.run(['','intersect', '--help'], executable='bedtools')
-
preexec_fn, 该参数可绑定一个callable对象,该对象将在子进程执行之前在子进程中调用。需要注意的是,在应用程序中存在线程的情况下,该参数应该避免使用,可能会引发死锁。
def say_hello():
print('hello!!!')
subprocess.run(['ls'], preexec_fn=say_hello)
-
cwd, 指定该参数时,函数在执行子进程之前将会将工作目录设置为cwd。
Popen方法与属性
-
Popen.poll()check子进程是否已终止,如果结束则返回return code,反之返回None -
Popen.wait(timeout=None)等待子进程终止,如果timeout时间内子进程不结束,则会抛出TimeoutExpired异常
当使用stdout=PIPE或则stderr=PIPE时,避免使用该函数,使用``Popen.communicate()`以避免死锁的发生。 -
Popen.communicate(input=None, timeout=None)
与进程交互:将
input
指定数据发送到stdin;从stdout和stderr读取数据,直到到达文件末尾,等待进程终止。所以,返回值是一个tuple:
(stdout_data, stderr_data)
。如果timeout时间内子进程不结束,则会抛出
TimeoutExpired
异常。其中需要注意的是,捕获异常之后,可以再次调用该函数,因为子进程并没有被kill。因此,如果超时结束程序的话,需要现正确kill子进程:
proc = subprocess.Popen(...)
outs, errs = proc.communicate(timeout=15)
except TimeoutExpired:
proc.kill()
outs, errs = proc.communicate()
-
Popen.send_signal(signal)向子进程发送信号 -
Popen.terminate()停止子进程,在POSIX上,实际上即是向子进程发送SIGTERM信号;在windows上则是调用TerminateProcess()函数 -
Popen.kill()杀掉子进程,在POSIX上,实际上即是向子进程发送SIGKILL信号;在windows上则是调用terminate()函数 -
属性包括
.args:子进程命令;.returncode:子进程终止返回值;.pid:子进程进程号;.stdin,.stdout,.stderr分别代表标准输入输出,标准错误,默认为bytes,这几个属性类似于open()函数返回值,是一个可读的stream对象
异常处理
subprocess
模块共包含三个异常处理类: 基类
SubprocessError
, 及其两个子类
TimeoutExpired
,
CalledProcessError
,前者在在等待子进程超时时抛出;后者在调用
check_call()
或
check_output()
返回非零状态值时抛出。他们共同的属性包括:
-
cmd, 该子进程的命令 -
output, 子进程所capture的标准输出 (如调用run()或则check_output()),否则为None -
stdout,output的别名 -
stderr, 子进程所capture的标准错误 (如调用run()) ,否则为None
TimeoutExpired
还包括
timeout
,指示所设置的
timeout
的值;
CalledProcessError
则还包括属性
returncode
;
Subprocess 应用
在官方文档中给出很多例子指导我们如何使用subprocess替代旧的接口,具体例子如下:
-
shell 命令行, 比如要实现一个简单的shell command line 命令
ls -lhrt,可以有以下几种等价的方式:
## shell cmd ls -lhrt
>>> output = check_output(["ls", "-lhrt"])
>>> subprocess.run(['ls', '-lhrt'], stdout=subprocess.PIPE).stdout
>>> output = subprocess.Popen(["ls", "-lhrt"], stdout=subprocess.PIPE).communicate()[0]
shell 管道操作,比如想看一个文件前100行中哪些数据包含关键字’python’,shell cmd可以这样写:
cat test.txt | head -n 100 | grep python
,使用subprocess可以这样写:
>>> from subprocess import *
>>> p1 = Popen(["cat", 'test.txt'], stdout=PIPE)
>>> p2 = Popen(["head", "-n", "100"], stdin=p1.stdout, stdout=PIPE)
>>> p3 = Popen(["grep", "python"], stdin=p2.stdout, stdout=PIPE)
>>> output = p3.communicate()[0]
>>> output.decode()
2.替代
os.system()
,前面有提到
subprocess.call()
是为
os.system
设置的增强版,应用如下:
from subprocess import *
retcode = call("ls" + " -hrtl", shell=True)
if retcode < 0:
print("Child was terminated by signal", -retcode, file=sys.stderr)
else:
print("Child returned", retcode, file=sys.stderr)
except OSError as e:
print("Execution failed:", e, file=sys.stderr)
3.替代
os.spawn*()
,该家族包括八个变体,
os.spawnl()
,
os.spawnle()
,
os.spawnlp()
,
os.spawnlpe()
,
os.spawnv()
,
os.spawnve()
,
os.spawnvp()
,
os.spawnvpe()
,
l
和
v
变体分别代表fixed parameters和variable parameters,
p
变体函数默认使用环境变量
$PATH
寻找program file (如
ls
,
cp
),
e
变体则是函数增加一个
env
mappings 参数来指定子进程执行的环境变量,不使用当前进程的环境变量。官方建议这些函数都可用subprocess替代,如常见的两个场景如下:
### 场景1 P_NOWAIT
pid = os.spawnlp(os.P_NOWAIT, "ls", "ls", "-hlrt")
pid = Popen(["/bin/mycmd", "myarg"]).pid
### 场景2
retcode = os.spawnlp(os.P_WAIT, "/bin/mycmd", "mycmd", "myarg")
retcode = call(["/bin/mycmd", "myarg"])
4.替代
os.popen*()
,该系列一共包括4个变体,分别是
os.popen()
,
os.popen2()
,
os.popen3()
,
os.popen()4
,首先需要理解的是
os.popen()
是基于
subprocess.Popen
实现的一个方法,用于从一个命令打开一个管道,存在
r
或
w
两种模式。比如:
>>> f = os.popen(cmd='ls -lhrt', mode='r', buffering=-1) # cmd必须是字符串,其以shell的方式执行
>>> f.read()
'total 0\n-rw-r--r-- 1 liunianping qukun 8 Jan 29 20:50 test.txt\n'
>>> f.close()
而剩下三个变体其实不是基于subprocess来实现的,并且功能差别仅仅在于返回值,三者返回值依次是:
(child_stdin, child_stdout)
,
(child_stdin, child_stdout, child_stderr)
,
(child_stdin, child_stdout_and_stderr)
, 因此,我们自然也可以使用
subprocess
模块函数来替代它:
### popen2
(child_stdin, child_stdout) = os.popen2(cmd, mode, bufsize)
## ==>
p = Popen(cmd, shell=True, bufsize=bufsize,
stdin=PIPE, stdout=PIPE, close_fds=True)
(child_stdin, child_stdout) = (p.stdin, p.stdout)
### popen3
(child_stdin,
child_stdout,
child_stderr) = os.popen3(cmd, mode, bufsize)
## ==>
p = Popen(cmd, shell=True, bufsize=bufsize,
stdin=PIPE, stdout=PIPE, stderr=PIPE, close_fds=True)
(child_stdin,
child_stdout,
child_stderr) = (p.stdin, p.stdout, p.stderr)
### popen4
(child_stdin, child_stdout_and_stderr) = os.popen4(cmd, mode, bufsize)
## ==>
p = Popen(cmd, shell=True, bufsize=bufsize,
stdin=PIPE, stdout=PIPE, stderr=STDOUT, close_fds=True)
(child_stdin, child_stdout_and_stderr) = (p.stdin, p.stdout)
其他
subprocess
中还提供另外两个python2.x中
commands
模块中的旧版shell调用功能
getstatusoutput
和
getoutput
,查看python源码可以看到它的实现其实也非常简单,就是借助
subprocess.check_output()
函数捕获shell 命令的输出,最终返回
return_code
以及
output
:
def getstatusoutput(cmd):
data = check_output(cmd, shell=True, text=True, stderr=STDOUT)
exitcode = 0
except CalledProcessError as ex:
data = ex.output
exitcode = ex.returncode