同梱した Python スクリプトを実行する

PyInstaller で実行ファイルにした Python スクリプトをサイドカーとして同梱し、Command.sidecar() で呼んで JSON を受け取る。Windows の文字化けや、常駐させたときの終わらせ方も示す。

外部プロセス 対象: Tauri 2.x 更新日: 読了目安: 約10分 shell-010
目次
  1. 前提条件
  2. Python スクリプトを書く
  3. PyInstaller で実行ファイルにする
  4. 1. フロントエンドから実装する (TypeScript)
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

Python で書いた集計や変換、機械学習の推論などを Tauri アプリから使いたくても、利用者の PC に Python が入っているとは限りません。PyInstaller で Python 本体ごと 1 つの実行ファイルにまとめてサイドカーとして同梱すれば、インストールなしで動かせます。ここでは Python 側の書き方(引数・JSON・文字コード)、ビルド、JS / Rust からの呼び出し、常駐させるときの終わらせ方を扱います。ファイル名の規則や externalBin など、サイドカー自体の基本は 同梱したバイナリファイルを実行する にまとめています。

前提条件

npm run tauri add shell

tauri.conf.json の bundle.externalBin に "binaries/pytool" を登録し、JS から呼ぶ分の権限を capability に書きます。下の例は「1 番目は summary 固定、2 番目は .csv で終わる値だけ」を許可します。後半の常駐版は Rust から起動するので、JS 側の Shell 権限は要りません。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "shell:allow-execute",
      "allow": [
        { "name": "binaries/pytool", "sidecar": true, "args": ["summary", { "validator": ".+\\.csv" }] }
      ]
    }
  ]
}

Python スクリプトを書く

結果は標準出力に JSON で 1 行、エラーは標準エラー出力と終了コードで返す形にしておくと、呼び出し側が扱いやすくなります。summary は 1 回で終わる処理、serve は標準入力から 1 行ずつ依頼を受ける常駐版です。

# pytool.py
import csv
import json
import sys

# Windows でも UTF-8 で読み書きする(既定ではシステムの文字コードになる)
for stream in (sys.stdin, sys.stdout, sys.stderr):
    stream.reconfigure(encoding="utf-8")


def summary(path):
    with open(path, newline="", encoding="utf-8-sig") as f:
        rows = list(csv.reader(f))
    return {"columns": rows[0] if rows else [], "rows": max(len(rows) - 1, 0)}


def serve():
    # 1 行の JSON を受け取り 1 行の JSON を返す。標準入力が閉じたらループを抜けて終わる
    for line in sys.stdin:
        req = json.loads(line)
        try:
            res = {"id": req["id"], "result": summary(req["path"])}
        except OSError as e:
            res = {"id": req["id"], "error": str(e)}
        print(json.dumps(res, ensure_ascii=False), flush=True)


if __name__ == "__main__":
    if len(sys.argv) == 3 and sys.argv[1] == "summary":
        try:
            print(json.dumps(summary(sys.argv[2]), ensure_ascii=False))
        except OSError as e:
            print(f"cannot read: {e}", file=sys.stderr)
            sys.exit(1)
    elif sys.argv[1:] == ["serve"]:
        serve()
    else:
        print("usage: pytool summary <file.csv> | pytool serve", file=sys.stderr)
        sys.exit(2)
  • 文字コード: Windows では、パイプにつながった標準出力の文字コードが既定でシステムのもの(日本語版なら cp932)になります。Tauri 側は UTF-8 として読むので、冒頭の reconfigure() で揃えます。
  • flush: パイプへの出力はまとめて書き出されるため、常駐版で flush=True を付けないと返事がすぐ届きません。
  • 標準出力を汚さない: デバッグ用の print() は file=sys.stderr に出します。混ざると JSON として読めなくなります。

PyInstaller で実行ファイルにする

# Windows (PowerShell)
pip install pyinstaller
pyinstaller --onefile --name pytool pytool.py
$triple = (rustc -Vv | Select-String "host:").Line.Split(" ")[1]
Copy-Item dist/pytool.exe "src-tauri/binaries/pytool-$triple.exe"
# macOS / Linux
pip install pyinstaller
pyinstaller --onefile --name pytool pytool.py
cp dist/pytool "src-tauri/binaries/pytool-$(rustc -Vv | grep host | cut -f2 -d' ')"

--onefile で 1 ファイルにします。--onedir の出力は実行ファイルと付属フォルダの組なのでサイドカーとしては使えず、使うならフォルダごとリソースとして同梱し、capability の cmd に $RESOURCE から始まるパスを書いて Command.create() で起動する形になります。--windowed(--noconsole)は不要です。Windows ではサイドカーはもともとコンソールウィンドウなしで起動し、macOS では余分な .app が作られるだけです。PyInstaller はクロスコンパイルできないので、Windows 用は Windows で、macOS 用は macOS で作ります。

1. フロントエンドから実装する (TypeScript)

import { Command } from '@tauri-apps/plugin-shell';

type CsvSummary = { columns: string[]; rows: number };

export async function summarizeCsv(path: string): Promise<CsvSummary> {
  // 引数は capability の args と同じ並びで渡す
  const output = await Command.sidecar('binaries/pytool', ['summary', path]).execute();
  if (output.code !== 0) {
    throw new Error(output.stderr.trim() || `pytool exited with code ${output.code}`);
  }
  return JSON.parse(output.stdout) as CsvSummary;
}

execute() は呼ぶたびにプロセスを起動します。--onefile の実行ファイルは起動のたびに一時フォルダへ展開してから動くので、1 回あたり数秒かかることもあります。何度も呼ぶなら、次の常駐版にします。

2. バックエンドから実装する (Rust)

1 回で終わる処理は、Rust なら結果を構造体で受け取れます。常駐版は setup で起動し、依頼を標準入力に書き、返事をイベントでフロントに送ります。ここであえて kill() しないのがポイントです。--onefile は強制終了されると展開した一時フォルダ(_MEI で始まる名前)が消えずに残ります。Rust から起動したプロセスはプラグインが止めないので、アプリが終わると標準入力が閉じ、Python のループが抜けて普通に終了し、一時フォルダも片付きます。

use std::sync::Mutex;
use tauri::{Emitter, Manager};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;

#[derive(serde::Serialize, serde::Deserialize)]
struct CsvSummary {
    columns: Vec<String>,
    rows: usize,
}

/// 1 回で終わる呼び出し。JS の capability は不要
#[tauri::command]
async fn summarize_csv(app: tauri::AppHandle, path: String) -> Result<CsvSummary, String> {
    let output = app
        .shell()
        .sidecar("pytool")
        .map_err(|e| e.to_string())?
        .args(["summary", path.as_str()])
        .output()
        .await
        .map_err(|e| e.to_string())?;
    if !output.status.success() {
        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
    }
    serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())
}

/// 常駐版のプロセス(標準入力へ書くために持っておく)
struct PyWorker(Mutex<Option<CommandChild>>);

#[tauri::command]
fn ask_pytool(worker: tauri::State<'_, PyWorker>, id: u32, path: String) -> Result<(), String> {
    let mut guard = worker.0.lock().map_err(|e| e.to_string())?;
    let child = guard.as_mut().ok_or("pytool is not running")?;
    let line = serde_json::json!({ "id": id, "path": path }).to_string() + "\n";
    child.write(line.as_bytes()).map_err(|e| e.to_string())
}

fn start_pytool(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
    let (mut rx, child) = app.shell().sidecar("pytool")?.arg("serve").spawn()?;
    app.manage(PyWorker(Mutex::new(Some(child))));
    let handle = app.clone();
    tauri::async_runtime::spawn(async move {
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stdout(line) => {
                    let _ = handle.emit("pytool-reply", String::from_utf8_lossy(&line).trim().to_string());
                }
                CommandEvent::Stderr(line) => eprintln!("[pytool] {}", String::from_utf8_lossy(&line).trim_end()),
                _ => {}
            }
        }
    });
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .setup(|app| {
            start_pytool(app.handle())?;
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![summarize_csv, ask_pytool])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

フロントでは、依頼ごとに id を振り、同じ id の返事で Promise を解決します。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

type Reply = { id: number; result?: { columns: string[]; rows: number }; error?: string };

const waiting = new Map<number, (reply: Reply) => void>();
let nextId = 1;

await listen<string>('pytool-reply', (event) => {
  if (!event.payload) return;
  const reply = JSON.parse(event.payload) as Reply;
  waiting.get(reply.id)?.(reply);
  waiting.delete(reply.id);
});

export function askPytool(path: string): Promise<Reply> {
  const id = nextId++;
  const reply = new Promise<Reply>((resolve) => waiting.set(id, resolve));
  void invoke('ask_pytool', { id, path });
  return reply;
}

動作確認

npm run tauri dev で起動し、CSV の絶対パスを渡して summarizeCsv() と askPytool() を呼びます。見出し行と 120 行のデータがある CSV なら、どちらからも次の内容が返ります(常駐版は result の中)。2 回目以降は常駐版の方がすぐ返ります。

{"columns":["日付","品名","金額"],"rows":120}

アプリを閉じたあと、一時フォルダ(Windows なら %TEMP%)に _MEI で始まるフォルダが増えていなければ、常駐版も正しく終わっています。

よくあるエラーと対処法

  • 「invalid utf-8 sequence of … bytes from index …」: Python の出力が UTF-8 ではありません。Windows で日本語を出したときに起き、標準エラー出力だけが原因でも execute() 全体が失敗します。reconfigure(encoding="utf-8") を標準入出力の 3 つすべてに入れます。
  • JSON.parse() が SyntaxError になる: 標準出力に JSON 以外の行(デバッグ用の print() やライブラリの警告)が混ざっています。標準エラー出力へ移します。
  • 常駐版の返事が来ない、まとめて届く: flush=True がありません。
  • exe にしたら ModuleNotFoundError になる: 文字列から動的に読み込むモジュールは PyInstaller に見つけてもらえません。--hidden-import モジュール名 を付けてビルドし直します。
  • sys.argv に引数が入っていない: capability の args を省略すると、JS から渡した引数は捨てられます。名前や権限まわりのエラーは shell-012 の一覧を参照してください。

OS ごとの違いと注意点

  • Windows: 文字コードの問題はほぼ Windows だけで起きます。PyInstaller で作った実行ファイルはウイルス対策ソフトに誤検知されることがあるので、配布前に署名や検査を検討します。
  • macOS: Apple Silicon と Intel の両方に配るなら、それぞれの環境で作った実行ファイルを、対応するトリプル名で用意します。
  • Linux: /tmp が実行禁止(noexec)でマウントされた環境では --onefile は動きません(PyInstaller のドキュメントに記載)。
  • Python ごと入る: 実行ファイルは小さなスクリプトでも数 MB〜数十 MB になり、使うライブラリに比例して大きくなります。
  • .py をそのまま同梱する方法: 利用者に Python を入れてもらえるなら、スクリプトをリソースとして同梱し、システムの python に渡す方法もあります。この場合はサイドカーではなく通常のコマンドとして権限を書きます(外部コマンドを実行する)。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する