Python 标准库中文件对象未正确关闭后如何清理(异常后)

How to clean up after an improperly closed file object in the Python Standard Library (after an exception)

TL;DR:标准库在引发异常时无法关闭文件。我正在寻找处理这种情况的最佳方法。请随意阅读以 "Upon closer inspection of CPython's source code". 开头的段落,也可以向下滚动到问题的末尾以获取在 Windows.[=46 上重现此问题的独立脚本=]

我正在编写一个 Python 包,其中我使用 STL 的 ConfigParser (2.x) 或 configparser (3.x) 来解析用户配置文件(我将两者都称为 ConfigParser,因为问题主要在于 2.x 实现)。从现在开始,我在 GitHub 上的相关代码行将在适当的时候被链接起来。 ConfigParser.ConfigParser.read(filenames)(在我的代码中使用 here) raises a ConfigParser.Error exception when the config file is malformed. I have some code in my test suite targeting this situation, using unittest.TestCase.assertRaises(ConfigParser.Error). The malformed config file is properly generated with tempfile.mkstemp (the returned fd is closed with os.close before anything) and I attempt to remove the temp fileos.remove

os.remove 是麻烦开始的地方。我的测试在 Windows 上失败(同时在 OS X 和 Ubuntu 上工作),Python 2.7(参见 this AppVeyor build):

Traceback (most recent call last):
  File "C:\projects\storyboard\tests\test_util.py", line 235, in test_option_reader
    os.remove(malformed_conf_file)
WindowsError: [Error 32] The process cannot access the file because it is being used by another process: 'c:\users\appveyor\appdata\local\temp\1\storyboard-test-3clysg.conf'

注意,正如我上面所说,malformed_conf_file是用tempfile.mkstemp生成的,并立即用os.close关闭,所以它唯一一次打开是在我调用[=29=时] here inside the unittest.TestCase.assertRaises(ConfigParser.Error) context。所以罪魁祸首似乎是 STL 而不是我自己的代码。

在仔细检查 CPython 的源代码后,我发现 ConfigParser.ConfigPaser.read 在引发异常时确实没有正确关闭文件. 2.7 (here on CPython's Mercurial) 中的 read 方法具有以下几行:

for filename in filenames:
    try:
        fp = open(filename)
    except IOError:
        continue
    self._read(fp, filename)
    fp.close()
    read_ok.append(filename)

self._read(fp, filename) 引发异常(如果有的话),但如您所见,如果 self._read 引发,则 fp 不会关闭,因为 fp.close() 仅在 self._read returns.

之后调用

同时,来自 3.4 (here) 的 read 方法不会遇到同样的问题,因为这次他们在上下文中正确地嵌入了文件处理:

for filename in filenames:
    try:
        with open(filename, encoding=encoding) as fp:
            self._read(fp, filename)
    except OSError:
        continue
    read_ok.append(filename)

所以我认为问题很明显是 2.7 的 STL 中的一个缺陷。 处理这种情况的最佳方法是什么? 具体来说:

现在我想我会在 os.remove 中添加一个 try .. except OSError ..(有什么建议吗?)。


更新:可用于在 Windows 重现此问题的独立脚本:

#!/usr/bin/env python2.7
import ConfigParser
import os
import tempfile

def main():
    fd, path = tempfile.mkstemp()
    os.close(fd)
    with open(path, 'w') as f:
        f.write("malformed\n")
    config = ConfigParser.ConfigParser()
    try:
        config.read(path)
    except ConfigParser.Error:
        pass
    os.remove(path)

if __name__ == '__main__':
    main()

当我 运行 它与 Python 2.7 解释器时:

Traceback (most recent call last):
  File ".\example.py", line 19, in <module>
    main()
  File ".\example.py", line 16, in main
    os.remove(path)
WindowsError: [Error 32] The process cannot access the file because it is being used by another process: 'c:\users\redacted\appdata\local\temp\tmp07yq2v'

这是一个有趣的问题。正如 Lukas Graf 在评论中指出的那样,问题似乎是异常回溯对象持有对引发异常的调用框架的引用。这个调用帧包括当时存在的局部变量,其中之一是对打开文件的引用。因此该文件对象仍然有对它的引用并且没有正确关闭。

对于您的独立示例,只需删除 try/except ConfigParser.Error "works":有关格式错误的配置文件的异常未被捕获并停止程序。但是,在您的实际应用程序中,assertRaises 正在捕获异常以检查它是否是您要测试的异常。我不是 100% 确定为什么即使在 with assertRaises 块之后回溯仍然存在,但显然它确实存在。

对于您的示例,另一个更有希望的解决方法是将 except 子句中的 pass 更改为 sys.exc_clear():

try:
    config.read(path)
except ConfigParser.Error:
    sys.exc_clear()

这将摆脱讨厌的回溯对象并允许关闭文件。

然而,在您的实际应用程序中具体如何做到这一点并不清楚,因为有问题的 except 子句在 unittest 中。我认为最简单的事情可能是不直接使用 assertRaises 。相反,编写一个辅助函数来执行您的测试,检查您想要的异常,使用 sys.exc_clear() 技巧进行清理,然后引发另一个自定义异常。然后在 assertRaises 中包装对该辅助方法的调用。这样您就可以控制 ConfigParser 引发的有问题的异常并可以正确清理它(unittest 没有这样做)。

这是我的意思的草图:

# in your test method
assertRaises(CleanedUpConfigError, helperMethod, conf_file, malformed_conf_file)

# helper method that you add to your test case class
def helperMethod(self, conf_file, malformed_conf_file):
     gotRightError = False
     try:
          or6 = OptionReader(
               config_files=[conf_file, malformed_conf_file],
               section='sec',
          )
     except ConfigParser.Error:
          gotRightError = True
          sys.exc_clear()
     if gotRightError:
          raise CleanedUpConfigError("Config error was raised and cleaned up!")

当然,我还没有实际测试过这个,因为我没有用你的代码设置整个单元测试。你可能需要稍微调整一下。 (想想看,如果你这样做,你甚至可能不需要 exc_clear(),因为由于异常处理程序现在在一个单独的函数中,回溯应该在 helperMethod 退出时被正确清除。)但是,我认为这个想法可能会让你到达某个地方。基本上你需要确保捕获这个特定 ConfigParser.Errorexcept 子句是由你编写的,以便在你尝试删除你的测试文件之前可以清理它。

附录:似乎如果上下文管理器处理异常,回溯实际上会被存储到包含 with 块的函数结束,如本例所示:

class CM(object):
    def __init__(self):
        pass

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, tb):
        return True

def foo():
    with CM():
        raise ValueError
    print(sys.exc_info())

即使 with 块在 print 发生时已经结束,所以异常处理应该完成,sys.exc_info 仍然 returns 异常信息就好像有一个活跃的例外。这也是您的代码中发生的情况:with assertRaises 块导致回溯一直持续到该函数的末尾,从而干扰您的 os.remove。这似乎是错误的行为,我注意到它在 Python 3 中不再以这种方式工作(print 打印 (None, None None)),所以我想这是一个用 [ 修复的疣=61=] 3.

基于此,我怀疑在 os.remove 之前(在 with assertRaises 块之后)插入一个 sys.exc_clear() 可能就足够了。