Timee Product Team Blog

タイミー開発者ブログ

flake8 pluginを書いてみた

こんにちは、タイミーのデータエンジニアリング部 データサイエンス(以下DS)グループ所属のYukitomoです。

今回はPythonのLinterとしてメジャーなflake8のプラグインの作り方を紹介したいと思います。

コードの記述形式やフォーマットを一定に保つため、black/isort/flake8などのformat/lintツールを使うことはpythonに限らずよく行われていますが、より細部のクラス名や変数名を細かく規制したい(例:このモジュールのクラスはこういう名前付けルールを設定したい等)、けれどコードレビューでそんな細かい部分を目視で指摘するのは効率的でない、といったケースはありませんか?そんな時、flake8のプラグインを用意して自動検出できるようにしておくと便利です。

ネット上には公式サイトを含めいくつかプラグイン作成の記事があるのですが、我々の想定ケースと微妙に異なる部分がありそのままでは利用できなかったため、

  • 最新のflake8(2024/7現在, 7.1.0)を用い
  • 比較的新しいパッケージマネージャーであるpoetry(1.8.3を想定)を利用して
  • 2種類のプラグインのそれぞれの作り方

を改めてここにまとめます。

準備するもの

  • Python: Versionは特に問いませんが、3.11.9で動作確認しています。
  • Poetry: 1.8 以上 (後述しますが1.8より導入されたpackage-mode = falseを指定しているため)。この記述を変えることで1.8以前のバージョンでも動くとは思いますが、この記事では1.8を前提としています。

上記が利用可能な環境をvenvやコンテナを利用して作成しておいてください。flake8本体はpyproject.tomlの依存モジュールとして導入されるため事前に準備する必要はありません(3.8以降で動作するはずですが、本記事では7.1を利用します)。

全体の構成

サンプルで利用するファイル群は以下の通りです。構文木を利用するタイプと1行ずつ読み込んでいくタイプと2種類あるため、それぞれをtype_a、type_bとしてサンプルを用意し、それら2つのサンプルを束ねる上位のプロジェクトを一つ用意しています。本来なら各プラグイン毎にユニットテスト等も実装すべきですが、本記事ではプラグインの書き方自体の紹介が目的のため割愛しています。なお、type A, type Bの呼称はflake8プラグインにおいて一般的な呼び名ではなく、本記事の中で2つのタイプを識別するために利用しているだけなので注意してください。

# poetry.lock 等本記事の本質と関係のないものは省略しています
(.venv) % tree .  # この位置を$REPOSITORY_ROOTとします。
.
├── pyproject.toml
└── plugins
    ├── type_a
    │   ├── pyproject.toml
    │   └── type_a.py
    └── type_b
        ├── pyproject.toml
        └── type_b.py

${REPOSITORY_ROOT}/pyproject.tomlは以下の通り。

# cat ${REPOSITORY_ROOT}/pyproject.toml
[tool.poetry]
name = "flake8 plugin samples"
version = "0.0.1"
description = "A sample project to demonstrate flake8 plugins"
authors = ["timee-datascientists"]
package-mode = false # この記述を外せばきっとpoetry 1.8より前でも動くはず

[tool.poetry.dependencies]
python = ">=3.11.9"

[[tool.poetry.source]]
name = "PyPI"
priority = "primary"

[tool.poetry.group.dev.dependencies]
# flake8を利用するので一緒によく利用されるblack/isortも導入
flake8 = "~7.1.0"
isort = "~5.13.2"
black = "~24.4.0"

# プラグインはローカルからeditable modeで登録
type_a = { path="./plugins/type_a", develop = true}
type_b = { path="./plugins/type_b", develop = true}

[build-system]
requires = ["poetry>=1.8"]
build-backend = "poetry.masonry.api"

Type A: AST Treeを利用する場合

Python codeの1ファイルをparseして抽象構文木(AST)として渡すタイプのプラグインです。ネットでflake8のプラグインを検索した時、こちらのタイプの実装例が出てくることが多く、また、構文木の処理が実装できるなら、こちらの方が使いやすいです。

構文木で渡されたpython ファイルを巡回し、その過程で違反を発見するとエラーを報告しますが、本記事のサンプルでは構文木の巡回結果は無視し、巡回後必ずエラーを報告しています。詳細はast.NodeVisitorを参照いただきたいのですが、各ノードを巡回する際に呼ばれるvisit()だけでなく、visit_FunctionDef() などファイル内で関数定義された場合、など個別の関数が用意されているので、これらを適切に上書きすることで、目的の処理を実現していくことになります。

なお、プラグインのコンストラクタには抽象構文木(ast)の他、lines, total_lines等公式ドキュメントのここに記述されているものを追加することができます。

以下にサンプルの実装(type_a/type_a.py)とプロジェクトの定義ファイル(type_a/pyproject.toml)を示します。

# type_a/type_a.py
import ast
from typing import Generator, List, Tuple

# プラグインの本体
class TypeAPluginSample:
    def __init__(
        self, tree: ast.AST  #, lines, total_lines: int = 0
    ) -> None:
        self.tree = tree

    def run(self) -> Generator[Tuple[int, int, str, None], None, None]:
        visitor = MyVisitor()
        visitor.visit(self.tree)
        # サンプルでは常にエラーを報告するが本来ならvisitorに結果を溜め込んで
        # 結果に応じてエラーをレポート
        if True:
            yield 0, 0, "DSG001 sample error message", None

# プラグインから利用する構文木の巡回機
class MyVisitor(ast.NodeVisitor):
    # visit() やvisit_FunctionDef()を目的に応じて上書き
    pass

# 他のサンプルでは必須っぽく書いてあるが、pyproject.tomlのentry-points
# の指定と被ってるなぁと思ってコメントアウトしても動いたので今はいらない気がする。
# def get_parser():
#    return TypeAPluginSample
(.venv) % cat plugins/type_a/pyproject.toml
# 親プロジェクトから直接ロードするため [project]の記述もしていますが
# プラグイン単体で独立したプロジェクトとするなら不要。
[project]
name = "type_a"
version = "0.1.0"
description = "Sample type a plugin"
authors = [{name = "timee-datascientists", email = "your.email@example.com"}]

[tool.poetry]
name = "type_a"
version = "0.1.0"
description = "Sample type-a plugin"
authors = ["timee-datascientists"]

[build-system]
requires = ["setuptools", "wheel", "poetry>=1.8.3"]
build-backend = "setuptools.build_meta"

[tool.poetry.dependencies]
python = ">=3.11.9"
flake8 = ">=7.1.0"

# ここでプラグインのクラス名を登録
[project.entry-points."flake8.extension"]
DSG = "type_a:TypeAPluginSample"

Type B: 1行ずつ処理する場合

対象となるpython ファイルを1行ずつ処理していくタイプのプラグインです。公式ドキュメントにある通り、歴史的な経緯で2種類あるようですが、こちらの1行ずつ処理するタイプを使ったサンプルを見かけたことがありません。特に非推奨とされているわけでもないですし、実装したいルール自体がシンプルであればこちらの方法で実装するのもありだと私は思います。physical_lineもしくはlogical_lineを第一引数に設定し、physical_lineの場合はファイルに書かれている1行ずつ、logical_lineの場合はpython の論理行の単位で指定した関数が呼ばれます。physical_line, logical_lineの両方を同時に指定することはできず、他の変数を追加する場合もphysical_line/logical_lineは第一引数とする必要があります。

以下にサンプルの実装(type_b/type_b.py)とプロジェクトの定義ファイル(type_b/pyproject.toml)を示します。

# type_b/type_b.py
from typing import Optional

# プラグイン本体
def plugin_physical_lines(
    physical_line: Optional[str] = None,
    line_number: Optional[int] = None,
    filename: Optional[str] = None,
):
    if line_number == 2:
        yield line_number, "DSG002 sample error message"
(.venv) % cat plugins/type_b/pyproject.toml

# type_aのものとほぼ同じ。project.nameおよびtool.poetry.nameをtype_bに書き換えた後、
# 差分は以下。プラグイン本体の関数を指定してやれば良い。

:
[project.entry-points."flake8.extension"]
DSG = "type_b:plugin_physical_lines"

実行結果

以下のようなサンプルファイルを用意し、flake8を実行した結果を示します。

# sample.py
def main():
    print(
        'Hello, World!'
    )

if __name__ == '__main__':
    main()

実行結果

% flake8 sample.py
sample.py:0:1: DSG001 sample error message
sample.py:2:3: DSG002 sample error message

注意点

Type A, Type B両方とも公式ドキュメントに書いてある変数は全てコンストラクタに追加できるのですが、それぞれのタイプにおいて意味のあるものは限られるため、必要なもののみを追加すれば良いです。

まとめ

flake8 のプラグインの定義方法を2通りご紹介しました。

タイミーのデータサイエンスグループでは通常のformat/lintだけでカバーできない(けれど少しの工夫により機械作業で抽出できる)運用ルールを本記事のようなflake8プラグインを用いてCIで事前に検出することで、コードレビューはできるだけ本質的な部分に集中できるよう取り組んでいます。

We’re Hiring!

タイミーのデータエンジニアリング部・データアナリティクス部では、ともに働くメンバーを募集しています!!

現在募集中のポジションはこちらです!

「話を聞きたい」と思われた方は、是非一度カジュアル面談でお話ししましょう!

References