コンテンツにスキップ

Yokan

Write Python. Ship native.

Yokan(羊羹)は、静的に型付けされた Python のサブセットをネイティブコードにコンパイルする処理系です。書けるのは Python の一部ですが、その範囲のコードは Python とまったく同じに動きます。開発中はアプリ全体が本物の CPython で動き、リリースするときに同じソースが機械語の実行ファイルになります。その二つが同じに動くかどうかは、yokan gate で確かめられます

全体像

同じソースが、二つの道で動きます。 どちらの道も同じ Rust 製の土台の上です。 土台の名前は pixie(Yokan が経由する基盤言語)です。 ビルドの途中で書き出される .pix が、人の読める pixie のソースです。 生成された .pix を開けば、自分のアプリが何にコンパイルされたのかを目で確かめられます(yokan translate app.py がそれを出力します)。

Yokan の全体像: ひとつのソース、開発は VM の速いループ、リリースは VM なしのネイティブバイナリ(@py があるときだけ CPython を同梱)、共有の土台、そしてゲート

Yokan の全体像: ひとつのソース、開発は VM の速いループ、リリースは VM なしのネイティブバイナリ(@py があるときだけ CPython を同梱)、共有の土台、そしてゲート


どんな見た目になるか

Yokan で書いたダッシュボードのデモ OpsBoard

demo/opsboard — 3 モジュール構成のダッシュボード。 ストアが 2 つ、直和型のヘルスモデルをビューの match で分岐、チャート、仮想化アラートフィード、テーマ切替。 すべて Python で書かれ、1 つのネイティブバイナリになります。


書いて、動かして、配る

いちばん小さいアプリの全文がこちらです。

# /// script
# requires-python = ">=3.14"
# dependencies = ["yokan"]
# ///
from yokan import State, button, column, run, text

count: State[int] = State(0)


def view():
    with column(spacing=12, padding=16):
        text(f"count: {count()}", size=34)
        button("+1", on_click=lambda: count.set(count() + 1))


if __name__ == "__main__":
    run(view, title="counter")

uv run app.py で動かすと、この画面が開きます(描画エンジンは Zed エディタを支える gpui)。

counter を実行したところ

実行したままソースを直して保存すると、状態はそのままに、画面もハンドラの挙動も新しいコードに入れ替わります。 下の GIF がその様子です(別の小さなデモを編集しています)。 編集の前後でティックが止まっていないことに注目してください。

実行中のアプリのソースを編集すると、ティックを刻んだまま画面がその場で更新される

配るときはこうします。

$ yokan build app.py --release

@py を使っていないアプリなら、できあがる実行ファイルに CPython は入りません。 Python へのリンクもありません。 大きさは 14.7 MB(strip 後 11.3 MB)、起動は数ミリ秒です。 受け取る側のマシンに Python も pip も要りません。


「手元では動いたのに」

クリックや入力の並びを渡すと、CPython 版と機械語版の両方でそれを再生して、画面の結果をバイト単位で突き合わせます。 Yokan ではこれをゲートと呼んでいます。

$ yokan gate app.py --script "click:+1,input:Momo"
GATE OK — 2 dump lines identical in both runs

Yokan 自身のモジュール(ファイル、SQLite、HTTP、クリップボード)は、両方の実行が同じ実装を呼ぶので食い違いようがありません。 Python 自身のモジュール(mathredatetime など)は、開発中は CPython が、コンパイル後は双子が答えます。 その二つが同じ答えを返すことを確かめるのがゲートです。 まだできないことは、ツアー末尾の今できないことに理由付きでまとまっています。


移植した二本

付属デモのうち二本は、Python で書かれたアプリを移植したものです。 配るアプリに Python をどれだけ積むかで見ると、この二本はちょうど両端です。

Pyxel の例のゲームは、ほぼ一行ずつ書き写して移植しました。 ドット絵のキャンバスも、30fps も、押しっぱなしのキーの読み方もそのままです。 できあがるバイナリに Python は一行も入りません。 キー操作とフレームを並べたスクリプトが、両方の実行を再生します。 ゲートはその全フレームを比べます。

demo/shooterdemo/jump。 Pyxel の例を二本、キャンバスに移したものです。

もう一方の端が、Buzz の画面と流れを移したデモです。 録音のファイルを窓に落とし、モデルと言語を選びます。 進み具合を見て、区間の表を読み、TXT と SRT と VTT に書き出します。 文字起こしをするのは Whisper です。 @py の中で、埋め込みの CPython が動かす本物の Python です。 --bundle --app を付ければ、CPython を同梱した .app になります。 ゲートは文字起こしの本文も、書き出した SRT も、バイト単位で比べます。

録音を文字起こしした画面。区間ごとの時刻と本文が表に並び、TXT と SRT と VTT に書き出せる

demo/transcribe。 Buzz の画面と流れを、@py の中で動く mlx-whisper と組み合わせたものです。


エージェントに書かせるなら

エージェントはファイルを書き、返ってきたものを読みます。 だから、何が返ってくるかで往復の質が決まります。 最初の二つのコマンドは、コンパイラもウィンドウもなしに約1秒で答えます。 返ってくるのは、代わりに何を書けばよいかを示す拒否と、テキストになった画面です。 ゲートは最後の証明です。

エージェントが回す往復。中心の app.py を書き、yokan check と yokan show をそれぞれ約1秒で周り、コンパイルする yokan gate で輪を離れて、リリースへ向かう

エージェントが回す往復。中心の app.py を書き、yokan check と yokan show をそれぞれ約1秒で周り、コンパイルする yokan gate で輪を離れて、リリースへ向かう

往復の全体はエージェントと作るにあります。 エージェントに渡すガイドは skills/yokan/SKILL.md です。


ほかに入っているもの

  • 残りの Python もそのまま

    関数に @py を付ければ、その関数は実行ファイルに同梱された本物の CPython で動きます。 numpy も pandas も、手持ちのコードもそのままです。

  • Rust crate(crates.io も手元のも)

    yokan add app.py deunicode 1 — crates.io の version 指定でも手元の path でも、宣言すれば Yokan のコードから呼べます。 crate 側は普通の Rust のままで、Yokan のために書き足すものはありません。

  • 型チェックが通る

    同梱の型スタブで pyright / Pylance のチェックがそのまま通ります。 @store のシングルトンも @model / @value のコンストラクタも Weak[Node] も、実行時の形どおりに見えます。


次はどこへ

  • インストール

    開発は uv run だけ。 ネイティブビルドには Rust ツールチェーンが要ります。 いまのところ macOS(Apple silicon)と Linux です。

  • 言語ツアー

    状態、ビュー、フォーム、メモリ、Rust crate、ゲートまで書き方を一周。 末尾に「今できないこと」。

  • デモ

    付属デモを全部スクリーンショット付きで。 最小の counter から OpsBoard まで。

  • ソースコード

    コンパイラ、エンジン、デモ。


名前は和菓子の羊羹からとりました。 中身のぎっしり詰まったひと棹を、切り分けて配るお菓子です。 アプリをひとつの実行ファイルに詰めて配る、その姿と重なります。

"Python" は Python Software Foundation の商標です。