pre-commitでコミット時にコードの整形やチェックを行う

19 min read読了の目安(約17100字

この記事は?

pre-commitというフレームワークを用いて,コミット時にコードの整形や型チェック,コードの複雑度の計算などを行えるようにしました.その際に自分が調べた内容をこちらに書いておきます.言語はpythonを想定していますが,pre-commitでは他の言語のhookスクリプトも使用することができます.至らぬ点が多々あると思いますが,コメントにてご指摘いただけると幸いです.

この記事でわかること

  • pre-commitのインストール / 使い方
  • GitHubで公開されているhookの紹介
    • pre-commit-hooks
    • isort
    • black
    • mypy
    • markdownlint
    • flake8
  • flake8の拡張機能の紹介
    • flake8-bugbear
    • flake8-builtins
    • flake8-eradicate
    • pep8-naming
    • flake8-expression-complexity
    • flake8-cognitive-complexity

背景

pythonのコードを書く際に,blackやisortを用いてソースコードをフォーマットしたり,flake8やmypyでコードをチェックしている方も多いと思います.
自分はVSCodeを使っていて,自動でフォーマットするような設定にしていましたが,ある日を境にisortがうまく動かなくなってしまいました(参考).
isortを逐一実行するのは面倒なので,commit時に勝手に実行されてくれたら良いなと思い,調べてみたところpre-commitというパッケージを発見しました.

pre-commitとは

Gitのpre-commit hookを簡単に管理できるフレームワークです.複数の言語のhookに対応しています.

インストール

conda, pipでインストールできます.Macを使用している場合はbrewでもインストール可能です.

$ conda install -c conda-forge pre-commit
$ pip install pre-commit
$ brew install pre-commit

インストールが完了したら,以下のコマンドが実行されることを確認してください.

$ pre-commit --version
pre-commit 2.7.1

使い方

ターミナルにて,pre-commit hookを適用したいGit管理のディレクトリに移動したら,以下のコマンドを実行してください.

$ pre-commit sample-config > .pre-commit-config.yaml

このコマンドによって,pre-commitの設定ファイルを作成することができます.
設定ファイルの名前は.pre-commit-config.yamlとなるようにします.
中身を見てみると以下のようになっているはずです.

.pre-commit-config.yaml
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.4.0
    hooks:
    -   id: trailing-whitespace
    -   id: end-of-file-fixer
    -   id: check-yaml
    -   id: check-added-large-files

repos

repo, rev, hooksの3つを記したrepositoy mappingのリストになっています.

repo

hookのためのコードが,どのGitHubレポジトリからのものかを記載します.

rev

GitHubのレポジトリのブランチ名,もしくはリリースのバージョンかタグを指定します.

hooks

どのhookを使うか(hook mapping)のリストを記したものです.
hook mappingは,どのhookかを示すidとその他のオプションのパラメータで記述します.
その他のパラメータについては,公式ドキュメントを参照してください.

実際にpre-commitを使用してみる

Gitのhookスクリプトをセットアップするために以下のコマンドを実行します.

$ pre-commit install
pre-commit installed at .git/hooks/pre-commit

インストールが完了したら,コミットしてみます.

$ git add .pre-commit-config.yaml
$ git commit -m "pre-commit"
[INFO] Initializing environment for https://github.com/pre-commit/pre-commit-hooks.
[INFO] Installing environment for https://github.com/pre-commit/pre-commit-hooks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed

こんな感じでhookスクリプトが実行されたのが確認できたと思います.
一度セットアップが完了したディレクトリでは,今後コミットの前にpre-commit installを実行する必要はありません.
今回はhookが全てpassしていたのでそのままコミットができましたが,hookが失敗した場合には該当箇所の修正(フォーマッタに関するhookは自動で修正してくれるので手動での修正は不要)を行ってから,git addgit commitを実行する必要があります,

GitHubで公開されているhookの紹介

GitHubでは様々なhookが公開されています.
使用可能なhookの一覧は,こちらを参照してください.
今回は個人的に使えると思ったhookの紹介と,それらを使用できるように設定を変更していきます.

なおlocal hookに関しては,公式ドキュメント,もしくはPython製のツールpre-commitでGitのpre-commit hookを楽々管理!!を参照してください.

pre-commit-hooks

sample-configにも使われていたpre-commit-hooksでは,様々なhookが提供されています.

  • check-added-large-files
    容量が大きいファイルがコミットされるのを防ぐ
  • check-json
    jsonファイルのsyntaxをチェック
  • check-toml
    TOMLファイルのsyntaxをチェック
  • check-xml
    xmlファイルのsyntaxをチェック
  • check-yaml
    yamlファイルのsyntaxをチェック
  • debug-statements
    debuggerのインポートやpython3.7以上で用いられるbreakpoint()があるかどうかを確認する
  • detect-aws-credentials
    AWSのcredentialファイルがあるかどうかを確認.--allow-missing-credentialsオプションを追加することで,credentailファイルがない場合でもhookが通るようになる
  • detect-private-key
    秘密鍵があるかどうかをチェックする
  • end-of-file-fixer
    ファイルの最後が改行で終わっているかどうかを確認する
  • no-commit-to-branch
    特定のブランチに直接コミットできないようにする.args: [--branch, branch_name]を指定することで,どのブランチにコミットできないようにするかを設定できる.デフォルトでは,masterブランチに直接コミットができないようになる.
  • pretty-format-json
    jsonファイルの構文チェックとフォーマットが行える.--autofixオプションを追加することで,自動でフォーマットしてくれる.

以上のhookを追加した設定が以下です.

- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v3.3.0
  hooks:
  - id: check-added-large-files
  - id: check-json
  - id: check-toml
  - id: check-xml
  - id: check-yaml
  - id: debug-statements
  - id: detect-aws-credentials
    args: [--allow-missing-credentials]
  - id: detect-private-key
  - id: end-of-file-fixer
  - id: no-commit-to-branch   # to protect specific branches from direct checkins.
    args: [--branch, master]
  - id: pretty-format-json
    args: [--autofix]

isort

import文を並び替えるためのパッケージです.今回はblackというフォーマッターと併用するのですが,isortblackのimport文の整形は,デフォルトの設定のままだと競合してしまいます.これは--profile blackという引数を追加することで解決できます.(参考)

- repo: https://github.com/pycqa/isort
  rev: 5.5.2
  hooks:
    - id: isort
      args: ["--profile", "black"]

black

pythonのフォーマッターです.他のフォーマッターよりも制限が厳しく,整形後はほとんどコードが一意に決まるのが特徴です.詳しくは,もうPythonの細かい書き方で議論しない。blackで自動フォーマットしようが参考になります.

- repo: https://github.com/psf/black
  rev: stable
  hooks:
  - id: black
    language_version: python3

mypy

pythonではバージョン3.5から型アノテーションという機能が追加されました.この型アノテーションは,通常実行時にはチェックされないのですが,mypyというライブラリを使うことで,型アノテーションを元に静的解析を行うことができます.今回はpre-commitのためにミラーしたレポジトリを使用します.mypyのオプションに関しては,mypyのコマンドラインオプションたち
を参考にしてください.

- repo: https://github.com/pre-commit/mirrors-mypy
  rev: v0.790
  hooks:
    - id: mypy
      args: [--ignore-missing-imports]

flake8

flake8はpythonのコードのチェックツールです.コードのエラーのチェックや循環的複雑度の測定などをしてくれます.また拡張機能を追加することで,より詳しいコードの解析が行ったり,認知的複雑度や命名に関する警告など,様々なことができるようになります.flake8の拡張機能の詳しい紹介については後述します.pre-hookで拡張機能を使用するには,additional_dependenciesにパッケージの名前を追加します.

- repo: https://gitlab.com/pycqa/flake8
  rev: 3.8.1
  hooks:
  - id: flake8
    # max-line-length setting is the same as black
    # commit cannot be done when cyclomatic complexity is more than 10.
    args: [--max-line-length, "88", --ignore=E402, --max-complexity, "10", --max-expression-complexity=7, --max-cognitive-complexity=7]
    additional_dependencies: [flake8-bugbear, flake8-builtins, flake8-eradicate, pep8-naming, flake8-expression-complexity, flake8-cognitive-complexity]

markdownlint

Markdownのためのlinterです.こちらに記述されているルールを元に,よくない書き方に対して警告を出してくれます.
自分はmarkdownファイルにコマンドやその出力結果を記入することが多く,その際に1行あたりの文字数が規定を超えてしまうことが多いので,文字数の規制をなくすようなオプションを追加しています.

- repo: https://github.com/markdownlint/markdownlint
  rev: master
  hooks:
    - id: markdownlint
      # ignore line length of makrdownlint
      args: [-r, ~MD013]

flake8の拡張機能の紹介

flake8には様々な拡張機能があります.
公開されている拡張機能は,awesome-flake8-extensionsにまとまっているので,参照してみてください.
以下では自分が気になった拡張機能について紹介します.

flake8-bugbear

flake8-bugbearでは.pyflakepycodestyleで提供されていないような,より詳細なコードのチェックが可能になります.flake8-bugbearの警告の一覧は,こちらを参照してください.

flake8-builtins

使用している変数や関数名が,組み込みオブジェクトの名前と被っていないかどうかをチェックします.

flake8-eradicate

コメントアウトされているコードを見つけて警告する拡張機能.

pep8-naming

変数や関数の命名がPEP8に準拠しているかどうかをチェックしてくれます.

flake8-expression-complexity

表現の複雑さを測定して警告を投げてくれる拡張機能です.expression complexityの正確な定義は分かりませんが,どうやらブーリアンの表現の複雑さを表しているようです.

flake8-cognitive-complexity

コードの認知的複雑度を計測し,警告を投げてくれる拡張機能です.認知的複雑度については,こちらの記事(Cognitive Complexity で、コードの読みやすさを定量的に計測しよう)が参考になります.

Bandit

pythonのコードにおけるセキュリティに関する問題をチェックしてくれる拡張機能です.

pandas-vet

pandasを使ったコードのためのlinterです.

flake8-django

Djangoを使ったコードのためにlinterです.

flake8-pytest-style

pytestに関するコードスタイルをチェックしてくれる拡張機能です.

設定したhookを使ってみる

最終的な自分の設定は以下のようになりました.

.pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v3.3.0
  hooks:
  - id: check-added-large-files
  - id: check-json
  - id: check-toml
  - id: check-xml
  - id: check-yaml
  - id: debug-statements
  - id: detect-aws-credentials
    args: [--allow-missing-credentials]
  - id: detect-private-key
  - id: end-of-file-fixer
  - id: name-tests-test
    args: [--django]  # to match `test*.py`.
  # - id: no-commit-to-branch   # to protect specific branches from direct checkins.
    # args: [--branch, master]
  - id: pretty-format-json
    args: [--autofix]

- repo: https://gitlab.com/pycqa/flake8
  rev: 3.8.1
  hooks:
  - id: flake8
    # max-line-length setting is the same as black
    # commit cannot be done when cyclomatic complexity is more than 10.
    args: [--max-line-length, "88", --ignore=E402, --max-complexity, "10", --max-expression-complexity=7, --max-cognitive-complexity=7]
    additional_dependencies: [flake8-bugbear, flake8-builtins, flake8-eradicate, pep8-naming, flake8-expression-complexity, flake8-cognitive-complexity]

- repo: https://github.com/pre-commit/mirrors-mypy
  rev: v0.790
  hooks:
    - id: mypy
      args: [--ignore-missing-imports]
      additional_dependencies: [tokenize-rt==3.2.0]

- repo: https://github.com/psf/black
  rev: stable
  hooks:
  - id: black
    language_version: python3

- repo: https://github.com/pycqa/isort
  rev: 5.5.2
  hooks:
    - id: isort
      args: ["--profile", "black"]

# for docstrings in python codes
- repo: https://github.com/myint/docformatter
  rev: master
  hooks:
    - id: docformatter
      args: [--in-place]

# for markdown
- repo: https://github.com/markdownlint/markdownlint
  rev: master  # or specific git tag
  hooks:
    - id: markdownlint
      # ignore line length of makrdownlint
      args: [-r, ~MD013]

この設定を使って,pre-commitが実行されるかどうかを確認してみます.
試しに以下のようなダメダメなコードをcommitしようとしてみます.

sample.py
import pandas as pd
import numpy as np
def hoge(a:str, b={})->None:
    for i in range(10):
        for j in range(10):
            for k in range(10):
                if i==0:
                    print(a+i)

                if j==8:
                    print("hoge")

                if i==j:
                    pass
                elif i==j+1:
                    pass
                elif i==j+2:
                    pass

                for l in range(10):
                    if l == 5:
                        print("fuga")

コミットしてみます.すると多くの警告が出るのが確認できるかと思います.

$ git add sample.py
$ git commit -m "pre-commit test"
Check for added large files..............................................Passed
Check JSON...........................................(no files to check)Skipped
Check Toml...........................................(no files to check)Skipped
Check Xml............................................(no files to check)Skipped
Check Yaml...............................................................Passed
Debug Statements (Python)................................................Passed
Detect AWS Credentials...................................................Passed
Detect Private Key.......................................................Passed
Fix End of Files.........................................................Passed
Tests should end in _test.py.........................(no files to check)Skipped
Pretty format JSON...................................(no files to check)Skipped
flake8...................................................................Failed
- hook id: flake8
- exit code: 1

sample.py:1:1: F401 'pandas as pd' imported but unused
sample.py:2:1: F401 'numpy as np' imported but unused
sample.py:3:1: E302 expected 2 blank lines, found 0
sample.py:3:1: C901 'hoge' is too complex (11)
sample.py:3:1: CCR001 Cognitive complexity is too high (33 > 7)
sample.py:3:11: E231 missing whitespace after ':'
sample.py:3:19: B006 Do not use mutable data structures for argument defaults.  They are created during function definition time. All calls to the function reuse this one instance of that data structure, persisting changes between them.
sample.py:3:22: E225 missing whitespace around operator
sample.py:6:17: B007 Loop control variable 'k' not used within the loop body. If this is intended, start the name with an underscore.
sample.py:7:21: E225 missing whitespace around operator
sample.py:8:28: E226 missing whitespace around arithmetic operator
sample.py:10:21: E225 missing whitespace around operator
sample.py:13:21: E225 missing whitespace around operator
sample.py:15:23: E225 missing whitespace around operator
sample.py:15:26: E226 missing whitespace around arithmetic operator
sample.py:17:23: E225 missing whitespace around operator
sample.py:17:26: E226 missing whitespace around arithmetic operator
sample.py:20:21: E741 ambiguous variable name 'l'
sample.py:21:24: E741 ambiguous variable name 'l'

mypy.....................................................................Failed
- hook id: mypy
- exit code: 1

sample.py:8: error: Unsupported operand types for + ("str" and "int")
Found 1 error in 1 file (checked 1 source file)

black....................................................................Failed
- hook id: black
- files were modified by this hook

reformatted sample.py
All done! ✨ 🍰 ✨
1 file reformatted.

isort....................................................................Failed
- hook id: isort
- files were modified by this hook

Fixing /Users/yuchi/Documents/hoge_github/sample.py

docformatter.............................................................Passed
Markdownlint.........................................(no files to check)Skipped

flake8mypyからの警告が出ているのが確認できました.
また,blackisortがコードをフォーマットしてくれるのも確認できます.flake8mypyからの警告を修正してgit add sample.py->git commitすることで,コミットを通すことができます.

試しに適切なコードをコミットしてみます.

sample.py
def hoge(a: int, b: int) -> int:
    return a + b
$ git add sample.py
$ git commit -m "pre-commit test"
Check for added large files..............................................Passed
Check JSON...........................................(no files to check)Skipped
Check Toml...........................................(no files to check)Skipped
Check Xml............................................(no files to check)Skipped
Check Yaml...............................................................Passed
Debug Statements (Python)................................................Passed
Detect AWS Credentials...................................................Passed
Detect Private Key.......................................................Passed
Fix End of Files.........................................................Passed
Tests should end in _test.py.........................(no files to check)Skipped
Pretty format JSON...................................(no files to check)Skipped
flake8...................................................................Passed
mypy.....................................................................Passed
black....................................................................Passed
isort....................................................................Passed
docformatter.............................................................Passed
Markdownlint.........................................(no files to check)Skipped

無事コミットが通りました!
このような感じでpre-commitは,コミット時に様々なhookを設定して,コードを自動でチェックしたり,フォーマットすることができます.
皆さんもぜひpre-commitを有効活用してみてください.