Org-modeのコードブロックのPython環境をuvで管理する

Org-mode と org-babel を使えば、いわゆる文芸的プログラミングを容易に実現でき、プログラムの背景やアイデアも含めて、平易に管理することが可能です。

使い捨てのスクリプトであれば、ファイルに書き捨てて保存しておくだけで十分ですが、文芸的プログラミングのような場面では、後から中身を振り返って確認したくなることが少なくありません。

ここでは、特に org-mode 内で Python を用い、それを org-babel によって評価(実行)する場合について考えます。上記のような動機でコードを管理する際には、Python のバージョンや依存パッケージなど、環境の管理も重要になってきます。以下では、以前 「uvによる単一スクリプトのパッケージ管理」に関する記事 で紹介したPEP 723を利用したシンプルな方法を応用し、org-mode でも ファイルごと・コードブロックごとにPython の環境を管理する手法を紹介します。

単一コードブロックの環境整理

単一のコードブロックであれば、 uvでの単一スクリプトのパッケージ管理を紹介した記事 で紹介したPEP 723の記法をそのままorg-modeのコードブロックに応用することができます。

#+begin_src python :results output :python uv run -

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "polars>=1.0.0",
# ]
# ///

import importlib.util

found = importlib.util.find_spec("polars") is not None
print("polars is installed:", found)
#+end_src

上記のようなコードブロックを評価すると、そのコードブロック内で依存パッケージがインストールされ、その仮想環境で実行されます。 肝はヘッダー引数の :python です。 :python 以下の引数でコードブロック内のスクリプトが実行されます。 ここでは uv run - を指定することで、コードブロック内の記載をスクリプトとしてuv環境で実行しています。これによって、実行時にPEP 723で記載されたメタデータからuvが仮想環境を作成し、その環境で実行してくれます。

複数コードブロックの環境整理(session無し)

大きなプログラムを複数のコードブロックに分割し、それぞれの部分ごとに解説などを挟みたい場合がよくあります。 Org-modeでは、そのようなときには以下のようにコードブロックのヘッダー引数に :noweb を用いることで複数のコードブロックに分割して記述することができます。

name: dep
#+begin_src python

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "polars>=1.0.0",
# ]
# ///
#+end_src

(ここで分割できる)

#+begin_src python :results output :noweb yes :python uv run -
<<dep>>
import importlib.util

found = importlib.util.find_spec("polars") is not None
print("polars is installed:", found)
#+end_src

先に出てくるコードブロックに名前(例: dep )をつけ、続くコードブロックのヘッダー引数で :noweb yes としてあげればそのコードブロック内では <<dep>>noweb参照 され、その位置に dep コードブロックの記述が挿入されます。

これによって、コードブロック自体は分割されても、あたかも1つのスクリプトを実行しているかのように評価することができます。

複数コードブロックの環境整理(sessionあり)

Noweb参照を使うとコードブロックは分割できますが、org-mode内で評価するときにはそれら複数のコードブロックは全体で1つのスクリプトとして評価されます。つまり、iPythonやJupyterのようにそれぞれのコードブロックごとの評価はできません。

Pythonを含むいくつかの言語ではorg-modeのコードブロックのヘッダー引数に :session をつけてあげることで、それぞれのコードブロックごとに評価をしつつ、同じセッションを維持することができます。今回はこれをuvで環境構築をしつつ行ってみます。

Pythonでセッションを維持するためには裏でPythonが対話モードが動く必要がありますが、上記で紹介してきたPEP 723の記法で書かれたメタデータは uv run でスクリプトファイルとして読み込んだときのみに有効になり、対話モードでは使用できません。

そのためセッションを用いる場合には、今まで紹介した2つの環境構築よりも少しだけ手間が必要になります。具体的な手順としては

  1. コードブロックのPEP 732記法を一時ファイルとして書き出し
  2. 一時ファイルを用いてPython仮想環境を作成
  3. Python仮想環境も用いて :session 付きで実行

になります。もちろん全てorg-mode内で完結します。

コードブロックのPEP 732記法を一時ファイルとして書き出し

環境構築したい内容を含んだPEP 723記法をコードブロックに記し、これを出力します。以下のコードブロックは評価するのではなく、 org-babel-tangle でファイル出力を行う必要があります(誤って評価しないために :eval no をつけています)。このとき、ヘッダー引数 :tangle で変数 uv-tmp に出力した一時ファイルのパスが保存されます。

#+begin_src python :tangle (eval (setq uv-tmp (make-temp-file "uv_deps" nil ".py"))) :eval no

# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "polars>=1.0.0",
# ]
# ///

#+end_src

一時ファイルを用いてPython仮想環境を作成

次に、上で出力した uv-tmp の一時ファイルに記載されたPEP 723記法のメタデータを用いてPythonの仮想環境を作成します。また、そのPythonの仮想環境のパスを uv-venv で得ます。以下のシェルのコードブロックを評価します。

#+name: uv-venv
#+begin_src sh :var script=(symbol-value 'uv-tmp) :results none
uv sync --script "$script" --color never 2>&1 >/dev/null \
    | sed -r 's/^.* (\/.*)$/\1/' | tr -d '\n'
#+end_src

ここで実行している処理は、 uv sync によって一時ファイルから仮想環境を作成し、その際に標準エラー出力に表示される仮想環境のパスを、 sedtr を使って整形して取得しているだけです。

Python仮想環境も用いて :session 付きで実行

最後にPEP 723のメタデータで作成されたPythonの仮想環境を用いて、ヘッダー引数 :session 付きのコードブロックを実行します。

#+begin_src python :session uv-session :results output :python (concat (org-babel-ref-resolve "uv-venv") "/bin/python")
import importlib.util

found = importlib.util.find_spec("polars") is not None
print("polars is installed:", found)
#+end_src

一度 :session 付きのコードブロックを実行すれば、そこから先のコードブロックでも同じ :session を指定すれば、実行結果を引き継いでPythonを実行することができます。

#+begin_src python :session uv-session :results output
import polars as pl
df = pl.DataFrame({"a": [1,2,3]})
print(df)
#+end_src

さいごに

Python のパッケージ管理ツールは移り変わりが激しく、正直なところ、このまま uv が末永く使われ続けるかは不透明です。それでも 2025 年現在においては、スタンダードな選択肢の一つと言えるでしょう(個人的には、そろそろ標準に採用されて決着がついてほしいところです)。 多様な表現力を持つ org-mode においても、その恩恵を活用することができます。

この記事が、org-mode 上で気軽に再利用可能な Python スクリプトを管理したいと考えている方の参考になれば幸いです。

参考