FJCT Tech blog

富士通クラウドテクノロジーズ公式エンジニアブログです

富士通クラウドテクノロジーズ

FJCT/Tech blog

【CI戦術編 その6】Python開発の強い味方 Pylint

ネットワークサービス部のid:a8544です。

連載6回目の今回は、Pythonの静的コード解析ツール、 Pylint を取り上げます。

静的コード解析ツールは、プログラムを実行せずに(=静的に)その内容を解析するツールです。 同種のツールはリンター (linter) とも呼ばれます。

Pylintは、

  • コード中のミスやリファクタリングすべき箇所を発見する
  • 特定のコーディングスタイルを強制する

といった目的で利用できます。本記事では、例を交じえながらPylintの利用方法についてご紹介します。

なお、前回の記事は 【CI戦術編 その5】Pythonで明示的に型を書く理由 でした。

🤔 Pylintが検出できる項目の例

🐛 バグ、あるいはバグとなりうるコード上の問題

次のPythonコード中の test_fizz_buzz 関数は、 fizz_buzz 関数の挙動を確認するテストです。 しかし、このテストの実装には、おそらく開発者の意図とは異なるであろう挙動をする箇所が1つあります。 どこに問題があるでしょうか?

"""Fizz Buzz"""

import unittest
from unittest.mock import Mock, call


def fizz_buzz(limit, cb_fizz, cb_buzz):
    called_count = 0
    for i in range(1, limit + 1):
        if i % 3 == 0:
            cb_fizz(i)
            called_count += 1
        if i % 5 == 0:
            cb_buzz(i)
            called_count += 1
    return called_count


class TestFizzBuzz(unittest.TestCase):
    def test_fizz_buzz(self):

        cb_fizz = Mock()
        cb_buzz = Mock()

        count = fizz_buzz(21, cb_fizz, cb_buzz)

        cb_fizz.assert_has_calls(
            [call(3), call(6), call(9), call(12), call(15), call(18), call(21)]
        )
        cb_buzz.assert_has_calls([call(5), call(10), call(15), call(20)])
        count == cb_fizz.call_count + cb_buzz.call_count == 10


if __name__ == "__main__":
    unittest.main()

test_fizz_buzz 関数の最後、assert を書き忘れてassert文になっていないですね。 少し抜けている開発者は、その直前の行が assert 文でないために、ここに assert を書くのを忘れたのでしょう。

assert文になっていない式が書かれていること自体には何の構文上の問題はないので、 このコードは正常に実行されてしまいます。 ただ、これではテストを書いていても実行していないのと同じです。

$ python fizzbuzz.py
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
$

問題は、このようなバグ(と思われるコード)は、単に実行するだけでは発見できないことです。 そこでPylintが活躍します。実際にこのコードについてPylintを実行した結果が下記です。

$ pylint fizzbuzz.py
************* Module fizzbuzz
fizzbuzz.py:7:0: C0116: Missing function or method docstring (missing-function-docstring)
fizzbuzz.py:19:0: C0115: Missing class docstring (missing-class-docstring)
fizzbuzz.py:20:4: C0116: Missing function or method docstring (missing-function-docstring)
fizzbuzz.py:31:8: W0104: Statement seems to have no effect (pointless-statement)

------------------------------------------------------------------
Your code has been rated at 8.18/10 (previous run: 8.64/10, -0.45)

$

4つ目のメッセージに Statement seems to have no effect とあります。 assert を書き忘れた行にある等価比較はその値を使っておらず、効果がない(なくても変わらない)という指摘です。 このPylintの結果を見れば assert の書き忘れに無事気付くことができます。

このように、普通に実行するだけでは気付けないコード上の問題に気付くきっかけを与えてくれるのが、 Pylintをはじめとしたリンターの利点の一つです。

リファクタリングのヒント

Pylintが検出できるのは、コーディング上の誤りだけではありません。 例えば下記のコードをチェックするとどうなるでしょうか。

"""敵キャラクタを定義するモジュール"""
from dataclasses import dataclass


@dataclass
class Hostile:
    """敵キャラクタ"""

    name: str
    health_point: int
    attach_point: int
    defence_point: int
    equipment: str
    drop_item_rare: str
    drop_item_common: str
    ai_tier: int
    is_boss: bool
    is_befriendable: bool

結果が下記の通りです。

$ pylint hostile.py
************* Module hostile
hostile.py:6:0: R0902: Too many instance attributes (10/7) (too-many-instance-attributes)

------------------------------------------------------------------
Your code has been rated at 9.17/10 (previous run: 0.00/10, +9.17)

$

Too many instance attributes、つまり Hostile クラスに属性が多すぎると表示されています。 クラスが多くの属性を持つことは、それ自体直ちに問題になるわけではありません。 ただ、Pylintは属性の数を鑑みて「このクラスにはリファクタリング(クラスを分割する等)の余地があるのではないか」 と指摘しているのです。 この種の指摘も、コードとしては正常に問題なく実行できてしまうので、リンターなしには気付きにくいものです。

もちろん指摘されたからといっていつも簡単に修正できるとは限りませんし、 時には指摘が妥当でないと思われるときもあります。しかし、 コード設計をより良くできないかと考えさせるきっかけを与えてくれるという点に意味があります。

✨ コーディングスタイル上の指摘

このコードにはどのような問題があるでしょうか。

def v(p, chk):
    s = 0
    for i in range(0, len(p), 2):
        s += int(p[i])
    for i in range(1, len(p), 2):
        s += int(p[i]) * 3
    actualValue = 10 - s % 10
    if actualValue == 10:
        actualValue = 0
    if actualValue == chk:
        return True
    return False

…といわれても、そもそもこのコードが何をするコードなのか分からないという問題があります。 確かに眺めればチェックサムを計算したいんだな、ということは分かりますが、もう少し分かりやすい コメントやドキュメントが欲しいところです。また、 actualValue という変数名も気になります。 PEP 8にあるように、 Pythonでは変数名はスネークケース (snake_case) にするのが一般的だからです。

このコードに対するPylintの結果は下記の通りです。

$ pylint det.py

************* Module det
det.py:1:0: C0114: Missing module docstring (missing-module-docstring)
det.py:1:0: C0116: Missing function or method docstring (missing-function-docstring)
det.py:1:0: C0103: Function name "v" doesn't conform to snake_case naming style (invalid-name)
det.py:1:6: C0103: Argument name "p" doesn't conform to snake_case naming style (invalid-name)
det.py:2:4: C0103: Variable name "s" doesn't conform to snake_case naming style (invalid-name)
det.py:4:8: C0103: Variable name "s" doesn't conform to snake_case naming style (invalid-name)
det.py:6:8: C0103: Variable name "s" doesn't conform to snake_case naming style (invalid-name)
det.py:7:4: C0103: Variable name "actualValue" doesn't conform to snake_case naming style (invalid-name)
det.py:9:8: C0103: Variable name "actualValue" doesn't conform to snake_case naming style (invalid-name)

------------------------------------------------------------------
Your code has been rated at 2.50/10 (previous run: 2.50/10, +0.00)

$

Pylintは、このコードについて

  • docstringがモジュール・関数にないこと
  • 関数名・変数名がスネークケースになっていないこと(1文字変数や、キャメルケースなどは不可)

を指摘しています。これらを修正すると、例えば以下のようになります。

"""Checksum calculation utility"""


def is_valid(digits, expected_checksum):
    """Validate `digits` by comparing it with given expected checksum."""
    sum_ = 0
    for i in range(0, len(digits), 2):
        sum_ += int(digits[i])
    for i in range(1, len(digits), 2):
        sum_ += int(digits[i]) * 3
    actual_checksum = 10 - sum_ % 10
    if actual_checksum == 10:
        actual_checksum = 0
    if actual_checksum == expected_checksum:
        return True
    return False

docstringの追加や変数名の変更で、より処理が読みやすくなったのではないでしょうか (読みやすさは主観によりますので、そう感じられないときもあるかもしれません)。

ただ、キャメルケースを使ってはいけない、という点は納得できるとしても、 帰納変数(for i in ...i)以外に1文字変数を使ってはいけないというのは 厳しすぎるきらいがあります(これも私の主観です)。

後ほど説明するように、Pylintは指摘する問題の種類を細かく設定できるようになっています。 必要な指摘を十分に得られるよう、設定を調整することをおすすめします。

Pylintの利用

以下、 Pylint 2.17.0 で動作を確認しています。

💿 Pylintの導入手順

Pylintの導入は簡単です。PyPI よりインストールするだけです。

Poetry を使っている場合は

$ poetry add pylint -G dev
Using version ^2.17.0 for pylint

Updating dependencies
Resolving dependencies... (2.7s)

Writing lock file

Package operations: 11 installs, 0 updates, 0 removals

  • Installing lazy-object-proxy (1.9.0)
  • Installing typing-extensions (4.5.0)
  • Installing wrapt (1.15.0)
  • Installing astroid (2.15.0)
  • Installing dill (0.3.6)
  • Installing isort (5.12.0)
  • Installing mccabe (0.7.0)
  • Installing platformdirs (3.1.0)
  • Installing tomli (2.0.1)
  • Installing tomlkit (0.11.6)
  • Installing pylint (2.17.0)
$

とすればよいでしょう。 詳細はドキュメントのインストール方法の説明 をご確認ください。

🚀 Pylintの起動

$ pylint <モジュールまたはパッケージ>
$

で、指定されたモジュールまたはパッケージについてチェックを行ってくれます (Poetryでインストールした場合は、 poetry run を使います) 。

下記のようなディレクトリツリーがあるとします。

.
├── poetry.lock
├── pylint_test
│   ├── __init__.py
│   ├── __main__.py
│   └── model
│       ├── __init__.py
│       ├── chk.py
│       └── hostlie.py
├── pyproject.toml
└── tests
    ├── __init__.py
    └── test.py

ここでパッケージ pylint_test 以下のモジュールについてテストを行うには、 そのパッケージ名 pylint_test を指定してPylintを実行します。

$ poetry run pylint pylint_test
************* Module pylint_test.__main__
pylint_test/__main__.py:1:0: C0114: Missing module docstring (missing-module-docstring)
pylint_test/__main__.py:1:0: C0116: Missing function or method docstring (missing-function-docstring)
************* Module pylint_test.model.hostlie
pylint_test/model/hostlie.py:6:0: R0902: Too many instance attributes (10/7) (too-many-instance-attributes)
************* Module pylint_test.model.chk
pylint_test/model/chk.py:1:0: C0114: Missing module docstring (missing-module-docstring)
pylint_test/model/chk.py:1:0: C0116: Missing function or method docstring (missing-function-docstring)
pylint_test/model/chk.py:1:0: C0103: Function name "v" doesn't conform to snake_case naming style (invalid-name)
pylint_test/model/chk.py:1:6: C0103: Argument name "p" doesn't conform to snake_case naming style (invalid-name)
pylint_test/model/chk.py:2:4: C0103: Variable name "s" doesn't conform to snake_case naming style (invalid-name)
pylint_test/model/chk.py:4:8: C0103: Variable name "s" doesn't conform to snake_case naming style (invalid-name)
pylint_test/model/chk.py:6:8: C0103: Variable name "s" doesn't conform to snake_case naming style (invalid-name)
pylint_test/model/chk.py:7:4: C0103: Variable name "actualValue" doesn't conform to snake_case naming style (invalid-name)
pylint_test/model/chk.py:9:8: C0103: Variable name "actualValue" doesn't conform to snake_case naming style (invalid-name)

------------------------------------------------------------------
Your code has been rated at 5.71/10 (previous run: 5.71/10, +0.00)

$

このように、パッケージ pylint_test を指定すればそれ以下のモジュールについてチェックが可能です。 同様に、testsパッケージについても

$ poetry run pylint tests
************* Module tests.test
tests/test.py:7:0: C0116: Missing function or method docstring (missing-function-docstring)
tests/test.py:19:0: C0115: Missing class docstring (missing-class-docstring)
tests/test.py:20:4: C0116: Missing function or method docstring (missing-function-docstring)
tests/test.py:30:8: W0104: Statement seems to have no effect (pointless-statement)

------------------------------------------------------------------
Your code has been rated at 8.18/10 (previous run: 8.18/10, +0.00)

$

とすることでチェックが可能です。

なお、パッケージやモジュールではなくファイル名での指定も可能です。 詳細はドキュメントの利用方法の説明 をご確認ください。

🔧 設定の変更

設定ファイル

Pylintの設定はコマンドラインであたえることもできますが、いつも使うオプションはファイルに書いておくと便利です。 Pylintは、カレントワーキングディレクトリにある pylintrcpyproject.toml などといったファイルから設定を読み込むことができます (詳細はPylintのドキュメントのCommand line optionsの節 をご確認ください)。

Poetryを使っている場合等で pyproject.toml がすでにあるなら、そこに書いておくのが便利でしょう。

チェックの高速化

CPUが複数ある場合は、Pylintの実行を並列化することでより速くチェックが実行できます。

CPUの数に応じたサブプロセスを生成する場合は、 -j (jobs) オプションに0を指定します。 コマンドラインであれば次のようになります。

$ pylint -j 0 pylint_test
$

pyproject.toml で設定する場合はに下記のようになります。

[tool.pylint."MAIN"]
jobs = 0

なお、同様の設定を pyproject.toml ではなく pylintrc に書く場合は下記のようになります。

[MAIN]
jobs = 0

チェック項目の無効化

Pylintには大量のチェック項目があります(リスト)。 その中には過剰・不要に感じられる項目もあるでしょう。Pylintでは、 これら項目ごとにその有効・無効を指定できるようになっています。その方法をご説明します。

まず、Pylintの出力を確認します。各メッセージには、その項目の種類を示す情報が含まれています。 下記の例を見てみましょう。

pylint_test/model/chk.py:1:0: C0103: Function name "v" doesn't conform to snake_case naming style (invalid-name)

メッセージ冒頭にある C0103 はメッセージコードで、各項目を識別します。コード先頭の C はカテゴリを示します。 なお、カテゴリには以下のようなものがあります。

  • C はコーディング規約 (coding convention) のCで、 一般的なコーディング規約への違反を検出するものです。
  • R は「良き慣習」とされる指標から逸脱している部分で、リファクタリングすることが勧められる箇所です。
  • W は警告で、スタイル上、あるいは軽微なプログラミング上の問題を示します。
  • E は重大なプログラミング上の問題で、おそらくバグであるものです。

一方、末尾にある invalid-name はシンボリックネームで、メッセージコードと同様に各項目を識別します。

Pylintの設定では、各項目をそのコードまたはシンボリックネームで指定できます。 また、カテゴリを指定してカテゴリ一括での指定も可能です。 例えば、上記の例の invalid-name と、 Rカテゴリの全てのメッセージを無効化したければ、 pyproject.toml に下記を記述します。

[tool.pylint."MESSAGES CONTROL"]
disable = ["invalid-name", "R"]

なお、同様の設定を pyproject.toml ではなく pylintrc に書く場合は下記のようになります。

[MESSAGES CONTROL]
disable = invalid-name, R

設定例

Pylintの設定例として、実際に私たちが使っているPylintのルール例(抜粋)もご紹介します。 なるべく多くの項目を有効にしたいので、 enable=all をまず書いています。 そのあと、対応しないことに決めたものを disable に列挙しています。

[MESSAGES CONTROL]
enable=all
disable=
        broad-except,
        duplicate-code,
        invalid-name,
        locally-disabled,
        no-else-return,
        suppressed-message,
        too-few-public-methods,
        too-many-ancestors,
        too-many-arguments,

🛑 部分的な無効化

何らかの理由で一時的にPylintによる検出を無効化したいときは、 下記のようにコメントを書くことで無効化できます。

"""敵キャラクタを定義するモジュール"""
from dataclasses import dataclass


@dataclass
class Hostile:  # pylint: disable=too-many-instance-attributes
    """敵キャラクタ"""

    name: str
    health_point: int
    attach_point: int
    defence_point: int
    equipment: str
    drop_item_rare: str
    drop_item_common: str
    ai_tier: int
    is_boss: bool
    is_befriendable: bool

この例は行単位の無効化ですが、スコープやブロック単位でも無効にできます。細かい指定方法は PylintのドキュメントのBlock disablesの節をご参照ください。

今後も改修する予定がないとか、意図しない検出が多すぎる場合にはルールごと無効化するほうが簡単です。 しかし、部分的な無効化としてあえてコード中に残しておくことで、将来リファクタリングすべき箇所の目印にもできます。 必要に応じて、項目自体の無効化と部分的な無効化を使い分けると便利です。

🤖 CIでの利用

うまく設定ができたら、CIでも実行するようにしましょう。 Pylintは何らかの指摘事項を検出した際、非ゼロ終了コードで終了します(参照)。 よって、通常の「非ゼロ終了コードであれば失敗」の枠組みで、簡単にCIに組込むことができます。

さいごに

今回はPylintによるソースコードの静的解析についてご紹介しました。

リンターはフォーマッタとは違い、導入して終わりではありません。

過剰な検出や誤検出があれば都度無効化する必要がありますし、 リンター自体の更新で検出項目が増えて新しく検出される項目が増えることもあります。 しかし、その手間を踏まえても導入して得られる効果は大きく、 私たちは現在までPylintを使いつづけています。 実際、Pylintによって修正できたプログラミング上の誤りもありました(本記事で例に挙げているのが、そのうちの一部です)。

とはいえPylintに不満が全くないわけではありません。その1つに、実行時間が長いという点があります。 私たちのコードでも、すでに頻繁に実行するのは辛いくらいの時間がかかるようになっています。 そんななか昨年登場したPythonのリンターRuffは、 Pylintに比べ圧倒的に高速とうたっています。実際試してみたところ、 売り文句に違わぬ実力がありそうです。今度はRuffの記事ができるかもしれません…。

次回もコードのチェックに関するトピックで、pyupgradeを紹介する予定です。お楽しみに。

連載バックナンバー