pythonでCSVを消費する処理を再開可能にしたい

github.com

はじめに

CSVを消費する処理を再開可能にしたいという気持ちになりました。具体的には、1つ1つの処理にそこそこ時間が掛かる(30秒から1分)ものをそこそこ多く(104件くらい)処理しないといけないことがあったのですが。DBとか用意したり使ったりするの面倒だなと思ったときのことです。

CSVを消費したい(再開したい)

例えば以下の様なイメージです(実際の処理とは異なります)。

input.csv

id,x,y
1,10,20
2,100,100

このようなcsvがあって、これらの各行に対して処理を行う(例えば和を求める)必要があるとします。

output.csv

id,v
1,30 // 本当は結構重たい
<-- このあたりで止めたい(再開したい)
2,200

実際、重いと言っても計算的なものではなく主に帯域制限的なものが原因です。なので並列化とかほぼ意味がない状態なのですが。途中で失敗したら辛いという感じの状況です。

随時終わるたびに書き出していき、終わったところまで入力を削るみたいな作業をして手動で入力となるファイルを書き換えていっても良いのですが。だるい。

csvresumable

まぁそんなわけでだるかったので。ちょっとしたライブラリを作ることにしました(まだ開発途中なのでAPIの変更は普通にあると思います)。具体的には以下の様な形で動きます。

  • 通常のCSVのDictReaderと同様に動く
  • どこまで終わったのかを別途記録する(history.csv(実際には別の場所に記録されます))
  • (再開時には、記録していたところまでの入力はスキップする)

状態などを管理するのは面倒だったので、完全にinsertだけで済むようにしました。

例えば上の例で言えば、処理の途中で止めたいということは

id,x,y
1,10,20
<-- ここで止めたい
2,100,100

以下の様な履歴(history.csv)を用意し(csvである必要はない)

id
1

再開(resume)の際はこれとzipしたiterator(概念上)に対して処理を行えば良いということになります。履歴に残ったものはスキップしてしまえば良いということです。

ちなみに、pandasなどのinterfaceを用意しなかった理由は、そもそも計算や集計が目的ではなかったためです。単なる情報をくれるevent streamとしてCSVがあれば良いというだけだったので(つまりCSVである理由も特にない可能性があります。そのあたりも込みでinterfaceが変わりうるという感じです)。

実際の利用例

以下の様な形で書けます。csv.DictReaderのかわりにcsvresumable.DictReader を使います。

import json
import time
import sys
from csvresumable import DictReader

with open("input.csv") as rf:
    r = DictReader(rf)
    for row in r:
        print("start", row["id"], file=sys.stderr)

        # 重たい処理
        time.sleep(2)
        print(json.dumps(row))  # 重たい処理をした結果のつもり
        sys.stdout.flush()

CSVから1行ずつ取得していき、それを入力として何らかの処理を行うという形です。この処理がそこそこ重めの処理(と言っても先程言った通りにほぼほぼ流量制限が原因で高速化できない)になっており、数十秒程度掛かると言ったものだとします。

例えば先程のinput.csv (再掲)に対して実行し、

id,x,y
1,10,20
2,100,100

実行を途中で止めます(id=2の部分は計算が終わらない。あるいはエラー)。

$ python main.py
start 1
{"id": "1", "x": "10", "y": "20"}
start 2
    KeyboardInterrupt

途中で止まったので、途中から再開したいはずです。ここで RESUME=1 という環境変数と一緒に実行すると再開(resume)できます。

$ RESUME=1 python main.py
start 2
{"id": "2", "x": "100", "y": "100"}

# 全部実行し終わった後なら何も出力されない
$ RESUME=1 python main.py

ちなみにRESUMEをつけないとはじめからやり直しです(つまり何も知らない人にとってはただのcsv.DictReaderとして動く)。

$ python main.py
start 1
{"id": "1", "x": "10", "y": "20"}
start 2
{"id": "2", "x": "100", "y": "100"}

環境変数以外の方法

ところで環境変数で設定というのが、設定より規約(CoC)っぽい感じがして嫌という人いると思います。そんな人は真面目にオプションを与えてください。以下の様な形で。

--- 00add/main.py    2018-06-14 17:45:23.000000000 +0900
+++ 01add/main.py 2018-06-14 18:36:18.000000000 +0900
@@ -1,10 +1,15 @@
 import json
 import time
 import sys
+import argparse
 from csvresumable import DictReader
 
+parser = argparse.ArgumentParser()
+parser.add_argument("--resume", action="store_true")
+args = parser.parse_args()
+
 with open("input.csv") as rf:
-    r = DictReader(rf)
+    r = DictReader(rf, resume=args.resume)
     for row in r:
         print("start", row["id"], file=sys.stderr)

--resume を使ってresumeできます

$ python main.py
start 1
{"id": "1", "x": "10", "y": "20"}
start 2
    KeyboardInterrupt

# resume
$ python main.py --resume
start 2
{"id": "2", "x": "100", "y": "100"}

idとして扱う値を変えたい場合

idとして使う値を変えたくなることがあるかもしれません。その場合にはkeyオプションがあります。これはsorted()関数と同様のイメージで考えてもらえれば良いです。渡されるCSVというのはかならずしも常に自分の意図した通りの構造で渡されるということがなかったりしますし。

例えば、以下の様な形かもしれません。

groupId,userId,name,age,cache
1,1,foo,8,1000
1,2,bar,10,200
2,3,boo,2,0
3,4,bar,2,100

デフォルトでは左端をidとして扱いますが、上の例では左端のgroupIdではなくuserIdをidとして消費したくなると思います。このような場合には以下の様に書けば良いです。

import time
import sys
from csvresumable import DictReader

with open("input.csv") as rf:
    r = DictReader(rf, key=lambda row: row["userId"])  # key=を使う
    for row in r:
        print("start", row["userId"], file=sys.stderr)

        # 重たい処理
        time.sleep(2)
        print(row["name"], int(row["age"]) / int(row["cache"]))
        sys.stdout.flush()

テキトウに年齢(勤続年数?)を貯金で割って、1万円?を稼ぐのに何年掛かるのかというような値でも計算してみましょう(これはテキトーな例です)。

python main.py
start 1
foo 0.008
start 2
bar 0.05
start 3
Traceback (most recent call last):
  File "main.py", line 12, in <module>
    print(row["name"], int(row["age"]) / int(row["cache"]))
ZeroDivisionError: division by zero

おや、エラーになってしまいましたね。0除算を気にしてませんでした(まぁこういう感じでたまに考慮漏れのエラーがあったりします)。 テキトウに直したら。

--- 02groupid/main.py    2018-06-15 01:13:54.785744154 +0900
+++ 03groupid/main.py 2018-06-15 01:22:14.473955859 +0900
@@ -9,5 +9,9 @@
 
         # 重たい処理
         time.sleep(2)
-        print(row["name"], int(row["age"]) / int(row["cache"]))
+        if int(row["cache"]) == 0:
+            ans = "-"
+        else:
+            ans = int(row["age"]) / int(row["cache"])
+        print(row["name"], ans)
         sys.stdout.flush()

再開します。

$ RESUME=1 python main.py
start 3
boo -
start 4
bar 0.02

途中から再開できてますね。

複数のCSVを合成した結果を元に消費したい場合

ところで、今までは入力がひとつだけでしたが。複数の入力が必要になることもあると思います。そのような場合にも一応対応はしています。

直列につなぐ場合(concat)

単純に複数に分割されたファイルを入力だとしましょう。

input.csv

groupId,userId,name,age,cache
1,1,foo,8,1000
1,2,bar,10,200

input2.csv

groupId,userId,name,age,cache
2,3,boo,2,0
3,4,bar,2,100

そのような場合はつなげるだけです。

import time
import sys
from csvresumable import DictReader

# 1つではなく2つなのでforループ
for filename in ["input.csv", "input2.csv"]:
    with open(filename) as rf:
        r = DictReader(rf, key=lambda row: row["userId"])
        for row in r:
            print("start", row["userId"], file=sys.stderr)

            # 重たい処理
            time.sleep(2)
            if int(row["cache"]) == 0:
                ans = "-"
            else:
                ans = int(row["age"]) / int(row["cache"])
            print(row["name"], ans)
            sys.stdout.flush()

注意点としてはDictReaderのiteratorをリストなどにして消費しないようにしてください。

$ python main.py
start 1
foo 0.008
start 2
    KeyboardInterrupt
$ RESUME=1 python main.py
start 2
bar 0.05
start 3
boo -
start 4
    KeyboardInterrupt
$ RESUME=1 python main.py
start 4
bar 0.02

ファイルの切れ目など関係なくresumeできています。これは当然と言えば当然なのですが。渡すファイルの順序は常に一定にしてください(あるときは input2.csv input.csv などの順序であるなど順序が不定の場合にはおかしくなります)。

並列につなぐ場合(groupby)

今度は並列につなぐ場合を考えてみます。例えば先程のcsvについてgroupIdでgroupingされた結果に対する何らかの処理をしてみるということにしてみましょう。そのような場合でも考え方は同様です。毎回常に一定の順序でeventが発生するevent streamのようなものが構成されていればそれで十分です(入力がCSVである必要も特にありません)。

このような場合には csvresumable.iterate を使います。

import time
import sys
import csv
import itertools
import csvresumable

# event streamはiteratorであれば良い
def gen(files):
    sources = [csv.DictReader(open(f)) for f in files]
    sorted_sources = sorted(itertools.chain.from_iterable(sources), key=lambda row: row["groupId"])
    return itertools.groupby(sorted_sources, key=lambda row: row["groupId"])


for group_id, rows in csvresumable.iterate(gen(["input.csv", "input2.csv"])):
    print("start group_id", group_id, file=sys.stderr)
    time.sleep(2)
    print("total", sum(int(row["cache"]) for row in rows))

# groupingは以下の様な形
# 1 [{"groupId": "1", "userId": "1", "name": "foo", "age": "8", "cache": "1000"},
#    {"groupId": "1", "userId": "2", "name": "bar", "age": "10", "cache": "200"}
#   ]
# 2 [{"groupId": "2", "userId": "3", "name": "boo", "age": "2", "cache": "0"}]
# 3 [{"groupId": "3", "userId": "4", "name": "bar", "age": "2", "cache": "100"}]

例を見てわかる通り、実は入力がCSVである必要はありません。一定の順序を保った何らかのeventのstreamであれば大丈夫です(pythonで言えばiterator)。 defaultではitertools.groupbyなどと同様にiterateされた行をリストと捉えての最初の要素をidとして扱いますが(key=lambda xs: xs[0])、もちろんkeyオプションがとれます。

chainしてsortしてとやっているので、原理的には与えられたファイルを全部見ているわけですが。そもそも冒頭で触れたように元となる入力の数自体はせいぜい104程度しかありません。なのでそこまでコストというわけでもないです。

途中で止めてRESUMEで再開できます。

$ python main.py
start group_id 1
total 1200
    KeyboardInterrupt

$ RESUME=1 python main.py
start group_id 2
total 0
start group_id 3
total 100

おまけ

ちなみにchainしてsortしてgroupbyというのは結構よくやる処理なのですが。毎回書くのもめんどくさいのでconcat_groupbyという関数を用意しています。

def gen(files):
    source = [csv.DictReader(open(f)) for f in files]
    return csvresumable.concat_groupby(source, key=lambda row: row["groupId"])

ところで先頭N件だけ取りたいという場合にはitertools.isliceが使えます。

def gen(files, *, size=None):
    sources = [csv.DictReader(open(f)) for f in files]
    itr = csvresumable.concat_groupby(sources, key=lambda row: row["groupId"])
    if size is not None:
        itr = itertools.islice(itr, size)
    return itr

対象となるevent streamは消費しないように気をつけてください(消費というのはlist(gen(files))のようなことを指してます)。

再開時に過去の出力を覚えておきたい場合

さて、いままでは処理の中断・再開を扱ってきましたが。出力を全て通して行いつつ、実際の処理自体は中断・再開したいということがあります。例えば、先程のスクリプトが以下のようなMakefileに書かれていたタスクだったとします。

default:
  python main.py | tee output.csv

ここで、処理を再開したときには、過去分も含めた全ての実行結果が渡されて欲しいはずです(もちろん、呼び出すスクリプト側でファイル入出力を行い、追記でやるという案もあります)。

このようなときにちょっと一手間を加えると良い感じにできます。

import time
import sys
import csv
import csvresumable


def gen(files):
    source = itertools.chain.from_iterable([csv.DictReader(open(f)) for f in files])
    return csvresumable.concat_groupby(source, key=lambda row: row["groupId"])

# captueで包んだ
with csvresumable.capture():
    for group_id, rows in csvresumable.iterate(gen(["input.csv", "input2.csv"])):
        print("start group_id", group_id, file=sys.stderr)
        time.sleep(2)
        print("total", sum(int(row["cache"]) for row in rows))
        sys.stdout.flush()  # 呼び出し方によってはbufferingされてしまう場合もある

captureという名前が良いかはまだ微妙ですが。このコンテキストマネージャでくるんであげるとその間の出力を記録しておけます(ちなみに引数で記録したいstreamは変更できます。デフォルトが標準出力)。 再開時には記録していた出力を再度出力してくれるため、再開(resume)時にも過去も含めた全てを出力をしてれるようになります。

$ make
python main.py | tee output.txt
start group_id 1
total 1200
start group_id 2
    KeyboardInterrupt
make: *** [Makefile:2: default] Error 130

$ RESUME=1 make
python main.py | tee output.txt
total 1200
start group_id 2
total 0
start group_id 3
total 100

そんなわけでteeを使っていても、再開後のファイル中に全ての結果が残ります。

$ cat output.txt
total 1200
total 0
total 100

最後に

裏側の話はまた今度。

なぜmakeのeval,call,defineが欲しくなってしまうのか

なぜmakeのeval,call,defineが欲しくなってしまうのかについてぼーっと考えたりしてた(個人的な興味として、goのgithub.com/magefile/mageでmakeを置き換えられないかな−やっぱり無理かもみたいなものを考える上でmakeについてもう少しまじめに考える必要があった)。

結論から言うとタスク定義を動的にしたくなるからで。タスク定義を動的にしたい理由は、並列動作あるいはキャッシュの制御をmakeに任せる形式にしたいせい。

例えば、以下の様な例題を考えてみる

サブディレクトリ中のReadme.mdのタイトル部分だけを抜き出し表示する

もちろんこの例自体は並列実行する必要もなかったりするものではあるのだけれど。仮にこの処理がそこそこ重めの処理だったと仮定する。

(以下の様にして雑に作っておく)

$ function GEN () { mkdir -p $1 && echo "# $(basename $1)" > $1/readme.md && echo memo >> $1/readme.md; }
$ GEN docs/a
$ GEN docs/b
$ GEN docs/b/x
$ GEN docs/b/y
$ GEN docs/c

$ tree docs
docs
├── a
│   └── readme.md
├── b
│   ├── readme.md
│   ├── x
│   │   └── readme.md
│   └── y
│       └── readme.md
└── c
    └── readme.md

全てをシーケンシャルに実行すれば良い場合

特に何も考えずに書く場合にはforloopで済ませてしまうことが多い。シェルスクリプトを一個作るでも良いのだけれど。他に色々な操作がある場合にそれを1つのファイルでまとめたいというときにMakefileを使っている気がする。

例えば以下のようにMakefileを書く。

default:
  for i in `find . -name readme.md`; do cat $$i | grep -P '^#[^#]'; done

1行で済むし。短い。

Makefile中での $ の表記はmake由来の機能のほうに使われているのでエスケープのために $$ にする必要がある。同様にコマンドの実行結果を元にアレコレするにはバッククォート主体の方がやりやすい(実行時に既に展開されて欲しい場合には $(shell ...) を使う)。

実際に実行されるコマンドを調べるには -n オプションが便利。

$ make -n
for i in `find . -name readme.md`; do cat $i | grep -P '^#[^#]'; done

これを実行してみると以下の様になる。

$ make
for i in `find . -name readme.md`; do cat $i | grep -P '^#[^#]'; done
# c
# a
# x
# y
# b

全てをシーケンシャルに実行すれば良いだけならこれで十分。しかし以下の様な問題がある。

  • 途中でコマンドが失敗しても処理が中断されない
  • 全てのコマンドが全部実行されてしまう(不要なコマンドの呼び出しはスキップしたい)

make経由でキャッシュを効かせたいので手動でタスク定義する

とりあえず、上の条件の内、不要なコマンドの呼び出しをスキップしたいということだけに取り組むとする。要はmakeに xxx is up to date と言わせてタスク自体をスキップさせたい。

暗黙のルールの定義

基本的にmakeは、あるタスクの実行が必要か不要かを判断するためにmtimeを見る(PHONYなどを指定しない限り初めはファイルだと想定して扱う。そうでなかったらただのコマンド(アクション)としてのタスクと判断して実行される)。そんなわけで実行をスキップさせたりしたい場合には何らかのファイルを出力する必要がある。

例えば今回は.titleという拡張子のファイルに出力してみることにする。

.%.title: %.md
  cat $< | grep -P '^#[^#]' | tee $@

makeは暗黙のルールを指定することができ。これはある拡張子のファイルからある拡張子のファイルへの変換のタスクを動的に導出できるようにするための指定。 (上の例は.mdから.titleへの変換)

ファイルの依存関係の定義

暗黙のルールを書いただけではタスクとしては実行できない。まじめに依存関係を書く必要がある(実はここの記述は正確ではない。詳しくは末尾のおまけ参照)。このあたりでだんだんつらくなってくる。今現在の状況をそのまま直書きしてみることにする(冒頭のtreeコマンドの出力を参考に)。

docs/a/.readme.title: docs/a/readme.md
docs/b/.readme.title: docs/b/readme.md
docs/b/.x/readme.title: docs/b/x/readme.md
docs/b/.y/readme.title: docs/b/x/readme.md
docs/c/.readme.title: docs/c/readme.md

このように列挙してあげれば make docs/a/.readme.title でaに対するreadmeのタイトル部分だけを取り出したファイルが生成されるようになる。

$ make docs/a/.readme.title
cat docs/a/readme.md | grep -P '^#[^#]' | tee docs/a/.readme.title
# a

# 2回目はskipされる
$ make docs/a/.readme.title
make: 'docs/a/.readme.title' is up to date.

# 無理やり再実行したい場合には -B をつける
$ make -B docs/a/.readme.title
cat docs/a/readme.md | grep -P '^#[^#]' | tee docs/a/.readme.title
# a

全ての依存を利用したタスクの定義

そして先程定義した全ての依存を利用したタスクを定義してあげる。

default: docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
  cat $^

こうすると、依存されたファイルが更新されたら、それに対応するタスクだけが実行される(出力される成果物としてのファイル(上の例では.title)が再生成される)ということができるようになる。

$ make -B
cat docs/a/readme.md | grep -P '^#[^#]' | tee docs/a/.readme.title
# a
cat docs/b/readme.md | grep -P '^#[^#]' | tee docs/b/.readme.title
# b
cat docs/b/x/readme.md | grep -P '^#[^#]' | tee docs/b/x/.readme.title
# x
cat docs/b/y/readme.md | grep -P '^#[^#]' | tee docs/b/y/.readme.title
# y
cat docs/c/readme.md | grep -P '^#[^#]' | tee docs/c/.readme.title
# c
cat docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
# a
# b
# x
# y
# c

# 再実行
$ make
cat docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
# a
# b
# x
# y
# c

2回めの実行のときには、不要なタスクがスキップされている。

ついでに並行処理もさせたくなったら、-j オプションをつける(本来のタスクは重めのものなので並行で動いてくれると嬉しい)。

$ make -B -j 10
cat docs/a/readme.md | grep -P '^#[^#]' | tee docs/a/.readme.title
cat docs/b/readme.md | grep -P '^#[^#]' | tee docs/b/.readme.title
cat docs/b/x/readme.md | grep -P '^#[^#]' | tee docs/b/x/.readme.title
cat docs/b/y/readme.md | grep -P '^#[^#]' | tee docs/b/y/.readme.title
cat docs/c/readme.md | grep -P '^#[^#]' | tee docs/c/.readme.title
# b
# a
# c
# x
# y
cat docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
# a
# b
# x
# y
# c

ゴミのお掃除もしたくなるのでcleanみたいなタスクも定義しておく。

clean:
  find . -name ".readme.title" | xargs rm -vf
.PHONY: clean

と、まぁ、こんな感じで結構なたいへんなお仕事になる。

そして、新しい依存が増えるたびに設定を追加する必要がある(例えばdocs/e/readme.mdが増えた場合)。

--- 01.mk    2018-06-09 23:04:34.065070983 +0900
+++ Makefile  2018-06-09 23:04:57.320719432 +0900
@@ -1,7 +1,7 @@
 .%.title: %.md
    cat $< | grep -P '^#[^#]' | tee $@
 
-default: docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
+default: docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title docs/e/.readme.title
    cat $^
 
 docs/a/.readme.title: docs/a/readme.md
@@ -9,6 +9,7 @@
 docs/b/.x/readme.title: docs/b/x/readme.md
 docs/b/.y/readme.title: docs/b/x/readme.md
 docs/c/.readme.title: docs/c/readme.md
+docs/e/.readme.title: docs/e/readme.md
 
 clean:
    find . -name ".readme.title" | xargs rm -vf

全部まとめたmakefileは以下の様になる。

.%.title: %.md
  cat $< | grep -P '^#[^#]' | tee $@

default: docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
  cat $^

docs/a/.readme.title: docs/a/readme.md
docs/b/.readme.title: docs/b/readme.md
docs/b/.x/readme.title: docs/b/x/readme.md
docs/b/.y/readme.title: docs/b/x/readme.md
docs/c/.readme.title: docs/c/readme.md

clean:
  find . -name ".readme.title" | xargs rm -vf
.PHONY: clean

頑張ってタスクの依存関係を定義したことによって、以下の様なことが達成された。

  • キャッシュを効かせる(不要なタスクの実行をスキップ)
  • 並行して実行してくれるようにする
  • (エラーが発生したタイミングで実行を打ち切る)

とは言え、逆に言うと頑張ってタスクの依存関係を定義しなければこの恩恵が受けられないというわけで、ちょっとしたビルドシステムっぽい感じになってしまいなんとなくだるい。

実行される依存関係を動的に定義したい

ある操作のユーザーとしての利便性は、上の例のように手動で定義(静的に定義)することによって満たされたのだけれど。

このある操作の提供者として見た場合に、維持するのがなかなかに面倒でしんどい。これを楽にできないかと考えると結局、タスクの依存関係の動的な導出をしたいということになってしまう。

ここでevalとcallとdefineが出てくる。要はマクロ(プリプロセッサ)のようなものが必要になり一種のメタプログラミング的なものが必要ということになる。

callとdefineの必要性について

callとdefineが必要になるのは雑に今風に解釈するならテンプレートエンジン的なものが欲しくなるため。defineで定義してcallで呼び出す。

evalの必要性について

makeのファイルの文法(の一部)を簡略的に表すと以下の様になる。

<task>: <target> ...
    <action>

ここで単にcallとdefineで文字列置換をするだけだと、定義した文字列がタスク定義として解釈されない。そんなわけで動的にタスクを定義したい場合にはevalが必要になる。

実際例えば以下のようなmakefileを書いてみて確認することができる

define fooT
foo:
   echo $(1) yyyy
endef

$(call fooT,hello)

evalを使わず定義した(つもりになっている)fooを利用しようとしても無いと言われる。

$ make -n foo
make: *** No rule to make target 'echo', needed by 'foo'.  Stop.

evalをつけると大丈夫。

--- 03.mk    2018-06-09 23:25:22.718195260 +0900
+++ Makefile  2018-06-09 23:25:28.098113932 +0900
@@ -3,4 +3,4 @@
    echo $(1) yyyy
 endef
 
-$(call fooT,hello)
+$(eval $(call fooT,hello))

タスクは定義されている。

$ make -n foo
echo hello yyyy

eval,call,defineを使って動的に依存関係を定義

ファイルの依存関係の定義。

SUBDIRS := $(dir $(shell find . -name "readme.md"))

# 依存関係を定義

define genTitleT
$(1).readme.title: $(1)readme.md

endef

$(foreach d,${SUBDIRS},$(eval $(call genTitleT,$(d))))

eval,call,defineを使っている。どのように展開されるかはevalの部分をinfoにしたりすると確認できる(もう少し良い方法が欲しい(lispで言えばmacroexpand的なもの)。-pの結果を見ることでなんとなく確認することはできる)。

動的に定義される依存(展開結果)は以下のようなもの

# 依存関係を定義

./docs/c/.readme.title: ./docs/c/readme.md

./docs/a/.readme.title: ./docs/a/readme.md

./docs/b/x/.readme.title: ./docs/b/x/readme.md

./docs/b/y/.readme.title: ./docs/b/y/readme.md

./docs/b/.readme.title: ./docs/b/readme.md

すべての依存を使うdefaultタスクを定義。

SUBDIRS := $(dir $(shell find . -name "readme.md"))

# 全ての依存を使うタスクを定義
default: $(addsuffix .readme.title,${SUBDIRS})
  cat $^

これでタスクの依存関係に動的に定義することができた。作られたMakefileは以下の通り。

SUBDIRS := $(dir $(shell find . -name "readme.md"))

.%.title: %.md
  cat $< | grep -P '^#[^#]' | tee $@

# 全ての依存を使うタスクを定義
default: $(addsuffix .readme.title,${SUBDIRS})
  cat $^

# 依存関係を定義
define genTitleT
$(1).readme.title: $(1)readme.md

endef

$(foreach d,${SUBDIRS},$(eval $(call genTitleT,$(d))))

clean:
  find . -name ".readme.title" | xargs rm -vf
.PHONY: clean

それにしてもこのmakefileはすぐに読めるものなんだろうか。という疑問は常に残る。

どこにでもあるのでmakeを使っているけれど。良い代替があれば移行したい。

そういう意味では第一言語が決まっている組織というのは便利で良い(例えばほぼrubyだけならrakeを仮定して良いみたいな話)。goのmageについても同様の理屈が言えてしまい、他の言語の島の人にもmageを使ってとは言えないし。。。結局消極的にmakeを。。。という感じになってしまう。

(そしてmageには動的にタスクを定義する機能がないような気がする)

おまけ(暗黙のルールだけで済むなら依存関係の定義は不要の場合も)

実は今回に限っては暗黙のルールが定義されているので依存部分を書く必要がなかったりする。

SUBDIRS := $(dir $(shell find . -name "readme.md"))

.%.title: %.md
  cat $< | grep -P '^#[^#]' | tee $@

# 全ての依存を使うタスクを定義
default: $(addsuffix .readme.title,${SUBDIRS})
  cat $^

clean:
  find . -name ".readme.title" | xargs rm -vf
.PHONY: clean

というのも、欲しい .title が決まれば、どういう readme.md が必要かは定まるので。