FIELD NOTES: 書を持って街へ出よう

合同会社フィールドワークス プログラマ兼代表のブログ

ビルドツールをOMakeからpyKookへ乗り換えた話

Field Reports 1.1を開発するにあたり,ビルドツールをOMakeからpyKookへ乗り換えました。

なぜOMakeをやめたのか?

OMakeをやめた理由は以下のとおりですが,主に私がうまく使いこなせなかった事が原因です。
OMakeは素晴らしいツールであり,それ自体には特に問題はありません。

  • 各サブディレクトリを意図通りの順序でビルドするためには,ディレクトリをまたがってファイル間の依存関係を完璧に記述する必要がある。単純にディレクトリAからBの順にビルドすれば十分な場合でも簡単には表現できない場合がある(しばしば直感に反した動作をするため悩む)。
  • 標準のビルドルールを生成する関数(OCamlLibraryなど)の定義ではフラグの指定などが足りなくて改造する必要があった。改造するためにはOMake言語の仕様を泥縄式に学習する必要が生じ,OMakeで楽ができるはずがまったく楽ができなくなってしまった。

pyKookとは?


RMakeのPython版のようなビルドツールです。
http://www.kuwata-lab.com/kook/pykook-users-guide.html

通常は,Kookbook.py という名称のPythonスクリプトを作成して,以下のようにビルドルールを記述します。

# product "hello" depends on "hello.o".
@recipe("hello", ["hello.o"])
def file_hello(c):
    """generates hello command"""           # recipe description
    system("gcc -g -o hello hello.o")

# product "hello.o" depends on "hello.c" and "hello.h".
@recipe("hello.o", ["hello.c", "hello.h"])
def file_hello_o(c):
    """compile 'hello.c' and 'hello.h'"""   # recipe description
    system("gcc -g -c hello.c")

kk コマンドの実行により,ビルドが開始されます。

共通ルールの記述

最初から分かっていたことではありますが,OMakeの便利な機能への未練もあったのでいくつか工夫をしてみました。

まずpyKookでは,Properties.py というファイルに共通のプロパティをまとめて記述することはできますが,OMakerootのようにビルドルールや関数を置くことができません。そこで,プロジェクトのルートディレクトリに Kookware.py というファイルを置いて,共通ルールやプロパティを記述しておくことにしました。

## properties
# compile parameters (OCaml)
OCAMLFIND = prop('OCAMLFIND', 'ocamlfind')
OCAMLC = prop('OCAMLC', 'ocamlc')
OCAMLOPT = prop('OCAMLOPT', 'ocamlopt')
OCAMLDEP = prop('OCAMLDEP', 'ocamldep')
BYTE_CFLAGS = prop('BYTE_CFLAGS', '')
NATIVE_CFLAGS = prop('NATIVE_CFLAGS', '')
BYTE_LDFLAGS = prop('BYTE_LDFLAGS', '')
NATIVE_LDFLAGS = prop('NATIVE_LDFLAGS', '')

# compile parameters (Stub)
CC = prop('CC', 'ocamlc')
CFLAGS = prop('CFLAGS', '')
LD = prop('LD', 'ocamlmklib')
LDFLAGS = prop('LDFLAGS', '')

# include path
OCAML_INCLUDES = prop('OCAML_INCLUDES', [])

# package names to pass ocamlfind
OCAML_PACKS = prop('OCAML_PACKS', [])

# package names to pass '-for-pack' option
OCAML_FOR_PACKS = prop('OCAML_FOR_PACKS', [])

# OCaml libraries, without suffix
OCAML_LIBS = prop('OCAML_LIBS', [])

# OCaml objects, without suffix
OCAML_OBJS = prop('OCAML_OBJS', [])

# include path to stub libraries
OCAML_STUB_INCLUDES = prop('OCAML_STUB_INCLUDES', [])

# stub libraries, without suffix
OCAML_STUB_LIBS = prop('OCAML_STUB_LIBS', [])

## generic rules
@recipe
@product('*.cmi')
@ingreds('$(1).ml', if_exists('$(1).mli'))
def file_cmi(c):
    """generates cmi files"""
    include_flags = " ".join(["-I "+s for s in OCAML_INCLUDES])
    for_pack = ('-for-pack ' + '.'.join(OCAML_FOR_PACKS)) if OCAML_FOR_PACKS else ''
    system("%s %s %s %s -c %s" %
            (OCAMLC, BYTE_CFLAGS, include_flags, for_pack, c.ingreds[-1]))

@recipe('*.cmo', ['$(1).ml', '$(1).cmi'])
def file_cmo(c):
    """generates cmo files"""
    include_flags = " ".join(["-I "+s for s in OCAML_INCLUDES])
    for_pack = ('-for-pack ' + '.'.join(OCAML_FOR_PACKS)) if OCAML_FOR_PACKS else ''
    system("%s %s %s %s -c %s" %
            (OCAMLC, BYTE_CFLAGS, include_flags, for_pack, c.ingred))

@recipe('*.cmx', ['$(1).ml', '$(1).cmi'])
def file_cmx(c):
    """generates cmx files"""
    include_flags = " ".join(["-I "+s for s in OCAML_INCLUDES])
    for_pack = ('-for-pack ' + '.'.join(OCAML_FOR_PACKS)) if OCAML_FOR_PACKS else ''
    system("%s %s %s %s -c %s" %
        (OCAMLOPT, NATIVE_CFLAGS, include_flags, for_pack,  c.ingred))

@recipe('*.o', '$(1).c')
def file_o(c):
    """compile stub C files"""
    system("%s %s -c %s" % (CC, CFLAGS, c.ingred))

各サブディレクトリのKykook.py からは,execfileでKookware.py を読み込みます。

execfile("./Kookware.py")

サブディレクトリの渡り歩き

サブディレクトリの渡り歩きが楽なように,共通関数を作成しました。

## utility functions
def walk(dirs, target):
    for d in dirs:
        with chdir(d):
            system("kk " + target)

## task recipes
SUBDIRS = []
@recipe
def task_clean(c):
    walk(SUBDIRS, c.product)

@recipe
def task_all(c):
    walk(SUBDIRS, c.product)

単に各サブディレクトリでkkコマンドを実行するだけでよければ,各サブディレクトリの Kookbook.py に SUBDIRS のみ記述しておけばOKです。

SUBDIRS = ['A', 'B', 'C']

ビルドルール生成関数

ビルドルール生成関数も実現してみました。

def OCamlProgram(basename, files, target=''):
    """build OCaml program"""
    global file_cmi, file_cmo, file_cmx, file_run, file_opt
   
    result = []

    files = make_ocamldep(files)

    run_file = basename + ".run"
    opt_file = basename + ".opt"

    cmo_files = [s+'.cmo' for s in files]
    cmx_files = [s+'.cmx' for s in files]

    cma_files = " ".join([s+'.cma' for s in OCAML_LIBS])
    cmxa_files = " ".join([s+'.cmxa' for s in OCAML_LIBS])

    include_flags = ' '.join('-I '+s for s in OCAML_INCLUDES)

    ocamlfind = OCAMLFIND if USE_OCAMLFIND else ""
    package_flags = "-linkpkg -package " + ",".join(OCAML_PACKS) if USE_OCAMLFIND else ""

    if BYTE_ENABLED:
        @recipe(run_file, cmo_files)
        def file_run(c):
            """generates run file"""
            system("%s %s %s %s %s -o %s %s %s" % (ocamlfind, OCAMLC, package_flags,
                BYTE_LDFLAGS, include_flags, c.product, cma_files, " ".join(c.ingreds)))
        result.append(run_file)

    if NATIVE_ENABLED:
        @recipe(opt_file, cmx_files)
        def file_opt(c):
            """generates opt file"""
            system("%s %s %s %s %s -o %s %s %s" % (ocamlfind, OCAMLOPT, package_flags,
                NATIVE_LDFLAGS, include_flags, c.product, cmxa_files, " ".join(c.ingreds)))
        result.append(opt_file)

    return result

関数内で定義したビルドルールをpyKookに認識させるためには,global宣言してやる必要があります。make_ocamldep 関数については,次で説明します。

このルールを利用する側では次のように記述します。

execfile("./Kookware.py")

basename = 'hello'
files = [
    'hello',
    'hello_sub',
]
programs = OCamlProgram(basename, files)

@recipe
@ingreds(programs)
def task_all(c):
    pass

依存関係の解析

ocamldep コマンドの出力結果を解析してビルドルールを自動生成する関数も作成しました。

メタプログラミング的に関数定義を追加する方法がわからなかったので,Pythonコードの文字列を組み立てて exec() で評価しています。
また,ファイル間の依存関係にもとづいて,ファイルを位相ソートして返します。

def t_sort(files, relation):
    visited = {}
    result = []
    def visit(n):
        if not visited.has_key(n):
            visited[n] = 1
            for m in relation[n].keys():
                visit(m)
            result.append(n)
    for k, v in relation.items():
        visit(k)
    return result

def flatten(ls):
    return sum((flatten(i) for i in ls), []) if isinstance(ls, list) else [ls]

def make_ocamldep(files):
    import subprocess, re
    from StringIO import StringIO

    def write_rules(f, product, ingreds):
        f.write("global file_%s_%s\n" % (product[0], product[1]))
        f.write("@recipe('%s.%s', %s)\n" % (product[0], product[1], ingreds))
        f.write("def file_%s_%s(c):\n" % (product[0], product[1]))
        f.write("    file_%s(c)\n\n" % (product[1],))

    relation = {}
    for fi in files:
        relation[fi] = {}
    f = StringIO()
    include_flags = flatten([["-I", s] for s in OCAML_INCLUDES])
    ml_files = [s+".ml" for s in files]
    cmd = flatten([OCAMLDEP, include_flags, ml_files])
    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE).stdout
    lines = re.sub(r"\\\n", " ", "".join(pipe.readlines()))
    for line in lines.split('\n'):
        fs = line.strip().split(':')
        if len(fs) == 2 and fs[1]:
            product = fs[0].split('.')
            ingreds = re.split('\s+', fs[1].strip())
            for i in ingreds:
                child = i.split('.')
                if product[1] == child[1]:
                    d = relation.get(product[0], {})
                    d[child[0]] = 1
                    relation[product[0]] = d
            if product[1] in ['cmo', 'cmx']:
                write_rules(f, product, [product[0]+'.ml'] + ingreds)
    pipe.close()
    exec(f.getvalue())
    f.close()
    return t_sort(files, relation)
2011.12.06 追記

pyKook 0.6.0以降では,動的にルールを変更する機能を使ってmake_ocamldepを以下のように書き換えることができます。

def make_ocamldep(files):
    import subprocess, re
    def add_rules(product, ingreds):
        r = kookbook[".".join(product)]
        for ingred in ingreds[1:]:
            r.ingreds.append(ingred)
    relation = {}
    for fi in files:
        relation[fi] = {}
    include_flags = flatten([["-I", s] for s in OCAML_INCLUDES])
    ml_files = [s+".ml" for s in files]
    cmd = flatten([OCAMLDEP, include_flags, ml_files])
    pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE).stdout
    lines = re.sub(r"\\\r?\n", " ", pipe.read())
    for line in lines.split('\n'):
        fs = line.strip().split(':')
        if len(fs) == 2 and fs[1]:
            product = fs[0].split('.')
            ingreds = re.split('\s+', fs[1].strip())
            for i in ingreds:
                child = i.split('.')
                if product[1] == child[1]:
                    d = relation.get(product[0], {})
                    d[child[0]] = 1
                    relation[product[0]] = d
            if product[1] in ['cmo', 'cmx']:
                add_rules(product, [product[0]+'.ml'] + ingreds)
    pipe.close()
    return t_sort(files, relation)

まとめ

本腰を入れてOMakeを勉強したほうが楽だったんではないかとも思いますが,ともかくOMakeからpyKookへ移行に成功しました。
実際にはライブラリを構築するためのルールなども定義したのですが,プロジェクト固有の要素が分離できていないので,今回は公開しません。

OMakeと比べて荒削りな点が多々ありますが,

  • 慣れたPythonでアクションを記述できる。
  • ディレクトリをまたがった動作が逐次的なので把握しやすい。

といった点が気に入っています(完全に自己満足の世界ですが)。