メインコンテンツへスキップ

Python コードの品質を保つためのツールの導入(Ruff, pytest-cov, lizard)

·6 分
ひとりアドベントカレンダー2024 Python Ruff Lizard
目次

はじめに
#

「OKAZAKI Shogo のひとりアドベントカレンダー2024」の18日目です。ついに最終日です。 作ろうとしていたアプリの完成はまだですが、コードが肥大化して読めなくなってしまう前に、コードの品質を保つための仕組みを入れていきます。

静的コード解析ツール Ruff を導入する。
#

Ruff は 2022 年 8 月にリリースされた Python のリンター兼フォーマッターである。これまでよく利用されてきた

  • リンター:Flake8
  • フォーマッター:Black
  • import のソート:isort の 3 つのツールの機能をすべて実行することができる。

今回も poetry 経由で導入する:

1poetry add --group=dev ruff

基本的な使い方
#

フォーマッターを実行するには ruff format [ディレクトリ名] を実行する。

1poetry run ruff format .

リンターの実行には ruff check [ディレクトリ名] を実行する。以下は例:

1poetry run ruff check --extend-select I --fix .
  • --extend-select I:Ruff が検出する問題の種類を拡張するオプション。I は不要なインポートの削除や、インポート順序の整理を行ってくれる。
  • --fix:検出された問題を自動的に修正する。

リンターを実行すると以下のようにルール違反している箇所とその理由が出力される:

1app/__init__.py:12:5: D103 Missing docstring in public function
2   |
312 | def create_app(test_config=None):
4   |     ^^^^^^^^^^ D103
513 |     # appの設定
614 |     app = Flask(__name__, instance_relative_config=True)
7   |

GitHub Action で CI を走らせてチェックする場合は以下のように設定すれば良い。

1- name: Run Ruff (lint)
2  run: ruff check --output-format=github .
3- name: Run Ruff (format)
4  run: ruff format . --check --diff
  • --output-format=github: 出力を GitHub Actions に適した形式で表示する。この形式にすることで、解析結果が GitHub のプルリクエストやチェックページにわかりやすく表示される。
  • --check: 実際にフォーマットを適用せず、フォーマットが必要かどうかだけを確認する。
  • --diff: フォーマットが必要な箇所がある場合、その差分を表示する。

Ruff の設定
#

pyproject.toml に以下のように記載する。リンターの設定とフォーマッターの設定は分けて記述する:

 1[tool.ruff]
 2# Ruff の全般的な設定。Ruff の動作全体に影響を与える基本的なオプションを指定する。
 3
 4[tool.ruff.lint]
 5# Ruff を静的解析(Lint)ツールとして利用する際の設定。静的解析のルールやチェックの対象範囲を詳細に指定する。
 6
 7[tool.ruff.format]
 8# Ruff をコードフォーマッターとして利用する際の設定。コード整形に関連する設定を指定する。
 9
10[tool.ruff.lint.isort]
11# Ruff のインポート整理機能(isort互換)に関する設定。インポートの並び順やグループ化に関連する設定を指定する。

公式ドキュメントの説明 も参考にしながら、以下のような設定が可能である。

 1[tool.ruff]
 2# Ruff の設定
 3# 参考: https://docs.astral.sh/ruff/configuration/
 4src = ["src", "test"]
 5# よく無視されるディレクトリを除外する。
 6exclude = [
 7    ".bzr",
 8    ".direnv",
 9    ".eggs",
10    ".git",
11    ".git-rewrite",
12    ".hg",
13    ".ipynb_checkpoints",
14    ".mypy_cache",
15    ".nox",
16    ".pants.d",
17    ".pyenv",
18    ".pytest_cache",
19    ".pytype",
20    ".ruff_cache",
21    ".svn",
22    ".tox",
23    ".venv",
24    ".vscode",
25    "__pypackages__",
26    "_build",
27    "buck-out",
28    "build",
29    "dist",
30    "node_modules",
31    "site-packages",
32    "venv",
33]
34
35# Python 3.8 を想定。
36target-version = "py38"
37# Black と同じ設定。
38line-length = 88
39indent-width = 4
40
41[tool.ruff.lint]
42# アンダースコアで始まる未使用変数を許可する。
43dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
44
45# デフォルトで Pyflakes (`F`) と pycodestyle (`E`) の一部のコードを有効にする。
46# Ruff は Flake8 と異なり、デフォルトでは pycodestyle の警告 (`W`) や
47# McCabe の複雑性チェック (`C901`) を有効にしない。
48select = ["E4", "E7", "E9", "F"]
49ignore = []
50
51# すべての有効なルールに対して修正を許可する(`--fix` が指定された場合)。
52fixable = ["ALL"]
53unfixable = []
54
55[tool.ruff.format]
56# Black のように、文字列に二重引用符を使用する。
57quote-style = "double"
58
59# Black のように、タブではなくスペースでインデントする。
60indent-style = "space"
61
62# Black のように、マジックトレーリングカンマを尊重する。
63skip-magic-trailing-comma = false
64
65# Black のように、適切な改行コードを自動検出する。
66line-ending = "auto"
67
68# Docstring 内のコード例を自動フォーマットする。Markdown、reStructuredText のコード/リテラルブロック、および doctest に対応している。
69#
70# 現在はデフォルトで無効になっているが、将来的にはオプトアウト形式になる予定である。
71docstring-code-format = true
72
73# Docstring 内のコードスニペットをフォーマットする際に使用される行の長さの制限を設定する。
74#
75# この設定は `docstring-code-format` が有効になっている場合にのみ影響を与える。
76docstring-code-line-length = "dynamic"

テストのカバレッジを集計する
#

Pytest で実行したテストのカバレッジを測定できるようにする:

1poetry add --group dev pytest-cov

以下のようなコマンドを実行すると下記の通り出力が得られる:

 1$ poetry run pytest -v --cov=app --cov-report=term-missing
 2The currently activated Python version 3.13.1 is not supported by the project (3.8.12).
 3Trying to find and use a compatible version.
 4Using python3 (3.8.12)
 5=============================================================================== test session starts ================================================================================
 6platform darwin -- Python 3.8.12, pytest-8.3.4, pluggy-1.5.0 -- /Users/shogo/Library/Caches/pypoetry/virtualenvs/bshssa-member-system-GhAlEoqL-py3.8/bin/python
 7cachedir: .pytest_cache
 8rootdir: /Users/shogo/develop/bshssa-member-system
 9configfile: pytest.ini
10plugins: cov-5.0.0
11collected 16 items
12
13tests/models/test_file.py::test_FileモデルがDBに作成されること PASSED                                                                                                        [  6%]
14tests/models/test_file.py::test_FileモデルがDBで更新されること PASSED                                                                                                        [ 12%]
15tests/models/test_file.py::test_FileモデルがDBから削除されること PASSED                                                                                                      [ 18%]
16tests/models/test_manager_user.py::test_ManagerUserがDBに登録されること PASSED                                                                                               [ 25%]
17tests/models/test_manager_user.py::test_ManagerUserがのパスワードの設定と検証が PASSED                                                                                       [ 31%]
18tests/models/test_manager_user.py::test_manager_user_update PASSED                                                                                                           [ 37%]
19tests/models/test_manager_user.py::test_manager_user_delete PASSED                                                                                                           [ 43%]
20tests/models/test_manager_user.py::test_load_user PASSED                                                                                                                     [ 50%]
21tests/views/test_files.py::test_ファイル一覧に登録されたファイルが表示されること PASSED                                                                                      [ 56%]
22tests/views/test_index.py::test_indexにアクセスしてトップページが返されること PASSED                                                                                         [ 62%]
23tests/views/test_login.py::test_GETリクエストを送るとログイン画面が返却されること PASSED                                                                                     [ 68%]
24tests/views/test_login.py::test_POSTリクエストでユーザー名とパスワードを受け取りログインが成功すること PASSED                                                                [ 75%]
25tests/views/test_login.py::test_POSTリクエストで無効なユーザー名を受け取るとログインが失敗すること PASSED                                                                    [ 81%]
26tests/views/test_login.py::test_POSTリクエストで無効なパスワードを受け取るとログインが失敗すること PASSED                                                                    [ 87%]
27tests/views/test_manage_menu.py::test_ログインせずに保護されたエンドポイントにアクセスすると401エラー PASSED                                                                 [ 93%]
28tests/views/test_manage_menu.py::test_ログイン後に保護されたエンドポイントにアクセスすると管理者メニューに遷移すること PASSED                                                [100%]
29
30---------- coverage: platform darwin, python 3.8.12-final-0 ----------
31Name                            Stmts   Miss  Cover   Missing
32-------------------------------------------------------------
33app/__init__.py                    35      0   100%
34app/app.py                         28     28     0%   1-51
35app/models/__init__.py              0      0   100%
36app/models/file.py                 16      0   100%
37app/models/manager_user.py         23      0   100%
38app/util/google_drive.py           54     32    41%   48-52, 62-71, 76, 83-114
39app/views/__init__.py               0      0   100%
40app/views/files.py                  7      0   100%
41app/views/index.py                  8      0   100%
42app/views/login.py                 16      0   100%
43app/views/logout.py                 7      2    71%   9-10
44app/views/manage_file.py           10      3    70%   12-14
45app/views/manage_file_list.py       9      2    78%   14-15
46app/views/manage_menu.py            7      0   100%
47app/views/regist_file.py           59     41    31%   22-28, 32-44, 50-99
48app/views/update_file.py           23     13    43%   19-39
49-------------------------------------------------------------
50TOTAL                             302    121    60%
51
52
53================================================================================ 16 passed in 1.50s ================================================================================
  • -v: テスト実行時に詳細な出力を表示する(verbose モード)
  • --cov=app: pytest-cov プラグインを使用し、app ディレクトリのコードカバレッジを測定する
  • --cov-report=term-missing: テストがカバーしていない行(未実行行)を表示する
    • 上記の Missing の列がそれに当たる
    • --cov-report=html とすると、カバレッジレポートを HTML ファイルとして生成し、htmlcov ディレクトリに保存することができる

コードの複雑度を測る
#

コードの品質と複雑性を測定し、以下を特定するために、lizard を入れる。複雑度を測定すると、以下のことに役立つ:

  • 問題のある関数やファイル: 高いサイクロマティック複雑性(CCN)やコメントを除いたコードの行数(NLOC) を持つコードはリファクタリングの対象になる可能性がある。
  • テストやレビューの優先度: 特に複雑な関数に重点を置くことで、バグのリスクを減らせる。
  • コード全体の健康状態: 平均的な複雑性や行数の指標から、プロジェクト全体のコード品質を判断できる。
1poetry add --group=dev lizard

lizard を実行すると以下のような出力が得られる:

 1$ poetry run lizard app
 2The currently activated Python version 3.13.1 is not supported by the project (3.8.12).
 3Trying to find and use a compatible version.
 4Using python3 (3.8.12)
 5================================================
 6  NLOC    CCN   token  PARAM  length  location
 7------------------------------------------------
 8       6      3     31      1       7 __get_mime_type@46-52@app/util/google_drive.py
 9      13      2     81      1      17 __get_document_path@55-71@app/util/google_drive.py
10       4      1     24      1       5 __get_credentials@74-78@app/util/google_drive.py
11      30      4    167      1      34 upload_file_to_gdrive@81-114@app/util/google_drive.py
12       2      1     12      1       2 get_id@16-17@app/models/manager_user.py
13       3      1     19      2       3 set_password@19-21@app/models/manager_user.py
14       4      1     26      2       4 check_password@23-26@app/models/manager_user.py
15       3      1     30      1       3 load_user@30-32@app/models/manager_user.py
16       3      1     26      0       3 index@13-15@app/views/manage_file_list.py
17       3      1     26      0       3 index@9-11@app/views/files.py
18       3      1     15      0       3 index@8-10@app/views/logout.py
19       3      1     19      0       3 index@11-13@app/views/index.py
20      14      2     99      0      23 index@17-39@app/views/update_file.py
21       2      1     13      0       2 index@9-10@app/views/manage_menu.py
22       6      1     52      0       6 index@11-16@app/views/manage_file.py
23       6      3     45      1       9 __get_file_extension_if_allowed@20-28@app/views/regist_file.py
24      10      3     53      1      14 __get_file_size@31-44@app/views/regist_file.py
25      42      6    231      0      51 index@49-99@app/views/regist_file.py
26      11      4     93      0      12 index@10-21@app/views/login.py
27      28      2    197      1      43 create_app@12-54@app/__init__.py
28       3      1     17      1       3 handle_over_max_file_size@16-18@app/app.py
29       2      2     23      1       2 headers_to_string@21-22@app/app.py
30       7      1     20      0       7 log_request_info@31-37@app/app.py
31       4      1     17      1       4 log_response_info@41-44@app/app.py
32       3      1     20      1       3 handle_exception@49-51@app/app.py
3315 file analyzed.
34==============================================================
35NLOC    Avg.NLOC  AvgCCN  Avg.token  function_cnt    file
36--------------------------------------------------------------
37     83      13.2     2.5       75.8         4     app/util/google_drive.py
38      0       0.0     0.0        0.0         0     app/models/__init__.py
39     23       3.0     1.0       21.8         4     app/models/manager_user.py
40     16       0.0     0.0        0.0         0     app/models/file.py
41     11       3.0     1.0       26.0         1     app/views/manage_file_list.py
42      7       3.0     1.0       26.0         1     app/views/files.py
43      7       3.0     1.0       15.0         1     app/views/logout.py
44      8       3.0     1.0       19.0         1     app/views/index.py
45     23      14.0     2.0       99.0         1     app/views/update_file.py
46      7       2.0     1.0       13.0         1     app/views/manage_menu.py
47     12       6.0     1.0       52.0         1     app/views/manage_file.py
48     73      19.3     4.0      109.7         3     app/views/regist_file.py
49     16      11.0     4.0       93.0         1     app/views/login.py
50     35      28.0     2.0      197.0         1     app/__init__.py
51     32       3.8     1.2       19.4         5     app/app.py
52
53===============================================================================================================
54No thresholds exceeded (cyclomatic_complexity > 15 or length > 1000 or nloc > 1000000 or parameter_count > 100)
55==========================================================================================
56Total nloc   Avg.NLOC  AvgCCN  Avg.token   Fun Cnt  Warning cnt   Fun Rt   nloc Rt
57------------------------------------------------------------------------------------------
58       353       8.6     1.8       54.2       25            0      0.00    0.00

前半部分:関数ごとのメトリクス

  • NLOC (Non-Commenting Lines of Code): コメントを除いたコードの行数。
  • CCN (Cyclomatic Complexity Number): サイクロマティック複雑性。コードの分岐(条件分岐やループ)の数を示し、値が高いほど複雑な関数である。
  • PARAM: 関数が受け取るパラメータの数。
  • Length: 関数全体の行数(空行やコメントを含む)。
  • Location: 関数の名前と、その位置情報(行番号とファイルパス)。

後半部分:ファイルごとのメトリクス

  • NLOC: ファイル全体のコメントを除いたコードの行数。
  • Avg.NLOC: 関数ごとの平均 NLOC。
  • AvgCCN: 関数ごとの平均 CCN。
  • Avg.token: 関数ごとの平均トークン数。
    • トークンとは、プログラムコードを構成する最小単位であり、以下のような要素を含む:
      • キーワード(例: if, for, def など)
      • 識別子(変数名や関数名)
      • リテラル(例: 数値や文字列リテラル)
      • 演算子(例: +, -, = など)
      • 区切り記号(例: ,, ;, () など)
    • 平均トークン数は、(ファイル内の全トークン数)÷(ファイル内の関数の数) で定義される。
    • 値が大きい場合、関数が多くの処理を詰め込みすぎている可能性があり、リファクタリングの候補となる。
  • function_cnt: ファイル内の関数の数。
  • file: ファイル名。

上記の出力結果では、特に app/views/regist_file.pyindex 関数(CCN 6, 長さ 51 行)がやや複雑であり、リファクタリングの候補となる可能性が示唆されている。実際、この関数が含まれている app/views/regist_file.py 自体も AvgCCNAvg.token が高めであることがわかる。

参考資料
#