Jan's Blog

Python代码风格检查最佳实践

· Zhen Zhijian

本文介绍 Python 代码风格检查的实践,其中第3节渐进式介绍了4种方案,也可以直接看第4节“最佳实践”。

1. 项目需要统一的代码风格

代码风格类似文章的排版要求,一本杂志、书籍往往由多人合著,作者们需要达成共识用统一的排版风格。程序同样由多个程序员合作编写而成,甚至还会互相修改其他成员的代码,统一的风格犹为重要。

2. Python 项目的代码风格

Python 项目的代码风格,我认为需要分成两层:

  1. PEP 8,这是 Python 社区的共识,应该作为我们项目代码风格的底线。然而,PEP 8 的规定相当宽泛,符合 PEP 8的前提下,仍然可以写出风格迥异代码。因此,项目通常又会在 PEP 8的基础上制定
  2. 项目风格要求。在PEP 8基础上制定项目风格,实际上是基于一些的原因增加限制。比如规定字典元素太多时该如何换行。风格一,尽量在一行写入最多的元素,原因是这样能使代码更紧凑。
{'key1': 'value1', 'key2': 'value2', 'key3': 'value3', 'key4': 'value4',
 'key5': 'value5', 'key6': 'value6'}

风格二,一行一个元素,原因是将来增删改元素只会影响到一行,方便 diff review。

{
    'key1': 'value1',
    'key2': 'value2',
    'key3': 'value3',
    'key4': 'value4',
}

都有道理,需要项目成员达成共识,制定出项目的风格要求。

3. 代码风格检查

代码风格制定好了,如果没有相应检查机制,我相信制定好的代码风格并不会得到完整的执行。 如何检查 Python 代码风格是本文重点。我把代码风格检查机制按照自动化程度分成以下层次:

  1. 人工检查;
  2. 手动运行检查器;
  3. 自动运行检查器;
  4. 自动修正代码风格;

3.1. 人工检查

人工检查是最原始的,在 code review 的时候,看看是不是有代码风格问题。熟悉 PEP 8的 Python 程序员应该具备肉眼检查 PEP 8风格的能力,通常风格问题是很碍眼的,想看不出来都难。 然而人工检查存在着明显的问题:

  1. code review 还要把精力放在代码风格上;
  2. code review 阶段还要提一批风格相关的 issues;
  3. code review 阶段还得来回修改代码风格问题;
  4. 人毕竟不是机器,可能有遗漏。

缺点: 显然,人工检查存在效率和严谨性的问题,更何况有些项目并没有对每一个 commit 做 code review,人工检查得等到什么时候触碰到这些代码时才能进行。

3.2. 手动运行检查器

代码风格检查工作如此的机械化,显然适合让机器做,而不必浪费程序员的精力。

常用的检查器有:pep8 · PyPIflake8pylint。推荐 flake8,其结合 pep8 和其他一些风格检查,基本上忠实于 pep8,不会带来太多打扰。不推荐 pylint 是因为 pylint 的默认配置非常糟糕(不是 PEP 8,也不是哪个社区的共识),非开箱即用,得根据项目代码风格精心配置,否则相当打扰。不过 flake8 的可定制性较差,额外的项目风格无法检测,pylint 相对来说可定制性更高。

有了检查器,程序员可以在提交代码前运行检查,根检查器报告修改,直到完全通过。进阶一点可以用相应的编辑器插件,边写边检查。

缺点: 手动运行的问题是,程序员可能忘记运行了,或者新加入项目的程序员根本不知道有这个规则。

那么我们自然会想到自动运行检查器。

3.3. 自动运行检查器

自动运行检查器有这些方案:

  1. Git Hooks,git hooks 又分为
    1. 客户端 hooks,
    2. 服务端 hooks;
  2. CI 持续集成。

这些方案中我推荐客户端 Git Hooks。 相对服务端 hooks,客户端 hooks 不需要服务端支持,适应性高;相对于 CI,搭建成本低,且在代码提交前就检查好风格,而不是到了集成阶段再来检查。

客户端 hooks 的原理非常简单,.git/hooks 目录下有很多 .sample 文件,是各个阶段 hook 的例子。

$ ls .git/hooks/
applypatch-msg.sample	   pre-applypatch.sample  pre-receive.sample
commit-msg.sample	   pre-commit.sample	  prepare-commit-msg.sample
fsmonitor-watchman.sample  pre-push.sample	  update.sample
post-update.sample	   pre-rebase.sample

这里我们需要 pre-commit hook,创建一个 pre-commit 文件,内容是 shell 脚本,运行检查器。如果检查不通过就会阻止 commit。通过这样的机制就能确保检查器运行且检查通过。

然而,客户端的 hooks 是在本地的,不在仓库里管理,也就是说 hooks 不能共享不能同步。为了解决这个问题,又有了 pre-commit 这个工具,只需要在仓库根目录放置一个 .pre-commit-config.yaml ,定义需要的 hooks(github 上很多开源的 hook 可以在这里直接引用,当然也包括 flake8),把这个文件提交到仓库。然后在客户端运行一次 pre-commit install --install-hooks 即可注册到 .git/hooks/pre-commit 里,后继 .pre-commit-config.yaml 更新也同步到各个客户端。用法详见第4节。

到目前为止,已经做到确保代码在提交到仓库之前是通过代码风格检查的。

缺点: 要说还有什么不足,那就是检查虽然是自动,但修正还得手动。那么有没有自动修正的方案?

3.4. 自动修正代码风格

Python 代码风格自动修正,目前最好的应该是 Black。Black 是 PSF(Python Software Foundation,Python 软件基金会)的项目。为什么要强调是 PSF 的项目?因为符合 PEP 8的写法可以有很多种,而修正工具只能修正成其中一种。 那么哪一种写法才大家都喜欢的?这就需要项目成员达成共识,也就是第2节提到的项目的风格要求。而 Black 作为 PSF 的项目,一定程度上代表了 Python 社区的共识。 那么,我们没必要再花精力制定项目代码风格,直接交给 Black 就好了。

Black 在文档详细介绍了其代码风格及相应的原因,比如为什么默认一行的长度限制是88字符(可以配置),比 PEP 8标准增加10%,有兴趣可以看一下。

同样,把 Black 也加到 pre-commit hook 里,那么在提交代码的时候就会自动修正代码风格。再加一道风格检查,把不能自动修正的提示出来,让提交者手动修正。

这样就形成了本文的最佳实践,具体做法见下一节。

4. 最佳实践

在仓库根目录放置以下3个文件,并提交。执行一次 make init 即完成所有配置工作。

实现的功能有:在 commit 的时候,

  1. 自动重排 import 顺序,
  2. 修正代码风格,
  3. 检查代码风格,
  4. 修正其他文件格式问题。

.flake8

[flake8]
max-line-length = 80
select = C,E,F,W,B,B950
ignore = E203,E501,W503

.pre-commit-config.yaml:

repos:
  - repo: https://github.com/asottile/reorder_python_imports
    rev: v1.8.0
    hooks:
      - id: reorder-python-imports
        name: Reorder Python imports (src, tests)
  - repo: https://github.com/python/black
    rev: stable
    hooks:
      - id: black
  - repo: https://gitlab.com/pycqa/flake8
    rev: 3.7.9
    hooks:
      - id: flake8
        additional_dependencies: [flake8-bugbear]
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.4.0
    hooks:
      - id: check-byte-order-marker
      - id: trailing-whitespace
      - id: end-of-file-fixer

Makefile:

init:
	python3 -m venv .venv
	.venv/bin/pip install pre-commit
	.venv/bin/pre-commit install --install-hooks

clean:
	rm -rf .venv

.PHONY: init clean

该方案受 Flask 的 contributing guidelines 启发。