今回は Python で自作したライブラリなどをパッケージングして、配布できる状態にする方法について書いてみる。
現在の Python では、パッケージングに setuptools というサードパーティ製のライブラリを使うのがデファクトスタンダードになっている。 この setuptools は、pip などを使ってパッケージをインストールするときにも必要になるので、実は気づかずに使っているという場合も多いかもしれない。 また、サードパーティ製といっても PyPA (Python Packaging Authority) というコミュニティが管理しているので準公式みたいな位置づけ。
今回使った環境は次の通り。
$ sw_vers ProductName: Mac OS X ProductVersion: 10.14.2 BuildVersion: 18C54 $ python -V Python 3.7.2 $ pip list | grep setuptools setuptools 40.7.2
パッケージング用のサンプルコードを用意する
まずはパッケージングの対象となるサンプルコードを用意する必要がある。
そこで、今回は greet()
という関数を一つだけ備えたパッケージとして example
を用意した。
関数 greet()
は、呼び出すと標準出力にメッセージをプリントする。
$ mkdir example $ cat << 'EOF' > example/__init__.py # -*- coding: utf-8 -*- __version__ = '0.0.1' def greet(): """挨拶をする関数""" print('Hello, World!') EOF
ちょっと紛らわしいんだけど Python においてパッケージというのは、パッケージングされたライブラリのことを意味していない。 ここでは、Python のコードが入ったディレクトリ、くらいの感覚で考えてもらえれば良い。
ちゃんとした Python におけるモジュールとパッケージという概念については以下を参照のこと。
簡単にいうとモジュールは Python ファイル (*.py
) で、パッケージは __init__.py
という名前のファイルが入ったディレクトリのことを指している。
blog.amedama.jp
上記で作ったパッケージは、今いるディレクトリからであれば Python の処理系からインポートして使うことができる。 これは、カレントワーキングディレクトリに Python のパスが通っているため。
$ python -c "import example; example.greet()"
Hello, World!
しかし、当然のことながら別の場所に移動してしまうと使えなくなる。 これは、先ほどのパッケージに Python のパスが通っていないため。
$ cd /tmp && python -c "import example; example.greet()" Traceback (most recent call last): File "<string>", line 1, in <module> ImportError: No module named example
もちろん、明示的にパスを通してしまえば使うことはできる。 とはいえ、毎回こんなことをしたくはないはず。
$ PYTHONPATH=$HOME/workplace/packaging python -c "import example; example.greet()" Hello, World!
そこで、作成したライブラリに必要なもの一式をまとめた上で、あらかじめパスの通っている場所に配置できるようにする仕組みが、今回扱うパッケージングというわけ。
サンプルコードをパッケージングする
それでは、先ほど作った example
をパッケージングしてみよう。
setuptools を使ったパッケージングでは、まず setup.py
という名前の Python ファイルを用意する。
このファイルは、セットアップスクリプトと呼ばれる。
$ cat << 'EOF' > setup.py #!/usr/bin/env python # -*- coding: utf-8 -*- from setuptools import setup setup() EOF
セットアップスクリプトの中では setuptools に含まれる関数 setup()
を呼び出している。
引数には何も渡しておらず、これは比較的新しい書き方をしている。
古い書き方では setup()
にパッケージに関する様々な情報を引数で渡すことになっていた。
では、パッケージに関する様々な情報は、代わりに何処で渡すのかというと setup.cfg
という設定ファイルに記述する。
設定ファイルは ini フォーマットっぽい形式で書いていく。
パッケージの基本的な情報は [metadata]
というセクションで扱う。
$ cat << 'EOF' > setup.cfg [metadata] name = example version = attr:example.__version__ author = Your Name author_email = [email protected] description = example is a example package long_description = file:README.md url = https://example.com/your/project/page license = Apache License, Version 2.0 classifier = Development Status :: 1 - Planning Programming Language :: Python Intended Audience :: Developers Operating System :: POSIX Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 [options] zip_safe = False packages = find: EOF
なお、この setup.cfg
にメタデータなどを記述していく方式は setuptools のバージョン 30.0.3.0 (リリース日 2016年12月8日) から導入された。
この点は、インストール先となる環境の setuptools にもバージョン 30.0.3.0 以降が求められる点に注意が必要になる。
それより前のバージョンをサポートしたいときは、従来通り setup.py
の中にメタデータなどを関数の引数として渡す。
また、setup.cfg
では、いくつかの項目において外部のファイルを読み込むこともできる。
例えば、先ほどの例において long_description
では README.md
の内容を読み込むよう指定している。
なので、ファイルを作っておこう。
$ cat << 'EOF' > README.md ### What is this? A long description for example project. EOF
さて、これでパッケージングの準備ができた。 ディレクトリは、このような状態になっている。
$ ls README.md example setup.cfg setup.py
試しに pip を使ってパッケージをインストールしてみよう。 この操作は、ライブラリに必要なもの一式をまとめる作業と、インストールする作業を一気にやっているのに等しい。
$ pip install -U .
うまくいけば、次のように pip のインストール済みパッケージの一覧に表示される。
$ pip list | grep example example 0.0.1 $ pip show example Name: example Version: 0.0.1 Summary: example is a example package Home-page: https://example.com/your/project/page Author: Your Name Author-email: [email protected] License: Apache License, Version 2.0 Location: /Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages Requires: Required-by:
また、全然別の場所でパッケージをインポートして使うこともできるようになる。 これは、pip によって作成したパッケージがパスの通った場所に配置されたため。
$ cd /tmp && python -c "import example; example.greet()" Hello, World!
先ほど pip show
したときの Location
にあった通り、今回は以下のディレクトリにインストールされていた。
$ cat ~/.virtualenvs/py37/lib/python3.7/site-packages/example/__init__.py # -*- coding: utf-8 -*- __version__ = '0.0.1' def greet(): """挨拶をする関数""" print('Hello, World!')
動作確認が終わったら、一旦パッケージをアンインストールしておこう。
$ pip uninstall -y example
ソースコード配布物 (sdist) にパッケージングする
先ほどはライブラリに必要なもの一式をまとめるのとインストールするのを pip コマンドで一気にやってしまった。 続いては、この工程を別々に分けてやってみよう。
まずは、基本となるソースコード配布物 (sdist) へのパッケージングから。
パッケージングは先ほど作ったセットアップスクリプトを使って sdist
コマンドを実行する。
$ python setup.py sdist
すると dist
というディレクトリ以下に tarball ができる。
これこそ、できあがったソースコード配布物のファイル。
$ ls dist example-0.0.1.tar.gz
このソースコード配布物からパッケージをインストールできる。
$ pip install dist/example-0.0.1.tar.gz $ pip list | grep example example 0.0.1
確認できたら、また一旦アンインストールしておこう。
$ pip uninstall -y example
Wheel フォーマットでパッケージングする
ソースコード配布物は基本ではあるものの、使うとちょっとめんどくさい場合もある。 具体的には Python/C API を使った拡張モジュールがライブラリに含まれているパターン。 ソースコード配布物では、拡張モジュールをビルドしていないソースコードの状態で同梱する。 すると、インストール先の環境にはそれをビルドするための開発ツール類一式が必要になる。
そこで、最近は次世代のパッケージング形式として Wheel フォーマットというものが使われ始めている。 このフォーマットでは拡張モジュールはあらかじめビルドされた状態で同梱される。 ただし、環境に依存する場合には各環境ごとに Wheel ファイルを用意する必要がある。
実際に Wheel ファイルをパッケージングしてみよう。
これにはセットアップスクリプトで bdist_wheel
コマンドを実行する。
$ python setup.py bdist_wheel
すると、次の通り末尾が whl
の Wheel ファイルが dist
ディレクトリ以下に作られる。
$ ls dist | grep whl$ example-0.0.1-py3-none-any.whl
もちろんこの Wheel ファイルからもパッケージをインストールできる。
$ pip install dist/example-0.0.1-py3-none-any.whl $ pip list | grep example example 0.0.1
ちなみに、上記の Wheel ファイルは名前を見て分かる通り Python 3 専用としてビルドされている。
もし、Python 2 でも動く場合には setup.cfg
の [wheel]
セクションについて universal
フラグを有効にする。
$ cat << 'EOF' > setup.cfg [metadata] name = example version = attr:example.__version__ author = Your Name author_email = [email protected] description = example is a example package long_description = file:README.md url = https://example.com/your/project/page license = Apache License, Version 2.0 classifier = Development Status :: 1 - Planning Programming Language :: Python Intended Audience :: Developers Operating System :: POSIX Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 [options] zip_safe = False packages = find: [wheel] universal = 1 EOF
この状態でビルドすると Python 2/3 両対応の Wheel ファイルになる。
$ python setup.py bdist_wheel $ ls dist | grep whl$ example-0.0.1-py2.py3-none-any.whl example-0.0.1-py3-none-any.whl
動作が確認できたら、また一旦アンインストールしておこう。
$ pip uninstall -y example
パッケージングした成果物を PyPI に登録する
さて、パッケージングできるようになると、実は成果物を PyPI に登録して一般に公開できる。 この作業には PyPA 製の Twine というツールを使うのがおすすめ。 詳しくは、以下を参照のこと。
依存ライブラリを指定する
さて、話をちょっと戻して、ここからはパッケージングにおける色々なユースケースについて見ていく。
まずは、自作ライブラリが別のライブラリに依存している場合について。
この場合は [options]
セクションに install_requires
という項目で指定する。
例えば requests を依存ライブラリとして追加してみよう。
$ cat << 'EOF' > setup.cfg [metadata] name = example version = attr:example.__version__ author = Your Name author_email = [email protected] description = example is a example package long_description = file:README.md url = https://example.com/your/project/page license = Apache License, Version 2.0 classifier = Development Status :: 1 - Planning Programming Language :: Python Intended Audience :: Developers Operating System :: POSIX Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 [options] zip_safe = False packages = find: install_requires = requests [wheel] universal = 1 EOF
この状態でインストールしてみよう。
$ pip install -U .
すると、次のように requests が一緒にインストールされたことが分かる。
$ pip list | egrep "(example|requests)" example 0.0.1 requests 2.21.0
環境に依存した依存ライブラリを指定する
続いては環境によって必要だったり不要だったりする依存ライブラリの指定について。 例えばリレーショナルデータベースを扱うアプリケーションだと、接続先が MySQL なのか PostgreSQL なのかで必要なドライバが変わる。
例として接続先が MySQL の環境なら mysqlclient をインストールする、という状況を想定してみよう。
この場合、[options.extras_require]
セクションに環境名と必要なライブラリを指定していく。
$ cat << 'EOF' > setup.cfg [metadata] name = example version = attr:example.__version__ author = Your Name author_email = [email protected] description = example is a example package long_description = file:README.md url = https://example.com/your/project/page license = Apache License, Version 2.0 classifier = Development Status :: 1 - Planning Programming Language :: Python Intended Audience :: Developers Operating System :: POSIX Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 [options] zip_safe = False packages = find: install_requires = requests [options.extras_require] mysql = mysqlclient [wheel] universal = 1 EOF
この状態で、環境に mysql
を指定してパッケージをアップデートしてみよう。
$ pip install -U ".[mysql]"
すると、mysqlclient がインストールされることが分かる。
$ pip list | grep mysqlclient mysqlclient 1.4.1
ソースコード以外のファイルを成果物に含める
続いては、ソースコード以外のファイルを成果物に含める方法について。 実は、デフォルトでは成果物にソースコード以外のファイルは含まれない。 試しに実験してみよう。
まずはパッケージに greet_from_file()
という関数を追加する。
これはテキストファイルから読み込んだ内容を使ってメッセージをプリントする関数になっている。
$ cat << 'EOF' > example/__init__.py # -*- coding: utf-8 -*- from __future__ import print_function import os __version__ = '0.0.1' def greet(): """挨拶をする関数""" print('Hello, World!') def greet_from_file(): """テキストファイルの内容を使って挨拶する関数""" module_dir = os.path.dirname(__file__) filepath = os.path.join(module_dir, 'message.txt') with open(filepath, mode='r') as fp: print(fp.read(), end='') EOF
続いて、表示するメッセージの元となるファイルを用意する。
$ cat << 'EOF' > example/message.txt Hello, World! EOF
ローカルでは、これで動くようになる。
$ python -c "import example; example.greet_from_file()"
Hello, World!
では、インストールした上ではどうだろうか。
$ pip install -U .
試すと、そんなファイルないよと怒られる。 パッケージングした成果物の中にテキストファイルが含まれていないためだ。
$ cd /tmp && python -c "import example; example.greet_from_file()" Traceback (most recent call last): File "<string>", line 1, in <module> File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/example/__init__.py", line 19, in greet_from_file with open(filepath, mode='r') as fp: FileNotFoundError: [Errno 2] No such file or directory: '/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/example/message.txt'
ソースコード以外のファイルを成果物に含めるには、まず setup.cfg
の [options]
セクションにおいて include_package_data
の項目を有効にする。
$ cat << 'EOF' > setup.cfg [metadata] name = example version = attr:example.__version__ author = Your Name author_email = [email protected] description = example is a example package long_description = file:README.md url = https://example.com/your/project/page license = Apache License, Version 2.0 classifier = Development Status :: 1 - Planning Programming Language :: Python Intended Audience :: Developers Operating System :: POSIX Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 [options] zip_safe = False include_package_data = True packages = find: install_requires = requests [options.extras_require] mysql = mysqlclient [wheel] universal = 1 EOF
その上で MANIFEST.in
というファイルを用意する。
ここに、成果物に含めるソースコード以外のファイルを指定していく。
$ cat << 'EOF' > MANIFEST.in include example/message.txt EOF
この状態で、もう一度試してみよう。
$ pip install -U . $ cd /tmp && python -c "import example; example.greet_from_file()" Hello, World!
今度はエラーにならず実行できた。
コマンドを追加する
続いてはパッケージを追加したときに新たにコマンドが使えるようにする方法について。
今回はコマンド用に別の設定ファイルを用意することにした。
まずは setup.cfg
の [options]
セクションに entry_points
という項目を作る。
$ cat << 'EOF' > setup.cfg [metadata] name = example version = attr:example.__version__ author = Your Name author_email = [email protected] description = example is a example package long_description = file:README.md url = https://example.com/your/project/page license = Apache License, Version 2.0 classifier = Development Status :: 1 - Planning Programming Language :: Python Intended Audience :: Developers Operating System :: POSIX Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 [options] zip_safe = False include_package_data = True packages = find: entry_points = file:entry_points.cfg install_requires = requests [options.extras_require] mysql = mysqlclient [wheel] universal = 1 EOF
その上で、指定したのと同じ設定ファイルを用意する。
以下では example-greet
というコマンドを実行したとき example
パッケージの greet()
関数が呼ばれるようにしている。
$ cat << 'EOF' > entry_points.cfg [console_scripts] example-greet = example:greet EOF
それでは、パッケージを更新してみよう。
$ pip install -U .
すると example-greet
というコマンドが使えるようになっている。
$ example-greet Hello, World!
テストコードを成果物に含めない
パッケージングしたくなるようなコードには、もちろんテストコードを書くことになる。 続いてはテストコードを成果物に含めない方法とともに、具体的なオペレーションを紹介してみる。
まずはテストをする環境向けの依存ライブラリを [options.extras_require]
セクションで指定する。
テストフレームワークには pytest を使うことにした。
同時に、テストを成果物に含めないように [options.packages.find]
セクションで tests
というディレクトリを探索対象から除外する。
$ cat << 'EOF' > setup.cfg [metadata] name = example version = attr:example.__version__ author = Your Name author_email = [email protected] description = example is a example package long_description = file:README.md url = https://example.com/your/project/page license = Apache License, Version 2.0 classifier = Development Status :: 1 - Planning Programming Language :: Python Intended Audience :: Developers Operating System :: POSIX Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 [options] zip_safe = False include_package_data = True packages = find: entry_points = file:entry_points.cfg install_requires = requests [options.extras_require] mysql = mysqlclient testing = pytest [options.packages.find] exclude = tests [wheel] universal = 1 EOF
続いてはテストコードを入れるパッケージを用意する。 中身でやっているのは、あまり本質的ではないことなので気にしなくて良いと思う。
$ mkdir tests $ touch tests/__init__.py $ cat << 'EOF' > tests/test_example.py #!/usr/bin/env python # -*- coding: utf-8 -*- import os import sys import pytest from example import greet if sys.version_info >= (3, 0, 0): # Python 3 from io import StringIO else: # Python 2 from StringIO import StringIO def test_greet(): """挨拶は大事""" # 標準出力を入れ替える io = StringIO() sys.stdout = io # 挨拶する greet() # 挨拶した内容を確認する assert io.getvalue() == ('Hello, World!' + os.linesep) if __name__ == '__main__': pytest.main([__file__]) EOF
準備ができたら、テスト環境向けにインストールする。
$ pip install -U ".[testing]"
修正したときの手間などを考えるとパッケージ本体は普段はアンインストールしておいた方が良いかも。
$ pip uninstall -y example
テストを走らせるにはテストランナーを実行する。
$ py.test ====================================== test session starts ====================================== platform darwin -- Python 3.7.2, pytest-4.2.0, py-1.7.0, pluggy-0.8.1 rootdir: /Users/amedama/Documents/temporary/packaging, inifile: collected 1 item tests/test_example.py . [100%] =================================== 1 passed in 0.02 seconds ====================================
テストが実行できることが分かったので、続いては成果物の中身を確認してみよう。
$ rm -rf dist $ python setup.py sdist $ tar xvf dist/example-0.0.1.tar.gz -C dist $ find dist/example-0.0.1 dist/example-0.0.1 dist/example-0.0.1/PKG-INFO dist/example-0.0.1/example.egg-info dist/example-0.0.1/example.egg-info/PKG-INFO dist/example-0.0.1/example.egg-info/not-zip-safe dist/example-0.0.1/example.egg-info/SOURCES.txt dist/example-0.0.1/example.egg-info/entry_points.txt dist/example-0.0.1/example.egg-info/requires.txt dist/example-0.0.1/example.egg-info/top_level.txt dist/example-0.0.1/example.egg-info/dependency_links.txt dist/example-0.0.1/example dist/example-0.0.1/example/__init__.py dist/example-0.0.1/example/message.txt dist/example-0.0.1/MANIFEST.in dist/example-0.0.1/README.md dist/example-0.0.1/setup.py dist/example-0.0.1/setup.cfg
成果物の中には tests
というディレクトリが含まれていないことが分かる。
まとめ
- Python のパッケージングには setuptools というライブラリを使う
- パッケージングするときはセットアップスクリプト (
setup.py
) を用意する - 最近 (>= 30.0.3.0) の書き方では、なるべく別の設定ファイル (
setup.cfg
) に内容を記述する - パッケージングの成果物にはソースコード配布物 (sdist) と Wheel (whl) がある
スマートPythonプログラミング: Pythonのより良い書き方を学ぶ
- 作者: もみじあめ
- 発売日: 2016/03/12
- メディア: Kindle版
- この商品を含むブログ (1件) を見る