5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-08-29

概要

自分の知識が昔読んだ初版の『入門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から置き換えられるとのこと。
    その後パッケージ名を指定して以下のコマンドを実行。

5
7
0

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
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?