代码质量保证工具
Python 主要的代码质量保证工具有如下 6 种。
1.Pycodestyle(https://github.com/PyCQA/pycodestyle ):它是一个针对 PEP8 实现的检查工具,其实就是原来的 pep8,关于其改名的讨论见 Issues 466(https://github.com/PyCQA/pycodestyle/issues/466 )。它可以告诉你代码中哪些部分没有遵守 PEP8 的要求,并为每个问题给出其错误码。如果违反了必须遵守的规范,就会报出 E 开头的错误码;如果是其他的小问题,则会报 W 开头的错误码。
2.Pylint:一个 Python 代码风格的检查工具,它甚至能检查变量命名是否符合编码规范,声明的接口是否被真正的实现等。它对 Python 的要求近乎于苛刻,很难有项目能满足它的质量要求,但由于其可定制性非常好,使用者通常会选择忽略掉一些不希望检查的错误类型。
3.PyChecker:一个对 Python 源代码进行语法检查的工具,它不检查代码规范,只执行代码,看看是否报错,所以它能检测出一些编译期间才出现的错误,比如拼写错误、在赋值之前就使用了变量、使用的接口参数和接口定义不一致、重复定义同名的函数/类等问题。由于它真正地执行代码,既不安全,效率也很低下,现在已经被下面说的 Pyflakes 取代了。
4.Pyflakes:它的功能和 PyChecker 相似,但是没有真的像 PyChecker 那样去先执行再检查,所以很安全,也快了很多。一般不直接使用它,而使用集成了它的 Flake8 这个库。
5.Flake8:现在最流行的代码检查工具。它其实包含了 Pyflakes、Pycodestyle 和 McCabe。Flake8 还有非常好的插件机制,方便根据团队习惯自定义插件。
6.autopep8:顾名思义,它是自动地把代码格式化为符合 PEP8 标准的工具。它只支持大部分的 PEP 8 规范,但是有些 PEP 8 问题还需要人工介入。平时笔者也经常用它来处理遗留代码,甚至在笔者的个人编辑器里面集成了它(http://bit.ly/23e1lCx):原理是在保存文件前会先自动格式化成符合 pep8 要求的代码之后再保存。
Pycodestyle 对中文缩进的处理
使用 Pycodestyle 作为代码检查工具的团队可能遇到过这样的问题,对于下面的代码:
> cat ascii.py story_tags=[ (('xxxxxx', 38), [('xxxx', 39), ('xxxx', 40)]), ] > cat non-ascii.py story_tags=[ (('数码产品', 38), [('手机', 39), ('配件', 40)]) ]
用 Pycodestyle 执行检查的结果却是不同的:
> pycodestyle ascii.py > pycodestyle non-ascii.py non_ascii.py:4:25:E128 continuation line under-indented for visual indent
原因是这样的:
In : len('xxxx') Out: 4 In : len('数码') Out: 6
对编辑器和终端来说,屏幕的占位的长度是一致的,但是计算出的长度是不一致的,造成 Pycodestyle 误判。
Pycodestyle 维护者们不想接受笔者修改这个问题的 PR,因为增加了很多的代码和复杂度。具体的讨论见 pycodestyle#345(https://github.com/PyCQA/pycodestyle/pull/345 )。但对我们来说,这个问题无法绕过,只能迂回解决:写代码的时候设置合理的换行,不要让下一行的缩进起始位的左面出现这种非 ASCII 字符就可以了。以下是两种符合要求的风格:
story_tags=[ ( ('数码产品', 38), [('手机', 39), ('配件', 40)] ) ] story_tags=[ (('数码产品', 38), [('手机', 39), ('配件', 40)]) ]
Flake8
笔者所在团队都是强制要求项目通过 Flake8 检查的。当有历史遗留代码或者不得不做一些硬编码(比如项目中有 Thrift 生成的代码,生成的代码不符合 Python 编码规范)等情况下,可以适当放弃对一些文件或者类型的检查。在每个项目的代码库下一般都会有一个 setup.cfg 文件,其中存放了 Flake8 的设置:
[flake8] exclude=main.py,tests/*,venv/*,*/model/*_client ignore=F403,E265,F812,E402,E731,W503 max-complexity=20
对各选项的说明如下。
- exclude:不会对符合“main.py,tests/*,venv/*,*/model/*_client”规则的文件进行检查。应该尽量减少这样的文件。
- ignore:会忽略 F403、E265、F812、E402、E731、W503 这几种类型错误,但也应该尽量减少这种忽略的类型。
- max-complexity:表示接受的代码复杂度的最大值。因为代码有历史遗留问题,所以放宽了这个值。
Pylint
代码质量检查通常在 CI 期间执行,如果检测未通过,返回值不为 0,就会让本次测试失败。之前提到了使用 Flake8 做基本的代码规范、复杂度等检查。其实还远远不够,Python 这种动态语言运行期才会做类型检查。对于参数数量不匹配,方法没有加 self,循环引用,写了 import 语句但由于包名写错了事实上无法引用等 Flake8 无法检查到的问题,可以使用 Pylint 来做这样的静态检查。
Pylint 提供了非常多的检查类型,但是太苛刻,使用时应该只验证想要验证的类型。
代码质量检查程序如果不想调用 subprocess 执行命令获取返回值,可以使用如下方式集成 Pylint(pylint_example.py):
import os import sys from pylint import lint if len(sys.argv)>1: FILES=sys.argv[1:] else: FILES=[] for dirpath, dirnames, filenames in os.walk(os.getcwd()): FILES.extend( os.path.join(dirpath, filename) for filename in filenames if filename.endswith('.py') ) MESSAGES=['C0202', 'E0102', 'E0211', 'E0213', 'E1120', 'E1121', 'E1123', 'W0613', 'R0401', 'R0801'] args=[ '--reports=n', '--disable=all', '--msg-template="{path}:{line}:[{msg_id},{obj}]{msg}"', '--enable={}'.format(','.join(MESSAGES)) ] args.extend(FILES) sys.exit(lint.Run(args))
args 的参数含义如下:
- --reports=n 表示不生成报告。
- --disable=all 表示先全部禁止,再使用--enable=C0202,E0102...开启要检测的类型的方式。
- Pylint 的默认输出没有完整的错误序列号,所以通过--msg-template 重新定制了错误模板。
- 只检测 MESSAGES 列表中列出的类型。
其他代码质量保证工具
Gandalf
Gandalf(http://bit.ly/21osKjT )是笔者开源的一个基于 linty_fresh 实现的 GitHub Webhook 服务。使用 asyncio、rq、Flask 等技术。在提 PR 之后会计算本次修改是否符合 Flake8 检查,如果有不符合项会在 PR 的对应行上添加评论,指出错误。详细的使用方法和效果可以参考笔者的博客(http://bit.ly/1UQt5d6 )。
Pre-commit
Pre-commit 是一个质量非常高,代码测试覆盖率达到 99%以上的项目。它通过定义 Git 的 pre-commit/pre-push 钩子,在本地提交 commit 或者 push 时,对一些指标进行检查。如果不符合,则不允许提交成功。本地检查可以有效地提前发现一些代码质量问题,也减轻了线上持续集成系统的负担。
使用 AST 做静态检查
CPython 的编译过程中会先根据源代码建立抽象语法树(Abstract Syntax Tree,AST),最后编译为代码对象。Python 标准库中已经自带了 AST 模块,名为 ast。它通常用来做静态文件的检查和修改代码的执行效果:
In : import ast In : node=ast.parse('2+6', mode='eval') In : eval(compile(node, '<string>', 'eval')) Out: 8 In : node.body.op=ast.Sub() In : eval(compile(node, '<string>', 'eval')) Out: -4
通过修改 body 的 op 属性,让相加的操作变成了相减。这就是 AST 的威力。可以把代码想象成一棵树,缩进的代码块是一棵子树,每一条语句都是 ast 对应类型的实例。
修改语法树节点的更好的方法是通过 NodeTransformer 类完成:
class RewriteAddToSub(ast.NodeTransformer): def visit_Add(self, node): node=ast.Sub() return node node=ast.parse('2+6', mode='eval') node=RewriteAddToSub().visit(node) print eval(compile(x, '<string>', 'eval')) # 等于-4
如果只是浏览对应节点可以使用 ast.NodeVisitor。
Pylint、PyChecker 和 PyFlakes 都是基于 AST 做的静态检查。除了检查语法,同样可以检查业务流程,也就是检验代码实现中的漏洞。举个例子,Web 应用的模型不应该直接读数据库,需要添加缓存。新增、更新以及删除等操作都要清理缓存。有时候开发者忘记在删除的时候清除缓存了,这是非语法的错误,除非极为熟悉逻辑甚至之前踩过这样的坑,才能发现此类问题。这种隐藏的 bug 会给故障排除带来很大的难度。下面是一个正确的例子:
class MovieOrder(object): MC_KEY='chapter15:section2:movie_roder:%s' def __init__(self, id, type_id, order_id, price): self.id=id self.type_id=type_id self.price=price @classmethod @cache(MC_KEY%'{id}') def get(cls, id): sql=('select id, type_id, order_id, price from movie_order ' 'where id=%s') rs=store.execute(sql, id) return cls(*rs[0]) if rs else '' @classmethod def delete(cls, id): sql='delete from movie_order where id=%s' try: store.execute(sql, id) store.commit() except IntegrityError: store.rollback() return False cls.clear_mc(id) return True def update_price(self, price): sql='update movie_order set price=%s where id=%s' updated=store.execute(sql, (price, self.id)) if updated: store.commit() mc_delete(self.MC_KEY%self.id) return updated @classmethod def clear_mc(cls, id): mc_delete(cls.MC_KEY%id)
上面的模型有如下细节需要注意:
- 根据 id 从数据库获取条目之后,会缓存这个结果以备在未过期范围内重复使用。
- 把清理缓存独立成一个方法,执行 delete 方法时除了删除数据库中对应条目还要清理对应的缓存。
- 为了演示,update_price 中直接使用了 mc_delete 函数,而没有使用 clear_mc 方法。
如果我们用 AST,要怎么做呢?
1.如果类的方法中带有 SELECT 的 SQL 语句,那么它就应该被缓存,也就是需要使用 cache 这个装饰器。
2.如果类的方法中带有 UPDATE、DELETE 的 SQL 语句,就需要在执行成功后清理缓存。
3.清理缓存可以直接使用 mc_delete 这个方法,参数的文本需要包含 MC_KEY。也可以把缓存清理放在一个名为 clear_mc(约定)的方法中去做。
一开始先定义了 4 个变量:
USE_CLEAR_METHOD_LIST=[] NEED_CLEAR_LIST=[] IGNORE_LIST=[] MSG_MAP={}
设计得如此复杂主要是因为第二个需求,清理缓存既可以放在 clear_mc 这个方法中,也可以直接在方法内部使用 mc_delete。在一个方法中发现没有 mc_delete,但是确实有 clear_mc,可是这不代表 clear_mc 方法正确地清理了缓存,因为无法强制要求开发者一定要把 clear_mc 写在最上面,最先被遍历到,此时可能还没有遍历到 clear_mc 方法,所以要用三个列表作为缓存。
1.USE_CLEAR_METHOD_LIST:如果一个方法中使用 clear_mc,会把这个方法名保存下来。
2.NEED_CLEAR_LIST:如果一个方法中使用了更新或者删除的 SQL 语句,它就应该清理缓存,要在 NEED_CLEAR_LIST 中添加这个方法名。
3.IGNORE_LIST:即便 NEED_CLEAR_LIST 中包含了 IGNORE_LIST 中的方法名,程序也认为不应该报错,因为在方法内正确地使用了 mc_delete,我们就没有必要继续验证,直接忽略。
4.MSG_MAP:MSG_MAP 用来存放方法名和对应的打印错误信息的偏函数。
基于上述原则,先看一下遍历抽象语法树的方法:
with open(filename) as f: tree=ast.parse(f.read()) for stmt in ast.walk(tree): if not isinstance(stmt, ast.ClassDef): continue has_clear_method=False for body_item in stmt.body: if not isinstance(body_item, ast.FunctionDef): continue item_name=body_item.name
其中 has_clear_method 为 True,则表示存在 clear_mc 这个方法,且方法中清理了 MC_KEY 的缓存。
使用 ast.walk 遍历语法树,因为我们只需要找到 ClassDef 类型的节点。每个子树如果有节点就会有 body 属性,我们遍历 stmt.body 也会忽略非 ast.FunctionDef 实例的节点。
body_item 记录了这个方法的位置,遍历完之后假如不符合要求,要打印对应的行数、偏移量等信息,所以需要预先创建一个偏函数:
def msg(filename, lineno, offset, msg): print '{}:{}:{}{}'.format(filename, lineno, offset, msg) msg_p=partial(msg, filename, body_item.lineno, body_item.col_offset)
msg_p 现在只需要接受 msg 这个参数了。它会存放进 MSG_MAP,在最后检查的时候取出来执行。
遍历 body_item.body 就获得了方法内第一级节点。下面的检查都以 item 为单位,我们看一下使用了 select 的方法是否用了 cache 这个装饰器:
for item in body_item.body: if isinstance(item, ast.Assign): value=item.value if isinstance(value, ast.Str): value=item.value if 'select' in value.s: for deco in body_item.decorator_list: if isinstance(deco, ast.Call): if deco.func.id=='cache': break else: msg_p('Need`cache`decorator!')
本例的 SQL 都是一个赋值语句,所以只要找 ast.Assign 类型的节点,然后通过 ast.str 获得赋值的值的源码即可。如果包含 select 就是符合条件的方法,这个时候通过 body_item.decorator_list 找到对应方法的装饰器列表;如果 for 循环结束也没有找到 cache 这个装饰器,说明没有添加 cache 装饰器,然后通过 msg_p 把对应的错误打印出来。
value.s 就是 SQL 语句的值,除了看是不是包含 select,还能通过是否包含 delete 和 update 这两个字符串来确定是否需要清除缓存:
if 'select' in value.s: ... elif any(op in value.s for op in ('delete', 'update')): NEED_CLEAR_LIST.append(item_name) MSG_MAP[item_name]=msg_p
再看一下,确认方法中使用 clear_mc 以及确认 clear_mc 方法正确性的逻辑:
if isinstance(item, ast.Assign): ... elif isinstance(item, ast.Expr) and isinstance(item.value, ast.Call): func=item.value.func if isinstance(func, ast.Attribute): if func.attr=='clear_mc': USE_CLEAR_METHOD_LIST.append(item_name) else: if func.id !='mc_delete': continue for arg in item.value.args: if not isinstance(arg, ast.BinOp): continue if 'MC_KEY' in arg.left.attr: if body_item.name=='clear_mc': has_clear_method=True
上述代码做了两件事:
- 如果一个方法中调用了 clear_mc,则将此方法放入 USE_CLEAR_METHOD_LIST 中。
- 如果有 clear_mc 方法以及方法中正确清理了包含 MC_KEY 内容的键的缓存,则把 has_clear_method 设置为 True。
接下来检查方法内是否使用了正确的 mc_delete:
if isinstance(item, ast.Assign): ... elif isinstance(item, ast.If): for if_item in item.body: if isinstance(if_item.value, ast.Call): func=if_item.value.func if isinstance(func, ast.Name): if func.id !='mc_delete': continue for arg in if_item.value.args: if not isinstance(arg, ast.BinOp): continue if 'MC_KEY' in arg.left.attr: IGNORE_LIST.append(item_name)
由于封装的 store.execute 方法会带有表示执行操作是否成功的返回值,所以这种清理缓存的操作一般在 if 语句内部,判断方式和上面确认 clear_mc 方法中清理缓存的逻辑基本类似。
最后在遍历完整个类之后开始验证,验证的原则是:
1.NEED_CLEAR_LIST 列表中每个元素都是需要被确认的方法名,遍历它。
2.如果方法名在 IGNORE_LIST 列表中,直接忽略。
3.如果有 clear_mc 方法且使用正确,并且要验证的方法在 USE_CLEAR_METHOD_LIST 中,则使用方式是正确的,否则都是错误的。
4.通过错误的方法就可以在 MSG_MAP 中找到对应的函数打印错误信息。
for method_name in NEED_CLEAR_LIST: if method_name not in IGNORE_LIST: if not (has_clear_method and method_name in USE_CLEAR_METHOD_LIST): MSG_MAP[method_name]('Need clear mc!')
其他静态检查工具
bandit
bandit(https://github.com/openstack/bandit )是 OpenStack 安全小组开发的基于 AST 的静态分析工具,其中包含了很多有用的检查,尤其是安全方面的。它可以作为 Pylint 的补充。列举如下几个错误类型:
1.try_except_pass(B110)/try_except_continue(B112):错误的代码风格。
2.flask_debug_true(B201):开发者可能会在线上开启 DEBUG 模式,这非常不安全。
3.subprocess_popen_with_shell_equals_true(B602):使用 shell=True 的方式执行系统命令不安全。
4.hardcoded_sql_expressions(B608):工作中会发现有非常多不规范的拼 SQL 的代码,它们都有安全问题。
5.eval(B307):验证是否使用了 eval。如果必须要用 eval,可以使用安全的 ast.literal_eval 替代。
hacking
hacking(https://github.com/openstack-dev/hacking )是 OpenStack 开发者使用的风格检查工具,而且其中的每个检查都是一个 Flake8 插件。这个库主要使用 tokenize 和正则表达式对代码做检查,tokenize 是标准库中的词法解析模块,它可以把源码字符串拆分成单词。
列举几个常用检查类型:
- TODO 格式。
- 文档字符串格式。
- 异常处理的格式。和 bandit 一样,不允许 try_except_pass 的编码风格。
- 要求 import 按照字母顺序排列。
zhang-shasha
虽然 Pylint 也支持相似度的检查,但是如果要检查拷贝之后略加修改的重复代码,效果很差。zhang-shasha(https://github.com/timtadh/zhang-shasha )是基于树的编辑距离算法,通过 ast 解析抽象语法树,可以基于它增强代码相似度的检查。
编写 Flake8 扩展
Flake8 设计了非常好的插件系统,现有的常用插件有如下几种。
- flake8-immediate:不用等处理全部完成,有错误就打印出来。
- flake8-print:不允许代码中出现 print 语句。
- pep8-naming:检查命名风格,比如类名是否是首字母大写的 CapWords 风格,函数名是否是全小写,类方法第一个参数是 cls 等。
- flake8-quotes:强制要求使用单引号,而不能用双引号;或者相反。本书例子使用单引号风格。
现在把检查缓存使用的功能也改写成一个 Flake8 扩展。首先创建一个目录,然后添加 flake8_mc.py 文件,定义一个类:
class McChecker(object): name='flake8-mc' version='0.1.0' def __init__(self, tree, filename): self.tree=tree def run(self): return main(self.tree, self)
需要定义 name 和 version 这两个属性。在初始化的时候需要添加 tree 和 filename 参数,由于我们使用 AST,直接操作这个生成的语法树就好了,filename 只占位不使用。
main 函数的内容和上面的 check_mc.py 的逻辑大体相同,改动的地方有两处:
1.去掉通过 ast.parse 解析文件内容获得 tree 的步骤,直接使用参数中已经提供的 tree 变量。
2.把打印错误信息的部分替换成“yield (lineno, offset, msg, self)”这样的格式,其中 self 是 Flake8 需要的返回值,在 run 方法中把 self 作为参数传入使用。
对于第二点,举个例子,之前抛出的需要添加 cache 装饰器的错误是这样的:
msg_p('Need`cache`decorator!')
现在改成:
yield msg_p('D012 Need`cache`decorator!')
msg_p 这个偏函数要改成了如下方式:
def msg(lineno, offset, self, msg): return (lineno, offset, msg, self) msg_p=partial(msg, body_item.lineno, body_item.col_offset, self)
由于集成进了 Flake8,需要在错误信息前添加错误类型,本例使用了 D012 和 D013。
接下来创建 setup.py:
from setuptools import setup setup( name='flake8-mc', version='0.1.0', ... py_modules=['flake8_mc'], entry_points={ 'flake8.extension':[ 'flake8_mc=flake8_mc:McChecker', ], }, install_requires=['flake8'] )
一定要在 entry_points 中指定刚才添加的 McChecker 类。
然后安装它:
> cd ~/web_develop/chapter15/section2/flake8-mc > python setup.py install
现在查看 Flake8 扩展信息就可以看到 flake8-mc 了:
> flake8 --version 2.5.4 (pep8:1.7.0, flake8-mc:0.1.0, pyflakes:1.0.0, flake8_quotes:0.3.0, mccabe: 0.4.0, flake8-print:2.0.2) CPython 2.7.6 on Linux
使用 Flake8 来检查:
> flake8 ../movie_order.py # 没有错误 > flake8 ../movie_order_wrong.py ../movie_order_wrong.py:15:5: D012 Need`cache`decorator! ../movie_order_wrong.py:24:5: D013 Need clear mc! ../movie_order_wrong.py:36:5: D013 Need clear mc!
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论