フューチャー技術ブログ

Pythonによるパッケージ開発

はじめに

こんにちは。TIG/DXチームの栗田です。夏休み自由研究連載14日目の記事です。
最近業務ではGoを書くことが増えてきましたが、私は以前Pythonをよく書いていました。
今回はPythonの開発をスムーズに行っていく方法について記載します。

この文章の目的

機械学習やDeep Learnigに後押しされ、今やPythonは非常に人気の言語です。初学者でも扱いやすく、学生の方も数値計算や競技プログラミングあるいは研究で利用しています。

一方で触りやすいがために「Pythonは書ける」のと「Pythonで何かを開発できる」には、意外なギャップが生まれることがあります。

ここではそういった「Pythonは書けるし実際書いているけど独学だったしアプリは作ったことがない」ような人をターゲットとして、初学者向けに紹介されるようなツールをどのように組み合わせていくかを記載します。

目次

  • venv で開発環境を整える
  • pytest を書く
  • toxpytest を取り込む
  • さらに blackflake8mypy を組み込んでいく

前提

  • mypackage というパッケージを作ることとします

開発環境について:venv

仮想環境

Pythonは必要なパッケージを適宜 pip で導入可能なのが便利ですが、そのままインストールするとシステムにそのまま入っていきます。

個人で作業するのであればそれでも問題ないかもしれませんが、複数のバージョン固定が入るようなプロダクトを開発したり、あるいはOSSなどのようにチームで開発したりする場合は、開発対象ごとにパッケージを管理することが望ましいです。

そのために使われるのが仮想環境であり、Python2.x系のときは virtualenv という機能がありました。この virtualenv がPython3.3から標準機能として取り込まれたのが、venv です。

virtualenv is a tool to create isolated Python environments. Since Python 3.3, a subset of it has been integrated into the standard library under the venv module.

Virtualenv - virtualenv documentation

Python標準機能のツールになったので、 venv はPython自体のversion管理はできません
また、Python3.3, 3.4系では仮想環境の作成に pyvenv というツールの仕様が推奨されていましたが、Python3.6系からは非推奨です。
Python3.5系から venv が推奨とされています。

venvの使い方

以下の形で仮想環境を用意します。

$ cd ~/work/dir/path/mypackage
$ python -m venv .venv
$ source .venv/bin/activate
(.venv) $

この仮想環境から抜けるには、以下のようにします。

(.venv) $ deactivate
$

仮想環境へのパッケージインストール

先に用意した仮想環境は限りなくバニラな状態で、activate前にインストールしたパッケージは何も入っていません。

そのため、開発に必要なパッケージを改めてインストールする必要があります。例えばパッケージを作るためにsetup.pyを用意すれば、$ python setup.py install 実行時に作ったパッケージ自身とそれに依存するパッケージをインストールできます。

しかし、このコマンドでは開発中のパッケージを他のPythonパッケージのパスに移動するような操作になり、開発中のパッケージを含めて逐一 $ python setup.py install するのは面倒です。

できれば、作業中のディレクトリを直接参照したいです。そのため取られる手法の1つとして、以下のようなやり方があります。

  1. 依存パッケージは別ファイルに切り出して管理( requirements.txt などがそれに該当)
  2. 開発対象のパス自体を参照して仮想環境で import できるようにする

requirements.txt には依存関係にあるパッケージのバージョンまで記載できます。
このファイルと pip-e オプションを利用して、上記の2つを満たします。

(.venv) $ pip install -e . -r requirements.txt

この手法を取ることで、複数人における開発で同じ環境を共有できます。

Pythonによるテスト:pytest

テストツール比較

Python用のテストツールは複数あります。

ツール名 説明
unittest Pythonの標準ライブラリに含まれるテストモジュール。
doctest ドキュメンテーション中のインタラクティブなPythonセッションに見えるテキストを検索し、それが正しいかどうかを確認するモジュール。
nose unittestを拡張して実行できるツール。カバレッジなども取得できる。
pytest テストフレームワーク。unittestの形で記述されたコードも実行でき、拡張性が高かったりテスト結果がわかりやすかったりする。たまに見受けられる py.test は古いコマンド。
tox テスト環境の管理を自動化し、複数のインタプリタに対してテストするためのツール。コードチェックツールやフォーマットツールを組み合わせることができる。

unittestnose で書いたテストがそのまま実行できることもあり、 pytest が利用されるケースが多いと思います。

例えば最近のツールでの採用例としては、AWS Glueのテストも pytest で行えます。

今回は、これに flake8black を組み合わせた形で、 tox からコールします。有名なflasksphinxでも、このような構成となっています。今回はこの構成を実際に構築していきます。

pytest環境の構築

pytest の実行には、強く推奨されるディレクトリ構造があり、このディレクトリ構造であれば pip パッケージの開発をすすめることができます。
今回はその形式に則ります。

.
├── README.md
├── requirements.txt
├── setup.cfg
├── setup.py
├── venv
├── src
│ └── mypackage
│ ├── __init__.py
│ └── mypackage.py
└── tests
├── __init__.py
└── test_mypackage.py

ルートディレクトリの直下に src

テストコードについては、test_のプレフィックスを付けて、testsディレクトリ以下に設置します。パスの通りにファイルを作ったら、次のような中身にします。

mypackage.py
import numpy as np

def mypackage_func():
return np.pi

def main():
print(mypackage_func())

if __name__ == "__main__":
main()
test_mypackage.py
from mypackage.mypackage import mypackage_func

def test_mypackage_func():
assert "{:.2f}".format(mypackage_func()) == "3.14", "小数点以下2桁までを比較"
assert not mypackage_func() == 3, "円周率は3ではない"

これで pytest コマンドを実行すると、結果を確認できます。

(.venv) $ pytest
====================================== test session starts ======================================
platform linux -- Python 3.7.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kurita/mypackage
collected 1 item

tests/test_mypackage.py . [100%]

======================================= 1 passed in 0.12s =======================================

pytest の場合 assert が基本的な使い方になりますが、もちろん他の方法もできます。
例えば、例外検証する場合は、次のようになります。

test_mypackage.py
from mypackage.mypackage import mypackage_func
import pytest

def test_mypackage_func():
assert "{:.2f}".format(mypackage_func()) == "3.14", "小数点以下2桁までを比較"
assert not mypackage_func() == 3, "円周率は3ではない"

def test_zero_division():
with pytest.raises(ZeroDivisionError):
assert 0 == mypackage_func() / 0
(.venv) $ pytest -rsfp
====================================== test session starts ======================================
platform linux -- Python 3.7.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kurita/mypackage
collected 2 items

tests/test_mypackage.py .. [100%]

==================================== short test summary info ====================================
PASSED tests/test_mypackage.py::test_mypackage_func
PASSED tests/test_mypackage.py::test_zero_division
======================================= 2 passed in 0.12s =======================================

試しに、 pytest 実行時にオプションを付けてみました。失敗した内容を良く確認したり、カバレッジを確認したりできます。

他にも pytest には様々なプラグインが存在しています。

$ pip search pytest を実行すれば、たくさんのプラグインがあることがわかります。

pytest の細かい使い方については公式のドキュメントを読んでも良いですし、例えばエムスリーのテックブログに記載されています。

テスト環境自体の整理:tox

自分で使用するために開発しているのだと pytest で十分に思うかもしれませんが、Pythonの場合サポート中の言語が複数バージョン存在します。

パッケージとして提供するのであれば、複数のバージョンのPythonでテストしておきたいですが、そんなときに使えるのがtoxです。

tox は設定ファイルに記載した内容をもとに別々の virtualenv 環境を構築し、各環境の中でテストを実行して表示するツールです。tox の設定ファイルとしては tox.ini というファイルを記載します。

簡単な例としては、次のように書きます。

tox.ini
[tox]
# envlist: テスト環境の一覧。ここで記載した環境が構築されます。
# py37: インストールされている python3.7 コマンドを探し、 Python3.7 の virtualenv を作成します
envlist = py37


# [testenv]: テスト環境の設定。
[testenv]

# 環境にインストールするライブラリを指定します
# ここで渡したものが直接pipに渡されるため、requirements.txtの指定ができます
# `-r` と `requirements.txt` の間にスペースを入れるとエラーになります
deps = -rrequirements.txt


# 実行するコマンド: pytest
commands = pytest -rsfp
text
(.venv) $ tox
GLOB sdist-make: /home/kurita/mypackage/setup.py
py37 inst-nodeps: /home/kurita/mypackage/.tox/.tmp/package/1/mypackage-0.0.1.zip
py37 installed: appdirs==1.4.4,attrs==19.3.0,bleach==3.1.5,certifi==2020.6.20,cffi==1.14.1,chardet==3.0.4,colorama==0.4.3,cryptography==3.0,distlib==0.3.1,docutils==0.16,filelock==3.0.12,idna==2.10,importlib-metadata==1.7.0,iniconfig==1.0.1,jeepney==0.4.3,keyring==21.3.0,more-itertools==8.4.0,mypackage @ file:///home/hikaru/hikaru/program/mypackage/.tox/.tmp/package/1/mypackage-0.0.1.zip,mypy==0.782,mypy-extensions==0.4.3,numpy==1.19.1,packaging==20.4,pkginfo==1.5.0.1,pluggy==0.13.1,py==1.9.0,pycparser==2.20,Pygments==2.6.1,pyparsing==2.4.7,pytest==6.0.1,readme-renderer==26.0,requests==2.24.0,requests-toolbelt==0.9.1,rfc3986==1.4.0,SecretStorage==3.1.2,six==1.15.0,toml==0.10.1,tox==3.19.0,tqdm==4.48.2,twine==3.2.0,typed-ast==1.4.1,typing-extensions==3.7.4.2,urllib3==1.25.10,virtualenv==20.0.30,webencodings==0.5.1,zipp==3.1.0
py37 run-test-pre: PYTHONHASHSEED='468102422'
py37 run-test: commands[0] | pytest -rsfp
============================================= test session starts =============================================
platform linux -- Python 3.7.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
cachedir: .tox/py37/.pytest_cache
rootdir: /home/kurita/mypackage
collected 2 items

tests/test_mypackage.py .. [100%]

=========================================== short test summary info ===========================================
PASSED tests/test_mypackage.py::test_mypackage_func
PASSED tests/test_mypackage.py::test_zero_division
============================================== 2 passed in 0.12s ==============================================
___________________________________________________ summary ___________________________________________________
py37: commands succeeded
congratulations :)

envlist に他のPythonのバージョン、例えば py38 を追加すると、python3.8でのテストも一緒に実行されます。

コードフォーマッター:black

コードレビューの際に「このコードはこうあるべき」というのが気になることがありますが、そんなときに役立つのがコードフォーマッターです。いわずもがな、フォーマットされたコードは読みやすく、またおかしなことをしている部分を見つけやすくなります。

Pythonにもいくつかのコードフォーマッターがあり、古いものだと autopep8、go言語の gofmt を参考にgoogleが作った yapf などがありますが、ここではblackというツールを紹介します。PythonにはPEP8というPythonのコーディング規約があり、これに従うことが基本となります。

black も基本このPEP8に準拠する形ですが、その上で black で定義された形にファイルフォーマットされる、いわばより制約の強いPEP8のような振る舞いをします。black のさらなる特徴としては、上述の2つのツールに比べて自由度が少ないことです。

ユーザーの設定の余地が少ないことから開発者の好みによったフォーマットが起きづらく、見た目が揃いやすいです。PyConJPなど各種イベントの入門者用の発表でも、利用を推奨されているツールです。

blackの実行

pip で簡単にインストールできますので、試してみます。
black コマンドだけだと対象ファイルにフォーマットをかけて書き直しますが、--check オプションを付けることで、フォーマットがかかる対象を確認できます。

(.venv) $ pip install black
(.venv) $ $ black . --check
All done! ✨ 🍰 ✨
5 files would be left unchanged.

特段エラーが無いことがわかりました。

余談ですが、 black の数少ない設定として、除外ファイルの設定ができます。デフォルトでも、よくあるファイルについてはexcludeされています。

(.venv) $ black --help
...中略...
--exclude TEXT A regular expression that matches files and
directories that should be excluded on
recursive searches. An empty value means no
paths are excluded. Use forward slashes for
directories on all platforms (Windows, too).
Exclusions are calculated first, inclusions
later. [default: /(\.eggs|\.git|\.hg|\.mypy
_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-
out|build|dist)/]
...以下略...

ヘルプにかかれているように、 .tox.venv 以下のファイルについては、最初から black の対象外にされています。

試しにその中のファイルに対して black をかけて見えると、reformatがかかることがわかります。

(.venv) $ black .venv/bin/rst2odt.py --check
would reformat .venv/bin/rst2odt.py
Oh no! 💥 💔 💥
1 file would be reformatted.

.tox.venv ともに .gitignore に記載されるような対象ですので、 black で管理する必要もないでしょう。
ちなみに開発環境によって他の除外ファイルを設定したい場合、オプションとして渡すこともできますが、 pyproject.toml ファイルに記載することで、まとめて外すこともできます。

toxへの取り込み

black によってコードチェックができるようになりましたので、これをtoxにも組み込みます。

といっても、 tox.ini を次のようにするだけです。これで、 tox コマンド実行時に black をかけることができます。

tox.ini
[tox]
# envlist: テスト環境の一覧。ここで記載した環境が構築されます。
# py37: インストールされている python3.7 コマンドを探し、 Python3.7 の virtualenv を作成します
envlist = py37, black


# [testenv]: テスト環境の設定。
[testenv]

# 環境にインストールするライブラリを指定します
# ここで渡したものが直接pipに渡されるため、requirements.txtの指定ができます
# `-r` と `requirements.txt` の間にスペースを入れるとエラーになります
deps = -rrequirements.txt


# 実行するコマンド: pytest
commands = pytest -rsfp

# black用のテスト環境。指定がなければtestenv環境が優先されます。
[testenv:black]
basepython = python3.7
deps = black
commands = black . --check

depsについては -rrequirements.txt で指定してもいいですが、他のパッケージを毎回インストールするのは時間がかかるので省いて、必要最小限にしています。

文法チェックツール:flake8

flake8 は静的文法チェックツールで、これもPEP8に従う形でコードをチェックし、バグになりやすいソースコードを探します。

flake8の実行

これも pip でインストールできます。

(.venv) $ pip install flake8
(.venv) $ flake8 src/
src/mypackage/mypackage.py:7:1: E302 expected 2 blank lines, found 1

なにか見つかりましたが、これは 関数の前には2行のblank lineが必要なのに1行しかないよ というエラーです。

src/mypackage/mypackage.py
import numpy as np


def mypackage_func():
return np.pi

def main():
print(mypackage_func())


if __name__ == "__main__":
main()

確かに、 def main(): の前に1行しかblank lineがありません。
なので、1行加えてみます。

src/mypackage/mypackage.py
import numpy as np


def mypackage_func():
return np.pi


def main():
print(mypackage_func())


if __name__ == "__main__":
main()
(.venv) $ flake8 src/
(.venv) $

先程のE302の表示が消えました。

flake8のtoxへの組み込み

flake8tox に組み込んで、自動化しましょう。
これも tox.ini の中に記載します。

tox.ini
[tox]
# envlist: テスト環境の一覧。ここで記載した環境が構築されます。
# py37: インストールされている python3.7 コマンドを探し、 Python3.7 の virtualenv を作成します
envlist = py37, black, flake8


# [testenv]: テスト環境の設定。
[testenv]

# 環境にインストールするライブラリを指定します
# ここで渡したものが直接pipに渡されるため、requirements.txtの指定ができます
# `-r` と `requirements.txt` の間にスペースを入れるとエラーになります
deps = -rrequirements.txt


# 実行するコマンド: pytest
commands = pytest -rsfp

# black用のテスト環境。指定がなければtestenv環境が優先されます。
[testenv:black]
basepython = python3.7
deps = black
commands = black . --check

[testenv:flake8]
deps = flake8
commands = flake8 .

[flake8]
max-line-length = 88
ignore = E203, W503, W504
exclude = .git, __pychache__, build, dist, .tox, .venv

先に black を導入していますが、 flake8black は併用可能です。ただし、一部非互換の部分があるので、2箇所修正を入れています。

1つは max-line-length ですが、こちらは black 側でデフォルトで88文字なので、それに合わせます。もしもblack側で変更を入れている場合は、それに合わせる形で修正します。もう1つが、E203, W503, W504です。こちらも black 側で許容されてしまうので、ignoreにします。

最後に、開発用のファイルまでチェック対象にすると大変なので、それらのファイルをexcludeします。デフォルトで .git などが含まれていますが、 .tox.venv が含まれていないので、ここで明示的に設定します。なお、 builddistsetup.py を使うときに将来的に必要になることを見越して入れています。

静的解析ツール:mypy

Python3.5から型ヒントがサポートされるようになりましたが、これはPythonに変数の型チェックをもたらしました。

コードを見ただけでその変数にどのようなクラスや型の値が入っているかわかるようになりましたが、あくまでヒントであって実行時には役には立ちません。この型を静的にチェックするツールが、mypyです。

mypyの実行

pip でインストールして試してみます。

(.venv) $ pip install mypy

mypyの使い方は、引数の右側に : 型名 と、関数やメソッドの右側に -> 型名 とつけることによって行います。
試しに、 これまで作っていた src/mypackage/mypackage.py にmypyをかけてみます。

src/mypackage/mypackage.py
import numpy as np


def mypackage_func():
return np.pi


def main():
print(mypackage_func())


if __name__ == "__main__":
main()

(.venv) $ mypy  src/mypackage/mypackage.py
src/mypackage/mypackage.py:1: error: Skipping analyzing 'numpy': found module but no type hints or library stubs
src/mypackage/mypackage.py:1: note: See https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports
Found 1 error in 1 file (checked 1 source file)

numpy がなにか引っかかりました。
これは、 mypy がサードパーティモジュールについてはStubファイルという型ヒント用のファイルを読み込んでチェックをかけるのですが、現在の環境にはそれがないため怒られています。
Stubファイルを作ることもできますが、よくやる手法としてはサードパーティモジュールの型ヒントを無視することをします。
これには、 mypy の設定ファイルである mypy.ini というファイルを編集します。

mypy.ini
[mypy]

[mypy-numpy]
ignore_missing_imports = True
(.venv) $ mypy src/mypackage/mypackage.py
Success: no issues found in 1 source file

これで numpy の型ヒントは無視されました。
続いて、 src/mypackage/mypackage.py に対して型ヒントを付与します。

src/mypackage/mypackage.py
import numpy as np


def mypackage_func():
return np.pi


def mypy_func(x: int) -> None:
print("This is mypy test: x = {}".format(x))
return


def main():
print(mypackage_func())


if __name__ == "__main__":
main()

このファイルに mypy をかけても、特にエラーはでません。
続いて、わざとエラーを出させてみます。

src/mypackage/mypackage.py
...前略...
def mypy_func(x: int) -> str: # リターンをNone->strに変えた
print("This is mypy test: x = {}".format(x))
return
...以下略...
$ mypy src/mypackage/mypackage.py
src/mypackage/mypackage.py:10: error: Return value expected
Found 1 error in 1 file (checked 1 source file)

確かにエラーが出ました。
さらに、 mypackage_func() の方もエラーを出させてみます。

src/mypackage/mypackage.py
...前略...
def mypackage_func() -> None:
return np.pi
...以下略...
$ mypy src/mypackage/mypackage.py
src/mypackage/mypackage.py:10: error: Return value expected
Found 1 error in 1 file (checked 1 source file)

np.pifloat なので明らかに間違っていますが、 mypy でエラーが出ていません。
今回 numpy はignoreにする対象としていますので、今回のチェック外となっています。
こちらですが、明示的に型指定すると、 mypy でエラーを検知できます。

src/mypackage/mypackage.py
...前略...
def mypackage_func() -> None:
return float(np.pi) # floatで明示的にキャストした
...以下略...
(.venv) $ mypy src/mypackage/mypackage.py
src/mypackage/mypackage.py:5: error: No return value expected
src/mypackage/mypackage.py:10: error: Return value expected
Found 2 errors in 1 file (checked 1 source file)

toxへのmypyの組み込み

最後に、 mypytox.ini に追加します。

tox.ini
[tox]
# envlist: テスト環境の一覧。ここで記載した環境が構築されます。
# py37: インストールされている python3.7 コマンドを探し、 Python3.7 の virtualenv を作成します
envlist = py37, black, flake8, mypy


# [testenv]: テスト環境の設定。
[testenv]

# 環境にインストールするライブラリを指定します
# ここで渡したものが直接pipに渡されるため、requirements.txtの指定ができます
# `-r` と `requirements.txt` の間にスペースを入れるとエラーになります
deps = -rrequirements.txt


# 実行するコマンド: pytest
commands = pytest -rsfp

# black用のテスト環境。指定がなければtestenv環境が優先されます。
[testenv:black]
basepython = python3.7
deps = black
commands = black . --check

[testenv:flake8]
deps = flake8
commands = flake8 .

[flake8]
max-line-length = 88
ignore = E203, W503, W504
exclude = .git, __pychache__, build, dist, .tox, .venv

[testenv:mypy]
deps = mypy
commands = mypy src

無事すべてのテストをパスしました。

(.venv) $ tox
...中略...
_______________________________ summary ________________________________
py37: commands succeeded
black: commands succeeded
flake8: commands succeeded
mypy: commands succeeded
congratulations :)

補足:VSCodeにおけるblackとflake8とmypyを取り込む

今回紹介したツールは都度かければきれいなコードが書けますが、逐次手動で実行するのは面倒です。
テスト実行時に気づけるといっても、できればコーディング中に自動整形してほしいし、適宜指摘してほしいので、そのための設定を行います。

Python用Extensionの導入

あまり言うまでもないかもしれませんが、 Python ms-python.python を導入し、有効化します。

setting.jsonの記述

File->Preference->Settingsと開きます。

Workspaceを選択し、 settings.json を開きます。

次のような感じで記載します。

settings.json
{
// pythonの環境設定
"python.pythonPath": ".venv/bin/python",
"python.venvPath": ".venv",
// Linterの設定
"python.linting.pylintEnabled": false,
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"python.linting.lintOnSave": true,
"python.linting.flake8Args": [
"--max-line-length",
"88",
"--ignore=E203,W503,W504"
],
// Formatterの設定
"python.formatting.provider": "black",
"editor.formatOnSave": true,
"editor.formatOnPaste": false
}

あとは、VSCodeをReload(再起動)してください。
これで、開発中に black, flake8, mypy が動作します。

最後に

ここまで作成したコードについては、こちらで公開していますので、必要に応じてご覧ください。

自作したパッケージをPyPIに登録することをしようとすると、 setup.py で固めて twine でアップロードして、などの工程が発生しますが、それらのやり方はインターネット上でいろいろな方が行われているため、ここでは割愛します。

また、これでテスト環境が整いましたので、例えばCircleCIなどに乗せて自動テストできるようにすることもできます。