Style guide に即していない Python コードを自動で弾く

# 問題点として
Style guide に即していないコードのプルリクエストが来た場合、レビューでは Style guide の確認は困難です。

# 解決する手段として
Style guide をチェックするテストを組込み、Github と Circle CI の integration により、commit や PR の際に自動で Style guide をチェックできるようにします。

## 使用するツール、サービス
次のツール、サービスを使用します。

- nose による単体テスト
- PyLintによるのStyle guideのチェック
- Githubによるバージョン管理
- Circle CIによる継続的インテグレーション

※Python3.6を使用します。

## プロジェクトの構成
ここでは架空のPythonモジュール **"my_tools"** を開発している仮のプロジェクトを使用します。
最初のプロジェクトの構成です。

```tree
.
|____my_tools
| |______init__.py
| |_____adder.py
|____requirements.txt
```

モジュール **my_tools** には、加算器adder.pyが入っています。

```python:my_tools/adder.py
# -*- coding: utf-8 -*-
class Adder:
    def __init__(self) -> None:
        pass

    def add(self, x: int, y: int) -> int:
        return x + y
```

requirements.txt は本記事中で依存するを記述しておきます。

```txt:requirements.txt
nose
pylint
```

このプロジェクトにnoseによるテスト、PyLintによるコーディングスタイルのチェック、Circle CIによる継続的インテグレーションの仕組みを加えていきます。


# nose
noseによるテストを加えます。

```bash
.
|____my_tools
| |______init__.py
| |_____adder.py
|____tests
| |____my_tools_tests
|   |____test_adder.py
```

test_adder.py にテストを記述します。

```tests/my_tools_tests/test_adder.py
# -*- coding: utf-8 -*-
"""Adder のユニットテストが格納されたモジュール."""

from unittest import TestCase
from nose.tools import eq_
from my_tools.adder import Adder


class AdderTestCase(TestCase):
    """Adder クラスのユニットテスト."""

    def setUp(self) -> None:
        """各テスト毎の初期化."""
        pass

    def tearDown(self) -> None:
        """各テスト毎の後処理."""
        pass

    def test_adder(self) -> None:
        """加算メソッドのテスト."""
        adder = Adder()
        eq_(3, adder.add(1, 2))
```

テストを実行してみましょう。

```bash
$ nosetests
.
----------------------------------------------------------------------
Ran 1 test in 0.009s

OK
```

テストが通りました。
noseによるテストの詳細に関してはここでは省きます。

# PyLint
PyLintは論理エラーのチェックとPEP8スタイルガイドのチェックを行うツールです。

## コマンドラインからの実行
モジュール my_tools に PyLint をかけてみましょう。

```bash
$ pylint my_tools/
No config file found, using default configuration
************* Module my_tools.adder
C:  1, 0: Missing module docstring (missing-docstring)
C:  2, 0: Missing class docstring (missing-docstring)
C:  6, 4: Argument name "x" doesn't conform to snake_case naming style (invalid-name)
C:  6, 4: Argument name "y" doesn't conform to snake_case naming style (invalid-name)
C:  6, 4: Missing method docstring (missing-docstring)
R:  6, 4: Method could be a function (no-self-use)
R:  2, 0: Too few public methods (1/2) (too-few-public-methods)

-------------------------------------------------------------------
Your code has been rated at -4.00/10
```

いくつかメッセージが出力されましたね。
メッセージの先頭の記号はメッセージタイプと 行番号、列番号を示しています。

- [R]efactor for a “good practice” metric violation
- [C]onvention for coding standard violation
- [W]arning for stylistic problems, or minor programming issues
- [E]rror for important programming issues (i.e. most probably bug)
- [F]atal for errors which prevented further processing

エラーと警告を全て消えるように対処します。
そのための方策として下記２点を行います。

- ソースコードを修正する。
- PyLint 側を設定する。

以降でこれらのメッセージがなくなるよう対応していきます。

## ソースコードの修正
adder.pyとdivider.pyを下記のように修正しました。

```python:adder.py
# -*- coding: utf-8 -*-
"""加算に関するユーティリティーが入っているモジュールです."""


class Adder:
    """加算器."""

    def __init__(self) -> None:
        """初期化."""
        self.result = 0

    def add(self, x: int, y: int) -> int:
        """加算を実行するメソッド.

        :param x: 整数x
        :param y: 整数y
        :return: x と y の演算結果
        """
        self.result = x + y
        return self.result
```

コードチェックの結果を確認してみましょう。

```bash
$ pylint my_tools/
No config file found, using default configuration
************* Module my_tools.adder
C: 12, 4: Argument name "x" doesn't conform to snake_case naming style (invalid-name)
C: 12, 4: Argument name "y" doesn't conform to snake_case naming style (invalid-name)
R:  5, 0: Too few public methods (1/2) (too-few-public-methods)

-------------------------------------------------------------------
Your code has been rated at 5.00/10 (previous run: -4.00/10, +9.00)
```

いくつか減りましたね。
残りは次で対応します。

## エラーと警告の抑制する
先のコードチェック結果では引数x,yはスネークケースではないとの指摘がなされていました。
今度はソースコードを修正せず、チェックの抑制による解決を試みます。

実際の開発においても、過去に実績のあるコードなどで、そのコードに手を加えることが現実的でない場合もあります。
このような場合にも有効な手段になります。
設定には **3通りのやり方** があります。

- コマンドラインオプションでの設定

```bash
$ pylint my_tools/ --disable='invalid-name,too-few-public-methods'
No config file found, using default configuration

-------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 5.00/10, +5.00)
```

--disable (-d) オプションで invalid-name と too-few-public-methods のチェックを抑制することができました。


- ソースコード上での設定
- 
ファイルの冒頭でコメントの形式で下記コードを追加してみましょう。

```python:adder.py
# -*- coding: utf-8 -*-
# pylint: disable=invalid-name,too-few-public-methods
"""加算に関するユーティリティーが入っているモジュールです."""
```

なお、クラスやメソッドの前に記述することにより細かい制御もできます。

```bash
$ pylint my_tools/
No config file found, using default configuration

--------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)
```

エラーは抑制されました。

- 設定ファイルを使用した設定

設定ファイルを生成します。

```bash
$ pylint --generate-rcfile > .pylintrc
```

.pylintcの[MESSAGES CONTROL]にある**disable=** に今回の **invalid-name,too-few-public-methods** を追加します。

PyLint によるチェックを実行します。

```bash
$ pylint my_tools/
Using config file ....../test_proj_pylint/.pylintrc

--------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)
```
同様にメッセージは出力されなくなりました。


## PyLintをnoseテストから実行する

これらのコードチェックをユニットテストの１つとして実行できるようにします。
これは Jenkins や Circle CI のような継続的インテグレーションでコードチェックも行うための工夫です。
なおテストには nose を使用しますが、どのテスティングフレームワークでも要点は同じです。

テストのディレクトリに **my_tools_tests/test_pylint.py** を加えます。

```tree
|____tests
| |____my_tools_tests
| | |____test_pylint.py   ← 追加
| | |____test_adder.py
```

test_pylint.py の内容は次のようになります。

```python:test_pylint.py
# -*- coding: utf-8 -*-
"""PyLint のコードチェックを行うテストが格納されたモジュール."""

from subprocess import PIPE, Popen
from unittest import TestCase

from nose.tools import ok_
from pylint import epylint as lint


class PyLintTestCase(TestCase):
    """PyLintによるコードチェックを行うためのクラス."""

    def setUp(self) -> None:
        """Run setup function."""
        pass

    def tearDown(self) -> None:
        """Run tear down function."""
        pass

    def test_pylint_conformance(self) -> None:
        """PyLintによるコードチェックを行うテスト."""
        # 検査対象とするディレクトリ名
        dir_name = "my_tools"
        # 検査から除外するエラーや警告
        disables = [
            "R0801",    # Similar lines in 2 files.
            "C0326",    # bad-whitespace
            "C0103",    # invalid-name
            "R0903",    # too-few-public-methods
        ]
        # 検査から除外するファイル
        ignore_files = [
            "__init__.py",
        ]
        # PyLintに渡すオプション
        options = [
            "--max-line-length=160",
            "--variable-rgx='[a-z_]([a-z0-9_])?'",  # 変数名: v, v1, val_hoge1, __val 等
            f"--disable={','.join(disables)}",
            f"--ignore-patterns={','.join(ignore_files)}",
        ]
        cmd = " ".join([dir_name, " ".join(options)])

        # PyLint を実行します.
        # 結果は pylint_stdout に格納されます.
        pylint_stdout, _ = lint.py_run(cmd, return_std=True)

        # pylint_stdout から区切りの行と前後の空白、改行を取り除いた文字列を
        # messages に格納します.
        messages = []
        for line in pylint_stdout.readlines():
            s = line.strip()
            if len(s) and not s.startswith(("*", "-")):
                messages.append(s)

        if len(messages) > 1:   # 最初の１行目は結果にかかわらず表示されるサマリー.
            # テスト失敗
            # messages に何か内容が入っていればテストは失敗とみなし.内容をコンソールに表示する.
            ok_(False, "\n".join(messages))
        else:
            # テスト成功
            # messages に警告やエラーが無ければテストは成功とみなす.
            ok_(True)
```

PyLint を Python スクリプトから実行する箇所は中央部の下記コードになります。

```python:test_pylint.py
pylint_stdout, _ = lint.py_run(cmd, return_std=True)
```

PyLintの設定をこのメソッドの前半部分で作成しています。

- チェック対象とするディレクトリ
- 抑制するエラーや警告
- チェック対象外とするファイル
- オプション

```python
        # 検査対象とするディレクトリ名
        dir_name = "my_tools"
        # 検査から除外するエラーや警告
        disables = [
            "R0801",    # Similar lines in 2 files.
            "C0326",    # bad-whitespace
            "C0103",    # invalid-name
            "R0903",    # too-few-public-methods
        ]
        # 検査から除外するファイル
        ignore_files = [
            "__init__.py",
        ]
        # PyLintに渡すオプション
        options = [
            "--max-line-length=160",
            "--variable-rgx='[a-z_]([a-z0-9_])?'",  # 変数名: v, v1, val_hoge1, __val 等
            f"--disable={','.join(disables)}",
            f"--ignore-patterns={','.join(ignore_files)}",
        ]
        cmd = " ".join([dir_name, " ".join(options)])
```

その他の番号と名称の対応は[下記公式ドキュメント](https://media.readthedocs.org/pdf/pylint/latest/pylint.pdf) の Pylint checkers' options and switches を参照してください。


------------

メソッドの後半部分でチェック結果を判定しています。

- 装飾行を取り除き、メッセージの有無を判定します。
- メッセージが蓄積されていれば、テストは失敗とし、内容をコンソールに表示させます。
- 判定方法には他にも`rated at 10.00/10` を検知できたら成功とみなすなど、別の手段も考えられます。

```python
        # pylint_stdout から区切りの行と前後の空白、改行を取り除いた文字列を
        # messages に格納します.
        messages = []
        for line in pylint_stdout.readlines():
            s = line.strip()
            if len(s) and not s.startswith(("*", "-")):
                messages.append(s)

        if len(messages) > 1:   # 最初の１行目は結果にかかわらず表示されるサマリー.
            # テスト失敗
            # messages に何か内容が入っていればテストは失敗とみなし.内容をコンソールに表示する.
            ok_(False, "\n".join(messages))
        else:
            # テスト成功
            # messages に警告やエラーが無ければテストは成功とみなす.
            ok_(True)
```

テストを実行してみましょう。

```bash
$ nosetests
..
----------------------------------------------------------------------
Ran 2 tests in 1.268s

OK
```

テストはOKでした。

# Circle CI
ここではプロジェクトをGithubにプッシュすることでCircle CIによる継続的インテグレーションが実行される手順を記します。

## GithubとCircle CI
事前にGithubのレポジトリとCircle CIを関連付けておきます。


## 設定ファイル
Circle CIのためを設定ファイルを作成します。

```yml:.circleci/config.yml
version: 2
jobs:
  build:
    docker:
      - image: python:3

    steps:
      - checkout
      - add_ssh_keys:
          fingerprints:
            - "57:f1:**:**:**:**:**:**:**:**:**:**:**:**:**:**"
            - "00:5e:**:**:**:**:**:**:**:**:**:**:**:**:**:**"
      - run:
          name: Install dependencies
          command: pip install -r requirements.txt
      - run:
          name: Run tests
          command: python setup.py nosetests

workflows:
  version: 2

  build_deploy:
    jobs:
      - build
```


## 設定内容について

### workflows
Circle CI 2.0ではworkflowsにjobsを追加していくことで処理を記述していきます。

```yml:.circleci/config.yml
workflows:
  version: 2

  build_deploy:
    jobs:
      - build
```
ここではbuildというjobが実行されます。


### jobs
各jobはファイル上部に記載されています。
jobは使用するVMイメージと各処理を実行するstepsを記述します。

```yml:.circleci/config.yml
jobs:
  build:
    docker:
      - image: python:3

    steps:
      - checkout
      - add_ssh_keys:
          fingerprints:
            - "57:f1:**:**:**:**:**:**:**:**:**:**:**:**:**:**"
            - "00:5e:**:**:**:**:**:**:**:**:**:**:**:**:**:**"
      - run:
          name: Install dependencies
          command: pip install -r requirements.txt
      - run:
          name: Run tests
          command: python setup.py nosetests
```
- checkout: Githubからコードを取得します。
- add_ssh_keys: GithubとCircle CIを関連付けるフィンガープリントを記述します。
- run: noseのテストを実行します。

## Circle CIによるテストの実行

この設定でGithubにプッシュすると、noseのテストが実行されます。
noseのテスト内でPyLintによるスタイルガイドのチェックも同時に行われます。

以下はCircle CIでスタイルガイドのチェックを行った際のスクリーンショットです。
スタイルガイドのチェックに引っかかったブランチは Circle CI で失敗とみなされます。
(赤い表示の部分)

![shot1_edited.png](https://qiita-image-store.s3.amazonaws.com/0/242203/15f7df8c-d451-93b9-4247-e124b64652ea.png)

# 結果として
Style guide のチェックが commit ごとに行われるようになります。開発者は PR 前に Style guide に即していないコードを他の単体テストと同様に予め確認できるようになります。

これにより、レビュー者は PR によるレビューを依頼されたコードに関しては Style guide に即しているという前提でレビューを進めることができるようになります。

# サンプルスクリプト
GitHubの[こちらのリポジトリ](https://github.com/smn-ailab/style-autodoc-qiita)に公開しています。

# 参考元
- [nose](http://nose.readthedocs.io/en/latest/index.html)
- [rednose](https://pypi.python.org/pypi/rednose)
- [PyLint documentation](https://media.readthedocs.org/pdf/pylint/latest/pylint.pdf)
- [blog.jameskyle.org](http://blog.jameskyle.org/2014/05/pep8-pylint-tests-with-nose-xunit/)