在类似终端的近实时环境中运行命令并分别获取其标准输出和标准错误输出。

26
我正在尝试在Python中找到一种运行其他程序的方法,满足以下条件:
  1. 被运行程序的标准输出(stdout)和标准错误(stderr)可以分别记录。
  2. 被运行程序的标准输出(stdout)和标准错误(stderr)可以几乎实时地查看,这样如果子进程挂起,用户就可以看到。(即我们不等待执行完成才将stdout/stderr打印给用户)
  3. 额外要求:被运行的程序不知道它是通过Python运行的,因此不会做出意外行为(例如分块其输出而不是实时打印,或退出,因为它要求终端来查看其输出)。这个小条件几乎意味着我们需要使用pty。
这是我已经得到的东西... 方法1:
def method1(command):
    ## subprocess.communicate() will give us the stdout and stderr sepurately, 
    ## but we will have to wait until the end of command execution to print anything.
    ## This means if the child process hangs, we will never know....
    proc=subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, executable='/bin/bash')
    stdout, stderr = proc.communicate() # record both, but no way to print stdout/stderr in real-time
    print ' ######### REAL-TIME ######### '
    ########         Not Possible
    print ' ########## RESULTS ########## '
    print 'STDOUT:'
    print stdout
    print 'STDOUT:'
    print stderr

方法二
def method2(command):
    ## Using pexpect to run our command in a pty, we can see the child's stdout in real-time,
    ## however we cannot see the stderr from "curl google.com", presumably because it is not connected to a pty?
    ## Furthermore, I do not know how to log it beyond writing out to a file (p.logfile). I need the stdout and stderr
    ## as strings, not files on disk! On the upside, pexpect would give alot of extra functionality (if it worked!)
    proc = pexpect.spawn('/bin/bash', ['-c', command])
    print ' ######### REAL-TIME ######### '
    proc.interact()
    print ' ########## RESULTS ########## '
    ########         Not Possible

方法三:
def method3(command):
    ## This method is very much like method1, and would work exactly as desired
    ## if only proc.xxx.read(1) wouldn't block waiting for something. Which it does. So this is useless.
    proc=subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, executable='/bin/bash')
    print ' ######### REAL-TIME ######### '
    out,err,outbuf,errbuf = '','','',''
    firstToSpeak = None
    while proc.poll() == None:
            stdout = proc.stdout.read(1) # blocks
            stderr = proc.stderr.read(1) # also blocks
            if firstToSpeak == None:
                if stdout != '': firstToSpeak = 'stdout'; outbuf,errbuf = stdout,stderr
                elif stderr != '': firstToSpeak = 'stderr'; outbuf,errbuf = stdout,stderr
            else:
                if (stdout != '') or (stderr != ''): outbuf += stdout; errbuf += stderr
                else:
                    out += outbuf; err += errbuf;
                    if firstToSpeak == 'stdout': sys.stdout.write(outbuf+errbuf);sys.stdout.flush()
                    else: sys.stdout.write(errbuf+outbuf);sys.stdout.flush()
                    firstToSpeak = None
    print ''
    print ' ########## RESULTS ########## '
    print 'STDOUT:'
    print out
    print 'STDERR:'
    print err

要尝试这些方法,您需要 import sys,subprocess,pexpect pexpect 是纯 Python 的,可以通过以下方式获得:
sudo pip install pexpect
我认为解决方案将涉及到 Python 的 pty 模块 - 这是一种我找不到任何人知道如何使用的黑魔法。也许 Stack Overflow 知道 :)
作为一个提示,我建议您使用“curl www.google.com”作为测试命令,因为它出于某种原因会在 stderr 上打印其状态 :D
更新-1: 好吧,pty 库不适合人类消费。文档本质上就是源代码。 任何呈现的阻塞而不是异步的解决方案都无法在这里工作。Padraic Cunningham 的线程/队列方法非常好用,虽然添加 pty 支持不可能 - 而且它很“肮脏”(引用 Freenode 的 #python)。 似乎唯一适合生产标准代码的解决方案是使用 Twisted 框架,它甚至支持 pty 作为布尔开关来运行进程,就像它们是从 shell 调用的一样。 但是,在项目中添加 Twisted 需要对所有代码进行全面重写。这真是个大烦恼 :/
更新-2: 提供了两个答案,其中一个满足前两个条件,并且在您只需要 stdout 和 stderr 时使用线程和队列非常好用。另一个答案使用 select,一种用于读取文件描述符的非阻塞方法,以及 pty,一种“欺骗”生成的进程认为它正在运行真正的终端,就像直接从 Bash 运行一样 - 但可能会产生副作用。我希望我能接受两个答案,因为“正确”的方法实际上取决于情况和为什么首先进行子处理,但遗憾的是,我只能接受一个。

pyinvoke(http://www.pyinvoke.org/)有什么用处吗? - GIRISH RAMNANI
Moffat的sh模块是一个子进程替代品,允许将外部程序作为函数执行,并将stderr和stdout重定向到文件或函数。有关此内容的文档,请参见http://amoffat.github.io/sh/#redirection。可以使用'pip install sh'进行安装,其GitHub网站为https://github.com/amoffat/sh。 - user4322779
1
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - jfs
1
@TrisNefzger:sh回调可能可以分别和逐步获取标准输出和标准错误,但我没有看到它解决了stderr缓冲问题(我只看到tty_in,tty_out,虽然在大多数情况下这可能足够了,但是stderr!= STDOUT可能会有问题)。 - jfs
1
@J.F.Sebastian:_out_bufsize 控制 stderr 和 stdout 的缓冲区大小,将其设置为 0 可以禁用缓冲,参考 amoffat.github.io/sh/#buffer-sizes。 - user4322779
@TrisNefzger:除非sh执行了一些丑陋的不可移植的黑客技巧(比如stdbuf实用程序),否则_out_bufsize控制了一个错误的缓冲区。很可能它控制了父Python进程中的缓冲区。就像bufsize一样,它不能修复子进程中的缓冲行为。请查看我的答案中显示libc stdio缓冲区的图片(未显示父进程(shell)缓冲区)。 - jfs
3个回答

25

运行程序时,标准输出和标准错误可以分别记录。

你不能使用 pexpect,因为标准输出和标准错误都会被发送到同一个 pty,之后就无法将它们分开。

运行的程序的标准输出和标准错误可以近实时地查看,这样如果子进程挂起,用户就可以看到。(即我们在打印标准输出/标准错误之前不需要等待执行完成)

如果子进程的输出不是 tty,则 很可能使用块缓冲,因此如果它没有产生太多的输出,那么 它不会是“实时的”,例如,如果缓冲区大小为 4K,则您的父 Python 进程在子进程打印 4K 个字符并且缓冲区溢出或显式刷新(在子进程内部)之前将不会看到任何内容。这个缓冲区在子进程内部,没有标准方法可以从外部管理它。下面是一张显示了 command 1 | command2 shell 管道的 stdio 缓冲区和管道缓冲区的图片:

pipe/stdio buffers

正在运行的程序并不知道它是通过Python运行的,因此不会做出意外的行为(例如将其输出分块而不是实时打印,或者因为需要终端才能查看其输出而退出)。
看起来,你的意思是相反的,即如果输出被重定向到管道中(当你在Python中使用stdout=PIPE时),很可能子进程会将其输出分块而不是尽快刷新每个输出行。这意味着默认的线程asyncio解决方案在你的情况下不会起作用。
有几种解决方法可以解决这个问题:
  • the command may accept a command-line argument such as grep --line-buffered or python -u, to disable block buffering.

  • stdbuf works for some programs i.e., you could run ['stdbuf', '-oL', '-eL'] + command using the threading or asyncio solution above and you should get stdout, stderr separately and lines should appear in near-real time:

    #!/usr/bin/env python3
    import os
    import sys
    from select import select
    from subprocess import Popen, PIPE
    
    with Popen(['stdbuf', '-oL', '-e0', 'curl', 'www.google.com'],
               stdout=PIPE, stderr=PIPE) as p:
        readable = {
            p.stdout.fileno(): sys.stdout.buffer, # log separately
            p.stderr.fileno(): sys.stderr.buffer,
        }
        while readable:
            for fd in select(readable, [], [])[0]:
                data = os.read(fd, 1024) # read available
                if not data: # EOF
                    del readable[fd]
                else: 
                    readable[fd].write(data)
                    readable[fd].flush()
    
  • finally, you could try pty + select solution with two ptys:

    #!/usr/bin/env python3
    import errno
    import os
    import pty
    import sys
    from select import select
    from subprocess import Popen
    
    masters, slaves = zip(pty.openpty(), pty.openpty())
    with Popen([sys.executable, '-c', r'''import sys, time
    print('stdout', 1) # no explicit flush
    time.sleep(.5)
    print('stderr', 2, file=sys.stderr)
    time.sleep(.5)
    print('stdout', 3)
    time.sleep(.5)
    print('stderr', 4, file=sys.stderr)
    '''],
               stdin=slaves[0], stdout=slaves[0], stderr=slaves[1]):
        for fd in slaves:
            os.close(fd) # no input
        readable = {
            masters[0]: sys.stdout.buffer, # log separately
            masters[1]: sys.stderr.buffer,
        }
        while readable:
            for fd in select(readable, [], [])[0]:
                try:
                    data = os.read(fd, 1024) # read available
                except OSError as e:
                    if e.errno != errno.EIO:
                        raise #XXX cleanup
                    del readable[fd] # EIO means EOF on some systems
                else:
                    if not data: # EOF
                        del readable[fd]
                    else:
                        readable[fd].write(data)
                        readable[fd].flush()
    for fd in masters:
        os.close(fd)
    

    I don't know what are the side-effects of using different ptys for stdout, stderr. You could try whether a single pty is enough in your case e.g., set stderr=PIPE and use p.stderr.fileno() instead of masters[1]. Comment in sh source suggests that there are issues if stderr not in {STDOUT, pipe}


1
这是另一个很棒的答案,我觉得我们离一个通用解决方案非常接近了。在我测试你的代码时,我想提一下,我认为pty的整个目的就是“欺骗”连接的程序,让它认为自己正在与一个真实的终端进行通信,因此不会像将输出重定向到文件或其他进程时那样对其输出进行缓冲。也就是说,使用pty可以防止分块和其他非典型行为,使得Python能够像直接从shell运行命令一样执行子进程命令。 - J.J
@user3329564:是的,如果您提供了pty,大多数程序都会使用行缓冲的标准输出。我的回答哪一部分表明了不同的意思?子进程可以自由更改(或不更改)其行为的其他方面,例如,如果输出是tty,则curl不会报告进度。 - jfs
有时您的代码会使用b''(空二进制数组)和其他一些“null”字符串(可能是EOF),实际上什么也不做...也许可以添加一个防护程序: 如果有数据: 写() - Master Yogurt
@MasterYogurt这是不可能的。你在代码中看到了if not data吗? - jfs
也许那是其他的问题,一种不打印的空字符... 无论如何,在实现了类似于这样的东西之后,我发现了更好的解决方案,它不需要读取stderr。还是谢谢。 - Master Yogurt
@MasterYogurt:空字符看起来像b'\x00'--在字节表示中没有不可见的字符。 - jfs

3
如果你想要从stderr和stdout中分别读取并获取输出结果,你可以使用带有Queue的Thread,在一定程度上测试过,但代码如下所示:
import threading
import queue

def run(fd, q):
    for line in iter(fd.readline, ''):
        q.put(line)
    q.put(None)


def create(fd):
    q = queue.Queue()
    t = threading.Thread(target=run, args=(fd, q))
    t.daemon = True
    t.start()
    return q, t


process = Popen(["curl","www.google.com"], stdout=PIPE, stderr=PIPE,
                universal_newlines=True)

std_q, std_out = create(process.stdout)
err_q, err_read = create(process.stderr)

while std_out.is_alive() or err_read.is_alive():
        for line in iter(std_q.get, None):
            print(line)
        for line in iter(err_q.get, None):
            print(line)

一个单一的队列就足够了。这个答案根本没有解决硬缓冲问题。 - jfs
@J.F.Sebastian,如果您使用单个队列,您如何知道哪个是stderr,哪个是stdout? - Padraic Cunningham
我相信你可以自己回答这个问题。在查看代码示例链接之前,请花几分钟时间思考一下。 - jfs

2

J.F. Sebastian的回答确实解决了问题的核心,但我正在运行Python 2.7(这不是原始标准),所以我只是想把代码粘贴到任何其他疲倦的旅行者那里。 我还没有彻底测试过这个,但在我尝试的所有命令中,它似乎完美地工作:) 您可能需要将.decode('ascii')更改为.decode('utf-8') - 我仍在测试那一部分。

#!/usr/bin/env python2.7
import errno
import os
import pty
import sys
from select import select
import subprocess
stdout = ''
stderr = ''
command = 'curl google.com ; sleep 5 ; echo "hey"'
masters, slaves = zip(pty.openpty(), pty.openpty())
p = subprocess.Popen(command, stdin=slaves[0], stdout=slaves[0], stderr=slaves[1], shell=True, executable='/bin/bash')
for fd in slaves: os.close(fd)

readable = { masters[0]: sys.stdout, masters[1]: sys.stderr }
try:
    print ' ######### REAL-TIME ######### '
    while readable:
        for fd in select(readable, [], [])[0]:
            try: data = os.read(fd, 1024)
            except OSError as e:
                if e.errno != errno.EIO: raise
                del readable[fd]
            finally:
                if not data: del readable[fd]
                else:
                    if fd == masters[0]: stdout += data.decode('ascii')
                    else: stderr += data.decode('ascii')
                    readable[fd].write(data)
                    readable[fd].flush()
except:
    print "Unexpected error:", sys.exc_info()[0]
    raise
finally:
    p.wait()
    for fd in masters: os.close(fd)
    print ''
    print ' ########## RESULTS ########## '
    print 'STDOUT:'
    print stdout
    print 'STDERR:'
    print stderr

1
谢谢您的回答,我也在使用Python 2.7。但是,您能否将except: pass更改为其他内容或完全删除该行?如果有人更改代码并出现语法错误等问题,这将不会显示! - Mayra Delgado

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接