【Python】モダンなツール群でゼロからパッケージ開発

## 概要
自分の知識が昔読んだ初版の『[入門Python3](https://www.amazon.co.jp/dp/4873117380)』から更新されていないことに危機感を覚え、一念発起でモダンなツール群を調べた際の備忘録。以下のツール群[^tools]を駆使してゼロからパッケージを開発し、PyPiへの公開までやってみる。コードは[GitHub](https://github.com/dr666m1/npspy)に置いておくので、誰かの参考になれば。


* [Poetry](https://python-poetry.org/)
* [pytest](https://docs.pytest.org/en/6.2.x/index.html)
* [mypy](https://mypy.readthedocs.io/en/stable/#)
* [Black](https://black.readthedocs.io/en/stable/#)
* [Flake8](https://flake8.pycqa.org/en/latest/#)

[^tools]: モダンといいつつ、長く使われているツールも混ざっているかもしれない。少なくとも、いずれも初版の『入門Python3』では紹介されていなかった。

## 題材
手の込んだパッケージを作るのが目的ではないので、今回はシンプルに[NPS](https://www.macromill.com/research-words/net-promoter-score.html) (Net Promoter Score）を計算するパッケージを作ってみる。利用イメージは以下。

```python
import npspy
npspy.categorize(10)  # "promoter"
npspy.calculate([0, 7, 9])  # 0
```

それでは実装に移ろう。

## 実装
### 下準備
コードを書き始める前に、まずはいい感じのディレクトリを作りたい。これは[Poetry](https://python-poetry.org/)に任せる。以下のコマンドでインストールしよう[^poetry]。

```bash
curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py | python -
```
[^poetry]: powershell以外はこれでいけそうだが、うまくいかない場合はドキュメントの[該当ページ](https://python-poetry.org/docs/#installation)を見ること。ちなみに、紹介した`install-poetry.py`が最新で、`get-poetry.py`から置き換えられるとのこと。
その後パッケージ名を指定して以下のコマンドを実行。

```bash
poetry new npspy
```

このようなディレクトリができているはず。

```
npspy
├── npspy
│  └── __init__.py
├── pyproject.toml
├── README.rst
└── tests
   ├── __init__.py
   └── test_npspy.py
```

`pyproject.toml`にはすでに依存パッケージ（`pytest`）が指定されているので、ディレクトリに移動して以下のコマンドも実行しておく。

```bash
poetry install
```

※この時点の[コミット](https://github.com/dr666m1/npspy/tree/d2871f6bd72fcdfc9f6d1a85eeedfcc702ec6155)

### コーディング
続いて必要な機能を実装する。この記事の本題ではないのでさらっとコードを見せるだけ。

```python:npspy/core.py
class InvalidAnswerError(Exception):
    """Class for invalid answer related errors."""
    pass


def categorize(answer):
    """Categorize an answer for the NPS question.
    Args:
        answer: An answer for the NPS question.
    Returns:
        Answerer's category.
    Raises:
        InvalidAnswerError: If the answer is not between 0 and 10.
    """
    if 0 <= answer <= 6:
        return "detractor"
    elif 7 <= answer <= 8:
        return "passive"
    elif 9 <= answer <= 10:
        return "promoter"
    else:
        raise InvalidAnswerError(f"Invalid answer: {answer}")


def calculate(answers):
    """Calculates nps.
    Args:
        answers: Answers for the NPS question.
    Returns:
        Calculated nps.
    """
    count = {}
    for answer in answers:
        category = categorize(answer)
        count[category] = count.get(category, 0) + 1
    nps = (
        (count.get("promoter", 0) - count.get("detractor", 0))
        / sum(count.values())
        * 100
    )
    return nps
```

```python:npspy/__init__.py
from npspy.core import InvalidAnswerError, categorize, calculate

__version__ = '0.1.0'
```

※この時点の[コミット](https://github.com/dr666m1/npspy/tree/3d09db2b0fb129283c44774f57fbcfd8e5700ad8)

### テスト
単純なコードではあるが、テストは書いておきたい。ここでは[pytest](https://docs.pytest.org/en/6.2.x/index.html)の枠組みに乗っかろう。すでにある`tests/test_npspy.py`をこんな感じで書き換える。

```python:tests/test_npspy.py
import pytest
from npspy import InvalidAnswerError, categorize, calculate

def test_categorize_negative():
    with pytest.raises(InvalidAnswerError):
        categorize(-1)


def test_categorize_0():
    assert categorize(0) == "detractor"


def test_categorize_more_than_10():
    with pytest.raises(InvalidAnswerError):
        categorize(11)


def test_calculate_0():
    assert calculate([0, 9]) == 0


def test_calculate_100():
    assert calculate([9, 10]) == 100
```

関数名は`test`から始めること（詳細は[こちら](https://docs.pytest.org/en/6.2.x/goodpractices.html#conventions-for-python-test-discovery)）。テストを書き終えたら以下のコマンドを実行。`poetry run`が必要なのは、poetryが準備したvirtualenvで実行するため。

```bash
poetry run pytest
```

※この時点の[コミット](https://github.com/dr666m1/npspy/tree/dd1387d5fe496bb4b04f7221da137abbd27dbc17)

### 型テスト
とりあえず意図通りに動くことはテストできたが、今後のために型テストも導入したい。まずは[mypy](https://mypy.readthedocs.io/en/stable/#)のインストール。`--dev`を指定するのは、開発環境でのみ必要であることを明示するため。

```bash
 poetry add mypy --dev
```

先ほどのコードに型アノテーションを足すと、こんな感じ。

```python:npspy/core.py
from typing import Dict, Iterable, Literal

Categories = Literal["detractor", "passive", "promoter"]

class InvalidAnswerError(Exception):
    """Class for invalid answer related errors."""
    pass


def categorize(answer: int) -> Categories:
    """Categorize an answwer for the NPS question.
    省略
    """
    if 0 <= answer <= 6:
        return "detractor"
    elif 7 <= answer <= 8:
        return "passive"
    elif 9 <= answer <= 10:
        return "promoter"
    else:
        raise InvalidAnswerError(f"Invalid answer: {answer}")


def calculate(answers: Iterable[int]) -> float:
    """Calculates nps.
    省略
    """
    count: Dict[Categories, int] = {}
    for answer in answers:
        category = categorize(answer)
        count[category] = count.get(category, 0) + 1
    nps = (
        (count.get("promoter", 0) - count.get("detractor", 0))
        / sum(count.values())
        * 100
    )
    return nps
```

npspy以下のファイルを対象に型テストを実行。`--strict`フラグは付けておくのがおすすめ（詳細は[こちら](https://mypy.readthedocs.io/en/stable/getting_started.html#configuring-mypy)）。

```bash
poetry run mypy npspy/**.py --strict
```

※この時点の[コミット](https://github.com/dr666m1/npspy/tree/f376dea28956c9fdaecc8c9e93966229a3632502)

### フォーマッタ
コーディングスタイルを統一するためにフォーマッタもほしい。[Black](https://black.readthedocs.io/en/stable/#)を導入しよう。

```bash
poetry add black --dev
```

以下のように実行する。

```bash
poetry run black .
```

不足していた空行などがいい感じに補われた。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540947/e8fd15f6-4df3-6515-4208-3de633006360.png)
※この時点の[コミット](https://github.com/dr666m1/npspy/tree/65387554dd0f2b7d27ac616218c67ef6b7751cdb)

### linter
コードの品質を担保するためにlinterも導入したい。ここでは[Flake8](https://flake8.pycqa.org/en/latest/#)を導入する。

```bash
poetry add flake8 --dev
```

Blackと併用するために、設定ファイルを作っておく（詳細は[こちら](https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8)）。

```ini:.flake8
[flake8]
max-line-length = 88
extend-ignore = E203
```

実行方法は以下の通り。だいぶ注意深くコーディングしていたつもりだが怒られた。

```bash
poetry run flake8
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540947/e19699d5-35d8-777a-2cbe-3c3ca957c6a9.png)

せっかくなので`npspy/__init__.py`を修正。

```python:npspy/__init__.py
from npspy.core import InvalidAnswerError, Categories, categorize, calculate

__all__ = [
    "InvalidAnswerError",
    "Categories",
    "categorize",
    "calculate",
]
__version__ = "0.1.0"
```

※この時点の[コミット](https://github.com/dr666m1/npspy/tree/bb0bb17ff93180b05fca245354123f3c75631f92)

### 公開
公開もPoetryを利用すれば難しくない。先に進む前に以下は済ませておく。

* `README.md`の記載＆`pyproject.toml`への追記（[これ](https://github.com/dr666m1/npspy/blob/afa152908045483d45191b4c99d5a7c63eeda763/pyproject.toml#L6)）
* LICENSEの記載（[GitHubの機能](https://docs.github.com/ja/communities/setting-up-your-project-for-healthy-contributions/adding-a-license-to-a-repository)を使えば簡単）
* PyPiアカウントの作成（[本番用](https://pypi.org/account/register/)と[テスト用](https://test.pypi.org/account/register/)それぞれ）

上記の準備が済んだら、テスト用のPyPiを使えるように`poetry.toml`というファイルを作成しておく（本番用は特別な準備不要）。

```toml:poetry.toml
[repositories]
[repositories.testpypi]
url = "https://test.pypi.org/legacy/"
```

では、いよいよ公開。コマンドは以下（UsernameとPasswordの入力が必要）。

```bash
# テスト用
poetry build
poetry publish -r testpypi

# 本番用
poetry build
poetry publish
```
無事に公開できたようだ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540947/9b8546ce-c97e-1060-abf0-d8baaa7bbbc4.png)
※この時点の[コミット](https://github.com/dr666m1/npspy/tree/afa152908045483d45191b4c99d5a7c63eeda763)

## 最後に
どうにか公開まで漕ぎつけられた。この記事が、いろいろなツールを試すチュートリアルとして誰かの役にたてば幸い。