AI が生成する大量の Python コードの品質を管理するための pyproject.toml 設定
AI が生成する大量のコードをレビューできますか?
チームで Python の開発を行うとき、ChatGPT などで作られたコードをレビューすることになることも増えてきました
生成 AI によってコードは大量生産することが可能になりましたが、
レビューのプロセスでその大量のコードをチェックすることができる体制は整っていますでしょうか?
レビューにも AI を使う手法もありますが、次の問題があります:
- 料金
- ハルシネーション による誤検知やバグの見逃し
AI を使う前に、まずはアルゴリズムによるレビューの自動化を行うことが重要です
全体的な方針
最低でも Ruff は使いましょう
Python をコーディングするにあたって、最低でも Ruff は使いましょう
Ruff は Python の linter であり、formatter でもあります
Python は Linter が群雄割拠しており、それらを網羅的にインストールしていくことが大変でしたが、
Ruff はその多くを網羅しつつ、Rust で実装し直されており高速に動作します
そのため、何をおいてもまずは Ruff を使うことをお奨めします
求められる品質によっては Ruff 以外のツールも使うことを検討しましょう
Ruff は非常に多くの Linter を網羅していますが、まだその全てのルールを網羅しているわけではありません
そのため、プロジェクトの求められる品質によっては、Ruff 以外のツールも使うことを検討しましょう
以下は筆者が併用しているツールです
Formatter
- docformatter: docstring の書式を整えるため
Black, isort, autoflake などの Formatter は
Ruff による置き換えが十分に完了しているので、併用する必要はないと考えています
参考:
Linter
- Xenon (Radon): コードの循環的複雑度を一定以下に保つため
- Bandit: セキュリティの脆弱性を検知するため
- dodgy: パスワードや秘密鍵などの機密情報がコード内に含まれていないかを検知するため
-
Flake8: Ruff に導入されていないルールでリンティングするため
- pycodestyle: Python のコーディング規約 PEP 8 に従っているかをチェックするため
- hacking: OpenStack のコーディング規約に従っているかをチェックするため
- flake8-bugbear: pycodestyle の E501: line too long より高機能で不要な検知の少ないルールを利用するため
- Dlint: セキュリティの脆弱性を検知するため
- mypy: 関数呼び出しなどによる値の受け渡し時の型の矛盾を検知するため
- Pylint: 他の Linter では検知できないコードの問題を検知するため
- Semgrep: セキュリティの脆弱性を検知するため
テスト
- pytest: 単体テストを実行するため
- Coverage.py: テストのカバレッジを計測するため
pyproject.toml の設定
Python プロジェクトの設定は pyproject.toml
に記述します
上記の方針に沿って、Ruff を中心に、他のツールを併用するための設定とその理由を以下に示します:
[dependency-groups]
dev = [
"bandit",
# コードの凝集性のチェックを行いたい場合に Flake8 と併せてインストールします:
# - mschwager/cohesion: A tool for measuring Python class cohesion.
# https://github.com/mschwager/cohesion
"cohesion",
# Coverage 3.5.3 は依存関係を調べることが困難なので、依存関係計算処理のことを考慮して 3.5.3 以下は除外した方が良いです:
# - Command: "pipenv install --skip-lock" fails
# since it tries to parse legacy package metadata and raise InstallError
# · Issue #5595 · pypa/pipenv
# https://github.com/pypa/pipenv/issues/5595#issuecomment-1454769781
"coverage>=3.5.4",
# The dlint 0.14.0 未満は flake8 のバージョン指定に上限が設定されているので除外した方が良いです:
# - dlint/requirements.txt at 0.13.0 · dlint-py/dlint
# https://github.com/dlint-py/dlint/blob/0.13.0/requirements.txt#L1
"dlint>=0.14.0",
# サポートする Python の最新バージョンが 3.11 未満の場合、docformatter が pyproject.toml の設定を読み込めるように、次のように指定します:
"docformatter[tomli]; python_version < '3.11'",
"docformatter; python_version >= '3.11'",
"dodgy",
# Hacking (後述) が flake8 ~=6.1.0 or ~=5.0.1 or ~=4.0.1 に依存しており、
# このバージョン指定をしなかった場合、依存関係の計算処理が非常に遅くなることがあります
"flake8!=6.0.0,!=5.0.0,>=4.0.1",
# pycodestyle の E501 を、より高機能で不要な検知の少ない flake8-bugbear の B950 で置き換えるため:
# - Using Black with other tools - Black 25.1.0 documentation
# https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#bugbear
"flake8-bugbear",
# コマンド: flake8 --radon-show-closures を使うため
"flake8-polyfill",
# Flake8 の設定を pyproject.toml に記述できるようにするため
"Flake8-pyproject",
# 最新の hacking は依存する flake8 のバージョン上限を設定していることが多く、
# 古い hacking はそのバージョン上限を設定していません
# そのため、この hacking のバージョン下限設定をしないと、著しく古いバージョンの hacking がインストールされてしまいます
"hacking>=5.0.0; python_version >= '3.8'",
# 開発用ツール群を楽に使うためのツールです
"invokelint>=0.8.1",
"mypy",
"pylint",
"pytest",
# pytest のログの書式を設定したい場合、radon 6.0.0 では `$()d` のような書式が使えないので、
# 使いたい場合は次のようにバージョン上限を設定します:
# - Radon can't run when use pytest log fornat: `$()d` · Issue #251 · rubik/radon
# https://github.com/rubik/radon/issues/251
"radon<6.0.0",
"ruff",
"semgrep",
# invokelint を使う場合、ショートカットを定義する tasks.py で型の警告が表示されるため
"types-invoke",
"xenon",
]
[project]
# project ブロックを定義するために、最低でも name と version を定義する必要があります
# これらの値は、プロジェクトをパッケージとして公開するまでは、任意の値を設定しても問題ありません
# プロジェクトをパッケージとして公開することになった際には、PyPI のパッケージ名とバージョンを設定する必要があります
name = "yourpacagename"
version = "0.1.0"
# この設定は Ruff (または Black) がコードをフォーマットする際に動作を保証するバージョンとして考慮されます
# そのため、後方互換性を意識する場合 (プロジェクトを Python パッケージとして公開するなどの場合) でない限り、
# 最新の Python バージョンのみに限定するように設定した方が、コードが綺麗になります
requires-python = ">=3.7"
dependencies = [
# 開発時以外にも必要なパッケージはここに追加します
# 例えば、データベースを使う場合は次のように追加します:
"sqlalchemy",
]
# Bandit の設定の解説は次の URL を参照:
# - Configuration — Bandit documentation
# https://bandit.readthedocs.io/en/latest/config.html)
[tool.bandit.assert_used]
# Bandit はコード内で `assert` が使われていると警告を出すので、テストコードを除外します
skips = ["tests/*.py"]
[tool.coverage.report]
# カバレッジ計測から除外する設定です
# ここで設定している以外にも、
# 公式ドキュメントにいくつか除外設定しても良いのではないかと考えられるコードが記載されているので、
# 確認しておくと良いかもしれません:
# - Excluding code from coverage.py — Coverage.py 7.8.0 documentation
# https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion
exclude_also = [
# TYPE_CHECKING の if ブロックの中はテスト実行中に走査されないので、カバレッジ計測から除外する必要があります:
# - Assume `if TYPE_CHECKING: ... else: ...` block is covered · Issue #831 · nedbat/coveragepy
# https://github.com/nedbat/coveragepy/issues/831#issuecomment-517778185
"if TYPE_CHECKING:",
# 正しく実装されていれば、抽象メソッドは実行されないので、カバレッジ計測から除外する必要があります
"raise NotImplementedError",
]
[tool.coverage.run]
# カバレッジを計測するパッケージ、モジュールを指定します
# source を設定していると、include は設定できなくなります
source = ["yourpackagename"]
# Docformatter の設定の解説は次の URL を参照:
# - How to Use docformatter — docformatter 1.7.5 documentation
# https://docformatter.readthedocs.io/en/latest/usage.html
# - How to Configure docformatter — docformatter 1.7.5 documentation
# https://docformatter.readthedocs.io/en/latest/configuration.html
[tool.docformatter]
# Docformatter はデフォルトでは再起的にフォーマットしてくれないため
recursive = true
# Black のコーディング書式との互換性を保つために、次の設定をします:
# - How to Configure docformatter — docformatter 1.7.5 documentation
# https://docformatter.readthedocs.io/en/stable/configuration.html#a-note-on-options-to-control-styles
pre_summary_space = true
wrap-descriptions = 119
wrap-summaries = 119
[tool.flake8]
# flake8-bugbear の B950 は max_line_length の 10% 以上超過した行を検知します:
# - PyCQA/flake8-bugbear: A plugin for Flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle.
# https://github.com/PyCQA/flake8-bugbear?tab=readme-ov-file#opinionated-warnings
# Black の設定では flake8-bugbear を導入しない場合 88、導入する場合 80 に設定するので、
# それに倣うと 1 行の長さを 119 とする場合は 108 となります:
# - Using Black with other tools - Black 25.1.0 documentation
# https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#bugbear
max_line_length = 108
extend_ignore = [
# 次は Black との互換性のための設定です:
# - Using Black with other tools - Black 25.1.0 documentation
# https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8
"E203",
"E501",
"E701",
# 2025-05-24 現在、openstack/hacking は Ruff に導入されておらず、
# インポートの並べ替えは主に isort の設計思想に基づいて行われるので、
# openstack/hacking 側のインポートの並べ替えルールは無視します
"H306", # Alphabetically order your imports by the full module path
# 2025-05-24 現在、Cohesion の警告をエラーとして扱うと
# コードの保守管理にかなりの負担がかかるので、普段は無視する設定にしていますが、
# 時々は有効にして結果を確認すると学びが得られるかもしれません
"H601",
]
# かなり多くの警告が表示されるので、ターミナルに合計が表示されるようにしておくと
# 修正にかかる時間の目安がわかり便利なため
statistics = true
# ターミナル上でコードの状況が確認しやすくなるため
show_source = true
exclude = [
".venv",
]
# Mypy の設定の解説は次の URL を参照:
# - The mypy configuration file - mypy 1.15.0 documentation
# https://mypy.readthedocs.io/en/stable/config_file.html#using-a-pyproject-toml-file
[tool.mypy]
# いきなり strict にする必要はありませんが、
# 早い段階で strict にしておいた方が、コードベースが大きくなってきた時にバグ発生の確率を抑えられ
# 開発の時間を節約できると思います
strict = true
[tool.pylint.basic]
# 経験則ですが、7 行以内のクラスや関数であれば、
# ドキュメントがない方が直接コードを確認できて視認性が良いと感じているため、
# 次のように設定しています
docstring-min-length = "7"
[tool.pylint.format]
max-line-length = 119
[tool.pylint.options]
# 単一責任の原則から考えると public メソッドが 1 つのクラスは十分にあり得るため
# Pylint は初期値の根拠が明確でないこともあります:
# - python - Why does Pylint want two public methods per class? - Stack Overflow
# https://stackoverflow.com/a/40258006/12721873
min-public-methods = "1"
[tool.pytest.ini_options]
# 次の行をコメントインすると、pytest の詳細なログが確認できます
# log_cli = true
# log_cli_level = "DEBUG"
# log_format = "%(asctime)s %(process)d %(levelname)s %(name)s:%(filename)s:%(lineno)d %(message)s"
# 実行に時間がかかるテストに @pytest.mark.slow を付けることで、
# pytest の実行時に `-m "not slow"` を指定することで、実行時間の長いテストを除外できます
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
]
[tool.radon]
# 経験則的に、B にならないようにしていれば保守性が悪くなることはないと考えています
cc_min = "B"
# 表示することで見づらくなるなどの問題はないと思うので、
# 追加で表示できる情報は表示しておくと便利です
show_complexity = true
show_mi = true
[tool.ruff]
line-length = 119
[tool.ruff.lint]
# いきなり ALL にする必要はありませんが、
# 早い段階で ALL にしておいた方が、コードベースが大きくなってきた時にバグ発生の確率を抑えられ
# 開発の時間を節約できると思います
# ただし、ALL にしていると、Ruff をバージョンアップする度に新しい警告が増えて
# プロジェクトの保守管理に疲弊する可能性があるので、注意が必要です
select = ["ALL"]
ignore = [
# 7 行以下のクラスや関数であれば、ドキュメントがない方が直接コードを確認できて視認性が良いと感じているため、
# docstring がない場合でも警告が出ないように設定しています
# (7 行以上のクラスや関数に docstring がない場合は、pylint の方で警告が表示されるので問題ありません)
"D101", # Missing docstring in public class
"D102", # Missing docstring in public method
"D103", # Missing docstring in public function
"D105", # Missing docstring in magic method
"D106", # Missing docstring in public nested class
"D107", # Missing docstring in __init__
# 理由: `Line too long` はより高機能で不要な検知の少ない flake8-bugbear の B950 で確認するため
"E501", # Line too long ({width} > {limit})
]
fixable = [
# Ruff はデフォルトで、直せる警告を全て修正しようとしますが、
# 心配な場合は、問題なく修正できることが確認できた警告を適宜追加していくと良いでしょう
"COM812", # Trailing comma missing
# [tool.ruff.lint] ブロックで `fixable` を指定する場合、
# Ruff でインポートを並べ替えるために、この設定は必要です
# Ruff では isort のフォーマットを `ruff format` ではなく `ruff check --fix` で行うため
"I", # isort
"EM102", # Exception must not use an f-string literal, assign to variable first
"PT001", # Use `@pytest.fixture()` over `@pytest.fixture`
"PT006", # Wrong name(s) type in `@pytest.mark.parametrize`, expected `tuple`
"UP006", # Use {} instead of {} for type annotations
"UP015", # Unnecessary open mode parameters
"UP037", # Remove quotes from type annotation
]
unfixable = [
# Ruff によるフォーマットと別の Linter が競合する場合などの場合に、
# 修正しない警告を理由とともに追加しておくと、
# 「なぜ修正しないことにしたのか」が後で確認しやすくなります
# `return a and b != ""` を `return a and b` に修正すると、mypy で警告が発生するため:
# error: Incompatible return value type (got "Union[Literal[False], str]", expected "bool") [return-value]
"PLC1901",
# Pydantic カスタムデータタイプを使うと mypy で警告が発生する問題に対応するため
"SIM108", # Use ternary operator `extra_context = {} if extra_context is None else request.param` instead of `if`-`else`-block
]
[tool.ruff.lint.isort]
# Flake8 + openstack/hacking の H301: Do not import more than one module per line (*) に対応するため
force-single-line = true
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]
[tool.ruff.lint.pydocstyle]
# Google の書式が、記述量が少なく視認性にも優れていてお奨めです
# 次の記事で代表的な docstring の書式が比較できます:
# - Answer: coding style - What are the most common Python docstring formats? - Stack Overflow
# https://stackoverflow.com/a/24385103/12721873
convention = "google"
上記の pyproject.toml
は Python 3.7 以降で動作することを前提としています
- Ruff も uv も Python 3.7 以降のみで動作します
- Python 3.6 で開発を行うためには Pipenv や Poetry などを使って依存関係を管理する必要があります
- Python 3.6 は 2021-12-23 にサポートが終了しており、セキュリティの脆弱性が発見されても修正されません
Ruff や他のツール群が多すぎて使い方を忘れてしまう問題とその解決方法
Python 開発用ツールが多いことによって、次の問題があります:
- ツールを一つ一つ実行するのが面倒
- ツールの使い方を忘れてしまう
- ツールを実行する対象の Python ファイルを指定するのが面倒
これらの問題を解決するために、
全てのツールが Ruff に統合されるまでの「繋ぎ」として、ツールを開発しました:
Invoke Lint とは?
Invoke という、開発用のコマンドラインショートカットを Python で楽に実装できるツールがあるのですが、
Invoke Lint は Invoke を使って Python 開発用ツールを楽に実行するショートカットを追加できるツールです
Invoke Lint の利点
- ツールを一つ一つ実行する必要がなくなります
- 15 文字以内の次の 4 種類のコマンドで Ruff や他のツール群を網羅して一括で実行できます:
- ツールを実行する対象の Python ファイルを自動的に選択して実行します
これらの利点により、例えば ChatGPT でコードを生成して提出してくる人に対しても
「このツールでエラーが出なくなるまでコードを修正してから提出お願いします」
と言いやすくなります
Invoke Lint の導入方法
インストール
前述の pyproject.toml
の設定に Invoke Lint をインストールする設定が含まれているので、
前述の pyproject.toml
をプロジェクトのルートディレクトリに配置してから、
uv などでインストールすると、Invoke Lint と、その依存パッケージである Invoke がインストールされます
uv sync
これで Invoke の inv
コマンドが使えるようになります:
$ uv run inv
Usage: inv[oke] [--core-opts] task1 [--task1-opts] ... taskN [--taskN-opts]
Core options:
--complete Print tab-completion candidates for given parse remainder.
--hide=STRING Set default value of run()'s 'hide' kwarg.
--no-dedupe Disable task deduplication.
--print-completion-script=STRING Print the tab-completion script for your preferred shell (bash|zsh|fish).
--prompt-for-sudo-password Prompt user at start of session for the sudo.password config value.
--write-pyc Enable creation of .pyc files.
-c STRING, --collection=STRING Specify collection name to load.
-d, --debug Enable debug output.
-D INT, --list-depth=INT When listing tasks, only show the first INT levels.
-e, --echo Echo executed commands before running.
-f STRING, --config=STRING Runtime configuration file to use.
-F STRING, --list-format=STRING Change the display format used when listing tasks. Should be one of: flat (default), nested, json.
-h [STRING], --help[=STRING] Show core or per-task help and exit.
-l [STRING], --list[=STRING] List available tasks, optionally limited to a namespace.
-p, --pty Use a pty when executing shell commands.
-r STRING, --search-root=STRING Change root directory used for finding task modules.
-R, --dry Echo commands instead of running.
-T INT, --command-timeout=INT Specify a global command execution timeout, in seconds.
-V, --version Show version and exit.
-w, --warn-only Warn, instead of failing, when shell commands fail.
Invoke Lint のショートカットを Invoke に追加
Invoke の設定ファイルである tasks.py
をプロジェクトのルートディレクトリに配置し、
次のコードを記載します:
"""Tasks for maintaining the project.
Execute 'invoke --list' for guidance on using Invoke
"""
from invoke import Collection
from invokelint import dist
from invokelint import lint
from invokelint import path
from invokelint import style
from invokelint import test
ns = Collection()
ns.add_collection(dist)
ns.add_collection(lint)
ns.add_collection(path)
ns.add_collection(style)
ns.add_collection(test)
そして inv --list
を実行すると、次のように表示されます:
$ inv --list
Available tasks:
dist.dist (dist) Builds source and wheel packages into dist/ directory.
lint.bandit Lints code with bandit.
lint.cohesion Lints code with Cohesion.
lint.deep Runs slow but detailed linting (mypy, Pylint, semgrep).
lint.dodgy Lints code with dodgy.
lint.fast (lint) Runs fast linting (xenon, ruff, bandit, dodgy, flake8, pydocstyle).
lint.flake8 Lints code with flake8.
lint.mypy Lints code with mypy.
lint.pydocstyle Lints code with pydocstyle.
lint.pylint Lints code with Pylint.
lint.radon Reports radon both code complexity and maintainability index.
lint.radon-cc Reports code complexity.
lint.radon-mi Reports maintainability index.
lint.ruff Lints code with Ruff.
lint.semgrep Lints code with Semgrep.
lint.xenon Checks code complexity.
path.debug (path) Debugs and displays which path is recognized as project paths.
style.fmt (style) Formats code by docformatter and Ruff (option for only check available).
test.all Runs all tests.
test.coverage (test.cov) Runs all tests and report coverage (options for create xml / html available).
test.fast (test) Runs fast tests (not mark @pytest.mark.slow).
Invoke Lint を使った開発フロー
おそらく、開発したコードに対して次の順にショートカットを実行して、コードの改善を繰り返していくことになると思います:
Discussion