python doctests中的文学编程应用
在org-mode的邮件列表中,我们对在文学编程中如何使用noweb和org-mode进行了很好的讨论。那次讨论的结果被写进了博客里。 我想到了一个不同的文学编程应用程序来生成Python函数中的doctests。 我必须承认,我从来都不喜欢写这些doctests,因为我一直认为它们编写起来非常痛苦,基本上你必须把代码和结果放到一个文档字符串。 上面文学编程讨论中提出的观点让我想到了一种新的写法,这种写法看起来非常合理。
其思想就是将noweb占位符放在doctest的函数docstring中。当你tangle文件时,占位符被展开,它们从编写的其他代码块中获取内容并运行示例来进行测试。
我将使用org-mode和我在scimax中的ob-ipython来说明这个想法。我ob-ipython设置的缺省值在本例中并没有用,因为它会将执行计数和输出的mime类型也放在输出中。这些在REPL中是没有的,所以我们通过设置下面变量来关闭这个功能。
(setq ob-ipython-suppress-execution-count t
ob-ipython-show-mime-types nil)
现在,我们创建一个示例函数,它接受单个参数并返回一除以该参数的值。
这个块是可运行的,然后在jupyter kernel中定义该函数。
该函数的文档字符串中包含几个noweb引用,这些引用指向我们稍后定义的doctest块。目前,他们还不起作用。这些doctest块在 The noweb doctest block
这一节中。
此块还有一个tangle头,指明了要将结果tangle到哪个文件。当我运行这个块时,它被发送给Jupyter kernel并保存在内存中,以供后续块使用。
下面是一个没有web扩展的块。请注意,原始的org源代码要已发布的博客更容易阅读。
def func(a): """A function to divide one by a. <<doctest("doctest-1")>> <<doctest("doctest-2")>> <<doctest("doctest-3")>> Returns: 1 / a. """ return 1 / a
def func(a): """A function to divide one by a. <<doctest("doctest-1")>> <<doctest("doctest-2")>> <<doctest("doctest-3")>> Returns: 1 / a. """ return 1 / a
现在,我们可以编写一系列命名的块,这些块定义了我们希望用作doctest的各种测试。你可以在这里运行这些块,并验证它们是正确的。 稍后,当我们tangle文档时,这些结果将被合并到我们在上面定义的docstring中的tangle文件中。
func(5) == 0.2
True
下面这个测试将引发一个异常,我们只是运行它以确保它确实如此。
func(0)
ZeroDivisionErrorTraceback (most recent call last) <ipython-input-6-ba0cd5a88f0a> in <module>() ----> 1 func(0) <ipython-input-1-eafd354a3163> in func(a) 18 Returns: 1 / a. 19 """ ---> 20 return 1 / a ZeroDivisionError: division by zero
下面是一个带有缩进的doctest,这里演示如何使用它。
for i in range(1, 4): print(func(i))
1.0 0.5 0.3333333333333333
以上就是我希望合并到doctest中的示例。每个块都有一个名称,该名称就是函数docstring中noweb引用的参数。
1 添加运行测试的方法
这是一种常见的习惯用法,可以轻松运行doctest。这将被tangle到文件中。
if __name__ == "__main__": import doctest doctest.testmod()
2 Tangle 该 file
到目前为止,我们编写的Python代码只存在于org文件和内存中。所谓Tangling就是将代码提取到代码文件中。
我们运行下面这个命令,它会提取标记为tangling的代码块,并展开其中的noweb引用。
(org-babel-tangle)
test.py |
下面是结果:
def func(a): """A function to divide one by a. >>> func(5) == 0.2 True >>> func(0) Traceback (most recent call last): ZeroDivisionError: division by zero >>> for i in range(1, 4): ... print(func(i)) 1.0 0.5 0.3333333333333333 Returns: 1 / a. """ return 1 / a if __name__ == "__main__": import doctest doctest.testmod()
这看起来像是一个合法的python文件。可以看到doctest块已经如愿插入到文档字符串中了。 证据就是我们可以运行这些doctest并使用python模块。我们接下来展示一下。
3 运行测试
现在,我们可以检查是否能在全新运行时通过测试(即不使用存储在jupyter内核中的函数版本)。运行doctest的标准方法如下:
python test.py -v
Trying: func(5) == 0.2 Expecting: True ok Trying: func(0) Expecting: Traceback (most recent call last): ZeroDivisionError: division by zero ok Trying: for i in range(1, 4): print(func(i)) Expecting: 1.0 0.5 0.3333333333333333 ok 1 items had no tests: __main__ 1 items passed all tests: 3 tests in __main__.func 3 tests in 2 items. 3 passed and 0 failed. Test passed.
嗯,就是这样!它工作正常。现在我们有了一个可以导入和重用的python文件,以及一些演示它如何工作的doctest。例如,下面是一个小型Python脚本。
from test import func print(func(3))
0.3333333333333333
这里当然还有一些需要注意的地方。这只是一个简单的概念证明耳鸣,没有经过充分的证明。我不知道更复杂的文档测试会产生多少复杂性。但是,如果您喜欢使用doctest,喜欢使用org-mode和交互式/文学编程技术,那么继续研究下去似乎是一个好主意。
在我看来,使用noweb来构建更好的代码文件绝对是一种有趣的方式。
4 The noweb doctest block
下面这些块用于noweb扩展。每个块接受一个表示代码块命的变量。这个块获取指定名称的代码块的内容,并按在REPL一样对其进行格式化。
我们还获取指定名称代码块的结果并将其格式化为doctest所需的格式。我们使用一种启发式方法来检测Tracebacks并修改输出使之与doctest一致。在这种情况下,我们假设相关的Traceback在最后一行。
诚然,这确实做了一些让人感觉很脆弱的事情,比如到处删除空白行,引用引号(在本例中并没有实际使用),以及删除ob-ipython结果中的“:”部分。可能其他运行代码快方法并不适合这种情况。
(org-babel-goto-named-src-block name) (let* ((src (s-trim-right (org-element-property :value (org-element-context)))) (src-lines (split-string src "\n")) body result) (setq body (s-trim-right (s-concat ">>> " (car src-lines) "\n" (s-join "\n" (mapcar (lambda (s) (concat "... " s)) (cdr src-lines)))))) ;; now the results (org-babel-goto-named-result name) (let ((result (org-element-context))) (setq result (thread-last (buffer-substring (org-element-property :contents-begin result) (org-element-property :contents-end result)) (s-trim) ;; remove ": " from beginning of lines (replace-regexp-in-string "^: *" "") ;; quote quotes (replace-regexp-in-string "\\\"" "\\\\\""))) (when (string-match "Traceback" result) (setq result (format "Traceback (most recent call last):\n%s" (car (last (split-string result "\n")))))) (concat body "\n" result)))