Python已经演化出了一个广泛的生态系统,该生态系统能够让Python程序员的生活变得更加简单,减少他们重复造轮的工作。同样的理念也适用于工具开发者的工作,即便他们开发出的工具并没有出现在最终的程序中。本文将介绍Python程序员必知必会的开发者工具。
对于开发者来说,最实用的帮助莫过于帮助他们编写代码文档了。pydoc模块可以根据源代码中的docstrings为任何可导入模块生成格式良好的文档。Python包含了两个测试框架来自动测试代码以及验证代码的正确性:1)doctest模块,该模块可以从源代码或独立文件的例子中抽取出测试用例。2)unittest模块,该模块是一个全功能的自动化测试框架,该框架提供了对测试准备(test fixtures), 预定义测试集(predefined test suite)以及测试发现(test discovery)的支持。
trace模 块可以监控Python执行程序的方式,同时生成一个报表来显示程序的每一行执行的次数。这些信息可以用来发现未被自动化测试集所覆盖的程序执行路径,也 可以用来研究程序调用图,进而发现模块之间的依赖关系。编写并执行测试可以发现绝大多数程序中的问题,Python使得debug工作变得更加简单,这是 因为在大部分情况下,Python都能够将未被处理的错误打印到控制台中,我们称这些错误信息为traceback。如果程序不是在文本控制台中运行 的,traceback也能够将错误信息输出到日志文件或是消息对话框中。当标准的traceback无法提供足够的信息时,可以使用cgitb 模块来查看各级栈和源代码上下文中的详细信息,比如局部变量。cgitb模块还能够将这些跟踪信息以HTML的形式输出,用来报告web应用中的错误。
一旦发现了问题出在哪里后,就需要使用到交互式调试器进入到代码中进行调试工作了,pdb模块能够很好地胜任这项工作。该模块可以显示出程序在错误产生时的执行路径,同时可以动态地调整对象和代码进行调试。当程序通过测试并调试后,下一步就是要将注意力放到性能上了。开发者可以使用profile以及timit模块来测试程序的速度,找出程序中到底是哪里很慢,进而对这部分代码独立出来进行调优的工作。Python程序是通过解释器执行的,解释器的输入是原有程序的字节码编译版本。这个字节码编译版本可以在程序执行时动态地生成,也可以在程序打包的时候就生成。compileall模块可以处理程序打包的事宜,它暴露出了打包相关的接口,该接口能够被安装程序和打包工具用来生成包含模块字节码的文件。同时,在开发环境中,compileall模块也可以用来验证源文件是否包含了语法错误。
在源代码级别,pyclbr模块提供了一个类查看器,方便文本编辑器或是其他程序对Python程序中有意思的字符进行扫描,比如函数或者是类。在提供了类查看器以后,就无需引入代码,这样就避免了潜在的副作用影响。
文档字符串与doctest模块
如果函数,类或者是模块的***行是一个字符串,那么这个字符串就是一个文档字符串。可以认为包含文档字符串是一个良好的编程习惯,这是因为这些字符串可以给Python程序开发工具提供一些信息。比如,help()命令能够检测文档字符串,Python相关的IDE也能够进行检测文档字符串的工作。由于程序员倾向于在交互式shell中查看文档字符串,所以***将这些字符串写的简短一些。例如
- # mult.py
- class Test:
- """
- >>> a=Test(5)
- >>> a.multiply_by_2()
- 10
- """
- def __init__(self, number):
- self._number=number
- def multiply_by_2(self):
- return self._number*2
在编写文档时,一个常见的问题就是如何保持文档和实际代码的同步。例如,程序员也许会修改函数的实现,但是却忘记了更新文档。针对这个问题,我们可以使用 doctest模块。doctest模块收集文档字符串,并对它们进行扫描,然后将它们作为测试进行执行。为了使用doctest模块,我们通常会新建一 个用于测试的独立的模块。例如,如果前面的例子Test class包含在文件mult.py中,那么,你应该新建一个testmult.py文件用来测试,如下所示:
- # testmult.py
- import mult, doctest
- doctest.testmod(mult, verbose=True)
- # Trying:
- # a=Test(5)
- # Expecting nothing
- # ok
- # Trying:
- # a.multiply_by_2()
- # Expecting:
- # 10
- # ok
- # 3 items had no tests:
- # mult
- # mult.Test.__init__
- # mult.Test.multiply_by_2
- # 1 items passed all tests:
- # 2 tests in mult.Test
- # 2 tests in 4 items.
- # 2 passed and 0 failed.
- # Test passed.
在这段代码中,doctest.testmod(module)会执行特定模块的测试,并且返回测试失败的个数以及测试的总数目。如果所有的测试都通过了,那么不会产生任何输出。否则的话,你将会看到一个失败报告,用来显示期望值和实际值之间的差别。如果你想看到测试的详细输出,你可以使用testmod(module, verbose=True).
如果不想新建一个单独的测试文件的话,那么另一种选择就是在文件末尾包含相应的测试代码:
- if __name__ == '__main__':
- import doctest
- doctest.testmod()
如果想执行这类测试的话,我们可以通过-m选项调用doctest模块。通常来讲,当执行测试的时候没有任何的输出。如果想查看详细信息的话,可以加上-v选项。
- $ python -m doctest -v mult.py
单元测试与unittest模块
如果想更加彻底地 对程序进行测试,我们可以使用unittest模块。通过单元测试,开发者可以为构成程序的每一个元素(例如,独立的函数,方法,类以及模块)编写一系列 独立的测试用例。当测试更大的程序时,这些测试就可以作为基石来验证程序的正确性。当我们的程序变得越来越大的时候,对不同构件的单元测试就可以组合起来 成为更大的测试框架以及测试工具。这能够极大地简化软件测试的工作,为找到并解决软件问题提供了便利。
- # splitter.py
- import unittest
- def split(line, types=None, delimiter=None):
- """Splits a line of text and optionally performs type conversion.
- ...
- """
- fields = line.split(delimiter)
- if types:
- fields = [ ty(val) for ty,val in zip(types,fields) ]
- return fields
- class TestSplitFunction(unittest.TestCase):
- def setUp(self):
- # Perform set up actions (if any)
- pass
- def tearDown(self):
- # Perform clean-up actions (if any)
- pass
- def testsimplestring(self):
- r = split('GOOG 100 490.50')
- self.assertEqual(r,['GOOG','100','490.50'])
- def testtypeconvert(self):
- r = split('GOOG 100 490.50',[str, int, float])
- self.assertEqual(r,['GOOG', 100, 490.5])
- def testdelimiter(self):
- r = split('GOOG,100,490.50',delimiter=',')
- self.assertEqual(r,['GOOG','100','490.50'])
- # Run the unittests
- if __name__ == '__main__':
- unittest.main()
- #...
- #----------------------------------------------------------------------
- #Ran 3 tests in 0.001s
- #OK
在使用单元测试时,我们需要定义一个继承自unittest.TestCase的类。在这个类里面,每一个测试都以方法的形式进行定义,并都以test打头进行命名——例如,’testsimplestring‘,’testtypeconvert‘以及类似的命名方式(有必要强调一下,只要方法名以test打头,那么无论怎么命名都是可以的)。在每个测试中,断言可以用来对不同的条件进行检查。
实际的例子:
假如你在程序里有一个方法,这个方法的输出指向标准输出(sys.stdout)。这通常意味着是往屏幕上输出文本信息。如果你想对你的代码进行测试来证明这一点,只要给出相应的输入,那么对应的输出就会被显示出来。
- # url.py
- def urlprint(protocol, host, domain):
- url = '{}://{}.{}'.format(protocol, host, domain)
- print(url)
内置的print函数在默认情况下会往sys.stdout发送输出。为了测试输出已经实际到达,你可以使用一个替身对象对其进行模拟,并且对程序的期望值进行断言。unittest.mock模块中的patch()方法可以只在运行测试的上下文中才替换对象,在测试完成后就立刻返回对象原始的状态。下面是urlprint()方法的测试代码:
- #urltest.py
- from io import StringIO
- from unittest import TestCase
- from unittest.mock import patch
- import url
- class TestURLPrint(TestCase):
- def test_url_gets_to_stdout(self):
- protocol = 'http'
- host = 'www'
- domain = 'example.com'
- expected_url = '{}://{}.{}\n'.format(protocol, host, domain)
- with patch('sys.stdout', new=StringIO()) as fake_out:
- url.urlprint(protocol, host, domain)
- self.assertEqual(fake_out.getvalue(), expected_url)
urlprint()函数有三个参数,测试代码首先给每个参数赋了一个假值。变量expected_url包含了期望的输出字符串。为了能够执行测试,我们使用了unittest.mock.patch()方法作为上下文管理器,把标准输出sys.stdout替换为了StringIO对象,这样发送的标准输出的内容就会被StringIO对象所接收。变量fake_out就是在这一过程中所创建出的模拟对象,该对象能够在with所处的代码块中所使用,来进行一系列的测试检查。当with语 句完成时,patch方法能够将所有的东西都复原到测试执行之前的状态,就好像测试没有执行一样,而这无需任何额外的工作。但对于某些Python的C扩 展来讲,这个例子却显得毫无意义,这是因为这些C扩展程序绕过了sys.stdout的设置,直接将输出发送到了标准输出上。这个例子仅适用于纯 Python代码的程序(如果你想捕获到类似C扩展的输入输出,那么你可以通过打开一个临时文件然后将标准输出重定向到该文件的技巧来进行实现)。
Python调试器与pdb模块
Python在 pdb模块中包含了一个简单的基于命令行的调试器。pdb模块支持事后调试(post-mortem debugging),栈帧探查(inspection of stack frames),断点(breakpoints),单步调试(single-stepping of source lines)以及代码审查(code evaluation)。
有好几个函数都能够在程序中调用调试器,或是在交互式的Python终端中进行调试工作。
在所有启动调试器的函数中,函数set_trace()也许是最简易实用的了。如果在复杂程序中发现了问题,可以在代码中插入set_trace()函数,并运行程序。当执行到set_trace()函数时,这就会暂停程序的执行并直接跳转到调试器中,这时候你就可以大展手脚开始检查运行时环境了。当退出调试器时,调试器会自动恢复程序的执行。
假设你的程序有问题,你想找到一个简单的方法来对它进行调试。
如果你的程序崩溃时报了一个异常错误,那么你可以用python3 -i someprogram.py这个命令来运行你的程序,这能够很好地发现问题所在。-i选项表明只要程序终结就立即启动一个交互式shell。在这个交互式shell中,你就可以很好地探查到底发生了什么导致程序的错误。例如,如果你有以下代码:
- def function(n):
- return n + 10
- function("Hello")
如果使用python3 -i 命令运行程序就会产生如下输出:
- python3 -i sample.py
- Traceback (most recent call last):
- File "sample.py", line 4, in <module>
- function("Hello")
- File "sample.py", line 2, in function
- return n + 10
- TypeError: Can't convert 'int' object to str implicitly
- >>> function(20)
- 30
- >>>
如果你没有发现什么明显的错误,那么你可以进一步地启动Python调试器。例如:
- >>> import pdb
- >>> pdb.pm()
- > sample.py(4)func()
- -> return n + 10
- (Pdb) w
- sample.py(6)<module>()
- -> func('Hello')
- > sample.py(4)func()
- -> return n + 10
- (Pdb) print n
- 'Hello'
- (Pdb) q
- >>>
如果你的代码身处的环境很难启动一个交互式shell的话(比如在服务器环境下),你可以增加错误处理的代码,并自己输出跟踪信息。例如:
- import traceback
- import sys
- try:
- func(arg)
- except:
- print('**** AN ERROR OCCURRED ****')
- traceback.print_exc(file=sys.stderr)
如果你的程序并没有崩溃,而是说程序的行为与你的预期表现的不一致,那么你可以尝试在一些可能出错的地方加入print()函数。如果你打算采用这种方案 的话,那么还有些相关的技巧值得探究。首先,函数traceback.print_stack()能够在被执行时立即打印出程序中栈的跟踪信息。例如:
- >>> def sample(n):
- ... if n > 0:
- ... sample(n-1)
- ... else:
- ... traceback.print_stack(file=sys.stderr)
- ...
- >>> sample(5)
- File "<stdin>", line 1, in <module>
- File "<stdin>", line 3, in sample
- File "<stdin>", line 3, in sample
- File "<stdin>", line 3, in sample
- File "<stdin>", line 3, in sample
- File "<stdin>", line 3, in sample
- File "<stdin>", line 5, in sample
- >>>
另外,你可以在程序中任意一处使用pdb.set_trace()手动地启动调试器,就像这样:
- import pdb
- def func(arg):
- ...
- pdb.set_trace()
- ...
深入解析大型程序的时候,这是一个非常实用的技巧,这样操作能够清楚地了解程序的控制流或是函数的参数。比如,一旦调试器启动了之后,你就可以使用print或者w命令来查看变量,来了解栈的跟踪信息。
在进行软件调试时,千万不要让事情变得很复杂。有时候仅仅需要知道程序的跟踪信息就能够解决大部分的简单错误(比如,实际的错误总是显示在跟踪信息的***一行)。在实际的开发过程中,将print()函数插入到代码中也能够很方便地显示调试信息(只需要记得在调试完以后将print语句删除掉就行了)。调试器的通用用法是在崩溃的函数中探查变量的值,知道如何在程序崩溃以后再进入到调试器中就显得非常实用。在程序的控制流不是那么清楚的情况下,你可以插入pdb.set_trace()语句来理清复杂程序的思路。本质上,程序会一直执行直到遇到set_trace()调用,之后程序就会立刻跳转进入到调试器中。在调试器里,你就可以进行更多的尝试。如果你正在使用Python的IDE,那么IDE通常会提供基于pdb的调试接口,你可以查阅IDE的相关文档来获取更多的信息。
下面是一些Python调试器入门的资源列表:
- 阅读Steve Ferb的文章 “Debugging in Python”
- 观看Eric Holscher的截图 “Using pdb, the Python Debugger”
- 阅读Ayman Hourieh的文章 “Python Debugging Techniques”
- 阅读 Python documentation for pdb – The Python Debugger
- 阅读Karen Tracey的D jango 1.1 Testing and Debugging一书中的第九章——When You Don’t Even Know What to Log: Using Debuggers
程序分析
profile模块和cProfile模块可以用来分析程序。它们的工作原理都一样,唯一的区别是,cProfile模块 是以C扩展的方式实现的,如此一来运行的速度也快了很多,也显得比较流行。这两个模块都可以用来收集覆盖信息(比如,有多少函数被执行了),也能够收集性 能数据。对一个程序进行分析的最简单的方法就是运行这个命令:
- % python -m cProfile someprogram.py
此外,也可以使用profile模块中的run函数:
- run(command [, filename])
该函数会使用exec语句执行command中的内容。filename是可选的文件保存名,如果没有filename的话,该命令的输出会直接发送到标准输出上。
下面是分析器执行完成时的输出报告:
- 126 function calls (6 primitive calls) in 5.130 CPU seconds
- Ordered by: standard name
- ncalls tottime percall cumtime percall filename:lineno(function)
- 1 0.030 0.030 5.070 5.070 <string>:1(?)
- 121/1 5.020 0.041 5.020 5.020 book.py:11(process)
- 1 0.020 0.020 5.040 5.040 book.py:5(?)
- 2 0.000 0.000 0.000 0.000 exceptions.py:101(_ _init_ _)
- 1 0.060 0.060 5.130 5.130 profile:0(execfile('book.py'))
- 0 0.000 0.000 profile:0(profiler)
当输出中的***列包含了两个数字时(比如,121/1),后者是元调用(primitive call)的次数,前者是实际调用的次数(译者注:只有在递归情况下,实际调用的次数才会大于元调用的次数,其他情况下两者都相等)。对于绝大部分的应用 程序来讲使用该模块所产生的的分析报告就已经足够了,比如,你只是想简单地看一下你的程序花费了多少时间。然后,如果你还想将这些数据保存下来,并在将来 对其进行分析,你可以使用pstats模块。
假设你想知道你的程序究竟在哪里花费了多少时间。
如果你只是想简单地给你的整个程序计时的话,使用Unix中的time命令就已经完全能够应付了。例如:
- bash % time python3 someprogram.py
- real 0m13.937s
- user 0m12.162s
- sys 0m0.098s
- bash %
通常来讲,分析代码的程度会介于这两个极端之间。比如,你可能已经知道你的代码会在一些特定的函数中花的时间特别多。针对这类特定函数的分析,我们可以使用修饰器decorator,例如:
- import time
- from functools import wraps
- def timethis(func):
- @wraps(func)
- def wrapper(*args, **kwargs):
- start = time.perf_counter()
- r = func(*args, **kwargs)
- end = time.perf_counter()
- print('{}.{} : {}'.format(func.__module__, func.__name__, end - start))
- return r
- return wrapper
使用decorator的方式很简单,你只需要把它放在你想要分析的函数的定义前面就可以了。例如:
- >>> @timethis
- ... def countdown(n):
- ... while n > 0:
- ... n -= 1
- ...
- >>> countdown(10000000)
- __main__.countdown : 0.803001880645752
- >>>
如果想要分析一个语句块的话,你可以定义一个上下文管理器(context manager)。例如:
- import time
- from contextlib import contextmanager
- @contextmanager
- def timeblock(label):
- start = time.perf_counter()
- try:
- yield
- finally:
- end = time.perf_counter()
- print('{} : {}'.format(label, end - start))
接下来是如何使用上下文管理器的例子:
- >>> with timeblock('counting'):
- ... n = 10000000
- ... while n > 0:
- ... n -= 1
- ...
- counting : 1.5551159381866455
- >>>