LoginSignup
7

More than 1 year has passed since last update.

posted at

updated at

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

概要

自分の知識が昔読んだ初版の『入門Python3』から更新されていないことに危機感を覚え、一念発起でモダンなツール群を調べた際の備忘録。以下のツール群1を駆使してゼロからパッケージを開発し、PyPiへの公開までやってみる。コードはGitHubに置いておくので、誰かの参考になれば。

題材

手の込んだパッケージを作るのが目的ではないので、今回はシンプルにNPS (Net Promoter Score)を計算するパッケージを作ってみる。利用イメージは以下。

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

それでは実装に移ろう。

実装

下準備

コードを書き始める前に、まずはいい感じのディレクトリを作りたい。これはPoetryに任せる。以下のコマンドでインストールしよう2

curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py | python -

その後パッケージ名を指定して以下のコマンドを実行。

poetry new npspy

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

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

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

poetry install

※この時点のコミット

コーディング

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

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
npspy/__init__.py
from npspy.core import InvalidAnswerError, categorize, calculate

__version__ = '0.1.0'

※この時点のコミット

テスト

単純なコードではあるが、テストは書いておきたい。ここではpytestの枠組みに乗っかろう。すでにあるtests/test_npspy.pyをこんな感じで書き換える。

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から始めること(詳細はこちら)。テストを書き終えたら以下のコマンドを実行。poetry runが必要なのは、poetryが準備したvirtualenvで実行するため。

poetry run pytest

※この時点のコミット

型テスト

とりあえず意図通りに動くことはテストできたが、今後のために型テストも導入したい。まずはmypyのインストール。--devを指定するのは、開発環境でのみ必要であることを明示するため。

 poetry add mypy --dev

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

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フラグは付けておくのがおすすめ(詳細はこちら)。

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

※この時点のコミット

フォーマッタ

コーディングスタイルを統一するためにフォーマッタもほしい。Blackを導入しよう。

poetry add black --dev

以下のように実行する。

poetry run black .

不足していた空行などがいい感じに補われた。
image.png
※この時点のコミット

linter

コードの品質を担保するためにlinterも導入したい。ここではFlake8を導入する。

poetry add flake8 --dev

Blackと併用するために、設定ファイルを作っておく(詳細はこちら)。

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

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

poetry run flake8

image.png

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

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

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

※この時点のコミット

公開

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

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

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

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

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

# 本番用
poetry build
poetry publish

無事に公開できたようだ。
image.png
※この時点のコミット

最後に

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


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

  2. powershell以外はこれでいけそうだが、うまくいかない場合はドキュメントの該当ページを見ること。ちなみに、紹介したinstall-poetry.pyが最新で、get-poetry.pyから置き換えられるとのこと。 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
7