Scriptone Scriptone

PyTauri로 데스크톱 애플리케이션 만들기

개요

PyTauri는 Python 기반 데스크톱 앱을 만드는 프레임워크입니다. Rust 기반의 Tauri는 백엔드에 Rust, 프론트엔드에 TypeScript와 웹 프레임워크를 사용하여 크로스 플랫폼 데스크톱 앱을 만들 수 있습니다. PyTauri는 이 백엔드 부분을 Python으로 대체하여 앱을 만들 수 있게 한 프레임워크입니다. 재미있어 보여서 사전 학습 없이 실시간으로 문서를 읽으며 이 글을 작성하고 있습니다. 깔끔한 글이 아닐 수도 있지만 문서를 읽어가며 순서대로 작성하겠습니다.

PyTauri를 배우게 된 이유

최근 업무에서 Tauri를 사용해 데스크톱 앱을 만들고 있습니다. 원래 Python으로 만든 머신러닝과 생성형 AI를 활용한 앱의 배포가 번거로울 것으로 예상되어 대부분을 Rust로 전환하는 작업을 하고 있었습니다. 그 과정에서 Rust로 전환하기 어려운 머신러닝 부분을 PyO3를 통해 구현하려고 했는데, 자료를 찾다가 우연히 PyTauri를 발견했습니다. Rust로 전환하는 작업도 쉬운 일만은 아니었고, 전환이 어려운 부분은 PyO3를 사용해야 했기에 이름만 봐도 매우 흥미로웠습니다. 이 글을 쓰는 시점에는 PyTauri가 정확히 무엇인지 모르는 상태지만, PyTauri를 실제로 사용하면서 지금부터 앱을 만들어보겠습니다.

PyTauri 사용 방법

환경 구축

PyTauri 문서를 읽어가며 진행하겠습니다. 먼저 플랫폼은 Windows 10이 최우선이고, 다음으로 Linux(WSL 포함), Mac OS, Windows 7은 T3로 테스트가 많이 되지 않았다고 합니다. Windows 10은 없으므로 Windows 11로 진행하겠습니다. 이 글을 쓰는 시점에는 PyTauri 0.8을 기준으로 합니다. 사전에 Python 3.9 이상, 최신 stable 버전의 Rust를 설치해야 합니다.

System Dependencies에 Tauri 링크가 있으니 Prerequisites로 이동합니다. Windows의 경우 추가로 Microsoft C++ Build Tools를 다운로드하고, 설치 프로그램에서 “Desktop development with C++“를 선택하여 설치해야 합니다. WebView2, VBSCRIPT 관련 내용이 있지만 필요할 때 확인하면 될 것 같아 건너뜁니다. Rust도 미리 준비했을 것이므로 건너뜁니다. Node.js는 웹에 익숙한 분은 설치하는 것이 좋아 보입니다(추가: 튜토리얼이 웹을 사용하므로 설치하는 것이 좋습니다). Python의 FastAPI, Gradio, NiceGUI로도 데스크톱 앱을 만들 수 있지만, UI 자유도와 성능 관점에서 웹을 할 수 있거나 관심 있는 분은 Node.js를 설치하는 것이 좋아 보입니다. 반면 Python만으로 만들 수 있는 장점도 있으니 스킬과 선호도에 맞춰 선택하면 됩니다. “Configure for Mobile Targets”는 Tauri의 모바일 지원이라 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

다음으로 src-tauri/ 안에 pyproject.toml을 만듭니다. 백엔드 쪽 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를 실행하여 두 개를 가상 환경에 추가합니다. 그리고 지금까지 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.command() 데코레이터로 TypeScript에서 호출할 함수를 정의하고, 인수나 반환 값에는 Pydantic을 사용하여 안전하게 타입을 정의하는 것이 중요해 보입니다. FastAPI나 LangChain 등에서도 Pydantic이 사용되므로 신뢰성이 높지만, 동적 타입 언어이기 때문에 이 점은 Python 개발자에게는 직관적이지 않을 수 있습니다. 통신용 클라이언트는 asynciotrio를 사용할 수 있지만 특별한 이유가 없다면 asyncio로 충분합니다.

TypeScript에서 Python 함수 호출 준비

다음으로 TypeScript에서 Python 함수를 호출합니다. 함수 호출에는 추가 패키지가 필요하므로 npm install tauri-plugin-pytauri-api 등으로 추가합니다. 그리고 문서대로 복사 붙여넣기합니다. SvelteKit으로 만들고 있으므로 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에 대한 타입 선언을 찾을 수 없지만 신경 쓰지 않아도 됩니다. 그리고 Svelte에서 프로세스를 호출해봅니다.

<script lang="ts">
  import { greetToPerson } from "$lib/main"; // Python 프로세스를 main.ts에서 import

  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를 실행합니다. 오류가 없으면 창이 열리므로 이름을 입력해봅니다.

Greet

Python 함수의 설명대로 인사 문장이 표시되면 성공입니다!

여기까지의 소감

이후 PyTauri를 활용한 상태 관리나 Tauri 플러그인 사용 등의 항목도 계속되지만 글이 길어져서 여기서 멈추겠습니다. Tauri에 Python 모듈을 수동으로 삽입하는 절차가 있었지만 꽤 복잡하고 솔직히 번거로웠습니다. 또한 Python과 TypeScript(또는 JavaScript)를 연동하려면 Python 쪽에서 Pydantic을 사용하여 타입을 정의하고 함수 이름도 snake_case와 camelCase로 표기를 바꾸면서 맞춰야 하므로 다소 까다롭고 수동으로 작성하기 어렵습니다. 실제로 앱을 만든다면 저는 AI 코딩으로 편하게 할 것 같습니다. 하지만 Python은 머신러닝 라이브러리가 매우 강력하고, 생성형 AI 프레임워크인 LangChain/LangGraph도 사용할 수 있으며, 그 외에도 다양한 라이브러리가 있습니다. Python을 사용한 기능을 쉽게 통합할 수 있다는 점에서 장점이 있으며, Python 개발자나 Python 라이브러리를 활용하고 싶은 분이 배포하기 쉬운 형태로 데스크톱 앱을 만들고 싶을 때 좋은 선택이 될 수 있습니다.

마치며

PyTauri로 만들 수 있는 주제는 있지만 적극적으로 만들고 싶은 주제가 특별히 없는 상태이므로, PyTauri의 수작업이 줄어들기를 바라며 조금 숙성시킨 후 다시 PyTauri를 만져보고 싶습니다. Tauri는 Rust와 프론트엔드였으므로, 번거로움을 감수할 수 있다면 Python 개발자를 위한 Tauri로서 흥미로운 프레임워크인 것 같습니다. 호기심이 있으신 분은 꼭 시도해보시기 바랍니다!

관련 글