Scriptone Scriptone

PyTauriでデスクトップアプリを作る

概要

PyTauriはPython向けのデスクトップアプリを作るフレームワークです。RustのTauriはバックエンドにRust、フロントエンドにTypeScriptとWebフレームワークを使うことでクロスプラットフォームなデスクトップアプリが作れます。そのバックエンドの部分をPythonに置き換えてアプリを作れるようにしたフレームワークが今回紹介するPyTauriの位置付けとなります。なお、面白そうだと思ってしまったため事前の学習はほぼしていませんがリアルタイムでドキュメントを読みながらこの投稿を書きます。あまりきれいな文章ではない可能性もありますがドキュメントを読み進めながら順番に書いてきます。

PyTauriを学ぼうと思った理由

ここ最近、仕事でTauriを使ってデスクトップアプリを作っています。もともとPythonで作っていた機械学習と生成AIを交えたアプリの配布が面倒なことが予想されて大部分をRustに置き換える作業をしていました。その際にRustに置き換えきれない機械学習部分をPyO3を介して実装しようとしたのですが、その時の調べ物でたまたまPyTauriを見つけました。Rustで置き換える作業も楽なことばかりではなく、置き換え困難な箇所はPyO3を使う状態でしたからな名前だけみても非常に興味が沸きました。これを書いている時点ではPyTauriとは一体何者なのか不明な状態ではありますが、PyTauriを実際に使用しつつアプリを今から作り始めます。

PyTauriの利用方法

環境構築

PyTauriのドキュメントを読み進めながら利用を進めていきます。まずプラットフォームはWindows 10が最優先、時点でLinux(WSLを含む)、Mac OS, Windoes7がT3であまりテストしていないとのことです。手元にWindows 10はないのでWindows11でとりあえず進めることとします。この投稿をしている時点ではPyTauri 0.8を前提に進めます。事前にPython 3.9以上、最新のstable版のRustをインストールします。

System DpendenciesにTauriのリンクがありますので念のためにPrerequisitiesに移動します。そして、Windowsの場合には追加でMicrosoft C++ Build Toolsをダウンロードして、インストーラーの中でさらに「Desktop development with C++」を選択してインストールしてください。そのほかWebView2、VBSCRIPTの記述がありますが必要になったら読めばよさそうなので飛ばします。Rustも事前に準備しているはずなので飛ばします。Node.jsはWebが得意な方は入れたほうがよさそうです(追記:チュートリアルはWebを使っているので入れておいたほうがよさそうです)。PythonのFastAPIやGradio、NiceGUIでもデスクトップアプリを作れるようですが、UIの自由度とパフォーマンスの観点からWebができる方や興味がある方はNode.jsは入れたほうがよさそうに見えます。一方でPythonのみで作れる利点もありますからスキルやお好みに合わせて選んでも大丈夫でしょう。「Configure for MObile Targets」はTauriのMobile対応であまりPyTauriに関係なさそうなので飛ばします。

TauriではなくPyTauriを学びたいので一度PyTauriチュートリアルに戻ってGetting Startedを表示します。Create PyTauri Appのページが表示されます。ここで唐突にuvがでてきますのでPowershellやコマンドんぷトンぷ都などでuvをインストールしておきましょう。

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

ほかにtauri-apps/clicreate-tauri-appも必要なようなのでインストールします。

npm add -D @tauri-apps/cli
cargo install create-tauri-app --locked

プロジェクトの構築

そこから一度Tauriのプロジェクトを作るようなので適当にコマンドを打ちます。

> npm create tauri-app
Need to install the following packages:
create-tauri-app@4.6.2
Ok to proceed? (y) y


> npx
> create-tauri-app

 Project name · hello_pytauri
 Identifier · com.rmc-8.hello_pytauri
 Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, deno, bun)
 Choose your package manager · npm
 Choose your UI template · Svelte - (https://svelte.dev/)
 Choose your UI flavor · TypeScript

Template created! To get started run:
  cd hello_pytauri
  npm install
  npm run tauri android init

For Desktop development, run:
  npm run tauri dev

For Android development, run:
  npm run tauri android dev

好みと楽さでnpm, Svelte, TypeScriptを選択しましたが何でもよいと思います。プロジェクト構成は大まかにこのような状態です。

└─hello_pytauri
    ├─.vscode
    ├─src
    │  └─routes
    ├─src-tauri
    │  ├─capabilities
    │  ├─icons
    │  └─src
    └─static

hello_pytauriがアプリ全体、srcがフロントエンド、src-tauriがRust側です。ここまで見ているとPython要素がよくわからないまま他言語で埋まってしまっていますが続きを読んでみます。

PyTauriの準備

Using PyTauriに移るとようやくPythonが登場します。まず仮想環境をuvで作ります。

uv venv --python-preference only-system

そして仮想環境の利用を始めます。WindowsなのでPowerShellのコマンドを書きます。

.venv\Scripts\Activate.ps1

つぎにpyproject.tomlをsrc-tauri/の中に作ります。バックエンド側のRustにどうやらPythonの仕組みを割り込ませるようです。

[project]
name = "hello_pytauri"  
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.9"
dependencies = ["pytauri == 0.8.*"]  

[project.entry-points.pytauri]
ext_mod = "tauri_app.ext_mod"

[build-system]
requires = ["setuptools >= 80"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages]
find = { where = ["python"] }

そしてuvのpipでインストールします。

uv pip install -e src-tauri

その後、src-tauri/python/tauri_app/__init__.pyにコードを書きます。

from pytauri import (
    builder_factory,
    context_factory,
)


def main() -> int:
    app = builder_factory().build(
        context=context_factory(),
        invoke_handler=None,  # TODO
    )
    exit_code = app.run_return()
    return exit_code

src-tauri/python/tauri_app/__main__.pyにもコードを書きます。

import sys
from multiprocessing import freeze_support

from tauri_app import main

freeze_support()

sys.exit(main())

そしてCargo.tomlの[dependencies]にpytauriとpyo3を追加します。

[dependencies]
# ...
pytauri = { version = "0.8" }  
pyo3 = { version = "0.25" } 

src-tauri/tauri.conf.jsonのbuildの値として以下のように追加します。

{
    "build": {
        "features": ["pytauri/standalone"]
    }
}

そしてsrc-tauri/src/lib.rsにコードをコピペします。解説もなく唐突に出てきていますからいったんボイラープレートのようにとりあえず書くものとして迷わずコピペです。

use pyo3::prelude::*;

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

pub fn tauri_generate_context() -> tauri::Context {
    tauri::generate_context!()
}

#[pymodule(gil_used = false)]
#[pyo3(name = "ext_mod")]
pub mod ext_mod {
    use super::*;

    #[pymodule_init]
    fn init(module: &Bound<'_, PyModule>) -> PyResult<()> {
        pytauri::pymodule_export(
            module,
            // i.e., `context_factory` function of python binding
            |_args, _kwargs| Ok(tauri_generate_context()),
            // i.e., `builder_factory` function of python binding
            |_args, _kwargs| {
                let builder = tauri::Builder::default()
                    .plugin(tauri_plugin_opener::init())
                    .invoke_handler(tauri::generate_handler![greet]);
                Ok(builder)
            },
        )
    }
}

同様にsrc-tauri/src/main.rsもコピペしておきましょう。ただし、use hello_pytauri_libの箇所はアプリ名に合わせて書き換えていますのでご注意ください。

// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use std::{convert::Infallible, env::var, error::Error, path::PathBuf};

use pyo3::wrap_pymodule;
use pytauri::standalone::{
    dunce::simplified, PythonInterpreterBuilder, PythonInterpreterEnv, PythonScript,
};
use tauri::utils::platform::resource_dir;

use hello_pytauri_lib::{ext_mod, tauri_generate_context};

fn main() -> Result<Infallible, Box<dyn Error>> {
    let py_env = if cfg!(dev) {
        // `cfg(dev)` is set by `tauri-build` in `build.rs`, which means running with `tauri dev`,
        // see: <https://github.com/tauri-apps/tauri/pull/8937>.

        let venv_dir = var("VIRTUAL_ENV").map_err(|err| {
            format!(
                "The app is running in tauri dev mode, \
                please activate the python virtual environment first \
                or set the `VIRTUAL_ENV` environment variable: {err}",
            )
        })?;
        PythonInterpreterEnv::Venv(PathBuf::from(venv_dir).into())
    } else {
        // embedded Python, i.e., bundle mode with `tauri build`.

        let context = tauri_generate_context();
        let resource_dir = resource_dir(context.package_info(), &tauri::Env::default())
            .map_err(|err| format!("failed to get resource dir: {err}"))?;
        // 👉 Remove the UNC prefix `\\?\`, Python ecosystems don't like it.
        let resource_dir = simplified(&resource_dir).to_owned();

        // 👉 When bundled as a standalone App, we will put python in the resource directory
        PythonInterpreterEnv::Standalone(resource_dir.into())
    };

    // 👉 Equivalent to `python -m tauri_app`,
    // i.e, run the `src-tauri/python/tauri_app/__main__.py`
    let py_script = PythonScript::Module("tauri_app".into());

    // 👉 `ext_mod` is your extension module, we export it from memory,
    // so you don't need to compile it into a binary file (.pyd/.so).
    let builder =
        PythonInterpreterBuilder::new(py_env, py_script, |py| wrap_pymodule!(ext_mod)(py));
    let interpreter = builder.build()?;

    let exit_code = interpreter.run();
    std::process::exit(exit_code);
}

src-tauri/.taurignoreをつくり以下を記述します。

__pycache__

vite.config.(js|ts)のignoredに, "**/.venv/**"を追加します。

import { defineConfig } from "vite";

// https://vitejs.dev/config/
export default defineConfig(async () => ({
  // ...
  server: {
    watch: {
      // 3. tell vite to ignore watching `src-tauri`
      ignored: ["**/src-tauri/**", "**/.venv/**"],
    },
  },
}));

かなり長い準備を終えました。お疲れさまでした。いよいよバックエンドのPythonとフロントエンドのTypeScript側と通信しますがあと少し準備がありそうです。

PythonとTypeScriptの間で通信する準備

IPC between Python and JavaScriptに移ります。Cargo.tomlの[dependencies]にさらに追記します。

[dependencies]
tauri-plugin-pytauri = { version = "0.8" }

src-tauri/capabilities/default.jsonにPyTauriを使うための権限を追加します。

{
    // ...
    "permissions": [
        // ...
        "pytauri:default"
    ]
}

src-tauri/pyproject.toml[project]にpydanticとanyioを追加します。

# ...

[project]
# ...
dependencies = [
    # ...
    "pydantic == 2.*",
    "anyio == 4.*"
]

手作業で書き入れるだけだと仮想環境に追加されませんので、uv syncを実行して2つを仮想環境に入れます。そして、ここまでTypeScriptで進めてきているのでGenerate TypeScript Client for IPC¶に移動して続きをやります。npmで追加の渇ページをインストールします。

npm install json-schema-to-typescript --save-dev
npm instal json2ts --help

そしてsrc-tauri/python/tauri_app/__init__.pyを書きます。

import sys
from os import getenv
from pathlib import Path

from anyio.from_thread import start_blocking_portal  # noqa: F401
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from pytauri import Commands, builder_factory, context_factory

# TypeScript型定義の自動生成を有効化(開発時のみ)
PYTAURI_GEN_TS = getenv("PYTAURI_GEN_TS") != "0"

commands: Commands = Commands(experimental_gen_ts=PYTAURI_GEN_TS)


class _BaseModel(BaseModel):
    model_config = ConfigDict(
        # JavaScriptのcamelCaseをPythonのsnake_caseに自動変換
        alias_generator=to_camel,
        # 不明なフィールドを禁止(TypeScriptの型安全性向上)
        extra="forbid",
    )


class Person(_BaseModel):
    """A person to greet.

    @property name - The name of the person.
    """

    name: str


@commands.command()
async def greet_to_person(body: Person) -> str:
    """A simple command that returns a greeting message from Python backend.

    @param body - The person to greet.
    """
    python_version = ".".join(sys.version.split(".")[:2])  # e.g., "3.12"
    return f"Hello, {body.name}! 👋 This greeting comes from Python {python_version} backend!"


def main() -> int:
    with start_blocking_portal(
        "asyncio"
    ) as portal:  # 追加(asyncioの代わりにtrioも可能)
        if PYTAURI_GEN_TS:
            # TypeScriptクライアントコードをsrc/libディレクトリに生成
            output_dir = Path(__file__).parent.parent.parent.parent / "src" / "lib"
            json2ts_cmd = "npm json2ts --format=false"

            # バックグラウンドでTypeScript型定義を自動生成
            portal.start_task_soon(
                lambda: commands.experimental_gen_ts_background(
                    output_dir, json2ts_cmd, cmd_alias=to_camel
                )
            )

        app = builder_factory().build(
            context=context_factory(),
            invoke_handler=commands.generate_handler(portal),  # 追加
        )
        exit_code = app.run_return()
        return exit_code

@commands.comand()デコレータでTypeScriptから呼び出す関数を定義し、引数や返り値にはPydanticを使って安全に型を定義することが重要そうです。FastAPIやLangChainなどでもPydanticは使われるので信頼性が高いですが、動的型付けの言語であるのでこの点はPythonistaからすると直感的ではないポイントかもしれません。通信用のクライアントはasynciotrioを使えるようですが理由がなければasyncioでよいかと思います。

TypeScriptからPythonの関数を呼び出す準備

次にTypeScriptからPythonの関数を呼び出します。関数の呼び出しには追加のパッケージが必要なので、npm install tauri-plugin-pytauri-apiなどで追加します。そして、ドキュメント通りにコピペします。SveleKitで作っているのでsrc\lib\main.tsに配置してみます。

/* eslint-disable */
/**
 * This file was automatically generated by pytauri-gen-ts.
 * DO NOT MODIFY IT BY HAND. Instead, modify the source commands API,
 * and run pytauri-gen-ts to regenerate this file.
 */

import { pyInvoke } from "tauri-plugin-pytauri-api";
import type { InvokeOptions } from "@tauri-apps/api/core";

import type { Commands } from "./_apiTypes.d.ts";

/**
 * A simple command that returns a greeting message.
 *
 * @param body - The person to greet.
 */
export async function greetToPerson(
    body: Commands["greet_to_person"]["input"],
    options?: InvokeOptions
): Promise<Commands["greet_to_person"]["output"]> {
    return await pyInvoke("greet_to_person", body, options);
}

Pythonで定義したgreet_to_personとTypeScriptで定義したgreetToPersonとで整合性が取れているのが確認できます。この時点では./_apiTypes.d.tsに対して対応する型宣言が見つからない状態ですが気にせずOKです。そして、試しにSvelteから処理を呼び出してみます。

<script lang="ts">
  import { greetToPerson } from "$lib/main"; // Pythonの処理をmain.tsからipmort

  let name = $state("");
  let greetMsg = $state("");

  async function greet(event: Event) { // greetToPersonを関数化
    event.preventDefault();
    greetMsg = await greetToPerson({ name });
  }
</script>

<main class="container">
  <h1>Welcome to Tauri + Svelte</h1>

  <div class="row">
    <a href="https://vite.dev" target="_blank">
      <img src="/vite.svg" class="logo vite" alt="Vite Logo" />
    </a>
    <a href="https://tauri.app" target="_blank">
      <img src="/tauri.svg" class="logo tauri" alt="Tauri Logo" />
    </a>
    <a href="https://svelte.dev" target="_blank">
      <img src="/svelte.svg" class="logo svelte-kit" alt="SvelteKit Logo" />
    </a>
  </div>
  <p>Click on the Tauri, Vite, and SvelteKit logos to learn more.</p>

  <form class="row" onsubmit={greet}> <!-- greetを呼び出し -->
    <input id="greet-input" placeholder="Enter a name..." bind:value={name} />
    <button type="submit">Greet</button>
  </form>
  <p>{greetMsg}</p>
</main>

npm run tauri devでPyTauriを動かします。エラーが出なければWindowが立ち上がりますので、適当に名前を入力してみます。

Greet

これでPythonの関数の記述通りに挨拶の文章が表示されたら成功です!

ここまでの感想など

このあと、PyTauriを活用した状態管理やTauri Pluginの利用などの項目も続きますが長くなりましたのでここで止めます。手作業でTauriにPythonのモジュールを埋め込む手順がありましたがだいぶ煩雑で正直なところめんどくさいと思いました。また、PythonとTypeScript(もしくはJavaScript)を連携させるためにPython側ではPydanticを使って型を定義して関数名もスネークケースとパスカルケースで表記を揺らしながら合わせる必要があるのでややトリッキーでかつ、手作業で書くには大変だと思います。実際にアプリを作るとなったら私ならVibe Codingで楽すると思います。ただ、Tauriでブロードリスニングをした時の例のようにPythonは機械学習系のライブラリが非常に強力であり、生成AIのフレームワークのLangChain/LangGraphも使えて、そのほか多種多様なライブラリがあります。Pythonを使った機能を組み込みやすい点でメリットがあり、PythonistaやPythonのライブラリを活用したい方でデスクトップアプリを配布しやすい形で作りたい方に良い選択肢となるかもしれません。

最後に

PyTauriでつくれそうな題材はありつつ積極的に作りたい題材がとくにない状態なので、PyTauriの手作業が減ることを祈りつつ少し寝かせてからまたPyTauriを触ってみたいと思っています。TauriだとRustとフロントエンドではあったので、面倒さを許容できればPythonista向けのTauriとして面白そうなフレームワークだと思いました。物好きな方がいらっしゃったらぜひお試しいただきたいと思います!

関連記事