在C扩展和Python3中,Doctests因UnicodeDecodeError失败

6 投票
1 回答
737 浏览
提问于 2025-04-18 16:01

我在为一个同时支持Python2和Python3的C扩展模块搭建测试框架时遇到了困难。我希望通过doctest来检查我的文档字符串,以确保不会给用户提供错误的信息,所以我想把doctest作为测试的一部分来运行。

我认为问题的根源不在于文档字符串本身,而是在于doctest模块读取我的扩展模块的方式。如果我在Python2下运行doctest(使用的是针对Python2编译的模块),我得到了预期的输出:

$ python -m doctest myext.so -v
...
1 items passed all tests:
98 tests in myext.so
98 tests in 1 items.
98 passed and 0 failed.
Test passed.

但是,当我在Python3下做同样的事情时,我遇到了一个UnicodeDecodeError错误:

$ python3 -m doctest myext3.so -v
Traceback (most recent call last):
...
  File "/usr/local/Cellar/python3/3.3.3/Frameworks/Python.framework/Versions/3.3/lib/python3.3/doctest.py", line 223, in _load_testfile
    return f.read(), filename
  File "/usr/local/Cellar/python3/3.3.3/Frameworks/Python.framework/Versions/3.3/lib/python3.3/codecs.py", line 301, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xcf in position 0: invalid continuation byte

为了获取更多信息,我通过pytest运行了它,并查看了完整的错误追踪信息:

$ python3 -m pytest --doctest-glob "*.so" --full-trace
...
self = <encodings.utf_8.IncrementalDecoder object at 0x102ff5110>
input = b'\xcf\xfa\xed\xfe\x07\x00\x00\x01\x03\x00\x00\x00\x08\x00\x00\x00\r\x00\x00\x00\xd0\x05\x00\x00\x85\x00\x00\x00\x00\x...edString\x00_PyUnicode_FromString\x00_Py_BuildValue\x00__Py_FalseStruct\x00__Py_TrueStruct\x00dyld_stub_binder\x00\x00'
final = True

    def decode(self, input, final=False):
        # decode input (taking the buffer into account)
        data = self.buffer + input
>       (result, consumed) = self._buffer_decode(data, self.errors, final)
E       UnicodeDecodeError: 'utf-8' codec can't decode byte 0xcf in position 0: invalid continuation byte

/usr/local/Cellar/python3/3.3.3/Frameworks/Python.framework/Versions/3.3/lib/python3.3/codecs.py:301: UnicodeDecodeError    

看起来doctest实际上是在读取这个.so文件来获取文档字符串(而不是直接导入模块),但Python3不知道如何解码这个输入。我可以通过自己尝试读取.so文件来确认这一点,并复现这个字节字符串和错误追踪信息:

$ python3
Python 3.3.3 (default, Dec 10 2013, 20:13:18) 
[GCC 4.2.1 Compatible Apple LLVM 5.0 (clang-500.2.79)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> open('myext3.so').read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/Cellar/python3/3.3.3/Frameworks/Python.framework/Versions/3.3/lib/python3.3/codecs.py", line 301, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xcf in position 0: invalid continuation byte
>>> open('myext3.so', 'rb').read()
b'\xcf\xfa\xed\xfe\x07\x00\x00\x01\x03\x00\x00\x00\x08\x00\x00\x00\r\x00\x00\x00\xd0\x05...'

有没有人之前遇到过这个问题?有没有什么标准(或者不那么标准)的方法可以让doctest在Python3上对C扩展模块执行测试?

更新:我还应该补充一下,我在Travis-CI上得到了相同的结果(见这里),所以这并不是我本地构建特有的问题。

1 个回答

3

我找到了一种解决这个问题的方法,所以我来分享一下,但我觉得这个方法有点不太满意。我还在寻找更优雅、更简单的解决方案。


要让 doctest.py 正常工作,有三个问题需要解决:

1) 让 doctest 识别 .so 文件作为 Python 模块。

如果你查看 doctest.py 的源代码,你会发现测试运行器中有一段代码,看起来像这样(具体取决于你使用的 Python 版本):

if filename.endswith(".py"):
    # It is a module -- insert its dir into sys.path and try to
    # import it. If it is part of a package, that possibly
    # won't work because of package imports.
    dirname, filename = os.path.split(filename)
    sys.path.insert(0, dirname)
    m = __import__(filename[:-3])
    del sys.path[0]
    failures, _ = testmod(m)
else:
    failures, _ = testfile(filename, module_relative=False)

这里发生的事情是 doctest.py 在检查文件是否以 ".py" 结尾,如果是,它就把这个文件当作 Python 模块加载;如果不是,文件就像文本文件一样被读取(就像 README.rst 文件)。我们需要让 doctest.py 知道以 ".so" 结尾的文件也是 Python 模块。为此,只需在这个 if 语句中添加对 ".so" 后缀的检查,修改为:

if filename.endswith(".py") or filename.endswith(".so"):
    ...

2) 让 doctest 识别 C 扩展模块中的函数。

doctest.py 使用 inspect.isfunction 函数来判断模块对象中的哪些对象是函数。当它在模块中递归查找文档字符串时,问题是这个函数只识别用 Python 写的函数,而不识别用 C 写的(Python 将 C 扩展函数视为内置函数)。所以,为了在遍历模块时识别我们的函数,我们需要使用 inspect.isbuiltin

为了解决这个问题,我们需要找到 doctest.py 中的 DocTestFinder._find 方法,并改变它查找函数的方式。我把:

# Recurse to functions & classes.
if ((inspect.isfunction(val) or inspect.isclass(val)) and
    self._from_module(module, val)):
    self._find(tests, val, valname, module, source_lines,
               globs, seen)

改成了:

# Recurse to functions & classes.
if ((inspect.isbuiltin(val) or inspect.isclass(val)) and
    self._from_module(module, val)):
    self._find(tests, val, valname, module, source_lines,
               globs, seen)

3) 正确移除 .so 文件上的版本标签(仅限 Python3)。

在 Python3 中,C 扩展可以带有版本标识符(比如 "myext.cpython-3mu.so",请参见 PEP 3149)。我们需要知道在 doctest.py 测试运行器中进行初始导入时如何移除这个标识。

为此,我把这一行:

m = __import__(filename[:-3])

改成了:

from sysconfig import get_config_var
m = __import__(filename[:-3] if filename.endswith(".py") else filename.replace(get_config_var("EXT_SUFFIX"), ""))

这只在 Python3 中需要。


在做了这些修改后,我可以让 doctest 在 Python2 和 Python3 上都正常工作。由于这些修改有点麻烦,我写了一个 patch_doctest.py 脚本,可以自动完成这些操作,并把修改后的 doctest.py 放在你当前的目录下。如果你想使用,可以在这里获取这个文件 here。然后你可以像这样运行扩展模块的测试:

$ python2 patch_doctest.py
$ python2 -m doctest myext2.so
$ rm doctest.py
$ python3 patch_doctest.py
$ python3 -m doctest myext3.so

作为这个方法有效的证明,这是新的 Travis-CI 结果

撰写回答