返回介绍

代码质量保证工具

发布于 2025-04-20 18:52:19 字数 14496 浏览 0 评论 0 收藏

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!

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。