diff --git a/Cargo.toml b/Cargo.toml index 4381d50..44c15a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,9 @@ tauri = { version = "2" } serde = { version = "1", features = ["derive"] } thiserror = "2" lazy_static = "1.5.0" -pyo3 = { version = "0.23.3", features=["auto-initialize", "abi3-py39", "generate-import-lib"], optional = true } +pyo3 = { version = "0.24.1", features=["generate-import-lib"], optional = true } +# requires: cargo install --git https://github.com/ntamas/PyOxidizer.git pyoxidizer +pyembed = { git = "https://github.com/ntamas/PyOxidizer.git", optional = true } rustpython-pylib = { version = "0.4.0" } rustpython-stdlib = { version = "0.4.0", features = ["threading"] } rustpython-vm = { version = "0.4.0", features = [ @@ -32,7 +34,8 @@ dunce = "1.0.5" 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"] +venv = [] +pyembed = ["dep:pyembed", "dep:pyo3"] # automatically includes pyo3 +pyo3 = ["dep:pyo3"] diff --git a/README.md b/README.md index fe8fffd..13f54fc 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ This [tauri](https://v2.tauri.app/) v2 plugin is supposed to make it easy to use It uses [RustPython](https://github.com/RustPython/RustPython) or alternatively [PyO3](https://pyo3.rs) as interpreter to call python from rust. RustPython doesn't require python to be installed on the target platform and makes it -therefore easy to deploy your production binary. Unfortunately, it has some -compatibility issues and is slower than PyO3/CPython. PyO3 is also supported as optional Cargo feature for desktop applications. -PyO3 uses CPython as interpreter and therefore has a wide compatibility for available python libraries. +therefore easy to deploy your production binary. Unfortunately, it doesn't even support +some usually built-int python libraries and is slower than PyO3/CPython. +PyO3 is supported as optional Cargo feature for desktop applications. +PyO3 uses the usual CPython as interpreter and therefore has a wide compatibility for available python libraries. It isn't used as default as it requires to make libpython available for the target platform, which can be complicated, especially for mobile targets. @@ -29,14 +30,14 @@ and can get called during application workflow. https://github.com/tauri-apps/tauri/issues/11823 \ So python code cannot be read on android right now. Android is going to be supported as soon as reading resource files will be fixed. -`✓*` Linux, Windows and MacOS support PyO3 and RustPython as interpreter. Android and IOS +`✓*` Linux, Windows and MacOS support PyO3 and RustPython as interpreter. Android and iOS currently only support RustPython. Android and iOS might also be able to run with PyO3 in theory but would require to have CPython to be compiled for the target platform. I still need to figure out how to cross compile python and PyO3 for iOS and Android. Ping me if you know how to do that. -You can use this plugin for fast prototypes or for production code. +You can use this plugin for fast prototypes or for (early) production code. It might be possible that you want to use some python library or code that is not available for rust yet. In case that you want to ship production software packages, you need @@ -44,12 +45,42 @@ to make sure to also ship all your python code. If you use PyO3, you also need t ### Switch from RustPython to PyO3 +Using PyO3 will support much more python libraries than RustPython as it is using CPython. +Unfortunately, using PyO3 will use a shared libpython by default, which makes +local development easy but makes +deployment of releases more complicated. + ```toml # src-tauri/Cargo.toml tauri-plugin-python = { version="0.3", , features = ["pyo3"] } ``` +### Switch to PyEmbed + +Using PyEmbed will internally also use PyO3. It will perform static linking +of libpython, so deployment of a release binary is much easier. +It may support less libraries than PyO3, but much more than RustPython. +Unfortunately, development is more complicated as rust-analyzer may create issues +and the application may crash during startup with an error +`during initializing Python main: Failed to import encodings module` +if environment variables are not set correctly. +To use it, you can set the `PYO3_CONFIG_FILE` env variable, for example: +```sh +PYO3_CONFIG_FILE=${PWD}/src-tauri/target/pyembed/pyo3-build-config-file.txt npm run tauri dev +``` +This will be set automatically on 2nd compilation as the first build creates a `.cargo/config.toml` file +that will automatically set it. + +```toml +# src-tauri/Cargo.toml +tauri-plugin-python = { version="0.3", features = ["pyembed"] } +``` + +You also need to install `pyoxidizer` first, either by cargo +`cargo install pyoxidizer` or pip `pip install pyoxidizer`. + + ## Example app There is a sample Desktop application for Windows/Linux/MacOS using this plugin and vanilla @@ -65,10 +96,10 @@ These steps assume that you already have a basic tauri application available. Al ```python # src-tauri/src-python/main.py _tauri_plugin_functions = ["greet_python"] # make "greet_python" callable from UI -def greet_python(rust_var) +def greet_python(rust_var): return str(rust_var) + " from python" ``` -- add `"bundle": {"resources": [ "src-python/**/*"],` to `tauri.conf.json` so that python files are bundled with your application +- add `"bundle": {"resources": [ "src-python/"],` to `tauri.conf.json` so that python files are bundled with your application - add the plugin in your js, so - add `import { callFunction } from 'tauri-plugin-python-api'` - add `outputEl.textContent = await callFunction("greet_python", [value])` to get the output of the python function `greet_python` with parameter of js variable `value` @@ -85,16 +116,16 @@ Tauri events and calling js from python is currently not supported yet. You woul - add file `src-tauri/src-python/main.py` and add python code, for example: ```python # src-tauri/src-python/main.py -def greet_python(rust_var) +def greet_python(rust_var): return str(rust_var) + " from python" ``` - add `.plugin(tauri_plugin_python::init_and_register(vec!["greet_python"))` to `tauri::Builder::default()`, usually in `src-tauri/src/lib.rs`. This will initialize the plugin and make the python function "greet_python" available from javascript. -- add javascript for python plugin in the index.html file directly or in your somewhere in your javascript application. For vanilla javascript / iife, the modules can be found in `window.__TAURI__.python`. For modern javascript: +- add javascript for python plugin in the index.html file directly or somewhere in your javascript application. For vanilla javascript / iife, the modules can be found in `window.__TAURI__.python`. For modern javascript: ```javascript import { callFunction } from 'tauri-plugin-python-api' console.log(await callFunction("greet_python", ["input value"])) ``` --> this will call the python function "greet_python" with parameter "input value". Of course, you can just pass in any available javascript value. This should work with "boolean", "integer", "double", "string", "string[]", "double[]" parameter types. +→ this will call the python function "greet_python" with parameter "input value". Of course, you can just pass in any available javascript value. This should work with "boolean", "integer", "double", "string", "string[]", "double[]" parameter types. Alternatively, to have more readable code: ```javascript @@ -103,18 +134,51 @@ registerJs("greet_python"); console.log(await call.greet_python("input value")); ``` -## Deployment +## Using a venv -You either need to have python installed on the target machine or ship the shared -python library with your package. You also may link the python library statically - PyO3 -may do this by default if it finds a static python library. In addition, you need -to copy the python files so that python files are next to the binary. +Using a python venv is highly recommended when using pip dependencies. +It will be loaded automatically, if the folder is called `.venv`. +It would be recommended to create it in the project root: +```sh +python3 -m venv .venv +source .venv/bin/activate +pip install +``` + +You need to make sure that the relevant venv folders `include` and `lib` are +copied next to the `src-python` tauri resource folder: -The file `src-python/main.py` is required for the plugin to work correctly. +`tauri.conf.json` +```json +"resources": { + "src-python/": "src-python/", + "../.venv/include/": "src-python/.venv/include/", + "../.venv/lib/": "src-python/.venv/lib/" +} +``` + +## Deployment + +The file `src-python/main.py` is always required for the plugin to work correctly. You may also add additional python files or use a venv environment. -The included resources can be configurable in the `tauri.conf.json` file. +The included resources can be configured in the `tauri.conf.json` file. +You need to make sure that all python files are included in the tauri resource files and that +your resource file structure is similar to the local python file structure. + +There are no other extra steps required for **RustPython** as it will be linked statically. + +For **PyEmbed**, python will also be linked statically. Internally PyEmbed is using PyO3 too. +PyEmbed will be harder to build, but easier to deploy. +You need to make sure to set all PyO3 variables correctly. This is typically +the environment variable `PYO3_CONFIG_FILE`. +Otherwise, the application will typically crash during startup with an error +`during initializing Python main: Failed to import encodings module`. + +For **PyO3**, python will be linked dynamically by default. You either need to +have python installed on the target machine with the same version or ship the shared +python library with your package. +Check the PyO3 documentation for additional info. -Check the tauri and PyO3 documentation for additional info. ## Security considerations By default, this plugin cannot call arbitrary python code. Python functions can only be called if registered from rust during plugin initialization. @@ -136,5 +200,5 @@ It is a different approach to have all tauri functionality completely in python. This approach here with tauri-plugin-python is more lightweight and it is for you, if you - still want to write rust code - already have a tauri application and just need a specific python library -- just want to simply support rare custom plugins +- just want to simply support rare custom tauri plugins - if you want to embed python code directly in your javascript diff --git a/build.rs b/build.rs index c0a5fd7..ef69bf3 100644 --- a/build.rs +++ b/build.rs @@ -3,6 +3,10 @@ // Licensed under MIT License, see License file for more details // git clone https://github.com/marcomq/tauri-plugin-python +// python3 -m venv .venv +// .venv/bin/activate +// pip install pyoxidizer + const COMMANDS: &[&str] = &[ "run_python", "register_function", @@ -10,7 +14,53 @@ const COMMANDS: &[&str] = &[ "read_variable", ]; +#[cfg(feature = "pyembed")] +fn init_pyembed() { + use std::path::Path; + let pyoxidizer_exe = if let Ok(path) = std::env::var("PYOXIDIZER_EXE") { + path + } else { + "pyoxidizer".to_string() + }; + let out_dir = std::env::var("OUT_DIR").unwrap(); + let pyembed_dir = format!("{}/../../../../pyembed", out_dir); + let args = vec!["generate-python-embedding-artifacts", &pyembed_dir]; + // Setting PYO3_CONFIG_FILE here wouldn't set it while compiling pyo3 + // Therefore, we need to set it manually: + // `PYO3_CONFIG_FILE=${PWD}/src-tauri/target/pyembed/pyo3-build-config-file.txt npm run tauri dev` + // println!("cargo::rustc-env=PYO3_CONFIG_FILE={}/{}", pyembed_dir, "pyo3-build-config-file.txt"); + match std::process::Command::new(pyoxidizer_exe) + .args(args) + .status() + { + Ok(status) => { + if !status.success() { + panic!("`pyoxidizer run-build-script` failed"); + } + let src_tauri_dir = format!("{}/../../../../../../src-tauri", out_dir); + let cargo_config = Path::new(&src_tauri_dir).join(".cargo").join("config.toml"); + dbg!(&cargo_config); + if !cargo_config.exists() { + let content = r#" +[env] +PYO3_CONFIG_FILE = { value = "target/pyembed/pyo3-build-config-file.txt", relative = true }"#; + let _ignore = std::fs::create_dir(Path::new(&src_tauri_dir).join(".cargo")); + std::fs::write(cargo_config, content).unwrap(); + } + } + Err(e) => panic!( + "`pyoxidizer run-build-script` failed, please install pyoxidizer first: {}/n + cargo install pyoxidizer", + e.to_string() + ), + } +} + +#[cfg(not(feature = "pyembed"))] +fn init_pyembed() {} + fn main() { + init_pyembed(); tauri_plugin::Builder::new(COMMANDS) .global_api_script_path("./dist-js/index.iife.js") .android_path("android") diff --git a/examples/plain-javascript/src-tauri/Cargo.toml b/examples/plain-javascript/src-tauri/Cargo.toml index d77a8c1..8d73730 100644 --- a/examples/plain-javascript/src-tauri/Cargo.toml +++ b/examples/plain-javascript/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ tauri-build = { version = "2.0.2", features = [] } [dependencies] tauri = { version = "2", features = [] } -tauri-plugin-python = { path = "../../../" } +tauri-plugin-python = { path = "../../../", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/examples/plain-javascript/src-tauri/tauri.conf.json b/examples/plain-javascript/src-tauri/tauri.conf.json index 2ae9d66..a8b8329 100644 --- a/examples/plain-javascript/src-tauri/tauri.conf.json +++ b/examples/plain-javascript/src-tauri/tauri.conf.json @@ -22,9 +22,9 @@ "bundle": { "active": true, "targets": "all", - "resources": [ - "src-python/**/*" - ], + "resources": { + "src-python/": "src-python/" + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/error.rs b/src/error.rs index 92ef231..5cfd3e7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,7 +3,7 @@ // Licensed under MIT License, see License file for more details // git clone https://github.com/marcomq/tauri-plugin-python -#[cfg(feature = "pyo3")] +#[cfg(any(feature = "pyo3", feature = "pyembed"))] use pyo3::{prelude::*, PyErr}; use serde::{ser::Serializer, Serialize}; @@ -35,7 +35,7 @@ impl From<&str> for Error { } } -#[cfg(not(feature = "pyo3"))] +#[cfg(all(not(feature = "pyo3"), not(feature = "pyembed")))] impl From> for Error { fn from(error: rustpython_vm::PyRef) -> Self { let msg = format!("{:?}", &error); @@ -60,7 +60,7 @@ impl From> for Er } } -#[cfg(feature = "pyo3")] +#[cfg(any(feature = "pyo3", feature = "pyembed"))] impl From for Error { fn from(error: PyErr) -> Self { let error_msg = match pyo3::Python::with_gil(|py| -> Result> { diff --git a/src/lib.rs b/src/lib.rs index 5d1006b..d379ebb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,11 +17,11 @@ mod mobile; mod commands; mod error; mod models; -#[cfg(not(feature = "pyo3"))] +#[cfg(all(not(feature = "pyo3"), not(feature = "pyembed")))] mod py_lib; -#[cfg(feature = "pyo3")] +#[cfg(any(feature = "pyo3", feature = "pyembed"))] mod py_lib_pyo3; -#[cfg(feature = "pyo3")] +#[cfg(any(feature = "pyo3", feature = "pyembed"))] use py_lib_pyo3 as py_lib; pub use error::{Error, Result}; @@ -87,11 +87,13 @@ 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)) } } @@ -107,8 +109,7 @@ fn init_python(code: String, dir: PathBuf) { 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)); + sys_pyth_dir.push(print_path_for_python(&site_packages)); break; } } @@ -123,6 +124,7 @@ sys.path = sys.path + [{}] sys_pyth_dir.join(", "), code ); + py_lib::init(); py_lib::run_python_internal(path_import, "main.py".into()) .unwrap_or_else(|e| panic!("Error initializing main.py:\n\n{e}\n")); } diff --git a/src/models.rs b/src/models.rs index 7ca05d0..2bd5f4b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -11,7 +11,7 @@ pub struct StringRequest { pub value: String, } -#[cfg(feature = "pyo3")] +#[cfg(any(feature = "pyo3", feature = "pyembed"))] #[derive(Debug, Serialize, Deserialize, pyo3::IntoPyObject)] #[serde(untagged)] pub enum JsMany { @@ -23,7 +23,7 @@ pub enum JsMany { FloatVec(Vec), } -#[cfg(not(feature = "pyo3"))] +#[cfg(all(not(feature = "pyo3"), not(feature = "pyembed")))] use serde_json::Value as JsMany; #[derive(Debug, Deserialize, Serialize)] diff --git a/src/py_lib.rs b/src/py_lib.rs index 58b7c9b..8b1a6fd 100644 --- a/src/py_lib.rs +++ b/src/py_lib.rs @@ -23,6 +23,8 @@ lazy_static! { static ref GLOBALS: rustpython_vm::scope::Scope = create_globals(); } +pub fn init() {} + pub fn run_python(payload: StringRequest) -> crate::Result<()> { run_python_internal(payload.value, "".into()) } diff --git a/src/py_lib_pyo3.rs b/src/py_lib_pyo3.rs index 5d69ed1..6016559 100644 --- a/src/py_lib_pyo3.rs +++ b/src/py_lib_pyo3.rs @@ -7,10 +7,8 @@ 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 pyo3::{exceptions::PyBaseException, marker::Python, types::PyDict, Py, PyAny, PyErr}; use crate::{models::*, Error}; @@ -18,7 +16,48 @@ 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() })); + Mutex::new(py_run::with_gil(|py| { PyDict::new(py).into() })); +} + +#[cfg(feature = "pyembed")] +mod py_run { + include!(concat!( + env!("OUT_DIR"), + "/../../../../pyembed/default_python_config.rs" + )); + struct Interpreter<'a, 'b> { + pub inner: pyembed::MainPythonInterpreter<'a, 'b>, + } + super::lazy_static! { + static ref INTERPRETER: Interpreter<'static, 'static> = Interpreter { inner: pyembed::MainPythonInterpreter::new(default_python_config()).unwrap() }; + + } + pub fn with_gil(callback: F) -> R + where + F: for<'py> FnOnce(super::Python<'py>) -> R, + { + INTERPRETER.inner.with_gil(callback) + } + + unsafe impl Send for Interpreter<'static, 'static> {} + unsafe impl Sync for Interpreter<'static, 'static> {} +} + +#[cfg(not(feature = "pyembed"))] +mod py_run { + pub fn with_gil(callback: F) -> R + where + F: for<'py> FnOnce(super::Python<'py>) -> R, + { + super::Python::with_gil(callback) + } +} + +pub fn init() { + #[cfg(not(feature = "pyembed"))] + { + pyo3::prepare_freethreaded_python(); + } } pub fn run_python(payload: StringRequest) -> crate::Result<()> { @@ -26,7 +65,7 @@ pub fn run_python(payload: StringRequest) -> crate::Result<()> { } pub fn run_python_internal(code: String, _filename: String) -> crate::Result<()> { - marker::Python::with_gil(|py| -> crate::Result<()> { + py_run::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)?) @@ -41,7 +80,7 @@ pub fn register_function_str(fn_name: String, number_of_args: Option) -> cra if INIT_BLOCKED.load(std::sync::atomic::Ordering::Relaxed) { return Err("Cannot register after function called".into()); } - marker::Python::with_gil(|py| -> crate::Result<()> { + py_run::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(); @@ -84,7 +123,7 @@ if len(signature({}).parameters) != {}: } pub fn call_function(payload: RunRequest) -> crate::Result { INIT_BLOCKED.store(true, std::sync::atomic::Ordering::Relaxed); - marker::Python::with_gil(|py| -> crate::Result { + py_run::with_gil(|py| -> crate::Result { let arg = pyo3::types::PyTuple::new(py, payload.args)?; let map = FUNCTION_MAP .lock() @@ -105,7 +144,7 @@ pub fn call_function(payload: RunRequest) -> crate::Result { } pub fn read_variable(payload: StringRequest) -> crate::Result { - marker::Python::with_gil(|py| -> crate::Result { + py_run::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();