ビルドパイプラインが、ターミナルを開いて make tangle と打たなければ実行できないなら、あなたはリテラート・プログラムを持っていない。コンパイラの付いた日記を持っているだけだ。
リテラート・プログラミングの全目的は、散文とコードが単一事実情報源を共有することにある。Markdown ファイルがアーティファクトだ。それ以外のすべて——実行可能なソース、レンダリングされたドキュメント、テストファイル——は派生されたものだ。派生アーティファクトは CI のものであって、あなたの作業記憶のものではない。
問題は、CI が weave と tangle を できるか どうかではない。もちろんできる。Python と pandoc を実行できるマシンなら誰でもできる。問題は、リポジトリ構造が CI に派生ファイルの所有権を与えているか、あるいは .py ファイルが実際にはビルド出力なのに、まだソースコードであるかのように振る舞っているかどうかだ。
weave と tangle が実際に何をするか
Donald Knuth は2つの操作を weave と tangle と呼んだが、名前が混乱しすぎて人々はこの話題自体を避けるようになった。
tangle は、ナラティブ文書——.md または .w ファイル——を取り、コンパイラが期待する順序でコードブロックを抽出する。実行可能なソースファイルを生成する。weave はその逆だ。同じ文書を取り、pretty-print されたコード、相互参照、目次を備えた人間が読めるドキュメントを生成する。
両方とも決定論的変換だ。1つの入力を取り、一貫した出力を生成する。これは CI が処理すべきものの定義だ。
なぜほとんどのチームがパイプラインを逆に構築するのか
典型的なリテラート・プログラミングのワークフローはこうだ。algorithm.md を書く。algorithm.py を抽出するためにローカルスクリプトを実行する。algorithm.py に対してテストを実行する。algorithm.md と algorithm.py の両方を Git にコミットする。そしてプルリクエストを開く。
これはすでに壊れている。バージョン管理に同じロジックの2つのコピーがある。レビュアーが algorithm.py の変更を提案した場合、algorithm.md にバックポートすることを覚えておかなければならない。algorithm.md を編集して re-tangle することを忘れると、コミットされた .py ファイルは古くなる。単一事実情報源は虚構だ。
正しいワークフローはもっとシンプルだ。.md ファイルだけをコミットする。CI は .py ファイルを生成するために tangle を実行し、それに対してテストスイートを実行する。テストが通れば、CI はオプションで weave を実行してドキュメントを公開する。.py ファイルはバージョン管理に触れることはない。それは .o ファイルや Docker イメージのようなビルド出力だ。
Markdown から Python への機能する CI パイプライン
以下は、Markdown ファイルを唯一の真実の源として扱う完全な GitHub Actions ワークフローだ。.github/workflows/literate.yml として保存する:
name: Tangle and Test
on:
push:
paths:
- "**/*.md"
pull_request:
paths:
- "**/*.md"
jobs:
tangle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Tangle code from Markdown
run: python scripts/tangle.py src/*.md --out build/
- name: Run tests on extracted code
run: python -m pytest build/ -v
- name: Upload generated source as artifact
uses: actions/upload-artifact@v4
with:
name: generated-source
path: build/
paths フィルターは重要だ。このワークフローは、Markdown ファイルが変更されたときにのみ実行される。なぜなら、Markdown が唯一重要な入力だからだ。
tangle.py スクリプトは、ローカルで実行するのと同じ30行の抽出ツールだ。複数のファイルと出力ディレクトリを処理するバージョンは以下の通り:
import argparse
import re
import sys
from pathlib import Path
def tangle(md_path: Path, out_dir: Path) -> None:
content = md_path.read_text()
files: dict[str, list[str]] = {}
for block in re.findall(r"```python\n(.*?)```", content, re.DOTALL):
lines = block.strip().split("\n")
if lines and lines[0].startswith("# "):
filename = lines[0][2:].strip()
code = lines[1:]
else:
filename = "module.py"
code = lines
files.setdefault(filename, []).extend(code)
out_dir.mkdir(parents=True, exist_ok=True)
for filename, code_lines in files.items():
(out_dir / filename).write_text("\n".join(code_lines) + "\n")
def main() -> None:
parser = argparse.ArgumentParser(description="Extract code from Markdown files.")
parser.add_argument("inputs", nargs="+", type=Path, help="Markdown files to process")
parser.add_argument("--out", type=Path, required=True, help="Output directory")
args = parser.parse_args()
for md in args.inputs:
tangle(md, args.out)
if __name__ == "__main__":
main()
ローカルで実行して確認する:
$ python scripts/tangle.py src/calculator.md --out build/
$ python -m pytest build/ -v
CI ジョブはまったく同じことをする。唯一の違いは、CI がクリーンな環境で実行するため、作業ディレクトリに隠された古い .py ファイルがテストスイートを欺くことができないことだ。
同じパイプラインでドキュメントを weave する
tangle はコードを生成する。weave はドキュメントを生成する。両方とも CI で実行できる。
weave には、Markdown を表示可能な形式に変換するツールが必要だ。Pandoc は退屈だが信頼できる選択だ。相互参照、構文ハイライト、目次が必要なら、小さなテンプレートで十分だ:
- name: Weave documentation
run: |
mkdir -p docs/output
pandoc src/*.md \
--from markdown \
--to html5 \
--standalone \
--toc \
--highlight-style=tango \
--output docs/output/index.html
- name: Publish to GitHub Pages
uses: actions/upload-pages-artifact@v3
with:
path: docs/output/
レンダリングされた HTML も派生アーティファクトだ。main へのマージのたびに GitHub Pages、Netlify、S3 バケットに公開できる。Markdown ファイルは正規のもののままだ。HTML は読者が見るものだ。
コミット対生成のトレードオフ
一部のチームはこれに反発する。彼らは .py ファイルを Git に入れたい。なぜなら git grep が機能し、GitHub のコードレビュー UI が Markdown よりも Python 構文を理解しやすいからだ。
これらは実際の問題だが、それはアーキテクチャの問題ではなくツールの問題だ。コードレビューツールが悪いから生成されたコードをチェックインするのは、パッケージマネージャーが遅いから node_modules をチェックインするようなものだ。機能するが、負債が蓄積する。
どうしても .py ファイルをリポジトリに入れる必要があるなら、CI で生成してボットアカウントでコミットし直す。パターンは以下の通り:
- 開発者が
algorithm.mdの変更をプッシュする。 - CI は
tangle.pyを実行してalgorithm.pyを生成する。 - 抽出されたコードが
mainのものと異なる場合、CI は更新された.pyファイルを含むプルリクエストを開く。 - 人間が生成された diff をレビューしてマージする。
これにより、生成されたファイルがバージョン管理内にありながら、ずれることを防ぐ。ボットが生成された出力の単一事実情報源となり、ボットは Markdown ファイルが要求するものだけを変更する。
CI 自動化が価値を持つ場合とそうでない場合
自動化された weave と tangle は、散文が長く、コードが複雑で、複数の人が文書を編集するときに輝く。研究コード、ライブラリドキュメント、アルゴリズムの実装はすべて、プッシュのたびに CI が検証する正規の Markdown ファイルから恩恵を受ける。
小さなスクリプトやボイラープレートが多いアプリケーションコードには値しない。Markdown ファイルが2段落と20行の関数だけなら、CI のオーバーヘッド、ランナー時間、メンタルモデルのコストが、解決しようとしている同期問題を上回る。良い docstring を書くだけでいい。
これが失敗するもう1つのケースは、言語ツールが .py ファイルをプライマリと見なす場合だ。デバッガー、プロファイラー、カバレッジツールは通常、Markdown ではなく生成されたソースを指したい。デバッグ前にローカルでファイルを生成することで回避できるが、それは摩擦だ。チームがデバッガーに依存しているなら、CI パイプラインがどれほど優れていても、リテラート・プログラミングは間違った選択かもしれない。
FAQ
生成された .py ファイルをコミットすべきか、それとも .gitignore すべきか?
.gitignore する。Markdown から決定論的に生成されるなら、それらはビルドアーティファクトだ。唯一の例外は、パッケージレジストリのような外部システムが tangle ステップを実行できない場合だ。その場合は、上で説明したボットコミットパターンを使う。
リテラート文書が複数の言語を混在させている場合はどうか?
tangle.py スクリプトは言語に依存しない。Rust ブロックには # filename.rs、Go ブロックには # filename.go を使うなど。CI は各言語に対して同じスクリプトを実行する。ワークフローでは言語ごとにテストステップが必要だ。
Knuth の WEB のような名前付きコードチャンクでも機能するか?
Entangled のような現代のツールは、名前付きチャンクと依存関係の追跡をサポートしている。そのレベルの表現力が必要なら、カスタムスクリプトの代わりに CI で Entangled を使う。パイプライン構造は同じだ。変わるのは抽出ツールだけだ。
複数の Markdown ファイルにまたがるインポートはどう扱うか?
各 Markdown ファイルは、それぞれ独自の出力ディレクトリに tangle される。math.md が add を定義し、geometry.md がそれをインポートする場合、両方のファイルを同じビルドディレクトリに tangle するか、相対インポートを持つパッケージとしてコードを構造化する。CI ジョブはテストを実行する前にすべてのファイルを tangle できるので、ファイル間の依存関係は自然に解決される。
Markdown ファイルを唯一重要なファイルにする
パイプラインを一度設定する。.md ファイルをコミットする。残りは CI に任せる。テストが失敗した場合、失敗はコードを生成した Markdown の行を指し、とにかくバージョン管理に存在すべきではない生成されたファイルを指すのではない。
リテラート・プログラミングの目的は単一事実情報源だ。人間が手動のステップを覚えておく必要がある情報源は、情報源ではない。それは提案に過ぎない。