純 Python で C のライブラリを呼び出す(Linux inotifyを例に)

Python は Batteries included 哲学のもと、豊富なモジュールが標準で組み込まれていますので、大抵のことは標準モジュールを使えばできますし、そうでなくても、どこかの誰かが Python binding を作ってくれているはずですので、Cのライブラリを直接呼び出すような必要はほとんどありません。

ただ、今回、Cのコンパイル無しに(≒モジュールの追加インストール無しに) inotify を使いたい、という事情があり、直接 libc を呼び出す binding を作りました。コード自体はすごく簡単なのですが、概念が若干わかりにくかったので、今日の記事はその備忘録です。

ctypes と struct を使う!

使うモジュールは主に ctypes です。加えて、inotify では、read() で構造体の読み込みを行うため、read で読んだバイナリ文字列をパースするために struct も必要になりました。

いずれも、公式の標準ライブラリドキュメントに詳細なチュートリアルがあるので、じっくり読み込めば理解はできます。(やることがマニアックなので、ちゃんと頭を回しながら読まないと辛いとおもいますが。)

ctypes でのライブラリ読み込み

まずは、目的とするライブラリを読み込みます。

ファイル名などが正確に分かっている場合はそれを指定して

libc = CDLL("libc.so.6")

などとします。
同名のライブラリでもバージョンが違うとAPIが変わる可能性があるため、バージョン番号まで含めた指名をする必要があります。

バージョンはどれでもよく、そのシステムにインストールされているものを使うのであれば、find_libray()を使って

libc = CDLL(find_library("c"))

といったやりかたができます。(が、もちろん、バージョンが違うと、元々欲しかったAPIが無かったり、変わっている可能性はありますので注意が必要です。)

関数の宣言

ライブラリが export している関数は、読み込んだ dll オブジェクトの属性としてアクセスできます。

print libc.time(None)

簡単な用途であれば、このまま使えばよいのですが、引数のチェックも働きませんし、戻り値も int とみなして扱われるため、このままでは不都合な場合もあると思います。

ですので、大抵の場合は、一旦関数プロトタイプをつくって、呼び出し用の外部関数オブジェクトをつくってやるのがよいでしょう。

inotify_add_watch = CFUNCTYPE(c_int, c_int, c_char_p, c_uint32)(
    ("inotify_add_watch", libc),
    ((1, "fd"), (1, "pathname"), (1, "mask"))
)

これで、 inotify_add_watch() 関数が使えるようになります。

CFUNCTYPE に与えている引数は、先頭が戻り値の型で、残りは引数の型です。CFUNCTYPEが返す関数に対して与えている引数は、第一引数が (シンボル名, dllオブジェクト) のタプルで、第二引数は引数の情報を表す (フラグ, パラメータ名, デフォルト値) タプルのリストになります。(各引数で、パラメータ名とデフォルト値は省略可能)

フラグは、1: 入力パラメータ 2: 出力パラメータ 4: デフォルトが整数0 の組み合わせの整数です。(ポインタを渡してそこに書かせるような関数なら 2 を指定するわけですね。)

パラメータ名は、関数を呼ぶときの名前付き引数として使えますし、デフォルト値まで指定しておけば、引数を省略して呼べるようになります。

ここで、引数の型として c_char_p などを使っていますが、これらは ctypes モジュールで定義されています。構造体などを自分で定義することもできますので、その時は公式リファレンスを参照のこと。

また、「戻り値が負の値だったら exception を raise する」とか、「出力パラメータの引数の値を関数の戻り値として返す」といったちょっと高度な結果処理をしたい場合、errcheck 属性を使うことで実現できます。こちらも公式リファレンス参照。

pure-python の inotify binding

というわけで、上記のテクニックを使って inotify の binding をつくってみました。

#!/usr/bin/env python2

from ctypes import CDLL, CFUNCTYPE, c_char_p, c_int, c_uint32
from ctypes.util import find_library
import argparse
import os
import struct
import sys

__libc = CDLL(find_library("c"))

inotify_init = CFUNCTYPE(c_int)(("inotify_init", __libc), ())
inotify_init1 = CFUNCTYPE(c_int, c_int)(("inotify_init", __libc), ((1, "flags"),))

inotify_add_watch = CFUNCTYPE(c_int, c_int, c_char_p, c_uint32)(
    ("inotify_add_watch", __libc),
    ((1, "fd"), (1, "pathname"), (1, "mask"))
)

inotify_rm_watch = CFUNCTYPE(c_int, c_int, c_int)(
    ("inotify_rm_watch", __libc),
    ((1, "fd"), (1, "wd"))
)

IN_ACCESS           = 0x00000001
IN_MODIFY           = 0x00000002
IN_ATTRIB           = 0x00000004
IN_CLOSE_WRITE      = 0x00000008
IN_CLOSE_NOWRITE    = 0x00000010
IN_OPEN             = 0x00000020
IN_MOVED_FROM       = 0x00000040
IN_MOVED_TO         = 0x00000080
IN_CREATE           = 0x00000100
IN_DELETE           = 0x00000200
IN_DELETE_SELF      = 0x00000400
IN_MOVE_SELF        = 0x00000800

IN_UNMOUNT          = 0x00002000
IN_Q_OVERFLOW       = 0x00004000
IN_IGNORED          = 0x00008000

IN_CLOSE            = IN_CLOSE_WRITE | IN_CLOSE_NOWRITE
IN_MOVE             = IN_MOVED_FROM | IN_MOVED_TO

IN_ONLYDIR          = 0x01000000
IN_DONT_FOLLOW      = 0x02000000
IN_EXCL_UNLINK      = 0x04000000
IN_MASK_ADD         = 0x20000000
IN_ISDIR            = 0x40000000
IN_ONESHOT          = 0x80000000

def _decode_flag(flag):
    flag_names = [x for x in globals().keys() if x.startswith('IN_')]
    flag_names.remove('IN_CLOSE')
    flag_names.remove('IN_MOVE')
    flag_names.sort(key=lambda x: globals()[x])
    r = []
    for n in flag_names:
        if globals()[n] & flag != 0:
            r.append(n)
    return r

def main():
    def integer(x):
        return int(x, base=0)
    parser = argparse.ArgumentParser()
    parser.add_argument('path', nargs='?', default='.',
        help='directory or file to watch')
    parser.add_argument('-f', '--flag', action='append', default=[],
        choices=[x for x in globals().keys() if x.startswith('IN_')],
        metavar='FLAG', help='event name to watch')
    parser.add_argument('-m', '--mask', default=0, type=integer,
        help='numeric event mask to watch')
    parser.add_argument('-a', '--all', action='store_const',
        dest='mask', const=0xfff, help='watch all event(-m 0xfff)')
    args = parser.parse_args()

    mask = args.mask
    for x in args.flag:
        mask |= globals()[x]
    if mask == 0:
        mask = IN_ACCESS

    print 'watching {} for inotify events: {}'.format(
        args.path, ",".join(_decode_flag(mask)))

    fd = inotify_init()
    wd = inotify_add_watch(fd, args.path, mask)
    try:
        while True:
            buf = os.read(fd, 4096)
            i = 0
            fmt = 'iIII'
            fmt_size = struct.calcsize(fmt)
            while i < len(buf):
                wd, mask, cookie, name_len = struct.unpack_from(fmt, buf, i)
                i += fmt_size
                name = buf[i:i+name_len]
                i += name_len
                print("{:08x}({}) {} {}".format(
                    mask, ",".join(_decode_flag(mask)), cookie, name))
    except KeyboardInterrupt:
        pass
    inotify_rm_watch(fd, wd)
    os.close(fd)

if __name__ == '__main__':
    sys.exit(main())

binding 本体は先頭の 20行ほどで終わっていて、残りは定数定義と、単体実行時の main() 関数になります。

このスクリプトを -a オプション付きで実行すると、カレントディレクトリに対するすべてのファイルアクセスを監視しますので、試してみてください。(他のオプションは -h で)