CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: 自作ライブラリのパッケージングについて

今回は 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 = your-email@example.com
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 の中にメタデータなどを関数の引数として渡す。

setuptools.readthedocs.io

また、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: your-email@example.com
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 = your-email@example.com
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 というツールを使うのがおすすめ。 詳しくは、以下を参照のこと。

blog.amedama.jp

依存ライブラリを指定する

さて、話をちょっと戻して、ここからはパッケージングにおける色々なユースケースについて見ていく。

まずは、自作ライブラリが別のライブラリに依存している場合について。 この場合は [options] セクションに install_requires という項目で指定する。 例えば requests を依存ライブラリとして追加してみよう。

$ cat << 'EOF' > setup.cfg 
[metadata]
name = example
version = attr:example.__version__
author = Your Name
author_email = your-email@example.com
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 = your-email@example.com
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 = your-email@example.com
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 = your-email@example.com
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 = your-email@example.com
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) がある