Pythonのスタブファイルにdocstringを付与してくれるライブラリを突貫で作った

最近お仕事などでPythonのスタブファイルを利用するケースが出てきました。しかしながらmypyのスタブファイル生成用のコマンドなどはdocstringをスタブファイルに付与してくれないので、付与してくれるライブラリを自分で作ってみたのでその辺りを記事にしておきます。

# そもそもスタブファイルってなに？

拡張子が`.pyi`になる、Pythonの型情報などを記載してロジック部分を省略したファイルです。
スタブファイルを利用することで以下のようなメリットがあります。

- デコレーターなどの都合やサードパーティーのライブラリの都合によって、補完や型チェックがうまく動作しないモジュールに対してそれらの補完やチェック有効にできる。
- 型アノテーションなどがされていないサードパーティーのライブラリなどにも別途スタブファイルを加えることで型情報を追加することができる（チェックなども有効化する）。
- 大きなモジュールなどで、型推定などが多く走ると負荷が高いものなどで、実装部分を省いた最低限のスタブファイルを使うことで負荷を少なく補完やチェックなどの遅延が軽減する。

逆にデメリットとしては、

- 元のモジュールに対してスタブファイルで型情報の定義などを上書きするため、プロジェクトコードなどで頻繁に更新がかかるケースにはモジュールの内容と乖離してしまって好ましくない（プロジェクトで使う場合にはなるべくリアルタイムにスタブファイルも更新される仕組みがあると快適）。
  - ※ライブラリのコードなどであれば基本頻繁に更新することは無いと思うため、エディタの負荷軽減的に相性が良い感じにはなります。

といったところが挙げられます。

スタブファイルの作り方などに関しては[PEP 561 に準拠した型ヒントを含むパッケージの作り方](https://blog.ymyzk.com/2018/09/creating-packages-using-pep-561/)などをご確認ください。

# なんでスタブファイルを使い始めたの？

プロジェクトで一部のコードが（デコレーターなどの都合で）VS Code上で補完や型チェックが効かなくなりました。前は動いていたものの、アップデートの都合なのかVS Code上での使っているライブラリの都合なのか不明ですが、しばらく待っていてもそのままだったのと放置したままだと不便なため一部のプロジェクトモジュールだけスタブファイルがリアルタイム気味に追加されるようにしました（他にも色々対策を試してみたのですが、結局部分的なスタブファイル対応が手っ取り早そうで落ち着きました）。

※GithubでOpenなままのissueは見たのでそのうち改善する可能性はありそうな感じではあります。

# 手動でスタブファイルを作りたくはないのでmypyのスタブファイル生成のコマンドを使うもののdocstringが消える

スタブファイルをプロジェクトで利用していくことを決めたはいいものの、手動でスタブファイルの更新が必要になると単純に工数が増えて好ましくありません。なるべく自動化しつつリアルタイムにスタブファイルを更新して欲しいところです。

自動でのスタブファイルの生成に関してはmypyがstubgenというコマンドの機能を提供してくれているのでそちらを利用しています。

[Automatic stub generation (stubgen)](https://mypy.readthedocs.io/en/stable/stubgen.html)

これで手作業のスタブファイルの生成は避けられるので大分負担が減ります。しかしこのstubgenを使って生成されたスタブファイルにはdocstringが消えます。

例えば以下のようなモジュールのコードがあったとすると、

```py
from random import randint

sample_int: int = 100


def sample_func(a: int, b: str) -> bool:
    """
    Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    ed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

    Parameters
    ----------
    a : int
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    b : str
        ed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

    Returns
    -------
    c : bool
        Ut enim ad minim veniam, quis nostrud exercitation.
    """
    return True


class SampleClass:

    def __init__(self) -> None:
        """
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
        """

    @property
    def sample_property(self) -> int:
        """
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.

        Returns
        -------
        d : int
            ed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
        """
        return randint(0, 100)
```

このモジュールの内容をstubgenコマンドを使ってスタブファイルを生成すると以下のようなものになります。

```py
sample_int: int

def sample_func(a: int, b: str) -> bool: ...

class SampleClass:
    def __init__(self) -> None: ...
    @property
    def sample_property(self) -> int: ...
```

型情報は残っていることが分かりますが、docstringが消えてしまっています。VS Code上で使っている各拡張機能（Pylanceなど）ではスタブファイルをモジュールと同じディレクトリなどに設置した場合にはスタブファイルの方が優先されるようで、この場合エディタ上でdocstringの内容が表示されません。

# というわけで自分で作ってPyPI（pip）登録してみた

ということで突貫でスタブファイルに元のモジュールのdocstringを付与してくれるライブラリを書いてPyPI（pip）に登録しました（MITライセンス）。

仕事で書いても良かったのですが、プライベートでも使うかもという感じがしたので折角なのでプライベートでOSSとして書いて対応しました（プライベートの別のプロジェクトなどでも使えるように）。

[stubdoc - Github](https://github.com/simon-ritchie/stubdoc)

あまり時間をかけずに終わらせた（他にやりたいことが色々ある）ので、粗削りなのはご容赦ください（こだわると数日で終わらなくなるので・・・）。仕事でも近いうちに使い始めると思うため、そちらでうまく動いてくれないところに気づいたら随時アップデートしようとは思います。

インストールはpipコマンドで行えます。

```
$ pip install stubdoc
```

使い方は`-m`引数（もしくは`--module_path`）にスタブファイルの元のモジュールファイルのパス（モジュールとしてこのパスを参照してimportを内部でしているので、上の階層の`../`とかルートの`/`的なパスの指定は効きません。）、`-s`引数（もしくは`--stub_path`）にdocstringを追加したいスタブファイルのパスを指定します。

コマンド例 :

```
$ stubdoc -m samples/sample.py -s out/samples/sample.pyi
```

もしくはPython上で扱うこともできます。

```py
from stubdoc import add_docstring_to_stubfile

add_docstring_to_stubfile(
    original_module_path='sample/path.py',
    stub_file_path='sample/path.pyi')
```

結果としては先ほど例として上げたスタブファイルの内容が以下のようにdocstring付きのものに置換されます。

```py
sample_int: int

def sample_func(a: int, b: str) -> bool:
    """
    Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    ed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

    Parameters
    ----------
    a : int
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    b : str
        ed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

    Returns
    -------
    c : bool
        Ut enim ad minim veniam, quis nostrud exercitation.
    """

class SampleClass:
    def __init__(self) -> None:
        """
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
        """
    @property
    def sample_property(self) -> int:
        """
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.

        Returns
        -------
        d : int
            ed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
        """
```

# 制限事項

Ellipsisインスタンス（`...`）やpassキーワードの記述が関数のコロンの後に改行無しで記述されている状態（mypyのstubgenコマンドの出力結果と同じ形）でのみ動作します。例えば以下のようにEllipsisインスタンスの前に改行が加わっているスタブファイル（手動でスタブファイルなどを生成した場合など）に関してはサポートしていません。

```py
def sample_func(a: int, b: str) -> bool:
    ...

class SampleClass:
    def __init__(self) -> None:
        ...
    @property
    def sample_property(self) -> int:
        pass
```

また、ネストした関数などはサポートしていません。トップレベルに定義されている関数もしくはクラスのメソッドのみが対象になります。例えば以下のようにネストしている方の関数まではチェックしていません。

```py
def sample_func_1(a: int, b: str) -> bool:
    def sample_func_2(c: list) -> None: ...
```

# 参考文献・参考サイトまとめ

- [PEP 561 に準拠した型ヒントを含むパッケージの作り方](https://blog.ymyzk.com/2018/09/creating-packages-using-pep-561/)
- [Automatic stub generation (stubgen)](https://mypy.readthedocs.io/en/stable/stubgen.html)