Airflow の DAG を説明スライドに変換するツールを作った
背景
Cloud Composer の DAG を説明する場面が、業務でわりと出てきます。新規導入のキックオフ、移行プロジェクトのビフォーアフター、障害対応の経路説明……どれも「この DAG はこう動きます」を一枚の絵で見せたい場面で、PowerPoint で手で描くと 1 図 30 分くらいかかる上に、修正のたびに矢印を引き直してさらに 10 分、というのが地味に効いてきました。
そこで、YAML にタスクと依存だけ書けば PowerPoint に変換できるツールを作ってみました。本当は Claude Code 等から直接 .pptx を吐いてもらいたかったのですが、まずは YAML で内容を定義してレンダラに任せる構成でやってみます。
コードは GitHub で公開しています: dhindhindhin/dag2pptx
なお、そもそも提案資料に PowerPoint を使わないプロジェクトの方や、ソースコードから資料をどんどん生成できる環境にいる方は、この記事はそっと閉じていただけたらと思います。
全体構成
構成を 3 つに分けてあります。内容(タスク・依存・リード文)は YAML、見た目(色・フォント・寸法・バッジ)は theme.py、描画ロジックは dag_renderer.py、という分担です。新しい DAG を作るときは YAML だけ、色やフォントを変えたいときは theme.py だけ触れば済むようにしてあります。
YAML はだいたいこんな感じで書きます。
slides:
- title: "ETL パイプラインの例"
header: |
ETL の例。START から extract_a / extract_b に分岐し、transform を経て load_mart / run_inference に分岐する。
boxes:
- { id: start, col: 0, row: 1, label: START }
- { id: ex, col: 1, row: 0, label: extract_a, status: new }
- { id: ex2, col: 1, row: 2, label: extract_b }
- { id: tr, col: 2, row: 1, label: transform }
- { id: load, col: 3, row: 0, label: load_mart }
- { id: ml, col: 3, row: 2, label: run_inference, status: new }
edges:
- "start >> [ex, ex2] >> tr >> [load, ml]"
実行は CLI 一発です。
python render.py input.yaml output.pptx
これを実行すると、出力された pptx の中身はこんな感じになります。
YAML の書き方
書き味で気にしたのは「Airflow の DAG 定義に近い感覚で読める / 書けること」と「位置調整みたいなレイアウト系の値を YAML に持ち込まないこと」の 2 点です。タスクと依存関係さえ書けば一枚絵が出てくる、という流れにしたかった次第です。
スライド単位の構造
ファイル全体は slides: 配下に複数のスライドを並べる形にしてあります。1 スライドにつき、タイトル・リード文・箱・矢印を持つだけのシンプルな構造です。
slides:
- title: "スライドのタイトル"
header: |
タイトル直下に出るリード文。3 行くらいが目安。
boxes:
- { id: a, col: 0, row: 0, label: "タスク A" }
- { id: b, col: 1, row: 0, label: "タスク B" }
edges:
- "a >> b"
boxes の書き方
箱は id で識別して、col / row で論理的な位置だけを指定します。物理的な座標やサイズは書きません(そこはレンダラ側で決めます)。
| フィールド | 役割 |
|---|---|
id | edges から参照する識別子 |
col | 列番号(0 始まり、左から右へ) |
row | 行番号(0 始まり、上から下へ) |
label | 箱に表示するタスク名 |
status | (任意)new / changed / deleted |
status を付けると、移行プロジェクトの「ここが新規」「ここが変更」「ここが廃止」を色付きバッジで強調できます。
| status | バッジ色 | 文言 | 用途 |
|---|---|---|---|
new | 青 | NEW | 新規追加 |
changed | 緑 | 変更後 | 中身を差し替えた |
deleted | 赤 | 削除 | 廃止される(点線枠) |
ビフォーアフターを並べたいときは、スライドを 2 枚作って status だけを書き分けるという運用が一番楽でした。
edges の書き方
edges は Airflow の >> 演算子のセマンティクスをそのまま借りていて、隣接ステージはクロス積で結ばれます。
edges:
- "a >> b" # 1 本: a→b
- "a >> b >> c" # チェーン: a→b, b→c
- "a >> [b, c]" # ファンアウト: a→b, a→c
- "[a, b] >> c" # ファンイン: a→c, b→c
- "a >> [b, c] >> d" # 結合: a→b, a→c, b→d, c→d(4 本)
Airflow を普段書いているのと同じ感覚で書きたい、と思って合わせた部分です。
矢印を綺麗に描く話
このツールで一番苦戦したのは、地味ですが箱と箱を繋ぐ矢印です。
python-pptx でエルボーコネクタ(鍵型に折れる矢印)を描く素直なコードは、こんな感じになります。begin_connect / end_connect で繋ぎたい箱の辺(3 = 右中央 / 1 = 左中央)を指定すれば、コネクタの両端はその辺の中央に張り付きます。
conn = slide.shapes.add_connector(MSO_CONNECTOR.ELBOW, x1, y1, x2, y2)
conn.begin_connect(src_box, 3) # 右中央
conn.end_connect(dst_box, 1) # 左中央
1 対 1 の接続ならこれで綺麗に描けるのですが、困るのが 1 つの箱に複数の線が出たり入ったりする ファンアウト・ファンインのケースです。3 本出すと 3 本とも箱の右中央(idx=3)の同じ点から出てしまい、根元で線が重なって見にくくなります。end_connect で指定できる接続ポイントは 0〜3(上・左・下・右の中央)の 4 つしかなく、「右辺の上 1/3 のところから出す」みたいな指定はできないようです。
色々試している中で、ふと新人研修の頃に「こういうときはこうやる方法もありますよ」と聞いた話を思い出し、そのやり方を試してみました。具体的には、箱の中に小さなアンカー shape を矢印の本数だけ仕込んで、各矢印を別々のアンカーに繋ぐ やり方です。3 本の矢印を出したいなら、箱の右辺に沿ってアンカーを 3 つ等分配置し、それぞれにコネクタを 1 本ずつ割り当てます。
┌─────────────┐ ┌─────┐
│ □──────────►│ T0 │
│ │ └─────┘
│ │ ┌─────┐
│ Task □──────────►| T1 │
│ │ └─────┘
│ │ ┌─────┐
│ □──────────►│ T2 │
└─────────────┘ └─────┘
▲
アンカー(□)を矢印の本数だけ
箱の右辺の内側に等分配置(実際は箱の裏に隠れて見えない)
接続情報は XML に直接書き込みます。素直に begin_connect(anchor, 3) で繋いでもよさそうなのですが、これを呼ぶと python-pptx が内部でコネクタの座標を再計算してしまい、その副作用でエルボーの折れ位置が崩れる(縦線が斜めになったり、箱と繋がる位置に行ったり)ことがあります。なので座標には触らず、接続情報だけを XML レベルで書き込むようにしました。コネクタ shape の XML の中に <a:stCxn>(始点の接続情報)と <a:endCxn>(終点の接続情報)という要素を追加して、それぞれに「どの shape(アンカー)の」「どの辺(idx)に繋がるか」を書き込みます。
def _attach_connector_xml(conn, src_anchor, dst_anchor):
"""コネクタの XML に <a:stCxn> / <a:endCxn> を直接追加して、
src_anchor の右中央(idx=3)と dst_anchor の左中央(idx=1)に繋ぐ。"""
cNvCxnSpPr = conn._element.find(qn("p:nvCxnSpPr")).find(qn("p:cNvCxnSpPr"))
st = etree.SubElement(cNvCxnSpPr, qn("a:stCxn"))
st.set("id", str(src_anchor.shape_id))
st.set("idx", "3")
en = etree.SubElement(cNvCxnSpPr, qn("a:endCxn"))
en.set("id", str(dst_anchor.shape_id))
en.set("idx", "1")
最後に箱を最前面に持ち上げると、アンカーは箱の背面に完全に隠れます。見た目は「箱の右辺の異なる位置から複数の矢印が綺麗に出ている」絵になります。
接続情報をちゃんと張ってあるので、生成された pptx を PowerPoint で開いた後に同僚が箱を背面のアンカーごと別の場所にドラッグすれば、矢印もちゃんと追従してくれます(箱だけ動かすとアンカーが取り残されるので、範囲選択で一緒に動かす運用です)。お客様レビュー後の手直しで配置を変えるときに矢印を引き直さなくて済むのは、地味ですが意外と効いてきます。
Airflow の Web UI って DAG レイアウトを自動で綺麗に描いてくれるん、すごいありがたいなぁ。
細かい工夫
メインのアンカー技以外にも、絵を綺麗に保つためのヒューリスティクスをいくつか入れています。上の段に繋がる矢印は箱の上のアンカーから出す(これで矢印のクロスが消える)、ファンアウト・ファンインで上下対称になるペアを同じ縦線列に揃える、スライドサイズと最大列数から箱の物理寸法とフォントサイズを連続スケールする、など。
このあたりの細部は AI に相談しながらゴリ押しで足していった部分で、絵が崩れるたびにルールを 1 つ追加…という感じで育ててきました。
今後の課題
ここまで自動化と書いてきましたが、col / row は今も人間がゴリ押しで指定している部分です。新しい DAG を作るたびに「これは 0 列目、これは 1 列目、これは 1 列目の 2 行目……」と頭の中で並びを組んでから書き下している感じで、ここはいずれ自動化したいなと思っています。タスクと依存関係だけ書けばあとはレンダラが並べてくれるのが理想ですが、矢印が交差しない並びを自動で選ぶのは思ったより難しそうです。
ここまで行ければ、Airflow Python の DAG インスタンスから task.task_id / task.upstream_task_ids を引っこ抜いて、CLI 一発で pptx を吐くワンクリック生成まで射程に入りそうです。そこまで持っていければ運用がだいぶ楽になるので、いずれ手をつけたい気持ちはあります。