pre-commitで、コミット時にフォーマットと型チェックを行う

## 対象読者

- Python のコードは書けるようになったので品質をあげたいと思っている人
- GitHubを日常的に使っている人
- コードの品質に悩む管理者

## なぜやろうと思ったのか

私は個人で小さな開発を請け負うことが多かったのですが、たまに比較的大きな案件を請け、協力会社にコーディングを発注することがありました。その際に、コードの品質がバラバラでその後の変更やリファクタリングに苦労した(現在も)経験があります。
そこでコード品質を担保するためのツールに興味を持ち調べる中で、Ruffとmypyの2つを知りました。
Ruffは高速なフォーマッタおよびリンターで、mypyはpythonの型チェックを行うものです。

Ruffに関しては自分のVScodeでは設定しましたが、他の人が書いたコードを触った時に自動でフォーマットされて変更箇所を管理するのが大変になりました。

https://qiita.com/The_Past_of_Dice/items/07f69b314ebea782f325

そこで、開発者がコードをcommitする時とプルリクの時にRuffとmypyで確認をすれば、コードの質を担保できるのではないかと思い取り組みました(今回はcommit時にRuffとmypyを実施)。

## 今回のゴール

ローカルでの作業をGitHubにcommitする時、Ruffでの静的解析とmypyの型チェックを行って問題点があればエラーを返してcommitできないようにする。

## pre-commit

GitHub Actionsでプルリク時にチェックする方法もありますが、もっと手前で気がつけるようにしたり、GitHub Actionsとのダブルチェック体制が良いのではないかと思い調べたところ、pre-commitで出てきました。

ローカルの環境に依存するのはよくないですが、私の理解だとuvなど仮想環境系のツールを使えば、ある程度は揃うんじゃないかと思っています。
どうなんだろ。

### pre-commitを導入する
```bash
uv add pre-commit

uv sync #同期を忘れない
```

### .pre-commit-config.yamlの作成

```yaml:.pre-commit-config.yaml
repos:
  - repo: https://github.com/charliermarsh/ruff-pre-commit
    rev: v0.0.280  # stableを指定すると最新のバージョンになりますが、バージョン固定が推奨
    hooks:
      - id: ruff

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.4.1
    hooks:
      - id: mypy
        args: ["--strict"]

```

### Gitフックのインストール
下記コマンドでGitフックのインストールを行います。

```bash
uv run pre-commit install
```

Gitフックって何？という話ですが、
特定のGit操作（コミットやプッシュなど）に対して自動的に実行されるスクリプトを設定することだそうです。

上記コマンドを実行すると`.git/hooks/`ディレクトリが作成され、そこに自動的に処理される内容が記載されています。
このディレクトリは表示されないことが多いので、本当に作成されているか不安な時は`ls -a`などで確認してみると良いと思います。
`.git/hooks/`はローカル設定が書かれているので、GitHubにあげる必要はありません。
なので、開発環境個々で設定ないといけないので、全体の品質を上げるには個別に操作してもらう必要が結局ありそうです。

## 実行してみて

さて実際に今回の設定分をcomitしてみましょう。
```bash
$ git commit
[INFO] Initializing environment for https://github.com/charliermarsh/ruff-pre-commit.
[INFO] Initializing environment for https://github.com/pre-commit/mirrors-mypy.
[INFO] Installing environment for https://github.com/charliermarsh/ruff-pre-commit.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
[INFO] Installing environment for https://github.com/pre-commit/mirrors-mypy.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
ruff.................................................(no files to check)Skipped
mypy.................................................(no files to check)Skipped
```

うまく動いているけど`(no files to check)Skipped`と表示されていて、結局何もチェックされてない？
と思い調べてみましたら、基本的にステージング(git addされている)部分だけチェックするようなので、今回はライブラリ関連やyamlファイルしかなかったのでチェックされなかったようです。

ということで、pre-commitを手動で実行して確認してみます。

```bash
$ uv run pre-commit run --all-files
ruff.....................................................................Passed
mypy.....................................................................Failed
- hook id: mypy
- exit code: 1

pytest/src/converter_4.py:4: error: Function is missing a type annotation  [no-untyped-def]
pytest/src/converter_4.py:9: error: Function is missing a type annotation  [no-untyped-def]
pytest/src/converter_4.py:49: error: Returning Any from function declared to return "str"  [no-any-return]
pytest/src/converter_4.py:49: error: Call to untyped function "capitalize_after_digits" in typed context  [no-untyped-call]
pytest/src/converter_4.py:57: error: Returning Any from function declared to return "str"  [no-any-return]
pytest/src/converter_4.py:57: error: Call to untyped function "capitalize_after_digits" in typed context  [no-untyped-call]
pytest/src/converter_1.py:5: error: Function is missing a type annotation  [no-untyped-def]
hello.py:1: error: Function is missing a return type annotation  [no-untyped-def]
hello.py:1: note: Use "-> None" if function does not return a value
hello.py:6: error: Call to untyped function "main" in typed context  [no-untyped-call]
pytest/tests/test_converter_4.py:7: error: Module has no attribute "fixture"  [attr-defined]
pytest/tests/test_converter_4.py:8: error: Function is missing a return type annotation  [no-untyped-def]
pytest/tests/test_converter_4.py:223: error: Module has no attribute "mark"  [attr-defined]
pytest/tests/test_converter_4.py:236: error: Function is missing a type annotation  [no-untyped-def]
pytest/tests/test_converter_3.py:7: error: Module has no attribute "fixture"  [attr-defined]
pytest/tests/test_converter_3.py:8: error: Function is missing a return type annotation  [no-untyped-def]
pytest/tests/test_converter_3.py:31: error: Module has no attribute "mark"  [attr-defined]
pytest/tests/test_converter_3.py:38: error: Function is missing a type annotation  [no-untyped-def]
pytest/tests/test_converter_1.py:6: error: Function is missing a return type annotation  [no-untyped-def]
pytest/tests/test_converter_1.py:6: note: Use "-> None" if function does not return a value
pytest/tests/test_converter_1.py:8: error: Call to untyped function "convert_to_snake_case" in typed context  [no-untyped-call]
pytest/tests/test_converter_2.py:7: error: Module has no attribute "fixture"  [attr-defined]
pytest/tests/test_converter_2.py:8: error: Function is missing a return type annotation  [no-untyped-def]
pytest/tests/test_converter_2.py:30: error: Function is missing a type annotation  [no-untyped-def]
Found 22 errors in 7 files (checked 11 source files)
```

私はVScodeで保存時にRuffを使用して、フォーマットしているので、Ruffのエラーはありませんでした。VSCodeとかで設定していない人だと、エラーの山になりそうです。
mypyは山のようにエラー出てますね。
どの箇所でどのようなエラーが出ているかは書いてあるので、
エラー内容を読みながら、型の修正をしていきます。

## pytestとmypyの相性が悪い

基本的なエラーは型指定をして解消しましたが、一部どうしても解消しないエラーがありました。
pytestのデコレータ（@fixture, @mark）の部分で、mypy が型を認識できていないことが原因のようです。

調べたり生成AIに聞いたりして、mypyの例外を作るとかあったのですが私の環境では正常に無視することができませんでした。
```ini:mypy.ini
; このやり方はうまくいきませんでした。
[mypy]
ignore_missing_imports = True

[mypy-pytest]
ignore_missing_imports = True


[mypy-pytest.*]
ignore_missing_imports = True
```

結局、この方法は努力のないなくうまくいきませんでした。

なので、望ましい形ではないですが該当箇所にコメントでmypyの例外を入れることにしました。

```python:pytest
@pytest.fixture  # type: ignore[attr-defined, misc]
```

これで一旦エラーは解消できました。

```bash
$ uv run pre-commit run --all-files
ruff.....................................................................Passed
mypy.....................................................................Passed
```

しかし、これを多用してしまうとmypyの意味がなくなってしまう気もするので本当は統一した基準を入れるのが理想だと思います。
設定ファイルで、環境全体で特定のエラーを回避したり、pytestとmypyのその辺は解決したらまた記事にします。
最後歯切れが悪くてすみません。

自分で作ったルールですが、commit時にチェックをpassするか緊張するようにしました。
今までの自分の開発環境が無法地帯すぎました。
個人開発や学習時でも癖づけるのが大切かなと思います。

この記事の内容は下記リポジトリで公開しています。

https://github.com/boss-kasai/LearningPythonLab