はじめに
メダリスト、めっちゃよかった、よく言った! 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パターンでゼロから組み上げる実装もできるんですね〜
- autoflakeのようなツールでお片付けも自動化できます↩
- flake8-kotohaを開発していたおかげで、プラグイン機構を使っていると気づけました ↩
-
all_namesですが、今回はモジュールに__all__を持たないので省略します↩ - https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L588↩
- Visitorパターンっぽかったので、精査せずにnodeのハンドラ側に進みました。https://github.com/PyCQA/pyflakes/blob/3.2.0/pyflakes/checker.py#L1261↩
- ここまで理解したことで気づいたのですが、モジュール内ではunusedでも別のモジュールでimportされたとしても、そのモジュール(ファイル)にflake8を走らせたらF401が指摘されるんですね↩