rcmdnk's blog
Last update

20241110_python_200_200

Pythonのプロジェクト管理ツールをpoetryからuvに移行するようにしたので、 ついでに(?), フォーマッターとlinterもruffを使うように変更していきます。

ruff

ruffもuvと同じくRust製でPythonのlinterやformatterの統合ツールです。 開発元も同じくAstral。

rust製なので圧倒的に速いというのが売りですが、 後発システムの利点でこれまで出ているいろいろなツールの機能を統合しているので これ1つでカバーできる部分が多いのがうれしいところです。

最初はformatterでblackの代わりという感じでしたが、 現在はflake8によるlinter的な機能、またそのplugin関係の機能も追加されています。 さらにはisortのimportの整理する機能も追加されています。

なのでruffで色々置き換えると整理出来るのでいずれ試そうと思ってましたが、 今回のpoetryからuvへの移行を機にやってみることにしました。

これまでの主なやり方

自分で扱ってるレポジトリのほとんどで使ってるPython用ツールとしては以下のものがあります。

  • Black: 全体的なformatter
  • blacken-docs: blackをREADME.mdなどのドキュメント内に書かれたPythonコードに適用するもの
  • autoflake: pyflakesによるチェック項目に対応するものを直すlinter/formatter
  • autopep8: PEP 8に従うformatter
  • isort: import部分を整理するformatter
  • flake8: プラグインの追加で拡張できるlinter
  • bandit: セキュリティチェックツール
  • mypy: 型チェックツール
  • numpydoc: numpydoc形式のdocstringチェックツール

autoflakeやautompep8などと他のツールが重複している部分があったりしますが、 完全に重複しているわけではないのでできるだけ詰め込んでいます。

全部を実行したとしても、結局mypyの型チェックが一番時間がかかるので いくつか削除したとしても全体の実行時間はそれほど得しないので。

flake8に関しては flake8-builtins など14個程度のプラグインを追加して使っています。

これらのツールの設定はすべてpyproject.tomlで管理しています。

flake8はpyproject.tomlでの設定ができませんが、 Flake8-pyproject を使うことでpyproject.tomlでの設定ができるようになります。

banditに関してはしては flake8-bandit というflake8用のプラグインがありますが、これだとFlake8-pyprojectを使ってもpyproject.tomlでの設定ができないので 別途直接使っています。

かなり数があるのでこれらに関する設定だけでpyproject.tomlはかなり長くなってしまいます。

pyproject.tomlの例
pyproject.toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
...

[dependency-groups]
dev = [
  ...
  "black >= 24.3.0",
  "blacken-docs >= 1.16.0",
  "flake8-pyproject >= 1.2.3",
  "flake8-annotations-complexity >= 0.0.8",
  "flake8-bugbear >= 24.0.0",
  "flake8-builtins >= 2.1.0",
  "flake8-comprehensions >= 3.14.0",
  "flake8-debugger >= 4.1.2",
  "flake8-docstrings >= 1.7.0",
  "flake8-executable = 2.1.3",
  "flake8-pep3101 >= 2.0.0",
  "flake8-print >= 5.0.0",
  "flake8-rst-docstrings >= 0.3.0",
  "flake8-string-format >= 0.3.0",
  "pep8-naming >= 0.14.0",
  "pycodestyle >= 2.11.0",
  "autoflake >= 2.2.1",
  "autopep8 >= 2.0.4",
  "isort >= 5.12.0",
  "bandit[toml] >= 1.7.5",
  "mypy >= 1.5.1",
  "numpydoc >= 1.8.0",
  ...
]

[tool.black]
line-length = 79

[tool.autoflake]
remove-all-unused-imports = true
expand-star-imports = true
remove-duplicate-keys = true
remove-unused-variables = true

[tool.autopep8]
ignore = "E203,E501,W503"
recursive = true
aggressive = 3

[tool.isort]
profile = "black"
line_length = 79

[tool.flake8]
# E203 is not PEP8 compliant and black insert space around slice: [Frequently Asked Questions - Black 22.12.0 documentation](https://black.readthedocs.io/en/stable/faq.html#why-are-flake8-s-e203-and-w503-violated)
# E501: Line too long. Disable it to allow long lines of comments and print lines which black allows.
# E704: multiple statements on one line (def). This is inconsistent with black >= 24.1.1 (see ttps://github.com/psf/black/pull/3796)
# W503 is the counter part of W504, which follows current PEP8: [Line break occurred before a binary operator (W503)](https://www.flake8rules.com/rules/W503.html)
# D100~D106: Missing docstrings other than class (D101)
# D401: First line should be in imperative mood
ignore = "E203,E501,E704,W503,D100,D102,D103,D104,D105,D106,D401"
max-complexity = 10
docstring-convention = "numpy"

[tool.bandit]
exclude_dirs = ["tests"]

[tool.mypy]
files = ["src/**/*.py"]
strict = true
warn_return_any = false
ignore_missing_imports = true
scripts_are_modules = true
install_types = true
non_interactive = true

[tool.numpydoc_validation]
checks = [
    "all",   # report on all checks, except the below
    "EX01",  # "No examples section found"
    "ES01",  # "No extended summary found"
    "SA01",  # "See Also section not found"
    "GL08",  # "The object does not have a docstring"
    "PR01",  # "Parameters {missing_params} not documented"
    "PR02",  # "Unknown parameters {unknown_params}"
    "RT01",  # "No Returns section found"
]

...

上記のツールはすべてpre-commitで使用しますが、 pre-commitのシステムにツールを入れてしまうと直接使いたいときにちょっと面倒なので 上のpyproject.tomlにあるように開発環境の方に直接いれるようにしています。

実際にはパッケージの数がかなり多くて大変なので、 pyproject-pre-commit というパッケージのまとめとシステムに入れたツールを直接使うpre-commitのhookをまとめた パッケージを作っています。

これを使うと上記のpyproject.toml

pyproject.toml
1
2
3
4
5
6
7
8
9
10
...

[dependency-groups]
dev = [
  ...
  "pyproject-pre-commit >= 0.2.0",
  ...
]

...

と、1行だけで済むようになります。

ruffの導入

ruffの導入にあたって、代替できるのは 基本的にはmypy以外の部分です。

一番時間がかかるmypyの高速化されたツールが今後期待されますが、一旦それ以外の部分をruffに移行してみます。

あと、blacken-docsのようなドキュメントファイル内に書かれたPythonコードに対してはruff自体では出来ないので これは必要なら別途対応することになります。

ruffはデフォルトでほぼblackと同じような動作をするように作られています1

line-lengthなどもデフォルトでは88でblackに合わせてあります 2

一部違うところは後発の強みを活かして気になる部分を潰している感じのところがほとんどです 3

これを踏まえて 上記と同じような設定にするために、以下のような設定のpyproject.tomlを作ります。

ruff用pyproject.tomlの例
pyproject.toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
...

[dependency-groups]
dev = [
  ...
  "pyproject-pre-commit[ruff] >= 0.3.2",
  ...
]

[tool.ruff]
line-length = 79

[tool.ruff.lint]
## select = ["ALL"]
## select = ["E4", "E7", "E9", "F"]  # default, black compatible
select = [  # similar options to black, flake8 + plugins, isort etc...)
  #"E4",  # Import (comparable to black)
  #"E7",  # Indentation (comparable to black)
  #"E9",  # Blank line (comparable to black)
  "F",   # String (comparable to black)
  "I",   # Import order (comparable to isort)
  "S",   # flake8-bandit (comparable to bandit)
  "B",   # flake8-bugbear
  "A",   # flake8-builtins
  "C4",   # flake8-comprehensions
  "T10",  # flake8-debugger
  "EXE",  # flake8-executable
  "T20", # flake8-print
  "N", # pep8-naming
  "E", # pycodestyle
  "W", # pycodestyle
  "C90", # mccabe
]

ignore = [
 "E203", # Not PEP8 compliant and black insert space around slice: [Frequently Asked Questions - Black 22.12.0 documentation](https://black.readthedocs.io/en/stable/faq.html#why-are-flake8-s-e203-and-w503-violated)
 "E501", # Line too long. Disable it to allow long lines of comments and print lines which black allows.
# "E704", # NOT in ruff. multiple statements on one line (def). This is inconsistent with black >= 24.1.1 (see ttps://github.com/psf/black/pull/3796)
# "W503", # NOT in ruff. is the counter part of W504, which follows current PEP8: [Line break occurred before a binary operator (W503)](https://www.flake8rules.com/rules/W503.html)
 "D100", "D102", "D103", "D104", "D105", "D106", # Missing docstrings other than class (D101)
 "D401", # First line should be in imperative mood
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]

[tool.ruff.lint.mccabe]
max-complexity = 10

[tool.ruff.format]
# quote-style = "single"
docstring-code-format = true

[tool.mypy]
files = ["src/**/*.py"]
strict = true
warn_return_any = false
ignore_missing_imports = true
scripts_are_modules = true
install_types = true
non_interactive = true

[tool.numpydoc_validation]
checks = [
    "all",   # report on all checks, except the below
    "EX01",  # "No examples section found"
    "ES01",  # "No extended summary found"
    "SA01",  # "See Also section not found"
    "GL08",  # "The object does not have a docstring"
    "PR01",  # "Parameters {missing_params} not documented"
    "PR02",  # "Unknown parameters {unknown_params}"
    "RT01",  # "No Returns section found"
]

...
  • パッケージリストはpyproject-pre-commit[ruff]に変更
    • pyproject-pre-commitをアップデートして[ruff]のextra指定でruffもインストールされるようにした。
  • tool.ruffの設定
    • 詳しくはRulesを見ながら必要なものを追加。
    • 特に[tool.ruff.lint]の所でflake8のプラグイン関連の設定を追加。あとIでisort互換の機能なので追加。
  • もとの設定では[tool.bandit]tests以下をすべてexcludeしていたが、一旦S101(assertの使用)だけを除外するように。
  • numpydoc_validationに関しては、一応ruffの中でdocstringのチェックはあるが、flake8でもチェックがあり、それで足りないものとして入れていたので一旦残してく。(他の機能を有効にしたり、設定次第である程度大丈夫そうなら外しても良いかも)

全体として設定項目が大きく減ってるわけではありませんが、 依存をruffに統合することで、整理はできていると思います。

flake8のプラグイン周りでruffに統合されていないものもいくつかありますが、 必須というほどでも無いのでとりあえずそのあたりは別途入れたりはせずにしてあります。

ruffを使うにあたって、 pyproject-pre-commit もアップデートしてpre-commitのhookに以下のようなものを追加しています。

  • ruff-lint-diff: ruff check -diffの結果を見せる
  • ruff-lint: ruff check -fixを実行する
  • ruff-format-diff: ruff format -diffの結果を見せる
  • ruff-format: ruff format -fixを実行する

black等でもやったように最初にdiffを実行して結果が変わる際にはその差分を表示してから 実際に変更する処理を行えるようにしてあります。

すべてlanguagesystemで開発環境にインストールしたruffを使います。

これで、実際に使うpre-commitのpython関連の設定は以前は

.pre-commit-config.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
repos:
  - repo: https://github.com/rcmdnk/pyproject-pre-commit
    rev: v0.2.5
    hooks:
      - id: black-diff
      - id: black
      - id: blacken-docs
      - id: autoflake-diff
      - id: autoflake
      - id: autopep8-diff
      - id: autopep8
      - id: isort-diff
      - id: isort
      - id: flake8
      - id: bandit
      - id: mypy
      - id: numpydoc-validation

だったものが

.pre-commit-config.yaml
1
2
3
4
5
6
7
8
9
10
repos:
  - repo: https://github.com/rcmdnk/pyproject-pre-commit
    rev: v0.3.2
    hooks:
      - id: ruff-lint-diff
      - id: ruff-lint
      - id: ruff-format-diff
      - id: ruff-format
      - id: mypy
      - id: numpydoc-validation

となりすっきりしました。

blacken-docsのようなドキュメントファイル内のコードを整形するものはruffには含まれていないので これも加えても良いかもしれません(それほど重要視してなかったので一旦外しています。)

追記: 2024/11/11

もともとこれ以外に一般的にレポジトリに必要なものとして mdformat をpre-commitをい入れていました。 hookもmdformatmdformat-check(チェック用 4)が用意されています。

mdformatはmarkdownのフォーマッターで、 プラグインによって様々な機能を追加することが出来、その中に mdformat-ruffというコードブロックを ruffにより整形するためのプラグインがあります。

これを使えば、ドキュメント内のコードもruffで整形することが出来ます。

mdformat-blackというblack用のプラグインもありますが、 別途blacken-docsを使っていたのは多分その時点でこの辺のことをちゃんと把握してなかったためだけのことかと。。。

[ruff]のextra指定でmdformat-ruffもインストールされるようにしたので、

.pre-commit-config.yaml
1
2
      - id: mdformat-check
      - id: mdformat

を入れて、mdformatも実行するようにすれば、ドキュメント内のPythonコードもruffで整形することが出来ます。

追記ここまで

実際いろいろやってみての感想

まずはblackなどの設定を残したまま上のようなruffの設定を追加して pre-commitを実行してどう変わるか、試したりしながら進めます。

基本的にはほとんど変化なしでいけますが、 一部 Known Deviations from Black にあるような部分でruffで変更したあとで再びblackが戻す、というような部分があり、 両方を有効にしたままというのは出来ない状態になるものもありました。

今あるコード、というのはそもそもこれまでチェックを通してきたものなので ruffだけで必要な部分をカバーできているかどうか、を確認するのは難しいところですが、 一旦blackやflake8のチェックは外して、 設定は残しておいて、たまに実行してチェックしてみるようにしていこうと思います。

ALLの実行

homebrew-file のレポジトリで、ruffのselectALLを指定してすべてのチェックを有効にした状態で コードを直してみました。

最終的に自分的にそこまで必要ないと思う部分や このレポジトリ特有の事情で外したものなどをまとめると以下のようになりました。

pyproject.toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[tool.ruff.lint]
select = ["ALL"]

ignore = [
 "E501", # Line too long. Disable it to allow long lines of comments and print lines which black allows.
 "D100", "D102", "D103", "D104", "D105", "D106", "D107", # Missing docstrings other than class (D101)
 "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible. Ignoring `one-blank-line-before-class`.
 "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible. Ignoring `multi-line-summary-second-line`.
 "C901", # Complex function
 "S603", # `subprocess` call: check for execution of untrusted input
 "S607", # Starting a process with a partial executable path
 "COM812", "ISC001", # The following rules may cause conflicts when used with the formatter: `COM812`, `ISC001`. To avoid unexpected behavior, we recommend disabling these rules, either by removing them from the `select` or `extend-select` configuration, or adding them to the `ignore` configuration.
 "ERA001", # Remove commented-out code
 "G004", # Logging statement uses f-string
 "SLOT000", # Subclasses of `str` should define `__slots__`
 "FBT001", "FBT002", "FBT003", # Boolean-typed positional argument in function definition
 "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR2004",
 "TCH001", "TCH003", # Move standard library import into a type-checking block
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "S603", "S607", "C901", "FBT001", "FBT003", "ARG001", "ARG005"]
"docs/**" = ["ALL"]

流石に結構大変でしたが、頑張って直してみました。(かなりの箇所で手直しする必要がありました。)

D203などはALLを指定する際にはD211と競合で自動的に無効になるものですがwarningが出るので無効にしています。

TCH001, TCH003は変更は簡単なのですが、 このレポジトリのsrc以下のファイルは最終的にbin/brew-fileに統合するため、 その際にimport関連の統合が面倒になるためTYPE_CHECKINGは使わないようになっているので無効にしています。

selectではEなどとするとEXXXのすべてが有効になりますが、 ignoreの設定ではEとか書いてもそんな項目ない、というエラーが出るのでそこがちょっと面倒かな、と思いました。

ALLを指定しなければ通常のignoreでそのような使い方をする必要はありませんが、 per-file-ignoresの方では結構そうやって指定したいこともあるのでは、と。

Issuesでopenになっているものだけでも1000個以上あって このようなことが上がってるかをチェックすること自体をすぐにチェックできなかったのですが、 必要そうならそのうちIssueを立てたりPRを送ったりしてみようかと。

docsにあるようにALLを使ってすべてを無効にすることは可能です。

ruffへの移行に伴って変更したスタイル

以下の設定を追加。

pyproject.toml
1
2
3
4
5
[tool.ruff.lint.flake8-quotes]
inline-quotes = "single"

[tool.ruff.format]
quote-style = "single"

quoteをsingleに統一しました。

blackでは変更不能で唯一不満があった部分がquoteをdouble quoteにすることでしたが、 これをruffではsingleに出来ます。

USキーボードを使っているとdouble quoteはShift-なので single quoteを書くのに比べて余計な手間がかかるだけなので ざっと自分でコードを書いてるときには基本的にsingle quoteで書いています。

ちゃんとしたコードでも後でのblackが勝手にdouble quoteにしてくれるので single quoteで書くことがほとんどでした。

なのでこれからは最終的なものもsingle quoteに統一することにしました。

Sponsored Links
  1. Black compatibility

  2. Configuring Ruff

  3. Known Deviations from Black

  4. 現在mdformatには--checkという実際には変更せずに変更があるかどうか、をチェックするだけのオプションがありますが、これだとdiffは表示されません。

    これに関しては結構長いこと開いているPRがありますが、まだマージされていません。 ただちょうど先月あたりにチェックが開始された感じなのでもしかするともうすぐマージされるかもしれません。

    現状チェック用なだけですが、pre-commitを実行した際、mdformatだけだと変更があっても正常終了して変更があったかもわからないのでチェックも入れるようにしています。

Sponsored Links

« Pythonのプロジェクト管理ツールをPoetryからuvに移行 Homebrew-fileへwhalerew, VSCodeの拡張機能管理を追加 »

}