写点什么

python subprocess- 更优雅的创建子进程

用户头像
jeffery
关注
发布于: 2021 年 02 月 01 日
python subprocess-更优雅的创建子进程

本文首发于jeffery0207 csdn

简介


PEP324所言,在任何编程语言中,启动进程都是非常常见的任务,python 也是如此,而不正确的启动进程方式会给程序带来很大安全风险。Subprocess 模块开发之前,标准库已有大量用于进程创建的接口函数(如os.systemos.spawn*),但是略显混乱使开发者难以抉择,因此 Subprocess 的目的是打造一个“统一”模块来提供之前进程创建相关函数的功能实现。与之前的相关接口相比,提供了以下增强功能:


  • 一个“统一”的模块来提供以前进程创建相关函数的所有功能;

  • 跨进程异常优化:子进程中的异常会在父进程再次抛出,以便检测子进程执行情况;

  • 提供用于在forkexec之间执行自定义代码的钩子;

  • 没有隐式调用/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函数的参数相同,即这个函数的大多数参数都被传递到该接口。(timeoutinputcheckcapture_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=PIPEstderr = PIPE创建标准输出和标准错误对象;传递stdoutstderr参数时不能同时传递capture_output参数。如果希望捕获并将两个 stream 合并为一个,使用stdout=PIPEstderr = STDOUT


  • check,如果check=True,并且进程以非零退出代码退出,则将抛出CalledProcessError异常。


  • input,该参数传递给Popen.communicate(),然后传递给子进程的 stdin。该参数数据类型应为字节序列(bytes);但如果指定了encoding , errors参数或则 text=True ,参数则必须为字符串。使用该参数时,内部 Popen 对象,将使用 stdin = PIPE 自动创建该对象,不能同时使用 stdin 参数。


  • timeout,该参数传递给Popen.communicate(),如果指定时间之后子进程仍未结束,子进程将被 kill,并抛出TimeoutExpired异常。


  • stdinstdoutstderr分别指定执行程序的标准输入,标准输出和标准错误文件的 file handles。如subprocess.PIPE, subprocess.DEVNULL, 或者 None。此外,stderr 可以设定为subprocess.STDOUT,这表示来自子进程的 stderr 数据应重定向到与 stdout 相同的 file handle 中。默认情况下,stdinstdoutstderr对应的 file handle 都是以 binary 的方式打开。


  • encoding, errors , text 。当传递encoding, errors参数 或 text=True时,stdinstdoutstderr对应的 file handle 以 text 的模式打开。universal_newlinestext同义,为了保持向下兼容而保留。默认情况下,文件对象以二进制的方式打开。


  • env,通过传递 mappings 对象,给子进程提供环境变量,该参数直接传递给Popen函数。


  • shell, 如果shell=True,则将通过 Shell 执行指定的命令。当使用shell=True时,shlex.quote() 函数可用于正确地转义字符串中的空格和 Shell 元字符。


  • 函数返回数据类型为subprocess.CompletedProcess, 该对象包含以下属性或方法:


* args, 调用该进程的参数,同subprocess.run(args,***) 中的args

* returncode,当值为 0 时,代表子进程执行成功;负值 -N 指示进程被 signal N所终止 (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,子进程如果执行失败,将会抛出异常;<u>该函数旨在对os.system()进行功能增强,同时易于使用</u>。


  • 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=PIPEstderr=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 信号相关;startnewsession, 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')
复制代码


> From 官方文档: executable replaces the program to execute specified by args. However, the original args is still passed to the program. Most programs treat the program specified by args as the command name, which can then be different from the program actually executed.


  • 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(...)
try:
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 替代旧的接口,具体例子如下:


  1. 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]
复制代码


1. 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()
复制代码


  1. 替代os.system(),前面有提到subprocess.call()是为os.system设置的增强版,应用如下:

   from subprocess import *
try:
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)
复制代码


  1. 替代os.spawn*(),该家族包括八个变体,os.spawnl(), os.spawnle(), os.spawnlp(), os.spawnlpe(), os.spawnv(), os.spawnve(), os.spawnvp(), os.spawnvpe(), lv变体分别代表 fixed parameters 和 variable parameters, p变体函数默认使用环境变量$PATH寻找 program file (如ls, cp),e变体则是函数增加一个env mappings 参数来指定子进程执行的环境变量,不使用当前进程的环境变量,具体见官方文档 os.spawn*。官方建议这些函数都可用 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"])
复制代码


  1. 替代os.popen*(),该系列一共包括 4 个变体,分别是os.popen(), os.popen2(), os.popen3(),os.popen()4,首先需要理解的是os.popen()是基于subprocess.Popen实现的一个方法,用于从一个命令打开一个管道,存在rw两种模式。比如:

   >>> 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_stdoutandstderr), 因此,我们自然也可以使用subprocess模块函数来替代它:

### popen2
(childstdin, childstdout) = os.popen2(cmd, mode, bufsize)
## ==>
p = Popen(cmd, shell=True, bufsize=bufsize,
stdin=PIPE, stdout=PIPE, close_fds=True)
(childstdin, childstdout) = (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
(childstdin, childstdoutandstderr) = os.popen4(cmd, mode, bufsize)
## ==>
p = Popen(cmd, shell=True, bufsize=bufsize,
stdin=PIPE, stdout=PIPE, stderr=STDOUT, close_fds=True)
(childstdin, childstdoutandstderr) = (p.stdin, p.stdout)
复制代码

其他


subprocess中还提供另外两个 python2.x 中 commands模块中的旧版 shell 调用功能getstatusoutputgetoutput,查看 python 源码可以看到它的实现其实也非常简单,就是借助subprocess.check_output()函数捕获 shell 命令的输出,最终返回return_code以及output:


def getstatusoutput(cmd):    try:        data = check_output(cmd, shell=True, text=True, stderr=STDOUT)        exitcode = 0    except CalledProcessError as ex:        data = ex.output        exitcode = ex.returncode    if data[-1:] == '\n':        data = data[:-1]    return exitcode, data
def getoutput(cmd): return getstatusoutput(cmd)[1]
复制代码


写在篇尾


subprocess是基于 python2 中popen2模块发展而来,专门为替代 python 中众多繁杂的子进程创建方法而设计,平时使用的过程中,subprocess.run()以及subprocess.call可以满足我们大多数的使用需求,但是更深入的了解该 package 的设计思想可以让我们更加灵活的控制复杂场景下的子进程任务。

参考

python3 subprocess

PEP324

封面来源


发布于: 2021 年 02 月 01 日阅读数: 18
用户头像

jeffery

关注

Leran and Live 2021.01.04 加入

python爱好者,专注python、linux

评论

发布
暂无评论
python subprocess-更优雅的创建子进程