Formatter & Lint チェックのCIを作成してみた。

## これは何？

- VSCodeでのPythonのLint、Formatterの設定方法
- GitHub ActionsでのLintチェックの導入方法
- `pre-commit` を使ったLintチェックの設定方法

#### Goal (Where)

- チーム全員が共通のコードフォーマットにて開発できる。
- 低レベルなバグや不要なコードに気が付きやすくする。
- そのための方法はできるだけ自動化されている。

#### 目的 (Why)

- 静的解析を行うことで、低レベルなバグやミスの発生を抑える。
    - 低レベルなバグ: 未使用のimportや構文ミスなど
- チームの全員が共通のルールでコードを書くことにより、コードチェックのコストを減らす。
- コードフォーマットについて考える、作業する工数を減らす。

##### 環境
- Python 3.9 (Django)
- VSCode

## 前提

Lintとは？
> **lint** とは、主に[C言語](https://ja.wikipedia.org/wiki/C%E8%A8%80%E8%AA%9E)の[ソースコード](https://ja.wikipedia.org/wiki/%E3%82%BD%E3%83%BC%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89)に対し、[コンパイラ](https://ja.wikipedia.org/wiki/%E3%82%B3%E3%83%B3%E3%83%91%E3%82%A4%E3%83%A9)よりも詳細かつ厳密なチェックを行なうプログラムである。静的解析ツールとも呼ばれる。以下に挙げるように、コンパイラではチェックされないが、[バグ](https://ja.wikipedia.org/wiki/%E3%83%90%E3%82%B0)の原因になるような曖昧な記述についても警告される。[構文](https://ja.wikipedia.org/wiki/%E6%A7%8B%E6%96%87%E8%A7%A3%E6%9E%90)（シンタックス）レベルのチェックだけでなく、[意味](https://ja.wikipedia.org/wiki/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0%E6%84%8F%E5%91%B3%E8%AB%96)（セマンティクス）レベルのチェックまで実行するものもある。
> Wikipedia lint (https://ja.wikipedia.org/wiki/Lint)

フォーマッターとは？
> [**コンピュータプログラム**](https://e-words.jp/w/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0.html#Section_%E3%82%B3%E3%83%B3%E3%83%94%E3%83%A5%E3%83%BC%E3%82%BF%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0)の開発ツールの一種で、開発者が[**プログラミング言語**](https://e-words.jp/w/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E8%A8%80%E8%AA%9E.html)で記述した[**ソースコード**](https://e-words.jp/w/%E3%82%BD%E3%83%BC%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89.html)を、一定のルールに従って整形してくれるものをフォーマッターという。
同じ動作を行う[**プログラム**](https://e-words.jp/w/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0.html)でも、行頭にどのくらい[**スペース**](https://e-words.jp/w/%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9.html)を空けるかなど、書き方には無数のバリエーションがあり、人によって好みの書き方が異なることも多い。フォーマッターは[**コード**](https://e-words.jp/w/%E3%82%B3%E3%83%BC%E3%83%89.html)の書き方に一定の決まり（[**コーディング規約**](https://e-words.jp/w/%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0%E8%A6%8F%E7%B4%84.html)）を設け、これに従って[**コード**](https://e-words.jp/w/%E3%82%B3%E3%83%BC%E3%83%89.html)の意味を変えずに[**書式**](https://e-words.jp/w/%E6%9B%B8%E5%BC%8F.html)を調整する。
> IT用語辞典 フォーマッター ([https://e-words.jp/w/フォーマッター.html](https://e-words.jp/w/%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%82%BF%E3%83%BC.html#:~:text=%E3%82%B3%E3%83%B3%E3%83%94%E3%83%A5%E3%83%BC%E3%82%BF%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0%E3%81%AE%E9%96%8B%E7%99%BA%E3%83%84%E3%83%BC%E3%83%AB,%E3%81%8C%E7%95%B0%E3%81%AA%E3%82%8B%E3%81%93%E3%81%A8%E3%82%82%E5%A4%9A%E3%81%84%E3%80%82))

- Lintは元々C言語用に作成された静的解析ツールであり、現在は他言語のコードに対して構文チェックや静的解析を行なうプログラムも含めて lintと呼ぶ。
- 意味上は、lintはコード構文などの静的解析を行うことで不要なコードの検知や低レベルのバグの防止を行い、フォーマッターはコードフォーマットを整形する役目を持つ。

簡単に調べてみましたが、一言で `lint` と言ってもパッケージによって実行できる範囲は違うため、あまり言葉にとらわれず、必要な機能を軸に考えた方がいい気もしました。


## 行ったこと

- 設定においては大きく2つを行いました。
    - VSCodeにおける設定
    - GitHub Actionsでの設定

- 設定したパッケージ
    - black: PEP8に準拠したフォーマッター (https://black.readthedocs.io/en/stable/)
    - flake8: PEP8に準拠したリンター (https://flake8.pycqa.org/en/latest/)
    - isort: PEP8に準拠したimportのソートを行うフォーマッター (https://pycqa.github.io/isort/)

基本的には全員が最初から、IDEにて共通の設定をしていれば問題ない気もしましたが、プロジェクトが進んでいく間に、未フォーマットのコードが紛れ込む可能性もありそのため、GitHub ActionsでもLintチェックを行うこととしました。

### VSCodeでの設定
上記の3つは全て、`VSCode`のExtensionが用意されているため、もしチームが`VSCode`を使用していればすぐに導入することができるようになります。未導入の人には導入を促すポップアップが出るように、rootディレクトリに以下ファイルを作成、設定しました。
```json:.vscode/extensions.json
{
  "recommendations": [
    "ms-python.python",
    "ms-python.flake8",
    "ms-python.black",
    "ms-python.vscode-pylance",
    "ms-python.isort"
  ]
}
```

またExtensionsの設定を `.vscode/settings.json` に設定しています。
Djangoのため、いくつかのファイルを対象外としています。
```json:.vscode/settings.json
{
  "python.languageServer": "Pylance",
  "[python]": {
    "editor.defaultFormatter": "ms-python.black-formatter",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.organizeImports": "explicit"
    }
  },

  "black-formatter.args": ["--line-length", "120", "--force-exclude", "(migrations|deploy)"],
  "isort.args": ["--profile", "black", "--extend-skip", "migrations", "--extend-skip", "deploy"],
  "flake8.args": [
    "--max-line-length=120",
    "--ignore=E203,W503,W504",
    "--extend-exclude=migrations,deploy"
  ]
}

```

### GitHub Workflowの設定

今回はPRの作成、更新、再開に合わせて`VSCode`の設定と同じフォーマットチェックをし、フォーマット整形すべき箇所があれば、 FAILEDさせるようにしたいと思います。
```yml: .github/workflows/lint.yml
name: Lint

on:
  workflow_dispatch:
  pull_request:
    branches: []
    types: [opened, synchronize, reopened]

jobs:
  build:
    runs-on: ubuntu-20.04
    env:
      MAX_LINE_LENGTH: 120
    strategy:
      max-parallel: 4

    steps:
      - name: Set up Python 3.9.6
        uses: actions/setup-python@v5
        with:
          python-version: '3.9.6'
      - name: Install Dependencies
        run: |
          python -m pip install --upgrade pip
          pip install black flake8 isort

      - name: Checkout to PR branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.ref }}
          fetch-depth: 0

      - name: Fetch base branch
        run: git fetch origin ${{ github.base_ref }}:${{ github.base_ref }}

      - name: Check diff files and lint
        run: |
          DIFF_FILES=$(git diff --name-only --diff-filter=d ${{ github.base_ref}} HEAD | (grep '\.py$' || true))
          if [ -z "$DIFF_FILES" ]; then
          echo "No files to lint"
          exit 0
          fi
          echo "Running flake8 lint check for only changed files"
          for FILE in $DIFF_FILES; do
            flake8 $FILE || (echo "flake8 found issues in $FILE" && exit 1)
          done

          echo "Running black format check for all files"
          black . --check \
          --line-length=${MAX_LINE_LENGTH}  \
          --force-exclude="(migrations|deploy)" || (echo "black found issues." && exit 1)

          echo "Running isort format check for all files"
          isort . --check --profile="black" \
          --extend-skip="migrations" \
          --extend-skip="deploy" || (echo "isort found issues." && exit 1)

```

`flake8` の設定は別でファイルを用意して設定を行いました。

```ini:.flake8
[flake8]
max-line-length = 120
extend-ignore = E203,W503,W504
extend-exclude = node_modules,__init__.py,migrations,deploy,.venv

```

### 検証

設定がうまくできているかの検証です。以下2点で問題ないことを確認しました。

- VSCodeではファイル保存時に、自動的に `black` , `isort` によるフォーマットがされること。
    - 設定した対象外ファイルではフォーマットがされないこと。
- GitHub Actionsにて動作していること。

## 補足
場合によっては、`GitHub Actions`を使用できないこともあると思います。自分のプロジェクトでも一時的に無料枠がなくなり、月を跨ぐまで使用できなくなる事態が起こりました。その際は、localでのcommit時に動かす `pre-commit` が解決策となりました。

### パッケージインストール

チーム開発で他の人とバージョンを合わせるため `requirements.txt` ファイルに記載することが多いかと思います。

```txt: requirements.txt
# Formatting and linting
black~=24.4.0
flake8~=7.0.0
isort~=5.13.0
```
```bash
pip install -r requirements.txt
```

### pre-commit　スクリプト作成、設定
- 以下ファイルを`root`ディレクトリに作成し、 `./pre-commit install` を実施。
    - その後は`git`にてコミットコマンドを実行するたびに、スクリプトが実行され、成功したら `commit` 、失敗したら `commit` しないという動作が実現できます。

```sh: pre-commit.sh
#!/usr/bin/env bash

# This is a pre-commit hook that validates code formatting.
#
# Install this by running the script with an argument of "install",
# which installs a symlink to .git/hooks/precommit:
# $ ln -s ../../hooks/pre-commit .git/hooks/pre-commit

root="$(git rev-parse --show-toplevel 2>/dev/null)"

set -e

# Some sanity checking.
[[ -n "$root" ]]

# Installation.
if [[ "$1" == "install" ]]; then
    hook="$root"/.git/hooks/pre-commit
    if [[ ! -e "$hook" ]]; then
        ln -s ../../pre-commit "$hook"
        echo "Installed git pre-commit hook at $hook"
    else
        echo "Hook already installed"
    fi
    exit
fi

save_merge_files() {
    # Move MERGE_[HEAD|MODE|MSG] files to the root directory, and let `git stash push` save them.
    for f in "$root"/.git/MERGE_*; do
        if [[ -e "$f" ]]; then
            t=$(basename $f)
            mv -f "$f" "$t"
        fi
    done
}

restore_merge_files() {
    # Moves MERGE files restored by `git stash pop` back into .git/ directory.
    for f in MERGE_*; do
        if [[ -e "$f" ]]; then
            if [[ -e "${root}/.git/${f}" ]]; then
                echo "Failed to restore ${f}. File already exists" 1>&2
            else
                mv -f "$f" "${root}/.git/${f}"
            fi
        fi
    done
}

# Check formatting.
echo "Check formatting"
stashfile=$(mktemp .pre-commit.stashXXXXXX)
trap 'set +e;git stash pop -q; rm -f "$stashfile"; restore_merge_files' EXIT
save_merge_files
git stash push -k -u -q -m "pre-commit stash"

len=120
if ! errors=($(black --line-length=${len} --force-exclude="(node_modules|deploy|migrations)" --check .)); then
    echo "Formatting errors found."
    exit 1
fi

if ! errors=($(isort . --check --profile="black" --extend-skip=deploy --extend-skip=migrations)); then
    echo "Import order errors found."
    exit 1
fi

if ! errors=($(flake8)); then
    echo "flake8 found issues."
    exit 1
fi

echo "Run pre-commit checks"

check() {
    msg="$1"
    shift
    if "$@"; then
        echo "$msg: OK"
    else
        res=$?
        echo "-----------------------"
        echo "$msg: FAILED ($res)"
        echo "  $@"
        exit $res
    fi
}
```

---
### 最後に
ここまで読んでいただきありがとうございました。
`Lint`や`Formatter`を初めて設定してみましたが、自動化により細かいミスを防ぐことができ、また開発者も改行などの細かいコーディング規約を意識することなく、コーディングできるというメリットがあると思います。
他の言語でも基本的な設定方法は同じかと思うので、今後はかならず実施していきたいと思います。