NumPyスタイルのdocstringをチェックしてくれるLintを作りました。

日々、Djangoプロジェクトなどで仕事をしていると、コードとdocstring（Pythonのコード説明のコメント的なもの）が乖離することがあります。
特に、何度も関数内容を変更・調整したりしていると顕著で、「うっかり1つ引数の説明追加し忘れていた」とか「返却値変えたのに説明直すの忘れた」とかをたまにやらかすことがあります。

コードの説明とコードの内実が合っていないと他の方に迷惑がかかってしまいます。（特に、自身が異動などした後に、他の方がその説明を読んで誤解してミスしたりすると辛い）

> コードと矛盾するコメントは、コメントしないことよりタチが悪いです。コードを変更した時は、コメントを最新にすることをいつも優先させてください！
> [PEP: 8 Python コードのスタイルガイド](https://pep8-ja.readthedocs.io/ja/latest/)

　

> あなたのコードをメンテナンスすることになる人が、
あなたの住所を知る強烈なサイコパスになりうるのを
常に想定してコーディングしなさい。
> [天才プログラマーの厳選名言まとめ Rick Osborneさん部分より](https://matome.naver.jp/odai/2135590861120978701) / [※オリジナルの発言は1991年のRick Osborneさん？](https://groups.google.com/forum/#!msg/comp.lang.c++/rYCO5yn4lXw/oITtSkZOtoUJ)

注意していてもうっかりミスは発生する、というのは体感していて、なんらかLintなりを入れないと、docstringはデフォルトではコンパイルもテストも通すわけではないので、どうしてもたまに乖離が発生してしまいます。

コードビューなどである程度は軽減できるとは思いますが、レビューではお互いもっと別の点に注力したいのと、少なくとも私は手作業でチェックしたりは気が向きません。プロジェクトメンバーの方にとってもあまり気が向かない作業だと思います。

そこで、NumPyスタイルのdocstringのチェックをしてくれるLintライブラリを作ってみました。（[Github: Numdoc Lint](https://github.com/simon-ritchie/numdoclint)）

NumPyスタイルのdocstringに関しては以前以下の記事を書いたので、必要に応じてご確認ください。
[[Python]可読性を上げるための、docstringの書き方を学ぶ（NumPyスタイル）](https://qiita.com/simonritchie/items/49e0813508cad4876b5a)

チェックは退屈な作業なので、Pythonに担当してもらうことにします。

<font color="#bbb">※英語力は
Google翻訳先生に頼りっきりで
残念な感じなので、明らかに意味が違うところとかあればこっそり弱めのマサカリいただけますと幸いです。</font>

# どんなライブラリなのかの概要

- そもそもdocstring自体を書き忘れている関数がないかチェックします。
- 各関数で、関数側に欠落がないか、docstring側に欠落がないかをチェックします。
- 引数とdocstringで、引数の順番が一致しているかチェックします。
- オプションで有効化した場合は、デフォルト値の指定で乖離が無いかチェックします。
- docstringに型の指定があることをチェックします。
- 返却値の有無をチェックします。
- 引数や返却値でdocstringの説明が欠落していないかチェックします。
- Pythonモジュールだけでなく、Jupyterのノートブックのチェックにも対応しています。
- Pythonコードから使ったり、もしくはコマンドライン経由で実行したりができます。
- 他のライブラリで、PEP257準拠だったり、NumPy含めた各docstringの書き方を一通り許容するようなLintライブラリはありますが、このライブラリはNumPyスタイルのみで他は弾かれます。1つのプロジェクトで複数のスタイルが混在するのは良くないと思うのと、仕事だとずっとNumPyスタイルで統一しているのでNumPyスタイルのみ対応しています。

> スタイルのあいだの選択は大部分が美的感覚のものですが、２つのスタイルは混用するべきではありません。ご自身のプロジェクトでひとつのスタイルを選び、一貫させましょう。
> [NumPy および Google スタイルの docstring をドキュメントに取り込む](http://www.sphinx-doc.org/ja/stable/ext/napoleon.html)

# インストール方法

pipでインストールができます。

```
$ pip install numdoclint
```

# 依存関係

sixというライブラリを一部使っています。Anacondaなどを使っていれば最初から入っていますが、そうではなく入っていない場合はpipコマンド実行時に一緒にインストールされます。

Pythonは3.6と2.7でテストしています。多分3.5とかでも問題なく動くはず・・・

# 使い方例

Pythonコード内で呼び出す方法とコマンドラインから実行する方法それぞれがあります。
Python経由の場合は結果をlist of dictsの形式で取れるので、返却値を使って色々操作する際に便利です。Djangoなどで扱う際にも便利かもしれません。

コマンドライン側は、flake8やisortなどと似たような感覚のインターフェイスです。さくっと実行して、結果の出力を得たい場合に便利です。

まずは一つのPythonモジュールに対する、Python経由での使い方です。`check_python_module`という関数を用意してあるので使います。パスにはPythonモジュールを指定します。

```py
>>> import numdoclint
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='../pandas/pandas/core/arrays/array_.py')
```

そうすると、以下のようなLintで引っかかった箇所の内容がprintされます。

```
../pandas/pandas/core/arrays/array_.py::array
The function description is not set to docstring.

../pandas/pandas/core/arrays/array_.py::array
There is an argument whose explanation does not exist in docstring.
Target argument name: data

...
```

返却値のリストには以下のようにlist of dictsの形式でチェック結果が格納されます。

```py
>>> lint_info_list

[{'module_path': '../pandas/pandas/core/arrays/array_.py',
  'func_name': 'array',
  'info_id': 6,
  'info': 'The function description is not set to docstring.'},
 {'module_path': '../pandas/pandas/core/arrays/array_.py',
  'func_name': 'array',
  'info_id': 2,
  'info': 'There is an argument whose explanation does not exist in docstring.\nTarget argument name: data'},
...
```

そのままPandasのデータフレームなどに放り込めるので、必要に応じてご利用ください。

再帰的に、一通りのモジュールをチェックしたい場合は`check_python_module_recursively`を利用し、パスにディレクトリを指定します。

```
>>> import numdoclint

>>> lint_info_list = numdoclint.check_python_module_recursively(
...     dir_path='../numpy/')
```

返却値や出力は単一のモジュールのチェックのときと同じです。

単一のJupyterのノートブックをチェックしたい場合は`check_jupyter_notebook`関数、再帰的にチェックしたい場合は`check_jupyter_notebook_recursively`関数を用意してあります。使い方はPythonモジュールの時とほぼ同じです。

```py
>>> check_result_list = numdoclint.check_jupyter_notebook(
...     notebook_path='./sample_notebook.ipynb')
```

```py
>>> check_result_list = numdoclint.check_jupyter_notebook_recursively(
...     dir_path='./sample_dir/')
```

結果のリストデータだけほしくて、標準出力は無視したい場合には、`verbose`引数を0にすることで調整できます。

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='../pandas/pandas/core/arrays/array_.py',
...     verbose=0)
```

特定の文字列で始まる関数名の関数を無視する場合には`ignore_func_name_prefix_list`引数に設定すると無視されます。デフォルトでは`test_`というプリフィクスの関数は無視するようにしてあります。それらもチェックしたい場合は空のリストを指定してください。

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='../pandas/pandas/core/arrays/array_.py',
...     ignore_func_name_prefix_list=['test_', '_main', '__init__'])
```

返却値のリストには以下のように`info_id`という値が含まれています。

```
[{'module_path': '../pandas/pandas/core/arrays/array_.py',
  'func_name': 'array',
  'info_id': 6,
  'info': 'The function description is not set to docstring.'},
...
```

もし特定のIDのチェックを無視したい場合には`ignore_info_id_list`引数にリストで指定すると無視されます。

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py',
...     ignore_info_id_list=[12],
...     verbose=0)
```

また、各IDは定数も用意してあります。好きな方をお使いください。

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py',
...     ignore_info_id_list=[
...         numdoclint.INFO_ID_LACKED_RETURN_VAL,
...     ],
...     verbose=0)
```

デフォルトでは、docstringの以下のようなデフォルト値の記述のチェックは行わないようにしてあります。

NumPyで多いケース(e.g., `default is 100`)

```py
    Parameters
    ----------
    price : int, default is 100
```

Pandasで多いケース(e.g., `default 100`) :

```py
    Parameters
    ----------
    price : int, default 100
```

Pandasでたまに使われるケース(e.g., `(default 100)`)

```py
    Parameters
    ----------
    price : int (default 100)
```

これらの記述は、各ライブラリを見ても書いてあったり書いてなかったりが関数ごとにばらけていたりしています。そのためデフォルトでチェックを有効にするのもなんだかなー・・・という感じなので最初はチェックしないようにしてあります。
チェックを有効化する場合には`enable_default_or_optional_doc_check`引数にTrueを指定してください。`default 100`みたいな記述が無い場合には怒られるようになります。

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='../pandas/pandas/core/frame.py',
...     enable_default_or_optional_doc_check=True)
```

Python経由だけでなく、コマンドラインからも以下のように実行できます。

```
$ numdoclint -p ./sample/path.py
```

以下のような引数を用意してあります。パスの指定のみ、指定が必須です。

- `-p` or `--path` : 対象のPythonモジュールパス、もしくはノートのパスや、ディレクトリパスです。
- `-r` or `--check_recursively` : 指定すると、再帰的にチェックがされます。こちらを指定した場合はパスはディレクトリを指定してください。
- `-j` or `--is_jupyter` : 指定すると、チェック対象がJupyterのノートブックになります。こちらを指定した場合はパスはノートブックもしくはディレクトリを指定してください。
- `-f` or `--ignore_func_name_prefix_list` : コンマ区切り（スペース無し）で、無視する関数名のプリフィクスを指定することができます。例 : `-f 'test_,sample_'`
- `-i` or `--ignore_info_id_list` : コンマ区切り（スペース無し）で、無視するチェックのIDのリストを指定することができます。例 : `-i '1,2,3'`
- `-o` or `--enable_default_or_optional_doc_check` : 指定すると、デフォルト値のdocstringの記述に対してチェックが実行されるようになります。

Jupyterのノート単体に対してチェックしたい場合は以下のようなコマンドになります。

```
$ numdoclint -j -p ./sample/path.ipynb
```

ノートを再帰的にチェックしたければ以下のようになります。

```
$ numdoclint -j -r -p ./sample/dir/
```

# どんな条件で引っかかるのかの例

## docstringに関数の説明が無い場合

```py
# sample.py

def sample_func(price):
    """
    Parameters
    ----------
    name : str
        Sample name.
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py')

./sample.py::sample_func
The function description is not set to docstring.
```

## docstring側に記載されている引数が、実際の引数側に存在しない場合

```py
# sample.py

def sample_func(price):
    """
    Sample function.

    Parameters
    ----------
    price : int
        Sample price.
    lacked_arg : str
        Sample string.
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py')

./sample.py::sample_func
An argument exists in docstring does not exists in the actual argument.
Lacked argument name: lacked_arg
```

## 実際の引数側にはあるけど、docstring側に欠落している引数がある場合

```py
# sample.py

def sample_func(price, lacked_arg):
    """
    Sample function.

    Parameters
    ----------
    price : int
        Sample price.
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py')

./sample.py::sample_func
There is an argument whose explanation does not exist in docstring.
Target argument name: lacked_arg
```

## 引数の型の記載がない場合

```py
# sample.py

def sample_func(price):
    """
    Sample function.

    Parameters
    ----------
    price
        Sample price (type not specified).
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py')

./sample.py::sample_func
Missing docstring argument type information.
Target argument: price
```

## docstringの引数の説明が無い場合

```py
# sample.py

def sample_func(price, name):
    """
    Sample function.

    Parameters
    ----------
    price : int
    name : str
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py')

./sample.py::sample_func
Missing docstring argument information.
Argument name: price

./sample.py::sample_func
Missing docstring argument information.
Argument name: name
```

## 実際の引数とdocstringの引数の順番が違っている場合

```py
# sample.py

def sample_func(price, name):
    """
    Sample function.

    Parameters
    ----------
    name : str
        Sample name.
    price : int
        Sample price.
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py')

./sample.py::sample_func
The order of the argument and docstring is different.
Order of arguments: ['price', 'name']
Order of docstring parameters: ['name', 'price']
```

## デフォルト値の記載がdocstringに無い場合

※注 : 引数でデフォルト値関係のチェックをするように指定した場合にのみチェックされます。デフォルトではチェックされないようにしてあります。

```py
# sample.py

def sample_func(price=100):
    """
    Sample function.

    Parameters
    ----------
    price : int
        Sample price.
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py',
...     enable_default_or_optional_doc_check=True)

./sample.py::sample_func
While there is no description of default value in docstring, there is a default value on the argument side.
Argument name: price
Argument default value: 100
```

## docstring側にはデフォルト値の記載があるけれども、実際の引数側にデフォルト値が無い場合

※注 : こちらも、デフォルト値関係のチェックを引数で有効化したときのみチェックされます。

```py
# sample.py

def sample_func(price):
    """
    Sample function.

    Parameters
    ----------
    price : int, default 100
        Sample price.
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py',
...     enable_default_or_optional_doc_check=True)

./sample.py::sample_func
The default value described in docstring does not exist in the actual argument.
Argment name: price
Docstring default value: 100
```

## 関数内にreturnステートメントがあるけどdocstringに返却値関係が無い場合

```py
# sample.py

def sample_func():
    """
    Sample function.
    """
    return 100
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py',
...     enable_default_or_optional_doc_check=True)

./sample.py::sample_func
While the return value exists in the function, the return value document does not exist in docstring.
```

## 返却値のdocstringの説明が無い場合

```py
# sample.py

def sample_func():
    """
    Sample function.

    Returns
    -------
    price : int
    """
    return 100
```

```py
>>> lint_info_list = numdoclint.check_python_module(
...     py_module_path='./sample.py')

./sample.py::sample_func
Docstring description of return value is missing.
Return value name: price
Return value type: int
```

## docstringに返却値の記載がある一方で、実際の関数内にreturnステートメントが無い場合

```py
# sample.py

def sample_func():
    """
    Sample function.

    Returns
    -------
    price : int
        Sample price
    """
    pass
```

```py
>>> lint_info_list = numdoclint.check_python_module(
>>>     py_module_path='./sample.py')

./sample.py::sample_func
While the return value document exists in docstring, the return value does not exist in the function.
```