0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Markdown 1ファイルを小さな Python プロジェクトにする ― FrontMatter に Taskfile、本文にコードを書いて uvx で動かす「mdfsrun」

0
Posted at

概要

PEP 723 のおかげで、依存パッケージ込みの Python スクリプトを1ファイルで配れるようになりました。uv run script.py だけで依存が入って動くのはとても便利です。

ただ、PEP 723 は「1ファイル=1スクリプト」が前提です。モジュールを分けたい、テストを書きたい、設定ファイルやデータも付けたい、となった瞬間に普通のプロジェクトに戻るしかありません。

そこで、PEP 723 をもう一歩進めて、複数ファイルとタスク定義まで含めて Markdown 1ファイルに収めて、読めて、そのまま動く 形にしてみました。

  • FrontMatter には Task(go-task)の Taskfile を書く

  • 本文 には説明と、ファイル名を指定したコードブロックを書く

  • コードブロックは Entangled で実際のファイルに展開し、そこでタスクを実行する
    これを行うランナーを mdfsrun という名前で PyPI に公開しました。必要なのは uv だけです。

  • GitHub: https://github.com/uniras/mdfsrun

  • PyPI: https://pypi.org/project/mdfsrun/
    FrontMatter に docker compose を書いてコンテナの中で展開する mdfs の姉妹版で、こちらは Docker なしで uv だけで動きます。.md の書式(コードブロックの書き方)は mdfs と共通です。

完成形

app.md はこんな感じです。

---
tasks:
  default:
    cmds:
      - uv run main.py
  test:
    cmds:
      - uv run --with pytest python -m pytest -q
---
 
# Hello
 
エントリポイント。依存は PEP 723 で書きます。
 
```python {.python file=main.py}
# /// script
# dependencies = ["rich"]
# ///
from pkg.core import greet
greet("world")
```
 
本体。
 
```python {.python file=pkg/core.py}
from rich import print
 
def greet(name: str) -> None:
    print(f"[bold green]Hello, {name}![/bold green]")
 
def add(a: int, b: int) -> int:
    return a + b
```
 
```python {.python file=pkg/__init__.py}
```
 
テスト。
 
```python {.python file=tests/test_core.py}
from pkg.core import add
 
def test_add():
    assert add(1, 2) == 3
```

実行はこれだけです。

$ uvx mdfsrun app.md
task: [default] uv run main.py
Hello, world!
 
$ uvx mdfsrun app.md test
task: [test] uv run --with pytest python -m pytest -q
.                                                                        [100%]
1 passed in 0.01s

.md の後ろに書いたものはそのまま task に渡るので、uvx mdfsrun app.md --list-all でタスク一覧も見られます。Task も Entangled も、Python 本体すら事前に入れる必要はありません。全部 uv が取ってきます。

仕組み

誰が 何をするか
mdfsrun .md を実行用フォルダにコピーし、FrontMatter を切り出して Task に渡す
Entangled 実行用フォルダで本文のコードブロックをファイルに展開する
Task FrontMatter を Taskfile として読み、実行用フォルダでタスクを実行する
uv mdfsrun・Task・Entangled と、展開したコードの依存を用意する

実行用フォルダは、既定では .md の隣の .mdfsrun/<.mdの名前>/ です。上の例を実行すると、こうなります。

.mdfsrun/app/
├── .entangled/       ← Entangled の状態(前回展開したファイルの一覧など)
├── .gitignore        ← 中身は「*」。Git から丸ごと無視される
├── entangled.toml    ← mdfsrun が生成
├── source.mdfsrun    ← .md のコピー
├── main.py
├── pkg/
│   ├── __init__.py
│   └── core.py
└── tests/
    └── test_core.py

FrontMatter は YAML なので、Taskfile としてそのまま標準入力経由(task -t -)で渡せます。version: '3' が無ければ mdfsrun が補います。

タスクには次の環境変数を渡しています。テンプレート({{.MD_DIR}})でもコマンド($MD_DIR)でも使えます。

変数 中身
MD 元の .md の絶対パス
MD_DIR 元の .md があるフォルダの絶対パス
OUT 実行用フォルダの絶対パス

たとえば、.md の隣に置いた .env を読みたいならこう書きます。

dotenv: ["{{.MD_DIR}}/.env"]

秘密情報を .md に埋め込まずに済むので、.env の扱いはこれで十分だと考えています。

pyproject.toml も書ける

PEP 723 の代わりに pyproject.toml を本文に書けば、普通の uv プロジェクトとして動きます。

```toml {.toml file=pyproject.toml}
[project]
name = "demo"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["rich"]
 
[dependency-groups]
dev = ["pytest"]
```

実行用フォルダに展開された pyproject.toml を uv run が読むので、dependencies も [dependency-groups](uv run pytest など)もそのまま使えます。[project.scripts] でコマンドを定義したい場合は、[build-system] も書いてプロジェクト自体をインストールさせる必要があります。.venv と uv.lock は実行用フォルダに作られ、次回の実行でも再利用されます。

設計の経緯

なぜ Task なのか

FrontMatter が YAML なので、YAML ベースのタスクランナーなら切り出したものを無加工で渡せます。Task は

  • 標準入力から Taskfile を読める(task -t -)
  • 知らないトップレベルのキーを黙って無視する(他の設定と同居できる)
  • シェルコマンドを Go 製の sh インタプリタで実行するので、Windows でも同じ書き方で動く
  • PyPI に go-task-bin というパッケージがあり、uv だけで入る
    と、この用途にちょうどよい性質がそろっていました。

なぜランナーを PyPI に上げたのか

最初は「FrontMatter を切り出して task -t - に渡す」だけの uv ワンライナーで済ませるつもりでした。ただ、そうすると Entangled での展開もタスクとして FrontMatter に書くことになり、dir: や deps: などのボイラープレートが .md ごとに増えます。

展開まわりを全部ランナー側に寄せて PyPI に置けば、利用者が打つのは uvx mdfsrun app.md だけになり、FrontMatter には本当にやりたいタスクだけを書けます。

実行用フォルダは .md の隣に置いて残す

実行用フォルダをプロジェクトの外(キャッシュ用のディレクトリなど)に置く案も考えましたが、どこに何があるのか分かりにくく、後始末も面倒になりそうなのでやめました。.md の隣に置き、.gitignore で Git からは見えなくしています。

フォルダは既定で残します。消したい場合は --rm(または MDFSRUN_RM=1)を付けます。残しておくと、pyproject.toml を使う構成で .venv を作り直さずに済みます。

書き戻しはしない

Entangled には、展開したファイルの変更を .md に書き戻す stitch という機能があります。ですが mdfsrun では使いません。正は常に .md で、実行用フォルダは毎回 .md から作り直します。

実行用フォルダのファイルは注釈コメントなし(--annotate naked)で展開しているので、そもそも書き戻しはできません。トレースバックの行番号がずれず、普通のファイルとして読める、という利点もあります。

ハマりどころ

Entangled は入力ファイルも出力先も指定できない

entangled tangle には、読む .md や展開先を指定するオプションがありません。作業ディレクトリ配下の .md を探して、作業ディレクトリ基準で展開します。設定の watch_list も絶対パスは受け付けませんでした。

そこで、.md を実行用フォルダにコピーし、そこを作業ディレクトリにして展開しています。

コピーした .md を自分の Entangled が拾ってしまう

最初は .md を app.md のままコピーしていました。すると、プロジェクト直下で自分で entangled tangle したときに、.mdfsrun/ の中のコピーも読み込まれ、同じファイル名のブロックが エラーも警告もなく連結 されてしまいました。隠しフォルダでも除外されません。

Entangled の既定の検索パターンは **/*.md なので、コピーは source.mdfsrun という .md 以外の名前にし、実行用フォルダの entangled.toml で明示的に指定するようにしました。

競合しても終了コードが 0

展開済みのファイルを手で編集してから再度展開すると、Entangled は「競合あり、中断」とエラーを出します。ところが終了コードは 0 なので、後続のタスクが止まらず、古いコードのまま実行されてしまいました。

--force を付けても安心できません。.md 側に変化が無いブロックのファイルは書き直さないので、手で編集した内容がそのまま残ります。最終的に、毎回「前回展開したファイル」を消してから展開する形にしました。前回展開したファイルの一覧は .entangled/filedb.json に入っているので、それを使っています。.venv などタスクが作ったものには触りません。

空のコードブロックで Entangled が落ちる

中身が空のブロック(空の __init__.py など)を --annotate naked で展開すると、Entangled 2.4.3 は内部エラー(IndexError)で落ちます。注釈付きなら注釈行があるので空にならず、表に出ない不具合です。mdfsrun 側で、空のブロックにだけ空行を1行補って回避しています。

トップレベルの dotenv から変数が見えない

最初は MD などを Task のコマンドライン変数(task -t - MD=...)で渡していました。ところが、トップレベルの dotenv: で {{.MD}} を使うと空になります。タスク単位の dotenv: なら見えるのですが、トップレベルの評価時点ではコマンドライン変数がまだ見えないようです。

環境変数として渡すようにしたら、トップレベルでも使えるようになりました。

Task は知らないキーを無視する(typo も)

知らないトップレベルのキーを無視してくれるのは便利なのですが、裏返しとして cmds を cmdz と書き間違えても、エラーにならず何も実行されずに終わります。動かないときは、まずキー名を疑ってください。

pyproject.toml が無いと親のプロジェクトを拾う

実行用フォルダは .md の隣にあるので、pyproject.toml を書いていない .md を別のプロジェクトの中に置くと、uv run python ... が親フォルダのプロジェクトを拾います。親フォルダに .venv や uv.lock が作られてしまいます。

PEP 723 のスクリプト(uv run script.py)は影響を受けません。それ以外を実行するなら、本文に pyproject.toml を書くか、uv run --no-project を使ってください。

自分で Entangled を使う場合

正は .md なので、編集するのは .md です。とはいえ、展開したファイルをエディタで編集して stitch で .md に書き戻したい、という使い方もあると思います。

実行用フォルダとは別に、.md の隣で自分で Entangled を使うのは問題ありません。次の entangled.toml を .md の隣に置いてください。

version = "2.0"
ignore_list = [".mdfsrun/**/*"]
 
[markers]
open  = '^(?P<indent>\s*)```[^\s{`]*\s*\{(?P<properties>[^{}]*)\}\s*$'
close = '^(?P<indent>\s*)```\s*$'
  • [markers] は ```python {.python file=...} の形を Entangled に認識させる設定です(mdfs と同じ)。
  • ignore_list は、実行用フォルダに展開された .md を読み込まないための設定です。.mdfsrun/** と書くと効かず、.mdfsrun/**/* なら効きました。
    mdfsrun が実行するのは常に .md の中身なので、書き戻していない変更は実行に反映されません。

mdfsrun の使い方

mdfsrun [--rm] FILE.md [TASK ...] [VAR=VALUE ...] [task のオプション ...]

mdfsrun 自身のオプションは .md より前に、Task に渡すものは .md より後ろに書きます。

オプション・環境変数 意味
--rm / MDFSRUN_RM=1 実行後に実行用フォルダを削除する
MDFSRUN_DIR 実行用フォルダの場所を変える

MDFSRUN_DIR に mdfsrun が作ったものではない中身入りのフォルダを指定すると、エラーで止まります。無関係なフォルダに展開したり、それを消したりしないためです。

普段使いするなら、インストールしておくと mdfsrun だけで呼べます。

$ uv tool install mdfsrun
$ mdfsrun app.md

uvx は一度取ってきたバージョンをキャッシュして使い回すので、最新版を確実に使いたいときは uvx mdfsrun@latest app.md としてください。

注意点

  • Python 3.13 以上が必要です(Entangled 2.4 系の要件)。無ければ uv が取ってきます。
  • 初回は Task、Entangled、コードの依存をダウンロードします。2回目以降は uv のキャッシュが効きます。
  • コードフェンスはバッククォート3つのものだけを認識します。そのため、file= 付きのブロックを含む Markdown を .md の中に埋め込むことはできません(内側のブロックが本物のブロックとして読まれます)。
  • .md を実行するということは、その FrontMatter とコードに書かれたことを実行するということです。信用できるファイルだけを実行してください。

まとめ

  • PEP 723 を広げて、複数ファイルとタスクを Markdown 1ファイルに収めた
  • FrontMatter はそのまま Taskfile として Task に渡し、本文は Entangled で展開する
  • 必要なのは uv だけで、uvx mdfsrun app.md で動く
    説明書きがそのまま動くプロジェクトになっている、という感覚は mdfs と同じで、こちらは Docker が要らないぶん気軽に使えます。
    興味があれば触ってみてください。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?