Skip to main content

tauri_plugin_python/
lib.rs

1//  Tauri Python Plugin
2//  © Copyright 2024, by Marco Mengelkoch
3//  Licensed under MIT License, see License file for more details
4//  git clone https://github.com/marcomq/tauri-plugin-python
5
6use tauri::{
7    path::BaseDirectory,
8    plugin::{Builder, TauriPlugin},
9    AppHandle, Manager, Runtime,
10};
11
12#[cfg(desktop)]
13mod desktop;
14#[cfg(mobile)]
15mod mobile;
16
17mod commands;
18mod error;
19mod models;
20use async_py::{self, PyRunner};
21
22pub use error::{Error, Result};
23use models::*;
24use std::{
25    collections::HashSet,
26    path::PathBuf,
27    sync::{atomic::AtomicBool, Mutex},
28    time::Duration,
29};
30
31/// Default per-call timeout applied to the Python worker so a single wedged call
32/// (e.g. a blocking `print()` on a hidden-console Windows build, or a network
33/// call whose own timeout never fires) cannot hang every later call forever.
34/// Generous on purpose so it won't interfere with legitimately long work.
35/// Override via the `TAURI_PLUGIN_PYTHON_TIMEOUT_SECS` env var (`0` disables it).
36const DEFAULT_TIMEOUT_SECS: u64 = 300;
37
38/// Python executed once at startup, before `main.py`, to make stdio safe.
39///
40/// On a Windows release build the console is hidden (`windows_subsystem =
41/// "windows"`), so the standard handles are missing/invalid and a bare
42/// `print()` can raise or abort the whole process (see issues #4/#15/#17). This
43/// wraps `sys.stdout`/`sys.stderr` so writes can never crash the app. It cannot
44/// un-block a write that hangs on a full, unread pipe - the call timeout is the
45/// backstop for that - but it removes the common crash-on-stdio failure mode.
46const PY_STDIO_GUARD: &str = r#"import sys
47
48class _TauriSafeStream:
49    def __init__(self, real):
50        self._real = real
51    def write(self, data):
52        try:
53            if self._real is not None:
54                return self._real.write(data)
55        except Exception:
56            pass
57        return 0
58    def flush(self):
59        try:
60            if self._real is not None:
61                self._real.flush()
62        except Exception:
63            pass
64    def isatty(self):
65        try:
66            return bool(self._real is not None and self._real.isatty())
67        except Exception:
68            return False
69    def __getattr__(self, name):
70        return getattr(self._real, name)
71
72sys.stdout = _TauriSafeStream(getattr(sys, "stdout", None))
73sys.stderr = _TauriSafeStream(getattr(sys, "stderr", None))
74"#;
75
76/// Builds the shared [`PyRunner`], applying the default per-call timeout unless
77/// the `TAURI_PLUGIN_PYTHON_TIMEOUT_SECS` env var overrides it (`0` = no timeout).
78fn build_runner() -> PyRunner {
79    let runner = PyRunner::new();
80    match std::env::var("TAURI_PLUGIN_PYTHON_TIMEOUT_SECS")
81        .ok()
82        .and_then(|v| v.trim().parse::<u64>().ok())
83    {
84        Some(0) => runner,
85        Some(secs) => runner.with_timeout(Duration::from_secs(secs)),
86        None => runner.with_timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
87    }
88}
89
90#[cfg(desktop)]
91use desktop::Python;
92#[cfg(mobile)]
93use mobile::Python;
94
95#[derive(Default)]
96struct PluginState {
97    init_blocked: AtomicBool,
98    function_map: Mutex<HashSet<String>>,
99}
100
101/// Prepends human-readable context to a failing Python operation and, in debug
102/// builds (`tauri dev`), prints the full detail - including the Python traceback
103/// carried in the underlying error - to stderr so it is visible in the dev
104/// console. The original error message is preserved in the returned error, so it
105/// also still reaches the frontend. In release builds nothing is logged.
106fn py_context<T, E: Into<Error>>(
107    result: std::result::Result<T, E>,
108    context: impl FnOnce() -> String,
109) -> crate::Result<T> {
110    result.map_err(|err| {
111        let msg = format!("{}: {}", context(), err.into());
112        #[cfg(debug_assertions)]
113        eprintln!("[tauri-plugin-python] {msg}");
114        Error::String(msg)
115    })
116}
117
118/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the python APIs.
119
120#[async_trait::async_trait]
121pub trait PythonExt<R: Runtime> {
122    fn python(&self) -> &Python<R>;
123    fn runner(&self) -> &PyRunner;
124    async fn run_python(&self, payload: StringRequest) -> crate::Result<StringResponse>;
125    async fn register_function(&self, payload: RegisterRequest) -> crate::Result<StringResponse>;
126    async fn call_function(&self, payload: RunRequest) -> crate::Result<StringResponse>;
127    async fn read_variable(&self, payload: StringRequest) -> crate::Result<StringResponse>;
128}
129
130#[async_trait::async_trait]
131impl<R: Runtime, T: Manager<R> + Sync> crate::PythonExt<R> for T {
132    fn python(&self) -> &Python<R> {
133        self.state::<Python<R>>().inner()
134    }
135    fn runner(&self) -> &PyRunner {
136        self.state::<PyRunner>().inner()
137    }
138    async fn run_python(&self, payload: StringRequest) -> crate::Result<StringResponse> {
139        py_context(self.runner().run(&payload.value).await, || {
140            "Error running Python code (runPython)".into()
141        })?;
142        Ok(StringResponse { value: "Ok".into() })
143    }
144
145    async fn register_function(&self, payload: RegisterRequest) -> crate::Result<StringResponse> {
146        let state = self.state::<PluginState>().inner();
147        if state
148            .init_blocked
149            .load(std::sync::atomic::Ordering::Relaxed)
150        {
151            return Err("Cannot register after function called".into());
152        }
153        let _tmp = py_context(
154            self.runner()
155                .read_variable(&payload.python_function_call)
156                .await,
157            || {
158                format!(
159                    "Cannot register '{}': not found in Python (is it defined/imported in main.py?)",
160                    payload.python_function_call
161                )
162            },
163        )?;
164        if let Some(num_args) = payload.number_of_args {
165            // Validate the argument count via `inspect.signature`, but only
166            // *reject* the registration on an actual mismatch. If the check
167            // itself can't run - e.g. the RustPython backend can't import
168            // `inspect` - the import failure is swallowed in Python so the call
169            // succeeds and registration proceeds without validation, rather
170            // than failing on an unrelated error.
171            let py_analyze_sig = format!(
172                r#"
173try:
174    from inspect import signature
175    _tauri_param_count = len(signature({0}).parameters)
176except Exception:
177    _tauri_param_count = None
178if _tauri_param_count is not None and _tauri_param_count != {1}:
179    raise Exception("Function parameters don't match in 'registerFunction'")
180"#,
181                &payload.python_function_call, num_args
182            );
183            self.runner().run(&py_analyze_sig).await.map_err(|_| {
184                Error::String(format!(
185                    "Function parameters don't match signature of {}.",
186                    payload.python_function_call
187                ))
188            })?;
189        };
190        state
191            .function_map
192            .lock()
193            .unwrap()
194            .insert(payload.python_function_call.clone());
195        Ok(StringResponse { value: "Ok".into() })
196    }
197
198    async fn call_function(&self, payload: RunRequest) -> crate::Result<StringResponse> {
199        let state = self.state::<PluginState>().inner();
200        state
201            .init_blocked
202            .store(true, std::sync::atomic::Ordering::Relaxed);
203        let function_name = payload.function_name;
204        if state
205            .function_map
206            .lock()
207            .unwrap()
208            .get(&function_name)
209            .is_none()
210        {
211            return Err(Error::String(format!(
212                "Function {function_name} has not been registered yet"
213            )));
214        }
215        let py_res = py_context(
216            self.runner()
217                .call_function(&function_name, payload.args)
218                .await,
219            || format!("Error calling Python function '{function_name}'"),
220        )?;
221        let value = match py_res.as_str() {
222            Some(s) => s.to_string(),
223            None => py_res.to_string(),
224        };
225        Ok(StringResponse { value })
226    }
227
228    async fn read_variable(&self, payload: StringRequest) -> crate::Result<StringResponse> {
229        let py_res = py_context(self.runner().read_variable(&payload.value).await, || {
230            format!("Error reading Python variable '{}'", payload.value)
231        })?;
232        Ok(StringResponse {
233            value: py_res.to_string(),
234        })
235    }
236}
237
238fn get_resource_dir<R: Runtime>(app: &AppHandle<R>) -> PathBuf {
239    app.path()
240        .resolve("src-python", BaseDirectory::Resource)
241        .unwrap_or_default()
242}
243
244fn get_src_python_dir() -> PathBuf {
245    std::env::current_dir().unwrap().join("src-python")
246}
247
248/// Initializes the plugin with functions
249pub fn init<R: Runtime>() -> TauriPlugin<R> {
250    init_and_register(vec![])
251}
252
253fn cleanup_path_for_python(path: &PathBuf) -> String {
254    dunce::canonicalize(path)
255        .unwrap()
256        .to_string_lossy()
257        .replace("\\", "/")
258}
259
260fn print_path_for_python(path: &PathBuf) -> String {
261    #[cfg(not(target_os = "windows"))]
262    {
263        format!("\"{}\"", cleanup_path_for_python(path))
264    }
265    #[cfg(target_os = "windows")]
266    {
267        format!("r\"{}\"", cleanup_path_for_python(path))
268    }
269}
270
271async fn init_python(runner: &PyRunner, dir: PathBuf) {
272    // Make stdio safe before anything else (incl. main.py) runs - see PY_STDIO_GUARD.
273    runner
274        .run(PY_STDIO_GUARD)
275        .await
276        .expect("ERROR: Error initializing python stdio");
277    let sys_pyth_dir = print_path_for_python(&dir);
278    let path_import = format!(
279        r#"import sys
280sys.path = sys.path + [{}]
281"#,
282        sys_pyth_dir,
283    );
284    runner
285        .run(&path_import)
286        .await
287        .expect("ERROR: Error setting python path");
288    #[cfg(feature = "venv")]
289    {
290        let venv_dir = dir.join(".venv").join("lib");
291        if venv_dir.exists() {
292            runner
293                .set_venv(venv_dir.as_path())
294                .await
295                .expect("ERROR: Error setting venv for python");
296        }
297    }
298}
299
300/// Initializes the plugin.
301pub fn init_and_register<R: Runtime>(python_functions: Vec<&'static str>) -> TauriPlugin<R> {
302    Builder::new("python")
303        .invoke_handler(tauri::generate_handler![
304            commands::run_python,
305            commands::register_function,
306            commands::call_function,
307            commands::read_variable
308        ])
309        .setup(|app, api| {
310            #[cfg(mobile)]
311            let python = mobile::init(app, api)?;
312            #[cfg(desktop)]
313            let python = desktop::init(app, api)?;
314            app.manage(python);
315            let runner = build_runner();
316            app.manage(runner);
317            app.manage(PluginState::default());
318
319            let mut dir = get_resource_dir(app);
320            let mut main_py = dir.join("main.py");
321            if !main_py.exists() {
322                println!(
323                    "Warning: 'src-tauri/main.py' seems not to be registered in 'tauri.conf.json'"
324                );
325                dir = get_src_python_dir();
326                main_py = dir.join("main.py");
327            }
328            tokio::runtime::Runtime::new()
329                .unwrap()
330                .block_on(async move {
331                    let runner = app.state::<PyRunner>().inner();
332                    init_python(runner, dir.to_path_buf()).await;
333                    runner
334                        .run_file(main_py.as_path())
335                        .await
336                        .expect("ERROR: Error running 'src-tauri/main.py'");
337                    register_python_functions(
338                        app,
339                        python_functions.iter().map(|s| s.to_string()).collect(),
340                    )
341                    .await;
342                    let functions = runner
343                        .read_variable("_tauri_plugin_functions")
344                        .await
345                        .unwrap_or_default();
346                    if let Ok(python_functions) = serde_json::from_value(functions) {
347                        register_python_functions(app, python_functions).await;
348                    }
349                });
350
351            Ok(())
352        })
353        .build()
354}
355
356async fn register_python_functions<R: Runtime>(app: &AppHandle<R>, python_functions: Vec<String>) {
357    for function_name in python_functions {
358        app.register_function(RegisterRequest {
359            python_function_call: function_name.clone(),
360            number_of_args: None,
361        })
362        .await
363        .unwrap();
364    }
365}
366
367#[cfg(test)]
368mod tests;