在通过python或ipython终端运行.py文件时抑制matplotlib图形

3 投票
2 回答
2423 浏览
提问于 2025-04-18 16:38

我正在写一个 test_examples.py 文件,用来测试一个包含多个 Python 示例的文件夹。目前我使用 glob 来解析这个文件夹,然后用 subprocess 来执行每个 Python 文件。问题是,有些文件是绘图的,它们会打开一个 Figure 窗口,这个窗口会一直停在那里,直到我把它关掉。

很多关于这个问题的提问都给出了在文件内部的解决方案,但我想知道,如何在不修改文件的情况下,外部运行这些文件时抑制输出呢?

到目前为止,我做了以下工作:

import subprocess as sb
import glob
from nose import with_setup

def test_execute():
    files = glob.glob("../*.py")
    files.sort()
    for fl in files:
        try:
            sb.call(["ipython", "--matplotlib=Qt4", fl])
        except:
            assert False, "File: %s ran with some errors\n" % (fl)

这样做有点效果,可以抑制图形的显示,但它不会抛出任何异常(即使程序出错)。我也不太确定它到底在做什么。是把所有的图形都加到 Qt4 里,还是说当那个脚本执行完后,图形就会从内存中移除呢?

理想情况下,我希望能运行每个 .py 文件,并捕获它的 stdoutstderr,然后根据退出状态来报告 stderr 并标记测试失败。这样当我运行 nosetests 时,它就会执行示例文件夹中的程序,并检查它们是否都能正常运行。

2 个回答

0

虽然我来得有点晚,但我也在尝试解决类似的问题,这里是我目前的想法。基本上,如果你的图表是通过调用比如说 matplotlib.pyplot.show 来显示的,你可以用一个叫做 mock 的方法来替代这个功能,使用一个 patch 装饰器。大概是这样的:

from unittest.mock import patch

@patch('matplotlib.pyplot.show')  # passes a mock object to the decorated function
def test_execute(mock_show):
    assert mock_show() == None  # shouldn't do anything
    files = glob.glob("../*.py")
    files.sort()
    for fl in files:
        try:
            sb.call(["ipython", fl])
        except:
            assert False, "File: %s ran with some errors\n" % (fl)

简单来说,这个 patch 装饰器应该会把被装饰的函数中所有对 matplotlib.pyplot.show 的调用替换成一个不做任何事情的假对象。理论上应该是这样工作的。不过在我的应用中,我的终端还是试图打开图表,这导致了错误。希望对你来说能更顺利一些,如果我发现上面有什么问题导致我的情况不对,我会更新的。

编辑:为了完整起见,你可能是通过调用 matplotlib.pyplot.figure()matplotlib.pyplot.subplots() 来生成图形,这种情况下你应该替代的就是这些,而不是 matplotlib.pyplot.show()。用法和上面一样,你只需要用:

@patch('matplotlib.pyplot.figure')

或者:

@patch('matplotlib.pyplot.subplots')
3

你可以通过在每个源文件的顶部插入以下几行代码,强制 matplotlib 使用 Agg 后端(这样就不会打开任何窗口):

import matplotlib
matplotlib.use('Agg')

这里有一个一行的命令,可以动态地在 my_script.py 的顶部插入这些行(而不修改磁盘上的文件),然后将输出传递给 Python 解释器执行:

~$ sed "1i import matplotlib\nmatplotlib.use('Agg')\n" my_script.py | python

你也可以使用 subprocess 来实现类似的调用,像这样:

p1 = sb.Popen(["sed", "1i import matplotlib\nmatplotlib.use('Agg')\n", fl],
              stdout=sb.PIPE)
exit_cond = sb.call(["python"], stdin=p1.stdout)

你可以通过将 stdout=stderr= 参数传递给 sb.call() 来捕获脚本的 stderrstdout。当然,这只在有 sed 工具的 Unix 环境中有效。


更新

这个问题其实挺有意思的。我想了想,觉得有一个更优雅的解决方案(虽然还是有点小技巧):

#!/usr/bin/python

import sys
import os
import glob
from contextlib import contextmanager
import traceback

set_backend = "import matplotlib\nmatplotlib.use('Agg')\n"

@contextmanager
def redirected_output(new_stdout=None, new_stderr=None):
    save_stdout = sys.stdout
    save_stderr = sys.stderr
    if new_stdout is not None:
        sys.stdout = new_stdout
    if new_stderr is not None:
        sys.stderr = new_stderr
    try:
        yield None
    finally:
        sys.stdout = save_stdout
        sys.stderr = save_stderr

def run_exectests(test_dir, log_path='exectests.log'):

    test_files = glob.glob(os.path.join(test_dir, '*.py'))
    test_files.sort()
    passed = []
    failed = []
    with open(log_path, 'w') as f:
        with redirected_output(new_stdout=f, new_stderr=f):
            for fname in test_files:
                print(">> Executing '%s'" % fname)
                try:
                    code = compile(set_backend + open(fname, 'r').read(),
                                   fname, 'exec')
                    exec(code, {'__name__':'__main__'}, {})
                    passed.append(fname)
                except:
                    traceback.print_exc()
                    failed.append(fname)
                    pass

    print ">> Passed %i/%i tests: " %(len(passed), len(test_files))
    print "Passed: " + ', '.join(passed)
    print "Failed: " + ', '.join(failed)
    print "See %s for details" % log_path

    return passed, failed

if __name__ == '__main__':
    run_exectests(*sys.argv[1:])

从概念上讲,这个方法和我之前的解决方案非常相似——它通过将测试脚本作为字符串读取,并在前面加上几行代码来导入 matplotlib 并设置为非交互式后端。然后将这个字符串编译成 Python 字节码并执行。主要的优点是,这个方法应该是平台无关的,因为不需要 sed

如果你像我一样,习惯这样写脚本,那么 {'__name__':'__main__'} 这个小技巧和全局变量是必要的:

    def run_me():
        ...
    if __name__ == '__main__':
        run_me()

需要考虑几点:

  • 如果你在一个已经导入了 matplotlib 并设置了交互式后端的 ipython 会话中运行这个函数,set_backend 的技巧就不管用了,你仍然会看到图形弹出来。最简单的方法是直接从命令行运行它(~$ python exectests.py testdir/ logfile.log),或者在一个没有为 matplotlib 设置交互式后端的(i)python 会话中运行。如果你在 ipython 会话中从不同的子进程运行它,也应该可以工作。
  • 我使用了来自 这个回答contextmanager 技巧,将 stdinstdout 重定向到日志文件。请注意,这个方法不是线程安全的,但我觉得脚本打开子进程的情况比较少见。

撰写回答