From 66364dcd1e85de190fc337fbb195094fe0fdb51e Mon Sep 17 00:00:00 2001 From: "marco.mengelkoch" Date: Sun, 28 Sep 2025 19:54:15 +0200 Subject: [PATCH 1/4] initial async_py usage --- Cargo.toml | 22 +++-- src/commands.rs | 8 +- src/error.rs | 125 +--------------------------- src/lib.rs | 198 +++++++++++++++++++++++++++++---------------- src/py_lib.rs | 146 --------------------------------- src/py_lib_pyo3.rs | 127 ----------------------------- 6 files changed, 144 insertions(+), 482 deletions(-) delete mode 100644 src/py_lib.rs delete mode 100644 src/py_lib_pyo3.rs diff --git a/Cargo.toml b/Cargo.toml index a8eed3b..55f990f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,10 @@ [package] name = "tauri-plugin-python" -version = "0.3.6" +version = "0.3.7" authors = [ "Marco Mengelkoch" ] description = "A tauri 2 plugin to use python code in the backend." keywords = ["rust", "python", "tauri", "gui"] edition = "2021" -rust-version = "1.77.2" exclude = ["/examples", "/webview-dist", "/webview-src", "/node_modules"] links = "tauri-plugin-python" license = "MIT" @@ -16,15 +15,11 @@ repository = "https://github.com/marcomq/tauri-plugin-python" tauri = { version = "2" } serde = { version = "1", features = ["derive"] } thiserror = "2" +async-trait = "0.1" + lazy_static = "1.5.0" -pyo3 = { version = "0.23.3", features=["auto-initialize", "generate-import-lib"], optional = true } -rustpython-pylib = { version = "0.4.0" } -rustpython-stdlib = { version = "0.4.0", features = ["threading"] } -rustpython-vm = { version = "0.4.0", features = [ - "importlib", - "serde", - "threading", -] } +async_py = { version = "0.2.0", default-features = false } +tokio = { version = "1", features = ["full"] } serde_json = "1.0.136" dunce = "1.0.5" @@ -33,6 +28,7 @@ tauri-plugin = { version = "2", features = ["build"] } [features] venv = [] -default = ["venv"] # auto load src-python/.venv -# default = ["venv", "pyo3"] # enable to use pyo3 instead of rustpython -pyo3 = ["dep:pyo3"] +default = ["venv", "rustpython"] # auto load src-python/.venv +# default = ["venv", "pyo3"] # enable to use pyo3 instead of rustpython. +rustpython = ["async_py/rustpython"] +pyo3 = ["async_py/pyo3"] diff --git a/src/commands.rs b/src/commands.rs index 6c261ac..ab3ce9b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -14,26 +14,26 @@ pub(crate) async fn run_python( app: AppHandle, payload: StringRequest, ) -> Result { - app.run_python(payload) + app.run_python(payload).await } #[command] pub(crate) async fn register_function( app: AppHandle, payload: RegisterRequest, ) -> Result { - app.register_function(payload) + app.register_function(payload).await } #[command] pub(crate) async fn call_function( app: AppHandle, payload: RunRequest, ) -> Result { - app.call_function(payload) + app.call_function(payload).await } #[command] pub(crate) async fn read_variable( app: AppHandle, payload: StringRequest, ) -> Result { - app.read_variable(payload) + app.read_variable(payload).await } diff --git a/src/error.rs b/src/error.rs index 92ef231..429e261 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,9 +2,8 @@ // © Copyright 2024, by Marco Mengelkoch // Licensed under MIT License, see License file for more details // git clone https://github.com/marcomq/tauri-plugin-python +use async_py::PyRunnerError; -#[cfg(feature = "pyo3")] -use pyo3::{prelude::*, PyErr}; use serde::{ser::Serializer, Serialize}; pub type Result = std::result::Result; @@ -18,6 +17,8 @@ pub enum Error { #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + #[error(transparent)] + PyRunner(#[from] PyRunnerError), } impl Serialize for Error { @@ -35,126 +36,6 @@ impl From<&str> for Error { } } -#[cfg(not(feature = "pyo3"))] -impl From> for Error { - fn from(error: rustpython_vm::PyRef) -> Self { - let msg = format!("{:?}", &error); - println!("error: {}", &msg); - if let Some(tb) = error.traceback() { - println!("Traceback (most recent call last):"); - for trace in tb.iter() { - let file = trace.frame.code.source_path.as_str(); - let original_line = trace.lineno.to_usize(); - let line = if file == "main.py" { - original_line - 2 // sys.path import has 2 additional lines - } else { - original_line - }; - println!( - " File \"{file}\", line {line}, in {}", - trace.frame.code.obj_name - ); - } - } - Error::String(msg) - } -} - -#[cfg(feature = "pyo3")] -impl From for Error { - fn from(error: PyErr) -> Self { - let error_msg = match pyo3::Python::with_gil(|py| -> Result> { - let traceback_module = py.import("traceback")?; - let traceback_object = error - .traceback(py) - .ok_or(pyo3::exceptions::PyWarning::new_err("No traceback found."))?; - let extract_traceback = traceback_module.getattr("extract_tb")?; - - // Get the formatted traceback lines - let result = extract_traceback.call1((traceback_object,)).and_then(|r| { - match r.extract::>() { - Ok(v) => { - let mut formatted_lines = Vec::new(); - for arg in v.iter() { - let frame = arg.bind(py); - - // Extract filename - let filename = match frame.getattr("filename") { - Ok(f) => match f.extract::() { - Ok(s) if s == "".to_string() => { - // Special handling for - frame.setattr("filename", "main.py")?; - let lineno = frame.getattr("lineno")?.extract::()?; - frame.setattr("lineno", lineno - 2)?; - "main.py".to_string() - } - Ok(s) => s, - Err(_) => "".to_string(), - }, - Err(_) => "".to_string(), - }; - - // Extract line number - let lineno = match frame.getattr("lineno") { - Ok(l) => match l.extract::() { - Ok(n) => n, - Err(_) => 0, - }, - Err(_) => 0, - }; - - // Extract function name - let name = match frame.getattr("name") { - Ok(n) => match n.extract::() { - Ok(s) => s, - Err(_) => "".to_string(), - }, - Err(_) => "".to_string(), - }; - - // Extract line content (if available) - let line = match frame.getattr("line") { - Ok(l) => match l.extract::>() { - Ok(Some(s)) => format!("\t{}", s), - _ => "".to_string(), - }, - Err(_) => "".to_string(), - }; - - // Format the line like requested - let formatted_line = format!( - "File \"{}\", line {}, in {}\n{}", - filename, lineno, name, line - ); - - formatted_lines.push(formatted_line); - } - - Ok(formatted_lines) - } - Err(_) => Err(PyErr::new::( - "Failed to extract traceback", - )), - } - })?; - - // Add traceback header - let mut full_traceback = vec!["Traceback (most recent call last):".to_string()]; - full_traceback.extend(result); - - // Add error type and message - full_traceback.push(error.to_string()); - - Ok(full_traceback) - }) { - Ok(formatted) => formatted.join("\n"), - Err(_) => error.to_string(), // Fall back to simple error message - }; - - Error::String(error_msg) - } -} - impl From for Error { fn from(error: tauri::Error) -> Self { Error::String(error.to_string()) diff --git a/src/lib.rs b/src/lib.rs index 5d1006b..05709ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,50 +17,91 @@ mod mobile; mod commands; mod error; mod models; -#[cfg(not(feature = "pyo3"))] -mod py_lib; -#[cfg(feature = "pyo3")] -mod py_lib_pyo3; -#[cfg(feature = "pyo3")] -use py_lib_pyo3 as py_lib; +use async_py::{self, PyRunner}; +use lazy_static::lazy_static; pub use error::{Error, Result}; use models::*; -use std::path::{Path, PathBuf}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::{atomic::AtomicBool, Mutex}, +}; #[cfg(desktop)] use desktop::Python; #[cfg(mobile)] use mobile::Python; +lazy_static! { + static ref INIT_BLOCKED: AtomicBool = false.into(); + static ref FUNCTION_MAP: Mutex> = Mutex::new(HashSet::new()); +} + /// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the python APIs. + +#[async_trait::async_trait] pub trait PythonExt { fn python(&self) -> &Python; - fn run_python(&self, payload: StringRequest) -> crate::Result; - fn register_function(&self, payload: RegisterRequest) -> crate::Result; - fn call_function(&self, payload: RunRequest) -> crate::Result; - fn read_variable(&self, payload: StringRequest) -> crate::Result; + fn runner(&self) -> &PyRunner; + async fn run_python(&self, payload: StringRequest) -> crate::Result; + async fn register_function(&self, payload: RegisterRequest) -> crate::Result; + async fn call_function(&self, payload: RunRequest) -> crate::Result; + async fn read_variable(&self, payload: StringRequest) -> crate::Result; } -impl> crate::PythonExt for T { +#[async_trait::async_trait] +impl + Sync> crate::PythonExt for T { fn python(&self) -> &Python { self.state::>().inner() } - fn run_python(&self, payload: StringRequest) -> crate::Result { - py_lib::run_python(payload)?; + fn runner(&self) -> &PyRunner { + self.state::().inner() + } + async fn run_python(&self, payload: StringRequest) -> crate::Result { + self.runner().run(&payload.value).await?; Ok(StringResponse { value: "Ok".into() }) } - fn register_function(&self, payload: RegisterRequest) -> crate::Result { - py_lib::register_function(payload)?; + + async fn register_function(&self, payload: RegisterRequest) -> crate::Result { + if INIT_BLOCKED.load(std::sync::atomic::Ordering::Relaxed) { + return Err("Cannot register after function called".into()); + } + + FUNCTION_MAP + .lock() + .unwrap() + .insert(payload.python_function_call.clone()); + let _tmp = self + .runner() + .read_variable(&payload.python_function_call) + .await?; + dbg!(&_tmp); Ok(StringResponse { value: "Ok".into() }) } - fn call_function(&self, payload: RunRequest) -> crate::Result { - let py_res: String = py_lib::call_function(payload)?; - Ok(StringResponse { value: py_res }) + + async fn call_function(&self, payload: RunRequest) -> crate::Result { + INIT_BLOCKED.store(true, std::sync::atomic::Ordering::Relaxed); + let function_name = payload.function_name; + if FUNCTION_MAP.lock().unwrap().get(&function_name).is_none() { + return Err(Error::String(format!( + "Function {function_name} has not been registered yet" + ))); + } + let py_res = self + .runner() + .call_function(&function_name, payload.args) + .await?; + Ok(StringResponse { + value: py_res.to_string(), + }) } - fn read_variable(&self, payload: StringRequest) -> crate::Result { - let py_res = py_lib::read_variable(payload)?; - Ok(StringResponse { value: py_res }) + + async fn read_variable(&self, payload: StringRequest) -> crate::Result { + let py_res = self.runner().read_variable(&payload.value).await?; + Ok(StringResponse { + value: py_res.to_string(), + }) } } @@ -87,44 +128,38 @@ fn cleanup_path_for_python(path: &PathBuf) -> String { } fn print_path_for_python(path: &PathBuf) -> String { - #[cfg(not(target_os = "windows"))] { + #[cfg(not(target_os = "windows"))] + { format!("\"{}\"", cleanup_path_for_python(path)) } - #[cfg(target_os = "windows")] { - format!("r\"{}\"", cleanup_path_for_python(path)) + #[cfg(target_os = "windows")] + { + format!("r\"{}\"", cleanup_path_for_python(path)) } } -fn init_python(code: String, dir: PathBuf) { - #[allow(unused_mut)] - let mut sys_pyth_dir = vec![print_path_for_python(&dir)]; +async fn init_python(runner: &PyRunner, dir: PathBuf) { + let sys_pyth_dir = print_path_for_python(&dir); + let path_import = format!( + r#"import sys +sys.path = sys.path + [{}] +"#, + sys_pyth_dir, + ); + runner + .run(&path_import) + .await + .expect("ERROR: Error setting python path"); #[cfg(feature = "venv")] { let venv_dir = dir.join(".venv").join("lib"); if Path::exists(venv_dir.as_path()) { - if let Ok(py_dir) = venv_dir.read_dir() { - for entry in py_dir.flatten() { - let site_packages = entry.path().join("site-packages"); - // use first folder with site-packages for venv, ignore venv version - if Path::exists(site_packages.as_path()) { - sys_pyth_dir - .push(print_path_for_python(&site_packages)); - break; - } - } - } + runner + .set_venv(venv_dir.as_path()) + .await + .expect("ERROR: Error setting venv for python"); } } - let path_import = format!( - r#"import sys -sys.path = sys.path + [{}] -{} -"#, - sys_pyth_dir.join(", "), - code - ); - py_lib::run_python_internal(path_import, "main.py".into()) - .unwrap_or_else(|e| panic!("Error initializing main.py:\n\n{e}\n")); } /// Initializes the plugin. @@ -142,35 +177,58 @@ pub fn init_and_register(python_functions: Vec<&'static str>) -> Tau #[cfg(desktop)] let python = desktop::init(app, api)?; app.manage(python); + let runner = PyRunner::new(); + app.manage(runner); let mut dir = get_resource_dir(app); - let mut code = std::fs::read_to_string(dir.join("main.py")).unwrap_or_default(); - if code.is_empty() { + let mut main_py = dir.join("main.py"); + if !main_py.exists() { println!( "Warning: 'src-tauri/main.py' seems not to be registered in 'tauri.conf.json'" ); dir = get_src_python_dir(); - code = std::fs::read_to_string(dir.join("main.py")).unwrap_or_default(); - } - if code.is_empty() { - println!("ERROR: Error reading 'src-tauri/main.py'"); - } - init_python(code, dir); - for function_name in python_functions { - py_lib::register_function_str(function_name.into(), None).unwrap(); - } - let functions = py_lib::read_variable(StringRequest { - value: "_tauri_plugin_functions".into(), - }) - .unwrap_or_default() - .replace("'", "\""); // python arrays are serialized usings ' instead of " - - if let Ok(python_functions) = serde_json::from_str::>(&functions) { - for function_name in python_functions { - py_lib::register_function_str(function_name, None).unwrap(); - } + main_py = dir.join("main.py"); } + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let runner = app.state::().inner(); + init_python(runner, dir.to_path_buf()).await; + runner + .run_file(main_py.as_path()) + .await + .expect("ERROR: Error running 'src-tauri/main.py'"); + let functions = runner + .read_variable("_tauri_plugin_functions") + .await + .unwrap_or_default() + .as_str() + .unwrap() + .replace("'", "\""); // python arrays are serialized usings ' instead of " + register_python_functions( + app, + python_functions.iter().map(|s| s.to_string()).collect(), + ).await; + if let Ok(python_functions) = serde_json::from_str::>(&functions) { + register_python_functions(app, python_functions).await; + } + }); + Ok(()) }) .build() } + +async fn register_python_functions( + app: &AppHandle, + python_functions: Vec, +) { + for function_name in python_functions { + app.register_function(RegisterRequest { + python_function_call: function_name.clone(), + number_of_args: None, + }) + .await + .unwrap(); + } +} \ No newline at end of file diff --git a/src/py_lib.rs b/src/py_lib.rs deleted file mode 100644 index 58b7c9b..0000000 --- a/src/py_lib.rs +++ /dev/null @@ -1,146 +0,0 @@ -// Tauri Python Plugin -// © Copyright 2024, by Marco Mengelkoch -// Licensed under MIT License, see License file for more details -// git clone https://github.com/marcomq/tauri-plugin-python - -use std::sync::atomic::AtomicBool; -use std::{collections::HashSet, sync::Mutex}; - -use rustpython_vm::py_serde; - -use lazy_static::lazy_static; - -use crate::{models::*, Error}; - -fn create_globals() -> rustpython_vm::scope::Scope { - rustpython_vm::Interpreter::without_stdlib(Default::default()) - .enter(|vm| vm.new_scope_with_builtins()) -} - -lazy_static! { - static ref INIT_BLOCKED: AtomicBool = false.into(); - static ref FUNCTION_MAP: Mutex> = Mutex::new(HashSet::new()); - static ref GLOBALS: rustpython_vm::scope::Scope = create_globals(); -} - -pub fn run_python(payload: StringRequest) -> crate::Result<()> { - run_python_internal(payload.value, "".into()) -} - -pub fn run_python_internal(code: String, filename: String) -> crate::Result<()> { - rustpython_vm::Interpreter::without_stdlib(Default::default()).enter(|vm| { - let code_obj = vm - .compile(&code, rustpython_vm::compiler::Mode::Exec, filename) - .map_err(|err| vm.new_syntax_error(&err, Some(&code)))?; - vm.run_code_obj(code_obj, GLOBALS.clone()) - })?; - Ok(()) -} - -pub fn register_function(payload: RegisterRequest) -> crate::Result<()> { - register_function_str(payload.python_function_call, payload.number_of_args) -} - -pub fn register_function_str( - function_name: String, - number_of_args: Option, -) -> crate::Result<()> { - if INIT_BLOCKED.load(std::sync::atomic::Ordering::Relaxed) { - return Err("Cannot register after function called".into()); - } - rustpython_vm::Interpreter::without_stdlib(Default::default()).enter(|vm| { - let var_dot_split: Vec<&str> = function_name.split(".").collect(); - let func = GLOBALS - .globals - .get_item(var_dot_split[0], vm) - .unwrap_or_else(|_| { - panic!("Cannot find '{}' in globals", var_dot_split[0]); - }); - if var_dot_split.len() > 2 { - func.get_attr(&vm.ctx.new_str(var_dot_split[1]), vm) - .unwrap() - .get_attr(&vm.ctx.new_str(var_dot_split[2]), vm) - .unwrap(); - } else if var_dot_split.len() > 1 { - func.get_attr(&vm.ctx.new_str(var_dot_split[1]), vm) - .unwrap_or_else(|_| { - panic!( - "Cannot find sub function '{}' in '{}'", - var_dot_split[1], var_dot_split[0] - ); - }); - } - - if let Some(num_args) = number_of_args { - let py_analyze_sig = format!( - r#" -from inspect import signature -if len(signature({}).parameters) != {}: - raise Exception("Function parameters don't match in 'registerFunction'") -"#, - function_name, num_args - ); - - let code_obj = vm - .compile( - &py_analyze_sig, - rustpython_vm::compiler::Mode::Exec, - "".to_owned(), - ) - .map_err(|err| vm.new_syntax_error(&err, Some(&py_analyze_sig)))?; - vm.run_code_obj(code_obj, GLOBALS.clone()) - .unwrap_or_else(|_| { - panic!("Number of args doesn't match signature of {function_name}.") - }); - } - // dbg!(format!("Added '{function_name}'")); - FUNCTION_MAP.lock().unwrap().insert(function_name); - Ok(()) - }) -} -pub fn call_function(payload: RunRequest) -> crate::Result { - INIT_BLOCKED.store(true, std::sync::atomic::Ordering::Relaxed); - let function_name = payload.function_name; - if FUNCTION_MAP.lock().unwrap().get(&function_name).is_none() { - return Err(Error::String(format!( - "Function {function_name} has not been registered yet" - ))); - } - rustpython_vm::Interpreter::without_stdlib(Default::default()).enter(|vm| { - let posargs: Vec<_> = payload - .args - .into_iter() - .map(|value| py_serde::deserialize(vm, value).unwrap()) - .collect(); - let var_dot_split: Vec<&str> = function_name.split(".").collect(); - let func = GLOBALS.globals.get_item(var_dot_split[0], vm)?; - Ok(if var_dot_split.len() > 2 { - func.get_attr(&vm.ctx.new_str(var_dot_split[1]), vm)? - .get_attr(&vm.ctx.new_str(var_dot_split[2]), vm)? - } else if var_dot_split.len() > 1 { - func.get_attr(&vm.ctx.new_str(var_dot_split[1]), vm)? - } else { - func - } - .call(posargs, vm)? - .str(vm)? - .to_string()) - }) -} - -pub fn read_variable(payload: StringRequest) -> crate::Result { - rustpython_vm::Interpreter::without_stdlib(Default::default()).enter(|vm| { - let var_dot_split: Vec<&str> = payload.value.split(".").collect(); - let var = GLOBALS.globals.get_item(var_dot_split[0], vm)?; - Ok(if var_dot_split.len() > 2 { - var.get_attr(&vm.ctx.new_str(var_dot_split[1]), vm)? - .get_attr(&vm.ctx.new_str(var_dot_split[2]), vm)? - } else if var_dot_split.len() > 1 { - var.get_attr(&vm.ctx.new_str(var_dot_split[1]), vm)? - } else { - var - } - .str(vm)? - .to_string()) - }) -} diff --git a/src/py_lib_pyo3.rs b/src/py_lib_pyo3.rs deleted file mode 100644 index 5d69ed1..0000000 --- a/src/py_lib_pyo3.rs +++ /dev/null @@ -1,127 +0,0 @@ -// Tauri Python Plugin -// © Copyright 2024, by Marco Mengelkoch -// Licensed under MIT License, see License file for more details -// git clone https://github.com/marcomq/tauri-plugin-python - -use std::sync::atomic::AtomicBool; -use std::{collections::HashMap, ffi::CString, sync::Mutex}; - -use lazy_static::lazy_static; -use pyo3::exceptions::PyBaseException; -use pyo3::types::{PyAnyMethods, PyDictMethods}; -use pyo3::PyErr; -use pyo3::{marker, types::PyDict, Py, PyAny}; - -use crate::{models::*, Error}; - -lazy_static! { - static ref INIT_BLOCKED: AtomicBool = false.into(); - static ref FUNCTION_MAP: Mutex>> = Mutex::new(HashMap::new()); - static ref GLOBALS: Mutex> = - Mutex::new(marker::Python::with_gil(|py| { PyDict::new(py).into() })); -} - -pub fn run_python(payload: StringRequest) -> crate::Result<()> { - run_python_internal(payload.value, "".into()) -} - -pub fn run_python_internal(code: String, _filename: String) -> crate::Result<()> { - marker::Python::with_gil(|py| -> crate::Result<()> { - let globals = GLOBALS.lock().unwrap().clone_ref(py).into_bound(py); - let c_code = CString::new(code).expect("CString::new failed"); - Ok(py.run(&c_code, Some(&globals), None)?) - }) -} -pub fn register_function(payload: RegisterRequest) -> crate::Result<()> { - register_function_str(payload.python_function_call, payload.number_of_args) -} - -pub fn register_function_str(fn_name: String, number_of_args: Option) -> crate::Result<()> { - // TODO, check actual function signature - if INIT_BLOCKED.load(std::sync::atomic::Ordering::Relaxed) { - return Err("Cannot register after function called".into()); - } - marker::Python::with_gil(|py| -> crate::Result<()> { - let globals = GLOBALS.lock().unwrap().clone_ref(py).into_bound(py); - - let fn_dot_split: Vec<&str> = fn_name.split(".").collect(); - let app = globals.get_item(fn_dot_split[0])?; - if app.is_none() { - return Err(Error::String(format!("{} not found", &fn_name))); - } - let app = if fn_dot_split.len() > 2 { - app.unwrap() - .getattr(fn_dot_split.get(1).unwrap())? - .getattr(fn_dot_split.get(2).unwrap())? - } else if fn_dot_split.len() > 1 { - app.unwrap().getattr(fn_dot_split.get(1).unwrap())? - } else { - app.unwrap() - }; - if !app.is_callable() { - return Err(Error::String(format!( - "{} not a callable function", - &fn_name - ))); - } - if let Some(num_args) = number_of_args { - let py_analyze_sig = format!( - r#" -from inspect import signature -if len(signature({}).parameters) != {}: - raise Exception("Function parameters don't match in 'registerFunction'") -"#, - fn_name, num_args - ); - let code_c = CString::new(py_analyze_sig).expect("CString::new failed"); - py.run(&code_c, Some(&globals), None) - .unwrap_or_else(|_| panic!("Could not register '{}'. ", &fn_name)); - } - // dbg!("{} was inserted", &fn_name); - FUNCTION_MAP.lock().unwrap().insert(fn_name, app.into()); - Ok(()) - }) -} -pub fn call_function(payload: RunRequest) -> crate::Result { - INIT_BLOCKED.store(true, std::sync::atomic::Ordering::Relaxed); - marker::Python::with_gil(|py| -> crate::Result { - let arg = pyo3::types::PyTuple::new(py, payload.args)?; - let map = FUNCTION_MAP - .lock() - .map_err(|msg| PyErr::new::(msg.to_string()))?; - match map.get(&payload.function_name) { - Some(app) => { - // dbg!(&arg); - let res = app.call1(py, arg)?; - // dbg!(&res); - Ok(res.to_string()) - } - _ => Err(Error::String(format!( - "{} not found", - payload.function_name - ))), - } - }) -} - -pub fn read_variable(payload: StringRequest) -> crate::Result { - marker::Python::with_gil(|py| -> crate::Result { - let globals = GLOBALS.lock().unwrap().clone_ref(py).into_bound(py); - - let var_dot_split: Vec<&str> = payload.value.split(".").collect(); - let var = globals.get_item(var_dot_split[0])?; - if let Some(var) = var { - Ok(if var_dot_split.len() > 2 { - var.getattr(var_dot_split.get(1).unwrap())? - .getattr(var_dot_split.get(2).unwrap())? - } else if var_dot_split.len() > 1 { - var.getattr(var_dot_split.get(1).unwrap())? - } else { - var - } - .to_string()) - } else { - Err(Error::String(format!("{} not set", &payload.value))) - } - }) -} From b4e5c31a4aac7d2244322f8556876a6daff86806 Mon Sep 17 00:00:00 2001 From: "marco.mengelkoch" Date: Sun, 28 Sep 2025 20:30:20 +0200 Subject: [PATCH 2/4] remove dbg and fix --- Cargo.toml | 4 ++-- .../src-tauri/.cargo/config.toml | 3 --- src/lib.rs | 16 ++++++---------- src/models.rs | 13 ------------- 4 files changed, 8 insertions(+), 28 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 55f990f..d647920 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ thiserror = "2" async-trait = "0.1" lazy_static = "1.5.0" -async_py = { version = "0.2.0", default-features = false } +async_py = { version = "0.2.1", default-features = false } tokio = { version = "1", features = ["full"] } serde_json = "1.0.136" dunce = "1.0.5" @@ -28,7 +28,7 @@ tauri-plugin = { version = "2", features = ["build"] } [features] venv = [] -default = ["venv", "rustpython"] # auto load src-python/.venv +default = ["venv", "pyo3"] # auto load src-python/.venv # default = ["venv", "pyo3"] # enable to use pyo3 instead of rustpython. rustpython = ["async_py/rustpython"] pyo3 = ["async_py/pyo3"] diff --git a/examples/plain-javascript/src-tauri/.cargo/config.toml b/examples/plain-javascript/src-tauri/.cargo/config.toml index 74dc094..e69de29 100644 --- a/examples/plain-javascript/src-tauri/.cargo/config.toml +++ b/examples/plain-javascript/src-tauri/.cargo/config.toml @@ -1,3 +0,0 @@ - -[env] -PYO3_CONFIG_FILE = { value = "target/pyembed/pyo3-build-config-file.txt", relative = true } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 05709ad..bff521e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -76,7 +76,6 @@ impl + Sync> crate::PythonExt for T { .runner() .read_variable(&payload.python_function_call) .await?; - dbg!(&_tmp); Ok(StringResponse { value: "Ok".into() }) } @@ -93,7 +92,7 @@ impl + Sync> crate::PythonExt for T { .call_function(&function_name, payload.args) .await?; Ok(StringResponse { - value: py_res.to_string(), + value: py_res.as_str().unwrap().to_string(), }) } @@ -198,18 +197,15 @@ pub fn init_and_register(python_functions: Vec<&'static str>) -> Tau .run_file(main_py.as_path()) .await .expect("ERROR: Error running 'src-tauri/main.py'"); - let functions = runner - .read_variable("_tauri_plugin_functions") - .await - .unwrap_or_default() - .as_str() - .unwrap() - .replace("'", "\""); // python arrays are serialized usings ' instead of " register_python_functions( app, python_functions.iter().map(|s| s.to_string()).collect(), ).await; - if let Ok(python_functions) = serde_json::from_str::>(&functions) { + let functions = runner + .read_variable("_tauri_plugin_functions") + .await + .unwrap_or_default(); + if let Ok(python_functions) = serde_json::from_value(functions) { register_python_functions(app, python_functions).await; } }); diff --git a/src/models.rs b/src/models.rs index 7ca05d0..eb12e84 100644 --- a/src/models.rs +++ b/src/models.rs @@ -11,19 +11,6 @@ pub struct StringRequest { pub value: String, } -#[cfg(feature = "pyo3")] -#[derive(Debug, Serialize, Deserialize, pyo3::IntoPyObject)] -#[serde(untagged)] -pub enum JsMany { - Bool(bool), - Number(u64), - Float(f64), - String(String), - StringVec(Vec), - FloatVec(Vec), -} - -#[cfg(not(feature = "pyo3"))] use serde_json::Value as JsMany; #[derive(Debug, Deserialize, Serialize)] From e694f66492a6adb60453fa923cd897ee1fb02ead Mon Sep 17 00:00:00 2001 From: "marco.mengelkoch" Date: Sun, 28 Sep 2025 22:52:32 +0200 Subject: [PATCH 3/4] add check for number of variables --- src/lib.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index bff521e..0bc8ba5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,15 +67,28 @@ impl + Sync> crate::PythonExt for T { if INIT_BLOCKED.load(std::sync::atomic::Ordering::Relaxed) { return Err("Cannot register after function called".into()); } - FUNCTION_MAP .lock() .unwrap() .insert(payload.python_function_call.clone()); + let _tmp = self .runner() .read_variable(&payload.python_function_call) .await?; + if let Some(num_args) = payload.number_of_args { + let py_analyze_sig = format!( + r#" +from inspect import signature +if len(signature({}).parameters) != {}: + raise Exception("Function parameters don't match in 'registerFunction'") +"#, + &payload.python_function_call, num_args + ); + self.runner().run(&py_analyze_sig).await.unwrap_or_else(|_| { + panic!("Number of args doesn't match signature of {}.", payload.python_function_call) + }); + }; Ok(StringResponse { value: "Ok".into() }) } From bed1cefb19b0683b28a8f8e526a59e971b3773d5 Mon Sep 17 00:00:00 2001 From: "marco.mengelkoch" Date: Sun, 28 Sep 2025 23:07:24 +0200 Subject: [PATCH 4/4] add tests & fixes --- Cargo.toml | 3 ++ src/lib.rs | 11 +++++- src/tests.rs | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/tests.rs diff --git a/Cargo.toml b/Cargo.toml index d647920..d045a06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ dunce = "1.0.5" [build-dependencies] tauri-plugin = { version = "2", features = ["build"] } +[dev-dependencies] +tauri = { version = "2", features = ["test"] } + [features] venv = [] default = ["venv", "pyo3"] # auto load src-python/.venv diff --git a/src/lib.rs b/src/lib.rs index 0bc8ba5..30cd429 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -104,8 +104,12 @@ if len(signature({}).parameters) != {}: .runner() .call_function(&function_name, payload.args) .await?; + let value = match py_res.as_str() { + Some(s) => s.to_string(), + None => py_res.to_string(), + }; Ok(StringResponse { - value: py_res.as_str().unwrap().to_string(), + value, }) } @@ -240,4 +244,7 @@ async fn register_python_functions( .await .unwrap(); } -} \ No newline at end of file +} + +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..39f72bc --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,108 @@ +// Tauri Python Plugin +// © Copyright 2024, by Marco Mengelkoch +// Licensed under MIT License, see License file for more details +// git clone https://github.com/marcomq/tauri-plugin-python + +use super::*; +use tauri::{test::{self, MockRuntime}, AppHandle}; + +/// Creates a mock Tauri app and initializes the PyRunner state. +/// It also runs some initial Python code to set up a variable and a function for testing. +async fn mock_app_handle() -> AppHandle { + let app = test::mock_app(); + let runner = PyRunner::new(); + app.manage(runner); + + let runner = app.state::().inner(); + runner + .run("my_var = 123\ndef my_func(a, b):\n return a + b") + .await + .unwrap(); + + app.handle().clone() +} + +#[tokio::test] +async fn test_read_variable() { + let app = mock_app_handle().await; + let payload = StringRequest { + value: "my_var".into(), + }; + let response = app.read_variable(payload).await.unwrap(); + assert_eq!(response.value, "123"); +} + +#[tokio::test] +async fn test_run_python() { + let app = mock_app_handle().await; + let payload = StringRequest { + value: "new_var = 456".into(), + }; + app.run_python(payload).await.unwrap(); + + // Verify the code was run by reading the variable back + let read_payload = StringRequest { + value: "new_var".into(), + }; + let response = app.read_variable(read_payload).await.unwrap(); + assert_eq!(response.value, "456"); +} + +#[tokio::test] +async fn test_register_and_call_function() { + let app = mock_app_handle().await; + + // 1. Register the function + let register_payload = RegisterRequest { + python_function_call: "my_func".into(), + number_of_args: Some(2), + }; + app.register_function(register_payload).await.unwrap(); + + // 2. Call the registered function + let call_payload = RunRequest { + function_name: "my_func".into(), + args: vec![serde_json::json!(10), serde_json::json!(20)], + }; + let response = app.call_function(call_payload).await.unwrap(); + assert_eq!(response.value, "30"); +} + +#[tokio::test] +async fn test_call_unregistered_function_fails() { + let app = mock_app_handle().await; + let call_payload = RunRequest { + function_name: "unregistered_func".into(), + args: vec![], + }; + let result = app.call_function(call_payload).await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Function unregistered_func has not been registered yet")); +} + +#[tokio::test] +async fn test_register_after_call_fails() { + let app = mock_app_handle().await; + + // 1. Register and call a function to set the INIT_BLOCKED flag + let register_payload = RegisterRequest { + python_function_call: "my_func".into(), + number_of_args: Some(2), + }; + app.register_function(register_payload).await.unwrap(); + let call_payload = RunRequest { + function_name: "my_func".into(), + args: vec![serde_json::json!(1), serde_json::json!(2)], + }; + app.call_function(call_payload).await.unwrap(); + + // 2. Attempt to register another function, which should now fail + let second_register_payload = RegisterRequest { + python_function_call: "my_var".into(), // can be anything + number_of_args: None, + }; + let result = app.register_function(second_register_payload).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Cannot register after function called")); +} \ No newline at end of file