続はじめてのPythonモジュール:AI的言語処理その4

形態素解析システムJUMANのPythonモジュールjumanyをPOSIX対応しました。

本稿では、POSIXではソースからライブラリをビルドし、またWindowsにはビルド済ライブラリを提供する場合の、setup.pyの書き方についてレポートします。

はじめに

前回、形態素解析システムJUMANのPythonモジュールを作成しました。

しかしWindows 64bitのビルド済みの共有ライブラリ同梱の形で、POSIXには対応していません。

そもそも、もともとのライブラリ(JUMAN)はPOSIXなので、setup.pyからmakeが出来れば、問題なくそのシステム対応の共有ライブラリが作れるはず。

ということで、setup.py内でライブラリをビルドするよう、POSIX対応することにしました。

前提条件

本稿は以下のシステムを対象としています。

  • Python3.3以降
  • setuptoolsを使ったsetup.pyの作成

distutilsのみ使ったsetup.pyには対応しません。

(といってもsetuptoolsはdistutilsを拡張しているので「from distutils import setup」を「from setuptools import setup」にするだけで対応なのですが。)

また本稿には、公式ドキュメントで裏を取っていない経験的な内容が多々含まれます。公式的にもっとスマートなやり方があるかもしれません。そのあたりをご了承ください。

開発環境はWindows10です。

結果

結果(たぶん)POSIX対応はできていて、PYPIにもアップしました。(頑張ってclassfiers書いたのに表示されてませんね。まあいいや。)

「pip install jumany」でインストール可能です。

POSIX対応はBash on Ubuntu on Windowsで試しているので、パーミッションなどでトラブる可能性はありえます。

教訓(lessons learned)

今回も学んだことを残しておきます。

主にsetuptoolsを使ったsetup.pyについてです。

参考:Setuptools’ documentation「Building and Distributing Packages with Setuptools

setuptoolsとdistutilsの違い

setuptoolsは、最近(Python3.4から?)はもう標準モジュール扱いのようです。最近のPythonのインストールにはもう入っていますので、こちらに乗り換えます。distutilsからの乗り換えは、先に書いたとおりで簡単です。

私の気がついた、setuptoolsdistutilsとの違いは以下です。

1点目において、Pythonのバージョンや他のモジュールの依存性が書けるので(python_requires, install_requires)、もうsetuptoolsを使ったほうがよいですね。

2点目のwheelは、現時点で主流のビルド済みのパッケージのことらしいです。(ソースのパッケージをpipでインストールすると「Wheelを作ってるよ」というメッセージがしばらく表示されます。なので予めこれにしておくと、ユーザーの時間短縮になりそうな。)

3点目には少し悩みました。古いMANIFESTが残っていて「なぜMANIFEST.inが反映されないのだろう」と。

「パッケージ名.egg-info」というディレクトリの中のSOURCE.txtがMANIFESTの代わりになります。

共有ライブラリのビルド

共有ライブラリの、ソース(*.c)からのビルドには、Extensionを使いました。

しかし教科書通りにしても、__init__.pyがあるパッケージのディレクトリにlibjuman.so(作ろうとしているライブラリ)を作ってくれません。

パケージのディレクトリにsoを置きたい理由

どこか他のPythonが探せる場所にできるらしいのですが、それを探すスクリプトは書きたくありません。その理由は以下です。

  • システムにJUMANがインストールされていて、そちらのlibjuman.soが参照される可能性。(名前の変更はあまりしたくありません。)
  • パケージディレクトリに置く、Windows用のビルド済みライブラリと同じ扱いにしたい。

もしかしたらよい方法があって杞憂なのかもしれませんが、パッケージのディレクトリにsoを置くことを目指します。

検索してもズバリの答えは見つかりません。自分の環境のsite-packages以下のモジュールでsoがあるものを探し、そのリポジトリ(github)からsetup.pyを見てみる、という手段でたどり着きました。

setup.pyの書き方

結局のところ、以下ようなの書き方で目標は達せられました。(以下は実行していないコードですので、間違いがあるかもしれません。実際はスクリプトでこの構造を作っています。)

import glob
from setuptools import setup, Feature, Extension
setup(
    (中略)
    features={
        'libjuman': Feature(
            'library of JUMAN',
            standard=True,
            ext_modules=[
                Extension(
                    name="jumany.libjuman",
                    sources=glob.glob('juman-7.01/lib/*.c'),
                    define_macros=[('HAVE_CONFIG_H', 1)],
                    include_dirs=['juman-7.01/lib', 'scripts'],
                    extra_compile_args=["-Wno-error=format-security"],
                )
            ]
        )
    }
    (後略)
)

教科書的には、9-17行のext_modulesを、直接setupの引数にします。その結果は先述の通り。(もしかしたら教科書的なExtensionの指定だけで行けてたのかもしれません。私の検討が悪かっただけで…。)

では、引数featuresと、そのdictに設定するクラスFeatureとは何でしょう?

謎です。検索しても解説は見つかりませんでした。setuptoolsの中のソースコードを読むしかありません。

コメントを眺めたところ、作るか作らないかをユーザーが選択できる拡張機能の書き方のようです。そして第一引数の文字列は、エラー表示するときに使われるそうです。以上。

仕様は謎ですが、パッケージディレクトリの中に、「libjuman.so」、或は「libjuman.<システムの略号>.so」というライブラリができるので、とにかく目標達成です。

 

ちなみにExtensionの引数をご説明いたしますと…、

11行のsourcesにはソースのファイル名をリストに列挙するのですが、面倒なのでglobでワイルドカードを展開し、ディレクトリ下のソースを丸ごと指定しています。

それ以降12-14行は、順に-D, -I, それ以外のコンパイルオプションに対応します。configureが作成するconfig.hだけは、予め作ってscriptsというディレクトリの中に入れてあります。なのでこのディレクトリも、ソースのディレクトリとともに「include_dirs」に指定しています。

「-Wno-error=format-security」は、buildの途中でコンパイルがエラー終了してしまいましたので入れました。

またパスの記述はsetup.py起点で良いようです。標準ライブラリのヘッダのある場所も特に指定していません。(C_INCLUDE_DIR等の環境変数は特に設定していません。)

ソースをパッケージに入れる

ソースのような、インストール・パッケージには入れるけどインストールはしない、というファイルはMANIFEST.inに指定します。

以下に、ライブラリに関連する部分を抜き出します。

# Shared Library
include scripts/config.h
include juman-7.01/lib/*.c
include juman-7.01/lib/*.h

予め作ったconfig.hだけ別に置いていますが、ソースはJUMANの展開ディレクトリから直接取得しています。

こちらもパスはsetup.py起点です。

読み込み側の書き方

Pythonの側で、ライブラリを読み込むときの書き方は以下のようになりました。

import glob
from ctypes import CDLL

LIBS = glob.glob(os.path.dirname(__file__) + '/libjuman.*so')
LIBC = CDLL(LIBS[0])

ライブラリの名前が「juman.so」なのか、それとも途中に環境の記述が入るのかはわかりませんが、とにかく同じディレクトリにある「libjuman.*so」にマッチするものを読み込みます。

(CDLL()の代わりに前回の「cdll.LoadLibrary()」でもOKです。また開発環境ではモジュールのディレクトリにsoがないので、実際はもうちょっと複雑にsoを探しています。)

カスタムビルド

以上でPOSIXの場合のライブラリのビルドができました。

Windowsのビルド済ライブラリに関しては、今まではパケージのディレクトリにコピーして、どんなシステムでもいつでも入るようにしていました。

しかしやはりPOSIXの場合には、余計なWindows用のファイルがない方がスマートです。またライセンスの記述も、Windows用のはLGPLもくっついて長いので、これもシステム別にインストールしたいところ。

そこで「python setup.py build」と入力したときに、ビルド(インストールされるファイルの構成)をカスタマイズできる方法を検討しました。

これに関してはStack Overflowに見つかりました。(distutilsと共通の方法です。setuptoolsには実はスマートな方法が実装されているのかもしれません。)

setup.pyの書き方

カスタムビルドの書き方を書き下すと、以下のようになります。

from setuptools import setup
from distutils.command.build import build

class CustomBuild(build):
    def run(self):
        build.run(self)
        (ここ以下にやりたい処理を書く)

setup(
    (中略)
    cmdclass={'build': CustomBuild},
    (後略)
)

4行のように、distutils.command.build.buildクラスを継承したクラスを定義し、そのrunメソッドの中で、親クラスのrun()を呼んだ後にやりたい処理を書きます。

そのクラスは、setupの引数cmdclassに、「build」の値として書いてあげます。(11行)

run()メソッド内の書き方

カスタムビルドのクラス(上でのCustomBuild)の、run()メソッド内の書き方について書きたいと思います。

まずCustomBuild へ情報を渡す場合ですが、インスタンスは見えませんから、グローバル変数か、作成したクラスのスタティック・メンバを経由することになります。後者がよいのでしょうね。

また、distutils.command.build.buildの親クラス、distutils.cmd.Commandには便利なメンバが定義されています。

詳細はdistutils内のソース(cmd.py)を見てもらうとして、以下に使いそうなものを挙げます。

  • self.build_lib :
    ライブラリのディレクトリ(build/lib)。パッケージ名を足せばパッケージディレクトリになり、そこにファイルを置くとインストールされる。
  • self.copy_file() : ファイルのコピー。distutils.file_util.copy_file()の呼び替え。
  • self.copy_tree() : ディレクトリのコピー。distutils.dir_util.copy_tree()の呼び替え。
  • self. mkpath() : ディレクトリの作成。distutils.dir_util.mkpath()の呼び替え。

私のやりたいこと(システムごとに異なるファイルをインストールすること)は、上から3つで事足りました。

さらに汎用的に

run()メソッドを、さら汎用的に書くと以下のようになります。

    def run(self):
        build.run(self)
        for rule in CustomBuild._rules:
            args = shlex.split(rule)
            cmd = args.pop(0)
            if cmd == 'copy':
                self.copy_file(*args)
            elif cmd == 'copy_tree':
                self.copy_tree(*args)
            elif cmd == 'move':
                self.move_file(*args)
            elif cmd == 'mkpath':
                self.mkpath(*args)
            elif cmd == 'execute':
                func = args.pop(0)
                self.execute(func, args)
            elif cmd == 'spawn':
                self.spawn(*args)

3行のCustomBuild._rulesには、したい処理をコマンドラインのような文字列のリストで設定してあげます。

それを一行ごと、4,5行にてコマンドと引数に分解し、以降でコマンド毎に対応するメソッドを呼んであげています。

実際のところは、copy_file, copy_tree, move_file, mkpathのdestに対応する引数には、先のself.build_libとパッケージ名を前に付けてあげる、という処理をして、パスの記述が短くて済むようにしています。

しかしこのコード、copy_file, copy_tree以外は試していません。executeやspawnなどは危険な匂いがします。

再利用可能に

カスタムビルドのクラスの定義がsetup.pyに入ると、全体の見通しが悪くなります。

これまで書いたことは使い回せそうですので、別ファイルにしたら、setup.py自体はスッキリするだろうと、思いました。

また、パッケージディレクトリ内のREADMEを読み込んで、long_descriptionに設定する処理は多くの人が書いています。これも使い回せそうです。

さらに、Pythonのバージョンについての依存性情報が、setupの引数python_requiresに書かれていて、さらにclassfiersの中にもすべてのバージョンを並べて書いていて、冗長です。

さらに私の場合はWindowsの64bitと32bitを別に判断する必要があって独自のシステム依存性チェックをしていました。これとclassfiersの中のOSの記述も重複します。

これらの情報を重複して書かなくてもよくする処理も含めて、別ファイルにして再利用可能にします。

そうしたら、もうsetup.pyでsetuptoolsをimportする必要すらなくなりました。

そのスッキリしたsetup.pyはこちら、汎用的な処理をまとめたファイルがこちらです。

はじめてのモジュールにしてはもう守破離の破ですが、共有ライブラリが絡んだためですので致し方なし。

リソースファイルと即席テスト

前々節にてシステムごとに違うファイルをインストールできるようにしましたが、これを使うとディレクトリ構成も自由に変更可能です。全システム共通の、リソースファイル、辞書ファイルも、開発環境の(__init__.pyのある)パッケージディレクトリに一旦コピーする必要もなくなります。

なので、setupの引数package_dataにしていた、これらのファイルのインクルードの指定ををMANIFEST.inに移動し、カスタムビルドの記述にパッケージディレクトリへのコピーの処理を加えてあげます。

するとsetup.pyも少々スッキリしましたし、モジュールパッケージの開発ディレクトリもスッキリしました。

しかし副作用もありました。

モジュールパッケージのディレクトリ下のpyファイルに書かれた、「if __name__ == '__main__':」以下の即席テストが実行できなくなるという…。(リソースファイルがパケージディレクトリにあること前提でしたので。)

とりあえず、パッケージディレクトリにない場合はコピー元の場所を見に行くように変えて対処しました。

このあたりは、setup.pyをスッキリさせるか即席テストを簡単にするか、開発者の好みになりそうです。(私は前者でしょうか。場合によりけりですが。)

Windowsの64bitと32bitの区別

これまで、Windowsの64bitと32bitは区別して別々のビルド済みライブラリを入れてあげるべき、という前提でやってきました。

Pythonのドキュメントにも、Extensionクラスのくだりでしたか、「WindowsはPOSIXと異なり、コンパイラが予め入ってないので考慮すべし」というようなことが書かれていたと思います。(それ以前にWindows にはPOSIXのAPIもないのですが…。)

しかしdistutilsやsetuptoolsのドキュメントの中に、そのような問題への対処が書かれている場所は、私には見つけられませんでした。(setuptoolsのドキュメントのDiscussionには、Todoで挙げられていましたが。)

Windowsの64bitか32bitかの取得が関数ではなく「if sys.maxsize > 2**32: #if 64bit」のようなTipsであったり、またsetupの引数のclassfiersのOSの記述に64bit・32bitの区別がなかったりするところが、Python界では区別が必要でないと考えられていた証左かもしれません。

しかしそれが出来たとしても、根本的な解決にはなりません。WindowsでもIntel以外のCPUが普及したら、もう個別のバイナリのサポートはできませんからね。

ということで根本解は、WindowsでPOSIXインタフェイスのソースのビルド・実行ができるミニマムな標準環境を、Microsoftが提供することです。(CygwinでもMinGWでもなく)

もしかしたらそれはBash on Ubuntu on Windowsで、もうPythonやその他OSSフレンドリなことは全部そこでやれ、ってことかもしれません。

(まだWindows7を使ってらっしゃる方も多いので、まだそれを強制することはできません。)

おわりに

JUMANのPythonモジュールをPOSIXに対応しました。

次回は、今回書けなかった、PYPIでの公開とテストについて書きたいと思います。

コメントを残す