Pythonの静的解析ツール"Ruff"を導入した話とおすすめの導入方法


# 概要
最近、Pythonの静的コード解析ツールである[Ruff](https://beta.ruff.rs/docs/)の話題を目にするので、社内ツールにRuffを適用してみました。
その際にハマったことや、おすすめの設定/手順について記載します。

## 動作環境
* ruff v0.255.0

# Ruffとは？
以下の記事が参考になります。

https://gihyo.jp/article/2023/03/monthly-python-2303

https://zenn.dev/harupy/articles/a14b146d289a6c

# 社内ツールにRuffを導入
[社内ツール](https://github.com/kurusugawa-computer/annofab-cli)にRuffを導入しました。

## Ruffを導入する前の状態
Ruffを導入する前は、以下のツールを利用していました。

* フォーマッター
    * black（ベースとあるフォーマッター）
    * isort（import文のソート）
    * autoflake（unused importを削除するため）
* リンター
    * mypy（型チェック）
    * flake8（簡易的な静的解析）
    * pylint（詳しい静的解析）


## Ruffを導入した後の状態
Ruffを導入した後の設定ファイルです。

[導入後のコミット](https://github.com/kurusugawa-computer/annofab-cli/commit/2fccc322731811e679b4b1fc42e0e6286f0c5b7a)（いろんな修正が混ざっています。。。）




### pyproject.toml

* `select=["ALL"]`を指定して、できるだけ多くのルールを適用した
* 無視したルールについて
    * ANN(flake8-annotations), PL(Pylint Refactor)はできれば適用したいのだが、指摘箇所が多いため一部を無視した。徐々にコードを修正して、適用するルールを増やしていきたい
    * それ以外のルールは、社内ツールに合わなかったので無視した

```toml:pyproject.toml

[tool.ruff]
target-version = "py38"

ignore = [
    "G004", # `logging-f-string` : loggingでf-stringを使いたいので無視する
    "PD901", #すでに`df`という変数をいろんなところで使っているため
    "PD002", #すでにpandasで`inplace=True`はいろんなところで使っているため
    "RUF001", # 全角記号など`ambiguous unicode character`も使いたいため
    "RUF002",# 全角記号など`ambiguous unicode character`も使いたいため
    "RUF003",# 全角記号など`ambiguous unicode character`も使いたいため
    "PLC1901", # compare-to-empty-string : `if a == "`のように空文字列で直接比較したいときがあるため
    "PLR2004", # magic-value-comparison: listのサイズで判定するときがよくあるため
    "ANN101", # missing-type-self: 引数selfには型ヒントは付けていないため
    "ANN102", # missing-type-cls: 引数clsには型ヒントは付けていないため
    "ANN002", # missing-type-args
    "ANN003", # missing-type-kwargs
    "ERA", # : 役立つこともあるが、コメントアウトしていないコードも警告されるので無視する

    # いずれ無視しないようにする
    "ANN201", # missing-return-type-public-function: 
    "ANN202", # missing-return-type-private-function:
    "PLR",  # pylint Refactor 

    # 以下のルールはannofabcliのコードに合っていないので無効化した
    "RSE", # flake8-raise
    "D", # pydocstyle, Docstringを中途半端にしか書いていないので、除外する
    "C90", # mccabe 
    "T20", # flake8-print
    "SLF", #  flake8-self
    "BLE", # flake8-blind-except
    "FBT", # flake8-boolean-trap
    "TRY", # tryceratops 
    "COM", # flake8-commas 
    "S", # flake8-bandit
    "EM",#flake8-errmsg
    "EXE", # flake8-executable
    "ICN", # flake8-import-conventions
    "RET",#flake8-return
    "SIM",#flake8-simplify
    "TCH", # flake8-type-checking
    "PTH", #pathlibを使わないコードが多いので、除外する
    "ISC", #flake8-implicit-str-concat
    "N", # pep8-naming
    "PT", # flake8-pytest-style
]

line-length = 120
select = [
    "ALL"
]

[tool.ruff.pydocstyle]
# pydocstyleを無視しているのでこの設定は無意味だが、
# 社内ツールのdocstringのスタイルは決まっているので、設定だけしておく
convention = "google"

[tool.ruff.pyupgrade]
# Python3.8をサポートしているため、`typing.List`などの型ヒントは警告しないようにする
# https://beta.ruff.rs/docs/settings/#keep-runtime-typing
keep-runtime-typing = true

[tool.ruff.pylint]
max-args = 10
```


[差分](https://github.com/kurusugawa-computer/annofab-cli/compare/dfba2f8a1357c2e7329cd112b110129e8d65c60f..c27b88786cfb7cbfbb649108f32b1ac0f813e5e0#diff-50c86b7ed8ac2cf95bd48334961bf0530cdc77b5a56f852c5c61b89d735fd711) (Ruffの導入以外の差分も含まれています）

### Makefile
* flake8, autoflake, isortをRuffに置き換えた
* テストコードは雑に書いているので、無視するルールを増やした。コマンドライン引数で無視するルールを指定した。[^1]

[^1]: 管理しやすくするため、コマンドライン引数よりも設定ファイルで指定した方がよいかもしれない。その場合は、`pyproject.toml`ではなく`ruff_for_test.toml`のような設定ファイルすべきか？


```plain:Makefile
format:
	poetry run black ${SOURCE_FILES} ${TEST_FILES}
	poetry run ruff check ${SOURCE_FILES} ${TEST_FILES} --fix-only --exit-zero

lint:
	poetry run ruff ${SOURCE_FILES}
	# テストコードはチェックを緩和する
	# pygrep-hooks, flake8-datetimez, line-too-long, flake8-annotations, unused-noqa
	poetry run ruff check ${TEST_FILES} --ignore PGH,DTZ,E501,ANN,RUF100
	poetry run mypy ${SOURCE_FILES} ${TEST_FILES}
	# テストコードはチェックを緩和するためpylintは実行しない
	poetry run pylint --jobs=0 ${SOURCE_FILES}
```

[差分](https://github.com/kurusugawa-computer/annofab-cli/compare/dfba2f8a1357c2e7329cd112b110129e8d65c60f..c27b88786cfb7cbfbb649108f32b1ac0f813e5e0#diff-76ed074a9305c04054cdebb9e9aad2d818052b07091de1f20cad0bbac34ffb52
)


## Ruffを導入してみた感想
* すごく速い。快適！
* チェックだけでなく修正できるのが便利。
* `--statistics`や`--add-noqa`などのオプションが便利（後述参照）
* チェック項目が増えたことにより、Pythonの良い書き方や機能を知ることができた。
    * `zip`関数には`strict=True`を指定した方がよい(B905)
    * `str.startswith`にタプルを指定すると、OR条件で判定できる（PIE810）
    * pandasで`inplace=True`はバグの元で、パフォーマンス上の利点はない（[PD002](https://beta.ruff.rs/docs/rules/use-of-inplace-argument/)）
    * などなど

## ハマったこと/気になったこと

### `target-version`はデフォルトで`py310`(Python3.10)
[`target-version`](https://beta.ruff.rs/docs/settings/#target-version)はデフォルトで`py310`(Python3.10)です。Pythonの最小バージョンが3.10でない場合は、`target-version`を指定しないと、Python3.10以降で利用できる機能について指摘されます。たとえば、`zip`関数の`strict`引数などです(B905)。

なお、pyproject.tomlに`project.requires-python`が記載されていれば、`target-version`は推論されます。

今回適用した社内ツールではpoetryを利用しています。pyproject.tomlには`project.requires-python`が記載されていないので、`target-version=py38`を指定しました。

### 重複したimport文はチェックされるが、修正されない
以下のような重複したimport文は、isortでは修正されます。Ruffでは指摘されますが、修正されません。

```python:sample.py
import pandas
import pandas
df = pandas.Series(["a", "b", "c"])
```

```
$ ruff sample.py  --fix
sample.py:2:8: F811 Redefinition of unused `pandas` from line 1
Found 1 error.
```


issueで問い合わせたところ、誤検知することがあるので意図的に修正しないようにしているらしいです。

>We used to fix this, but it's not always safe and can be hard to get right. Not in this trivial case, but, e.g., see #2044.

https://github.com/charliermarsh/ruff/issues/3477

### eradicate(ERA)は、半角括弧があるコメント行をコードだとみなすときがある
別記事にしました。

https://qiita.com/yuji38kwmt/items/9933227b9069caef7f68

### "Line too long"の結果がFlake8と微妙に異なる
別記事にしました。

https://qiita.com/yuji38kwmt/items/ff3661d8e127c56260be


### Ruffによる`E501`を修正した後にblackでフォーマットすると、再度RuffによりE501が発生する
別記事にしました。

https://qiita.com/yuji38kwmt/items/04208d2d2cafe1218287

### 片仮名の`ノ`をambiguous unicode character"と判断する
別記事にしました。

https://qiita.com/yuji38kwmt/items/c5eadf0700774ac22057

### Pylintは置き換えられない
RuffにはPylint(PL)のルールカテゴリがあります。
社内ツールのコードのチェック処理時間は、Pylintがボトルネックになっているので、RuffがPylintの代替ツールになることを期待していました。
しかし、[公式ドキュメント](https://beta.ruff.rs/docs/faq/#how-does-ruff-compare-to-pylint)によると、「RuffはPylintの"純粋な"代替ツールにはならない」とのことです。

>Pylint implements many rules that Ruff does not, and vice versa. For example, Pylint does more type inference than Ruff (e.g., Pylint can validate the number of arguments in a function call). As such, Ruff is not a "pure" drop-in replacement for Pylint (and vice versa), as they enforce different sets of rules.

現在RuffはPylintの89のルールを実装しています。

>At time of writing, Pylint implements ~409 total rules, while Ruff implements 440, of which at least 89 overlap with the Pylint rule set (you can find the mapping in #970).

issueを見る限りPylintの全項目（全部かどうかは把握できていないが）を実装しようとしているので、将来的にはPylintもRuffに置き換えることができるかもしれません。

https://github.com/charliermarsh/ruff/issues/970



# これからruffを導入するなら
これからRuffを導入する場合は、以下の順に実施するのがよいかと思います。

## ステージ1: flake8,isortをRuffに置き換える
[flake8-to-ruff](https://pypi.org/project/flake8-to-ruff/)を使って、Flake8の設定ファイルからRuffの設定ファイルを出力します。
その設定ファイルを使えば、flake8をRuffに置き換えることができます。
またisortは、以下のコマンドに置き換えることができます。

```
# `I`はisort
# `--exit-zero`は指定しないと、コードが修正されたときにステータスコードが1になるため
$ ruff check ${FILES} --select I --fix-only --exit-zero
```


## ステージ2: 適用するルールを増やす
Ruffには500個以上のルールがあり、40以上のカテゴリ（組み込みプラグイン）にまとまっています。ルールがたくさんあるので、Ruffをflake8,isortの代替ツールとして使うだけではもったいないです。是非、適用するルールを増やしましょう。
ただし最初から全ルールを適用すると、ものすごい数のエラーが出力されます。したがって、少しずつ適用するルールのカテゴリを増やすのがよいです。


40個以上のカテゴリを1個ずつ適用するのは時間がかかるので、個人的に適用したいルールのカテゴリをまとめました。
以下の順に、ルールを適用していくことをおすすめします。

1. 基本的なルール。Ruffを使うなら是非適用したい。
   * F: Pyflakes
   * E: pycodestyle error
   * W: pycodestyle warning
   * I: isort 
2. コードの品質をより向上させるルール
    * B: flake8-bugbear: バグになりそうな部分を教えてくれる
    * PL: Pylint
3. ライブラリ/フレームワークに特化したルール
    * PD: pandas-vet
    * NPY: NumPy-specific rules
    * DJ: flake8-django
4. その他
    * RUF: Ruff-specific rules: Ruff独自のルール
    * UP: pyupgrade: 新しいPythonバージョンでの書き方を教えてくれる
    * D: pydocstyle: docstringのスタイルをチェック






## ステージ3: `select=["ALL"]`を設定して、不要なルールを除外する
ステージ2を実施した上で、さらに適用するルールを増やしたい場合は、`select=["ALL"]`を設定して適用しないルールだけを指定するのが簡単です。
適用しないルールを決めるには、後述の`--statistics`オプションが便利です。

なお `select=["ALL"]`を設定すると、Ruffをバージョンアップしたときに、適用されるルールが暗黙的に増えます。CIでRuffを実行する場合は、Ruffのバージョンを指定するなどして、Ruffのバージョンアップによる意図しないエラーが出ないようにしましょう。

>Use ALL with discretion. Enabling ALL will implicitly enable new rules whenever you upgrade.

https://beta.ruff.rs/docs/configuration/#using-pyprojecttoml 引用

# Ruffによる指摘が多い場合
Ruffによる指摘が多い場合は、以下のコマンドラインオプションを利用すると便利です。

## `--statistics`オプションを付けて、除外するルールを決める
`--statistics`オプションを付けると、指摘ごとの件数が表示されます。
指摘件数が多い場合は、ルールの適用を諦めた方がよいかもしれません。

```
$ ruff check ${FILES} --select ALL --statistics
  6	PLR5501	[ ] Consider using `elif` instead of `else` then `if` to remove one indentation level
  8	PLC1901	[ ] `str_object != ""` can be simplified to `not str_object` as an empty string is falsey
 19	PLR2004	[ ] Magic value used in comparison, consider replacing 2 with a constant variable
  3	PLR0911	[ ] Too many return statements (7/6)
  1	PLR0912	[ ] Too many branches (13/12)
  1	PLR0915	[ ] Too many statements (111/50)
 78	BLE001 	[ ] Do not catch blind exception: `Exception`
```

## `--fix`オプションを付けて、一括で修正する
Ruffによる指摘には、自動修正可能な指摘があります。

```
$ ruff check ${FILES} --select ALL --statistics
...
 26	SIM102 	[*] Use a single `if` statement instead of nested `if` statements
  1	SIM103 	[ ] Return the condition `task is None` directly
 24	SIM108 	[*] Use ternary operator `args = parser.parse_args() if arguments is None else parser.parse_args(arguments)` instead of `if`-`else`-block
  6	SIM114 	[ ] Combine `if` branches using logical `or` operator
  1	SIM117 	[*] Use a single `with` statement with multiple contexts instead of nested `with` statements
139	D100   	[ ] Missing docstring in public module
```

`[*]`が付いているのが、修正可能な指摘です。

`--fix`オプションを付ければ、一括で修正することができます。

```
$ ruff check ${FILES} --select SIM102 --fix
```

## `--add-noqa`オプションを付けて、チェック対象から除外する
行末に`# noqa: {code}`を指定すれば、その行だけルール`{code}`のチェック対象から除外されます。

`--add-noqa`オプションを付ければ、一括でチェック対象から除外できます。
指摘項目に対して修正可能な部分は修正して、修正できない部分だけをチェック対象から除外したいときなどに便利です。


# まとめ
Ruffを導入することで、コードの静的解析時間が少し短くなりました（ボトルネックであるPylintが置き換えられなかったため、処理時間は少ししか短くならなかった）。また、チェック項目が増えたので、コードの品質が以前よりも担保されるようになりました。

Ruffはとても便利な静的解析ツールなので、是非利用することをおすすめします。