Doctests 在 C 扩展和 Python3 上出现 UnicodeDecodeError 错误

6
我在将一个 C 扩展模块应用到 Python2 和 Python3 上的测试框架时遇到了困难。 我想运行我的文档字符串 doctest,以确保我不向用户提供错误信息,因此我希望将其作为我的测试的一部分运行。
我认为问题的根源并不是文档字符串本身,而是 doctest 模块如何读取我的扩展模块。如果我使用 Python2(对 Python2 编译的模块)运行 doctest,则会得到我预期的输出:
$ 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上获得了相同的结果(请参见此处),因此它不是特定于我的本地构建。


也许你需要为Python 3编译不同版本的“doctest”? - Mark Ransom
对于 Python3,我正在使用标准库中的 doctest 模块。你有其他版本推荐吗?难道 doctest 不是纯 Python 模块,需要编译吗? - SethMMorton
抱歉,我以前从未使用过 doctest,不知道它是一个内置模块。也许你的 PYTHONPATH 将版本 2 的模块放在版本 3 的模块之前了? - Mark Ransom
我的PYTHONPATH是空的...我完全依赖于sys.path中硬编码的路径。此外,在第一个回溯中,您可以看到doctest.py文件位于Python 3.3标准库位置,因此我不认为这是问题所在。感谢您的建议,请继续提供! - SethMMorton
1个回答

3
我已经找到一个解决此问题的方法,我将把它发布出来,但我觉得这个解决方法并不是很令人满意。我仍在寻找更为优雅/不那么hacky的解决方案。

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 函数来确定哪些对象是函数,当递归搜索模块对象内的docstrings时。 该函数存在问题,它仅能识别用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)

to

# 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])

to

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

这仅适用于Python3。


进行这些修改后,我可以在Python2和Python3上正常使用doctest。由于这些修改相当繁琐,我制作了一个名为patch_doctest.py的脚本,可以自动完成此操作并将修补过的doctest.py放入当前目录中。如果您想使用它,您可以在此处获取此文件。然后,您可以像这样运行扩展模块的测试:

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

作为证明这个工具有效的证据,这里是Travis-CI的最新结果

考虑到这不需要太多的工作就能实现,我想知道Python开发人员是否会对此感兴趣... - SethMMorton

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