CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: pip と Wheel キャッシュについて

これは Python Advent Calendar 2015 の 1 日目の記事です。

Wheel というのは、最近になって導入された Python の新しいパッケージング規格です。 今回はその Wheel を pip が積極的にキャッシュすることで気づいた点について書いてみます。

今回使用した環境は次の通りです。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.1
BuildVersion:   15B42
$ pip list | egrep (pip|setuptools|wheel)
pip (7.1.2)
setuptools (18.5)
wheel (0.26.0)

気づけば Wheel を使っていた

Python を使っている方であれば、パッケージマネージャの pip は使ったことがあるとおもいます。 その pip ですが、実は最近のバージョンでは知らず知らずのうちに裏側で Wheel を使うような仕組みになっていることをご存知でしょうか。 実は pip を使って PyPI のパッケージをインストールしようとしたときには、必ず Wheel を使ってインストールするようになっています。

PyPI に Wheel があるとき

PyPI にはたくさんのパッケージが登録されていますが、その中には Wheel が用意されているものといないものがあります。 これはパッケージを作って登録したひとがソースコード配布物とは別に Wheel パッケージもアップロードするか否かによって変わります。 まずは Wheel が用意されている場合ですが、これはその Wheel がインストールにそのまま使われます。

試しに PyPI に Wheel が用意されているパッケージの代表として requests をインストールしてみましょう。

$ pip install requests
Collecting requests
  Downloading requests-2.8.1-py2.py3-none-any.whl (497kB)
    100% |████████████████████████████████| 499kB 545kB/s 
Installing collected packages: requests
Successfully installed requests-2.8.1
$ pip list | grep requests
requests (2.8.1)

ログから Wheel (*.whl) がダウンロードされてインストールに使われたことがわかります。

PyPI に Wheel がないとき

PyPI に Wheel がなくてソースコード配布物しか用意されていない場合はどうでしょうか。 実は、その場合にも内部的には Wheel が使われるようになっています。

これがどういうことなのか確認するため、Wheel が用意されていないパッケージの代表として lxml をインストールしてみましょう。 この作業には lxml に含まれる C 拡張モジュールのコンパイルが必要なため時間がかかります。 time コマンドを使ってインストールに要した時間も記録しておきましょう。

$ time pip install lxml
Collecting lxml
  Downloading lxml-3.4.4.tar.gz (3.5MB)
    100% |████████████████████████████████| 3.5MB 138kB/s 
Building wheels for collected packages: lxml
  Running setup.py bdist_wheel for lxml
  Stored in directory: /Users/amedama/Library/Caches/pip/wheels/62/29/26/11383932dbed36e9fe68552fc755a96cfc6fa5833d948620da
Successfully built lxml
Installing collected packages: lxml
Successfully installed lxml-3.4.4
pip install lxml  46.51s user 1.84s system 94% cpu 51.298 total

インストールには 46 秒かかりました。

ダウンロードされたのはソースコード配布物 (*.tar.gz) ですが、"Building wheels for collected packages: lxml" というログからもわかる通り、それを Wheel にビルドした上でインストールしていることがわかります。 そして、"Stored in directory: " となっているところは今回の本題となる Wheel のキャッシュです。

つまり、最近の pip は PyPI に Wheel が用意されていない場合、ソースコード配布物を Wheel にビルドした上でインストールし、更にそれをキャッシュするようになっているということです。

Wheel の何がうれしいのか?

Wheel の利点は色々ありますが、一番分かりやすいのは C 拡張モジュールがコンパイル済みの状態で同梱されているところだとおもいます。

試しに、先ほどインストールした lxml を一旦アンインストールした上で、もう一度インストールしてみましょう。 今度もインストールの際には time コマンドを使って要した時間を記録します。

$ pip uninstall -y lxml
Uninstalling lxml-3.4.4:
  Successfully uninstalled lxml-3.4.4
$ time pip install lxml
Collecting lxml
Installing collected packages: lxml
Successfully installed lxml-3.4.4
pip install lxml  0.71s user 0.11s system 98% cpu 0.825 total

なんと今度は 1 秒かからずにインストールが終わっています。 これはキャッシュされた Wheel を使ってインストールが行われたため、パッケージのダウンロードと C 拡張モジュールをコンパイルする時間がいらなくなったためです。

Wheel キャッシュにひそむ罠

このようにインストールにかかる時間の短縮に絶大な効果を発揮する Wheel のキャッシュですが、その反面思わぬ事態を招くこともあることに最近気付きました。

試しに pudb というパッケージをインストールしてみましょう。 これは Python 向けのグラフィカルなデバッガです。 このパッケージには、まだ PyPI に Wheel が用意されていないためソースコード配布物を手元でビルドしてそれをローカルにキャッシュすることになります。

$ pip install pudb==2015.4.1
Collecting pudb
  Downloading pudb-2015.4.1.tar.gz (49kB)
    100% |████████████████████████████████| 53kB 2.3MB/s 
Collecting urwid>=1.1.1 (from pudb)
  Downloading urwid-1.3.1.tar.gz (588kB)
    100% |████████████████████████████████| 589kB 635kB/s 
Collecting pygments>=1.0 (from pudb)
  Downloading Pygments-2.0.2-py3-none-any.whl (672kB)
    100% |████████████████████████████████| 675kB 666kB/s 
Building wheels for collected packages: pudb, urwid
  Running setup.py bdist_wheel for pudb
  Stored in directory: /Users/amedama/Library/Caches/pip/wheels/f5/42/17/0cb57e3de0702f797b1f19a1dcbe84b04d2b9ca3e750d75176
  Running setup.py bdist_wheel for urwid
  Stored in directory: /Users/amedama/Library/Caches/pip/wheels/20/ed/aa/f28c05890e0dd72e7023a9fb3e5966a0a9d7a1e9682a29a94f
Successfully built pudb urwid
Installing collected packages: urwid, pygments, pudb
Successfully installed pudb-2015.4.1 pygments-2.0.2 urwid-1.3.1

pudb のインストールが終わると pudb3 というコマンドが使えるようになります。

$ pudb3
Usage: pudb3 [options] SCRIPT-TO-RUN [SCRIPT-ARGUMENTS]

Options:
  -h, --help          show this help message and exit
  -s, --steal-output  
  --pre-run=COMMAND   Run command before each program run

この末尾についている 3 という数字は pudb 自体のバージョンではなく、インストール先の Python バージョンによって付与されるようになっています。

github.com

つまり、使っているのが 3.x 系であれば末尾に '3' が付与されて、2.x 系であればつかないという寸法です。 今回は使用したバージョンが 3.4 だったのでつきました。

$ python --version
Python 3.4.3

それでは、今度は Python 2.7 の仮想環境を作りなおした上で、改めて pudb をインストールしてみましょう。 これには前述した通り、先ほどビルドした Wheel のキャッシュが使われます。 なんだか嫌な予感がしてきたでしょうか? 尚、仮想環境の作成手順については省略しています。

$ python --version
Python 2.7.10
$ pip install pudb==2015.4.1
Collecting pudb
Collecting urwid>=1.1.1 (from pudb)
  Using cached urwid-1.3.1.tar.gz
Collecting pygments>=1.0 (from pudb)
  Downloading Pygments-2.0.2-py2-none-any.whl (672kB)
    100% |████████████████████████████████| 675kB 530kB/s 
Building wheels for collected packages: urwid
  Running setup.py bdist_wheel for urwid
  Stored in directory: /Users/amedama/Library/Caches/pip/wheels/20/ed/aa/f28c05890e0dd72e7023a9fb3e5966a0a9d7a1e9682a29a94f
Successfully built urwid
Installing collected packages: urwid, pygments, pudb
Successfully installed pudb-2015.4.1 pygments-2.0.2 urwid-1.3.1

インストール先の Python のバージョンが 2.7 なのだから、コマンド名に 3 はつかないはずです。 しかし、そうはなりません。 なぜか 3 が末尾に付与されたコマンドがインストールされています。

$ pudb
zsh: command not found: pudb
$ pudb3
Usage: pudb3 [options] SCRIPT-TO-RUN [SCRIPT-ARGUMENTS]

Options:
  -h, --help          show this help message and exit
  -s, --steal-output  
  --pre-run=COMMAND   Run command before each program run

一体、何が起こったのか?

ひと言にいえば、ビルドした環境の Python バージョンによってコマンド名が pudb3 に固定された状態でその Wheel がキャッシュされてしまったせいです。 誤解を避けるためにあらかじめ断っておきますが、これは Wheel というシステムの問題ではなく設定の不備によって引き起こされています。 適切に Wheel の設定が行われている限りこの問題は起きません。

それでは、詳しく見ていきましょう。 Wheel では、パッケージの作り方によっては Python のバージョン 2.x と 3.x に共通で使用できる単一の Wheel ファイル (*.whl) を作ることもできます。 これは、setup.cfg の wheel セクションに universal フラグを 1 にすることでそれを示すことになっています。 そして先ほどインストールした pudb (バージョン 2015.4.1) についてもそのようになっています。

github.com

しかし、前述した通り pudb はインストール先の Python のバージョン毎にコマンド名を変更するようになっています。 そして、コマンド名は Wheel ファイルをビルドするタイミングで決まってしまいます。 つまり、厳密にいえばひとつの Wheel ファイルをふたつのメジャーバージョンで使い回すことはできないわけです。

試しに手元で pudb のソースコードをチェックアウトして手動で Wheel ファイルをビルドしてみましょう。 できあがった Wheel のファイル名には py2.py3 というバージョン番号が入っている通り、どちらのバージョンでも使えることを示しています。

$ git clone https://github.com/inducer/pudb.git
$ cd pudb
$ git checkout -b v2015.4.1 v2015.4.1
$ python setup.py bdist_wheel
$ ls dist
pudb-2015.4.1-py2.py3-none-any.whl

しかし、実際には universal = 0 とすることで、Python 2.x と 3.x で別々に Wheel ファイルをビルドする必要があったわけです。

$ sed -i -e "s:^\(universal = \)1$:\10:" setup.cfg
$ cat setup.cfg
[flake8]
ignore = E126,E127,E128,E123,E226,E241,E242,E265,W503,E402
max-line-length=85
[wheel]
universal = 0
$ python setup.py bdist_wheel
$ ls dist | grep py2-
pudb-2015.4.1-py2-none-any.whl

設定に不備があるパッケージを扱うには?

ここまで見てきたように、設定の不備があるパッケージはたまにあるようです。 pip が積極的に Wheel ファイルをキャッシュするようになったのは最近のことなので、まだまだ問題に気づかれていないのだとおもいます。

では、設定に不備があるパッケージをそれでもインストールしなければならないときにはどうすればいいのでしょうか。 実は pip には --no-cache-dir というオプションがあります。 これを使えば Wheel ファイルをキャッシュせずにインストールできます。

$ pip install --no-cache-dir pudb

既にキャッシュされてしまった Wheel を削除したいときには、以下のコマンドで Wheel がキャッシュされている場所を調べましょう。

$ find $(python -c "from pip.locations import USER_CACHE_DIR; print(USER_CACHE_DIR)") -name "*.whl"
/Users/amedama/Library/Caches/pip/wheels/20/ed/aa/f28c05890e0dd72e7023a9fb3e5966a0a9d7a1e9682a29a94f/urwid-1.3.1-cp27-none-macosx_10_11_x86_64.whl
/Users/amedama/Library/Caches/pip/wheels/20/ed/aa/f28c05890e0dd72e7023a9fb3e5966a0a9d7a1e9682a29a94f/urwid-1.3.1-cp34-cp34m-macosx_10_11_x86_64.whl
/Users/amedama/Library/Caches/pip/wheels/62/29/26/11383932dbed36e9fe68552fc755a96cfc6fa5833d948620da/lxml-3.4.4-cp34-cp34m-macosx_10_11_x86_64.whl
/Users/amedama/Library/Caches/pip/wheels/f5/42/17/0cb57e3de0702f797b1f19a1dcbe84b04d2b9ca3e750d75176/pudb-2015.4.1-py2.py3-none-any.whl

まとめ

今回は、最近の pip が新しいパッケージング規格である Wheel をこっそりと裏側で使っていることを示しました。 その上で、Wheel をビルドする際の設定に不備があると、まずい状態の Wheel ファイルがキャッシュされてしまい、ハマる原因になることを解説しました。 パッケージを作る際には、setup.cfg の中にある wheel セクションの universal フラグを慎重に設定する必要があります。

補足

今回の件については pudb にプルリクエストを送って既にマージされたため、次のリリースでは直っているはずです。 github.com

おまけ

今回の件で pip のソースコードをちょこっと読みました。

以下は pip がキャッシュを取得するメカニズムです。 PyPI のソースコード配布物を示す URL から生成された Link オブジェクトが、WheelCache#cached_wheel() メソッドを通すとローカルのキャッシュを指し示すものに変換されています。

>>> from pip import wheel
>>> from pip import index
>>> from pip import locations
>>> 
>>> url = 'https://pypi.python.org/packages/source/p/pudb/pudb-2015.4.1.tar.gz#md5=2589255f1885a9eab10f666ca0f6204c'
>>> link = index.Link(url)
>>> cache = wheel.WheelCache(locations.USER_CACHE_DIR, index.FormatControl(set(), set()))
>>> cache.cached_wheel(link, 'pudb')
<Link file:///Users/amedama/Library/Caches/pip/wheels/f5/42/17/0cb57e3de0702f797b1f19a1dcbe84b04d2b9ca3e750d75176/pudb-2015.4.1-py2.py3-none-any.whl>

また、これもソースコードを読んでわかったのですが Wheel キャッシュに使われる長いディレクトリ名は URL を SHA224 でハッシュしたものになっているようです。

>>> url = 'https://pypi.python.org/packages/source/p/pudb/pudb-2015.4.1.tar.gz#md5=2589255f1885a9eab10f666ca0f6204c'
>>> import hashlib
>>> hashlib.sha224(url.encode()).hexdigest()
'f542170cb57e3de0702f797b1f19a1dcbe84b04d2b9ca3e750d75176'