nikkie-ftnextの日記

イベントレポートや読書メモを発信

Flake8はどのようにしてF401「Module imported but unused」を指摘するんだろう?

はじめに

メダリスト、めっちゃよかった、よく言った! nikkieです。

Flake8は(高速なRuffへの乗り換えが進んでいますが)歴史あるリンターです。
自分の知的好奇心を満たすためだけに、Flake8のリント機能の実装を覗きました。

目次

Flake8はpyflakesをプラグインとして使っている

F401は、使っていないimportがあることを指摘します。
指摘されたら消すのが望ましいです1(ゴミはゴミ箱へ)

Flake8自体はpycodestyle・pyflakes・mccabe 3つのツールを組合せてできています。
F401「Module imported but unused」はpyflakesが指摘します

Flake8はプラグイン機構を使って2、pyflakesの指摘にエラーコードを振っています。
https://github.com/PyCQA/flake8/blob/7.1.1/src/flake8/plugins/pyflakes.py

FLAKE8_PYFLAKES_CODES = {
    "UnusedImport": "F401",
    # 省略
}


class FlakesChecker(pyflakes.checker.Checker):
    # 省略
    def run(self) -> Generator[tuple[int, int, str, type[Any]], None, None]:
        for message in self.messages:
            col = getattr(message, "col", 0)
            yield (
                message.lineno,
                col,
                "{} {}".format(
                    FLAKE8_PYFLAKES_CODES.get(type(message).__name__, "F999"),
                    message.message % message.message_args,
                ),
                message.__class__,
            )

pyflakesのUnusedImportの指摘

Flake8のコードでベースクラスになっているpyflakes.checker.Checker、この初期化時にリントのチェックが行われています。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L717

class Checker:
    def __init__(self, tree, filename='(none)', builtins=None,
                 withDoctest='PYFLAKES_DOCTEST' in os.environ, file_tokens=()):
        # 今回関連するコードだけを抜き出し
        self.deadScopes = []
        self.scopeStack = []

        with self.in_scope(scope_tp):
            for builtin in self.builtIns:
                self.addBinding(None, Builtin(builtin))
            self.handleChildren(tree)
            self._run_deferred()

        self.checkDeadScopes()

checkDeadScopes()メソッドで、未使用のimportについてメッセージを出します3。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L889-L895

# Look for imported names that aren't used.
for value in scope.values():
    if isinstance(value, Importation):
        used = value.used or value.name in all_names
        if not used:
            messg = messages.UnusedImport
            self.report(messg, value.source, str(value))

valueはcheckerモジュールで定義されたBindingクラス(を継承したクラス)のインスタンスです。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L217
Bindingインスタンスのused属性は、初期値がFalseです。

class Binding:
    def __init__(self, name, source):
        self.name = name
        self.source = source
        self.used = False

value.usedがFalseと評価される例

from collections import namedtuple  # <- このコード片では使われていません

my_tuple = ('Grant', 'McConnaughey', 25)

AST(python -m ast <<"EOF"として確認できます。Python 3.12.6で確認)

Module(
   body=[
      ImportFrom(
         module='collections',
         names=[
            alias(name='namedtuple')],
         level=0),
      Assign(
         targets=[
            Name(id='my_tuple', ctx=Store())],
         value=Tuple(
            elts=[
               Constant(value='Grant'),
               Constant(value='McConnaughey'),
               Constant(value=25)],
            ctx=Load()))],
   type_ignores=[])

value.usedがTrueと評価される例

import sys

print(sys.version_info)  # <- sysを使っていますね
Module(
   body=[
      Import(
         names=[
            alias(name='sys')]),
      Expr(
         value=Call(
            func=Name(id='print', ctx=Load()),
            args=[
               Attribute(
                  value=Name(id='sys', ctx=Load()),
                  attr='version_info',
                  ctx=Load())],
            keywords=[]))],
   type_ignores=[])

pyflakesのCheckerはVisitorパターン

Checkerの__init__()ですが、breakpointを入れて動きを見ました。
見たAST2例はast.Moduleが共通しているので、scope_tpはModuleScope4に絞って読んでいます。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L721

ModuleScopeのベースクラスのScopeは、辞書を継承したクラスです。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L531
checkDeadScopes()で見たfor value in scope.values()は辞書の値を反復しています。
このscopeはself.deadScopesというリストから取り出されます。
__init__()内の以下コードにより、self.scopeStack(こちらもリスト)とself.deadScopesが更新されます。

with self.in_scope(scope_tp):
    for builtin in self.builtIns:
        self.addBinding(None, Builtin(builtin))
    self.handleChildren(tree)
    self._run_deferred()

コンテキストマネージャin_scope()
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L831-L837

  • コンテキストマネージャ内では、self.scopeStackにScopeインスタンスを追加し、それを更新
  • コンテキストマネージャを抜ける時に、self.deadScopesに取り出し、self.scopeStackから除く(末尾をpop。LIFO、まさにStackですね)

そして、import文を含むモジュールについて処理するのがself.handleChildren(tree)。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L1212-L1214
すべてのnodeについてself.handleNode(node, tree)を呼びます。
handleNode()がVisitorパターンのように思われます5。

import文

ASTではImportであり
https://docs.python.org/ja/3/library/ast.html#ast.Import
このハンドラがIMPORTでした。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L2040

def IMPORT(self, node):
    for alias in node.names:
        if '.' in alias.name and not alias.asname:
            importation = SubmoduleImportation(alias.name, node)
        else:  # 今回の2例はこちら
            name = alias.asname or alias.name
            importation = Importation(name, node, alias.name)
        self.addBinding(node, importation)

※ASTのImportFromは(Importと同様と思われたので)省略します
https://docs.python.org/ja/3/library/ast.html#ast.ImportFrom

addBinding()を呼び出して、"sys"や"collections"というモジュール名をModuleScope(辞書)に追加します。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L966

この段階では、"sys"や"collections"を表すImportationインスタンスのused属性はFalseです。

NameのLoad

Nameは変数名、
https://docs.python.org/ja/3/library/ast.html#ast.Name
Loadは変数の値のロード。
https://docs.python.org/ja/3/library/ast.html#ast.Load

NAMEハンドラにて
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L1855
handleNodeLoadを呼び出しています。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L1041

ここでused属性が更新されます。
https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L1076

scope[name].used = (self.scope, node)

scope[name]はIMPORTハンドラで見たImportationインスタンスです。
例えばsysの例(F401指摘なし)の例だと、タプルを代入してscope[name].used[1].idが'sys'となります。
ASTでは、AttributeのvalueのName(id='sys', ctx=Load())の処理でusedが更新されたわけですね

NameのLoadのハンドラで更新されなければ、used属性はFalseのまま。
変数はロードされていない=使われていないということですね

終わりに

Flake8はどのようにしてF401「Module imported but unused」を指摘するかを見てきました。

  • 指摘するのはpyflakes(Flake8のプラグイン機構!)
  • pyflakesはVisitorパターンでASTを処理
  • importでモジュールのスコープに入った名前が、同モジュールの中でロードされているかを見ている6

私は過去に標準ライブラリastのNodeVisitorを継承する形でFlake8プラグインを実装しました。
pyflakesのようにVisitorパターンでゼロから組み上げる実装もできるんですね〜


  1. autoflakeのようなツールでお片付けも自動化できます↩
  2. flake8-kotohaを開発していたおかげで、プラグイン機構を使っていると気づけました ↩
  3. all_namesですが、今回はモジュールに__all__を持たないので省略します↩
  4. https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L588↩
  5. Visitorパターンっぽかったので、精査せずにnodeのハンドラ側に進みました。https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L1261↩
  6. ここまで理解したことで気づいたのですが、モジュール内ではunusedでも別のモジュールでimportされたとしても、そのモジュール(ファイル)にflake8を走らせたらF401が指摘されるんですね↩