为什么subprocess.run输出与相同命令的shell输出不同?

10

我正在使用 subprocess.run() 进行一些自动化测试。主要是用于自动化执行以下操作:

dummy.exe < file.txt > foo.txt
diff file.txt foo.txt

如果您在shell中执行上述重定向,则两个文件始终相同。但是,每当 file.txt 太长时,下面的Python代码就不会返回正确的结果。
这是Python代码:
import subprocess
import sys


def main(argv):

    exe_path = r'dummy.exe'
    file_path = r'file.txt'

    with open(file_path, 'r') as test_file:
        stdin = test_file.read().strip()
        p = subprocess.run([exe_path], input=stdin, stdout=subprocess.PIPE, universal_newlines=True)
        out = p.stdout.strip()
        err = p.stderr
        if stdin == out:
            print('OK')
        else:
            print('failed: ' + out)

if __name__ == "__main__":
    main(sys.argv[1:])

这里是在dummy.cc中的C++代码:

#include <iostream>


int main()
{
    int size, count, a, b;
    std::cin >> size;
    std::cin >> count;

    std::cout << size << " " << count << std::endl;


    for (int i = 0; i < count; ++i)
    {
        std::cin >> a >> b;
        std::cout << a << " " << b << std::endl;
    }
}

file.txt 可以是像这样的任何东西:

1 100000
0 417
0 842
0 919
...

第一行的第二个整数表示接下来有多少行,因此这里的file.txt将会有100,001行。

问题:我是否误用了subprocess.run()?

编辑:

考虑到注释(换行符和rb),我的Python代码如下:

import subprocess
import sys
import os


def main(argv):

    base_dir = os.path.dirname(__file__)
    exe_path = os.path.join(base_dir, 'dummy.exe')
    file_path = os.path.join(base_dir, 'infile.txt')
    out_path = os.path.join(base_dir, 'outfile.txt')

    with open(file_path, 'rb') as test_file:
        stdin = test_file.read().strip()
        p = subprocess.run([exe_path], input=stdin, stdout=subprocess.PIPE)
        out = p.stdout.strip()
        if stdin == out:
            print('OK')
        else:
            with open(out_path, "wb") as text_file:
                text_file.write(out)

if __name__ == "__main__":
    main(sys.argv[1:])

这是第一个差异:

enter image description here

这是输入文件: https://drive.google.com/open?id=0B--mU_EsNUGTR3VKaktvQVNtLTQ


1
你可能没有正确地清空缓冲区。 - Falmarri
你是什么意思?我该如何刷新它? - user2346536
2
@user2346536 如果这是一个刷新问题,你可以使用 sys.stdout.flush()。这个文件的长度多长才算太长? - Chrispresso
1
Flush 没有起作用。所选答案解释了原因,因为在任何情况下,在 flush 之前我已经将所有内容存储在内存中。但还是很不错的尝试,+1 :) - user2346536
顺便提一下,这就是为什么测试应始终从已知状态开始,并在可能的情况下以已知状态结束。 (这也是为什么涉及文件系统的测试总是很丑陋,因为一个崩溃的测试无法在自己完成后清理自己。)因此,要么始终将输出存储在“outfile.txt”中,要么在测试完成时始终将其删除。 无论哪种方式,测试都应具有清晰的演示成功或失败的方式,而不是“查看该文件的内容是否与测试替换内容之前的内容不同”。 - Kevin J. Chase
显示剩余3条评论
2个回答

6
为了重现,使用Shell命令:
subprocess.run("dummy.exe < file.txt > foo.txt", shell=True, check=True)

在Python中没有Shell:

with open('file.txt', 'rb', 0) as input_file, \
     open('foo.txt', 'wb', 0) as output_file:
    subprocess.run(["dummy.exe"], stdin=input_file, stdout=output_file, check=True)

它可以处理任意大小的文件。

在这种情况下,您可以使用subprocess.check_call()(自Python 2以来可用),而不是仅在Python 3.5+中可用的subprocess.run()

非常好用,谢谢。但是原始代码为什么会失败?像Kevin Answer中的管道缓冲区大小一样?

这与操作系统管道缓冲区无关。 subprocess文档中@Kevin J. Chase引用的警告与subprocess.run()无关。仅当您使用process = Popen()并通过多个管道流(process.stdin/.stdout/.stderr)手动读取()/写入()时,您应该关心操作系统管道缓冲区。

事实证明,观察到的行为是由于Windows通用CRT中的错误所致。以下是在没有Python的情况下重现相同问题的方式:为什么重定向有效而管道失败?

错误描述所述,解决方法如下:
  • "使用二进制管道并在读取器端手动执行文本模式CRLF => LF转换"或直接使用ReadFile()而非std::cin
  • 或等待今年夏天的Windows 10更新(该错误应该会被修复)
  • 或使用不同的C++编译器,例如,如果您在Windows上使用g++,则没有问题(链接)
该错误仅影响文本管道,即使用<>的代码应该是正常的(stdin=input_file, stdout=output_file仍然可以工作,否则就是其他错误)。

非常好,谢谢。但是原来为什么会失败?是像Kevin回答中所说的管道缓冲区大小的问题吗? - user2346536
1
subprocess.run 文档(我越来越不信任)说:“完整的函数签名与 Popen 构造函数基本相同[...] 此函数的所有参数都通过该接口传递。” 因此,在 Popen 中发现的任何警告(包括 communicatewait)都必须适用于 run,包括“管道中输出过多”的警告。 话虽如此,subprocess 文档在某些地方直接自相矛盾... - Kevin J. Chase
1
@KevinJ.Chase:错误的。关于操作系统管道缓冲区的警告并不适用于.run(),因为它已经调用了.communicate()方法。你在文档中看到了“当使用管道时,请使用Popen.communicate()来避免这种情况。” - jfs
1
你确实在文档中看到了"使用Popen.communicate()..." --- 是的,我在我的回答中引用了它。这就是我上面提到的直接矛盾...waitcall的文档告诉你使用communicate来"避免"向PIPE写入大量数据的问题,而communicate的文档明确告诉你不要向PIPE写入大量数据。一个说"使用这个---它解决了问题",而另一个说"_不要_使用这个---它无法处理那个问题"。(我晚些时候有机会的话,我会单独提出一个关于这个问题的问题。) - Kevin J. Chase
1
@KevinJ.Chase:正如我之前所说,“OS管道缓冲区”问题与“内存不足”问题是不同的.communicate()表示,您不应尝试读取不适合内存的数据:这很简单:它将该数据作为必须在Python中存在的str/bytes对象返回。 “OS管道缓冲区”通常比可用内存小得多,为避免此问题,您只需在使用stdout=PIPE时消耗管道即可。 call().wait()不会消耗管道,因此不应将它们与PIPE一起使用。文档在这里并没有自相矛盾。 - jfs
显示剩余7条评论

1
我先声明一下:我没有Python 3.5(所以我不能使用run函数),并且我无法在Windows(Python 3.4.4)或Linux(3.1.6)上重现您的问题。话虽如此...

subprocess.PIPE和相关问题

subprocess.run文档说它只是旧的subprocess.Popencommunicate()技术的前端。subprocess.Popen.communicate文档警告说:

读取的数据在内存中缓冲,因此如果数据大小很大或不受限制,请勿使用此方法。

这听起来就像您的问题。不幸的是,文档没有说明多少数据是“大”,也没有说明在读取“太多”数据后会发生什么。只是“那样做是不好的”。

subprocess.call的文档提供了更详细的信息(重点是我的)...

不要在此函数中使用stdout=PIPEstderr=PIPE。子进程将会阻塞如果它生成足够的输出以填满操作系统管道缓冲区,因为这些管道没有被读取。

...同样subprocess.Popen.wait的文档也一样:

当使用stdout=PIPEstderr=PIPE并且子进程生成足够的输出以使其阻塞等待操作系统管道缓冲区接受更多数据时,这将导致死锁。使用Popen.communicate()来避免使用管道。

这似乎是使用 Popen.communicate 解决此问题的方案,但是 communicate 的文档说“如果数据量大,则不要使用此方法”,正是 wait 文档告诉您需要使用 communicate 的情况。 (也许通过在后台默默地丢弃数据来“避免”这种情况?)
令人沮丧的是,我没有看到任何安全使用 subprocess.PIPE 的方法,除非您确信可以比子进程写入速度更快地从中读取。
关于这一点...
替代方案:tempfile.TemporaryFile 您将所有数据保存在内存中...事实上是两次。 如果已经在文件中,那肯定不高效。
如果您可以使用临时文件,您可以逐行比较这两个文件非常容易。这样可以避免所有subprocess.PIPE混乱,并且速度更快,因为它一次只使用少量RAM。(取决于操作系统如何处理输出重定向),你可以更快地执行子进程的IO。再次说明,我无法测试run,因此这里提供一个稍旧的Popencommunicate解决方案(不包括main和其余设置):
import io
import subprocess
import tempfile

def are_text_files_equal(file0, file1):
    '''
    Both files must be opened in "update" mode ('+' character), so
    they can be rewound to their beginnings.  Both files will be read
    until just past the first differing line, or to the end of the
    files if no differences were encountered.
    '''
    file0.seek(io.SEEK_SET)
    file1.seek(io.SEEK_SET)
    for line0, line1 in zip(file0, file1):
        if line0 != line1:
            return False
    # Both files were identical to this point.  See if either file
    # has more data.
    next0 = next(file0, '')
    next1 = next(file1, '')
    if next0 or next1:
        return False
    return True

def compare_subprocess_output(exe_path, input_path):
    with tempfile.TemporaryFile(mode='w+t', encoding='utf8') as temp_file:
        with open(input_path, 'r+t') as input_file:
            p = subprocess.Popen(
              [exe_path],
              stdin=input_file,
              stdout=temp_file,  # No more PIPE.
              stderr=subprocess.PIPE,  # <sigh>
              universal_newlines=True,
              )
            err = p.communicate()[1]  # No need to store output.
            # Compare input and output files...  This must be inside
            # the `with` block, or the TemporaryFile will close before
            # we can use it.
            if are_text_files_equal(temp_file, input_file):
                print('OK')
            else:
                print('Failed: ' + str(err))
    return

很遗憾,即使我输入一百万行的内容也无法重现您的问题,所以我无法确定这个是否有效。如果没有其他问题,它应该会更快地给出错误答案。

变量:常规文件

如果您想保留测试运行的输出到foo.txt(来自您的命令行示例),那么您需要将子进程的输出定向到普通文件而不是TemporaryFile。这是J.F. Sebastian's answer推荐的解决方案。

从您的问题中我无法确定您是否想要foo.txt,或者它只是测试-然后-diff的副作用 --- 您的命令行示例将测试输出保存到一个文件中,而您的Python脚本则没有。如果您想要调查测试失败,保存输出会很方便,但需要为每个运行的测试想出一个唯一的文件名,以防止它们覆盖彼此的输出。


1
@user2346536:我修复了are_text_files_equal函数中的一个bug --- 如果两个长度不相等的文件在短文件结束前是完全一样的,那么该函数会被欺骗。它没有验证两个文件是否已经到达结尾才返回True - Kevin J. Chase
1
@J.F.Sebastian:我从未说过它会。虽然user2346536正在将子进程的_input_重定向到一个真实文件,但它的输出仍然会传输到subprocess.PIPE,而communicate/run文档明确指出不要对“大量或无限制”的输出使用该管道。我提供了TemporaryFile作为避免使用PIPE时出现问题的方法。作为奖励,TemporaryFile避免了在那些数据只用一行的情况下两次将大量数据加载到内存中的问题。 - Kevin J. Chase
1
@J.F.Sebastian:也许你说的是我引用了Popen.waitcall文档?确实,user23456536从未使用过这些函数。我引用它们是因为它们是subprocess模块文档中唯一另外一个涉及“太多”数据存储在PIPE中的想法的地方。它们是唯一描述后果的地方。它们指出问题是永久阻塞的子进程,而communicate通过替换其他未指定的问题来避免这个问题。这是这些文档最接近描述问题真正的本质,更不用说如何避免它了。 - Kevin J. Chase
2
@KevinJ.Chase:1- 你在文档中用粗体突出的警告:“如果生成的输出足以填满操作系统管道缓冲区”,与 subprocess.run() 没有任何关系——它只是不适用于此——如果你不明白为什么,请提一个单独的 SO 问题。2- 此外,OPs 代码中的错误不是由于内存不足(这在一般情况下适用于 subprocess.run(),但在这种情况下并不重要:输入/输出确实适合内存)。我猜测问题在于通用换行模式。 - jfs
1
如果J.F. Sebastian的回答既能够重现你的问题又解决了它,那么你应该接受他的回答。(即使使用他的方法,在Windows或Linux上我仍然无法重现你的问题。我仍然必须使用subprocess.callPopen而不是run,因为我没有Python 3.5,所以可能这就是区别所在。) - Kevin J. Chase
显示剩余4条评论

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