1+ // Async Python
2+ // © Copyright 2025, 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+
16//! A library for calling Python code asynchronously from Rust.
27
38use pyo3:: {
49 exceptions:: PyKeyError ,
5- ffi:: c_str,
610 prelude:: * ,
711 types:: { PyBool , PyDict , PyFloat , PyInt , PyList , PyString } ,
812 IntoPyObjectExt ,
913} ;
1014use serde_json:: Value ;
11- use std:: { ffi:: CString , thread} ;
15+ use std:: {
16+ ffi:: CString ,
17+ path:: { Path , PathBuf } ,
18+ thread,
19+ } ;
1220use thiserror:: Error ;
1321use tokio:: sync:: { mpsc, oneshot} ;
1422
@@ -75,11 +83,33 @@ fn handle_call_function(
7583 ) ) ) ;
7684 }
7785
78- let py_args = args. into_iter ( ) . map ( |v| json_value_to_pyobject ( py, v) ) . collect :: < PyResult < Vec < _ > > > ( ) ?;
86+ let py_args = args
87+ . into_iter ( )
88+ . map ( |v| json_value_to_pyobject ( py, v) )
89+ . collect :: < PyResult < Vec < _ > > > ( ) ?;
7990 let t_args = pyo3:: types:: PyTuple :: new ( py, py_args) ?;
8091 let result = func. call1 ( t_args) ?;
8192 py_any_to_json ( py, & result)
8293}
94+
95+ fn cleanup_path_for_python ( path : & PathBuf ) -> String {
96+ dunce:: canonicalize ( path)
97+ . unwrap ( )
98+ . to_string_lossy ( )
99+ . replace ( "\\ " , "/" )
100+ }
101+
102+ fn print_path_for_python ( path : & PathBuf ) -> String {
103+ #[ cfg( not( target_os = "windows" ) ) ]
104+ {
105+ format ! ( "\" {}\" " , cleanup_path_for_python( path) )
106+ }
107+ #[ cfg( target_os = "windows" ) ]
108+ {
109+ format ! ( "r\" {}\" " , cleanup_path_for_python( path) )
110+ }
111+ }
112+
83113/// Manages a dedicated thread for executing Python code asynchronously.
84114#[ derive( Clone ) ]
85115pub struct PyRunner {
@@ -170,16 +200,34 @@ impl PyRunner {
170200 Ok ( result)
171201 }
172202
173-
174203 /// Asynchronously executes a block of Python code.
175204 ///
176205 /// * `code`: A string slice containing the Python code to execute.
177- /// Returns `Ok(Value::Null)` on success, or a `PyRunnerError` on failure.
178206 /// This is equivalent to Python's `exec()` function.
179- pub async fn run ( & self , code : & str ) -> Result < Value , PyRunnerError > {
180- self . send_command ( CmdType :: RunCode ( code. into ( ) ) ) . await
207+ pub async fn run ( & self , code : & str ) -> Result < ( ) , PyRunnerError > {
208+ self . send_command ( CmdType :: RunCode ( code. into ( ) ) )
209+ . await
210+ . map ( |_| ( ) )
181211 }
182-
212+ /// Asynchronously runs a python file.
213+ /// * `file`: Absolute path to a python file to execute.
214+ /// Also loads the path of the file to sys.path for imports.
215+ pub async fn run_file ( & self , file : & Path ) -> Result < ( ) , PyRunnerError > {
216+ let file_path = cleanup_path_for_python ( & file. to_path_buf ( ) ) ;
217+ let folder_path = cleanup_path_for_python ( & file. parent ( ) . unwrap ( ) . to_path_buf ( ) ) ;
218+ let code = format ! (
219+ r#"
220+ import sys
221+ sys.path.insert(0, {})
222+ with open({}, 'r') as f:
223+ exec(f.read())
224+ "# ,
225+ print_path_for_python( & folder_path. into( ) ) ,
226+ print_path_for_python( & file_path. into( ) )
227+ ) ;
228+ self . run ( & code) . await
229+ }
230+
183231 /// Asynchronously evaluates a single Python expression.
184232 ///
185233 /// * `code`: A string slice containing the Python expression to evaluate.
@@ -196,7 +244,8 @@ impl PyRunner {
196244 /// to access attributes of objects (e.g., "my_module.my_variable").
197245 /// Returns the variable's value as a `serde_json::Value` on success.
198246 pub async fn read_variable ( & self , var_name : & str ) -> Result < Value , PyRunnerError > {
199- self . send_command ( CmdType :: ReadVariable ( var_name. into ( ) ) ) . await
247+ self . send_command ( CmdType :: ReadVariable ( var_name. into ( ) ) )
248+ . await
200249 }
201250
202251 /// Asynchronously calls a Python function in the interpreter's global scope.
@@ -213,7 +262,8 @@ impl PyRunner {
213262 self . send_command ( CmdType :: CallFunction {
214263 name : name. into ( ) ,
215264 args,
216- } ) . await
265+ } )
266+ . await
217267 }
218268
219269 /// Stops the Python execution thread gracefully.
@@ -222,6 +272,67 @@ impl PyRunner {
222272 self . send_command ( CmdType :: Stop ) . await ?;
223273 Ok ( ( ) )
224274 }
275+ /// Set python venv environment folder (does not change interpreter)
276+ pub async fn set_venv ( & self , venv_path : & Path ) -> Result < ( ) , PyRunnerError > {
277+ let set_venv_code = r##"
278+ import sys
279+ import os
280+
281+ def add_venv_libs_to_syspath(venv_path):
282+ """
283+ Adds the site-packages folder (and .pth entries) from a virtual environment to sys.path.
284+
285+ Args:
286+ venv_path (str): Path to the root of the virtual environment.
287+ """
288+ if not os.path.isdir(venv_path):
289+ raise ValueError(f"{venv_path} is not a directory")
290+
291+ if os.name == "nt":
292+ # Windows: venv\Lib\site-packages
293+ site_packages = os.path.join(venv_path, "Lib", "site-packages")
294+ else:
295+ # POSIX: venv/lib/pythonX.Y/site-packages
296+ py_version = f"python{sys.version_info.major}.{sys.version_info.minor}"
297+ site_packages = os.path.join(venv_path, "lib", py_version, "site-packages")
298+
299+ if not os.path.isdir(site_packages):
300+ raise RuntimeError(f"Could not find site-packages in {venv_path}")
301+
302+ # Add site-packages itself
303+ if site_packages not in sys.path:
304+ sys.path.insert(0, site_packages)
305+
306+ # Process .pth files inside site-packages
307+ for entry in os.listdir(site_packages):
308+ if entry.endswith(".pth"):
309+ pth_file = os.path.join(site_packages, entry)
310+ try:
311+ with open(pth_file, "r") as f:
312+ for line in f:
313+ line = line.strip()
314+ if not line or line.startswith("#"):
315+ continue
316+ if line.startswith("import "):
317+ # Execute import statements inside .pth files
318+ exec(line, globals(), locals())
319+ else:
320+ # Treat as a path
321+ extra_path = os.path.join(site_packages, line)
322+ if os.path.exists(extra_path) and extra_path not in sys.path:
323+ sys.path.insert(0, extra_path)
324+ except Exception as e:
325+ print(f"Warning: Could not process {pth_file}: {e}")
326+
327+ return site_packages
328+ "## ;
329+ self . run ( & set_venv_code) . await ?;
330+ self . run ( & format ! (
331+ "add_venv_libs_to_syspath({})" ,
332+ print_path_for_python( & venv_path. to_path_buf( ) )
333+ ) )
334+ . await
335+ }
225336}
226337
227338/// Recursively converts a Python object to a `serde_json::Value`.
@@ -302,6 +413,8 @@ fn json_value_to_pyobject(py: Python, value: Value) -> PyResult<pyo3::Py<pyo3::P
302413#[ cfg( test) ]
303414mod tests {
304415 use super :: * ;
416+ use std:: fs:: { self , File } ;
417+ use std:: io:: Write ;
305418
306419 #[ tokio:: test]
307420 async fn test_eval_simple_code ( ) {
@@ -323,9 +436,9 @@ x = 10
323436y = 20
324437z = x + y"# ;
325438
326- let result_module = executor. run ( code) . await . unwrap ( ) ;
439+ let result_module = executor. run ( code) . await ;
327440
328- assert_eq ! ( result_module, Value :: Null ) ;
441+ assert ! ( result_module. is_ok ( ) ) ;
329442
330443 let z_val = executor. read_variable ( "z" ) . await . unwrap ( ) ;
331444
@@ -366,7 +479,7 @@ def add(a, b):
366479 assert ! ( result2. is_ok( ) ) ;
367480 assert ! ( res3. is_ok( ) ) ;
368481
369- assert_eq ! ( res1. unwrap ( ) , Value :: Null ) ;
482+ assert ! ( res1. is_ok ( ) ) ;
370483 assert_eq ! ( result1. unwrap( ) , Value :: String ( "task1" . to_string( ) ) ) ;
371484 assert_eq ! ( result2. unwrap( ) , Value :: String ( "task2" . to_string( ) ) ) ;
372485 assert_eq ! ( res3. unwrap( ) , Value :: String ( "task3" . to_string( ) ) ) ;
@@ -424,4 +537,65 @@ def greet(name):
424537 "Hello World! Called 2 times from Python."
425538 ) ;
426539 }
540+
541+ #[ tokio:: test]
542+ async fn test_run_file ( ) {
543+ let runner = PyRunner :: new ( ) ;
544+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
545+ let dir_path = dir. path ( ) ;
546+
547+ // Create a module to be imported
548+ let mut module_file = File :: create ( dir_path. join ( "mymodule.py" ) ) . unwrap ( ) ;
549+ writeln ! ( module_file, "def my_func(): return 42" ) . unwrap ( ) ;
550+
551+ // Create the main script
552+ let script_path = dir_path. join ( "main.py" ) ;
553+ let mut script_file = File :: create ( & script_path) . unwrap ( ) ;
554+ writeln ! ( script_file, "import mymodule\n result = mymodule.my_func()" ) . unwrap ( ) ;
555+
556+ runner. run_file ( & script_path) . await . unwrap ( ) ;
557+
558+ let result = runner. read_variable ( "result" ) . await . unwrap ( ) ;
559+ assert_eq ! ( result, Value :: Number ( 42 . into( ) ) ) ;
560+ }
561+
562+ #[ tokio:: test]
563+ async fn test_set_venv ( ) {
564+ let runner = PyRunner :: new ( ) ;
565+ let venv_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
566+ let venv_path = venv_dir. path ( ) ;
567+
568+ // Get python version to create correct site-packages path
569+ let version_str = runner
570+ . eval ( "f'{__import__(\" sys\" ).version_info.major}.{__import__(\" sys\" ).version_info.minor}'" )
571+ . await
572+ . unwrap ( ) ;
573+ let py_version = version_str. as_str ( ) . unwrap ( ) ;
574+
575+ // Create a fake site-packages directory
576+ let site_packages = if cfg ! ( target_os = "windows" ) {
577+ venv_path. join ( "Lib" ) . join ( "site-packages" )
578+ } else {
579+ venv_path
580+ . join ( "lib" )
581+ . join ( format ! ( "python{}" , py_version) )
582+ . join ( "site-packages" )
583+ } ;
584+ fs:: create_dir_all ( & site_packages) . unwrap ( ) ;
585+
586+ // Create a dummy package in site-packages
587+ let package_dir = site_packages. join ( "dummy_package" ) ;
588+ fs:: create_dir ( & package_dir) . unwrap ( ) ;
589+ let mut init_file = File :: create ( package_dir. join ( "__init__.py" ) ) . unwrap ( ) ;
590+ writeln ! ( init_file, "def dummy_func(): return 'hello from venv'" ) . unwrap ( ) ;
591+
592+ // Set the venv
593+ runner. set_venv ( venv_path) . await . unwrap ( ) ;
594+
595+ // Try to import and use the dummy package
596+ runner. run ( "import dummy_package" ) . await . unwrap ( ) ;
597+ let result = runner. eval ( "dummy_package.dummy_func()" ) . await . unwrap ( ) ;
598+
599+ assert_eq ! ( result, Value :: String ( "hello from venv" . to_string( ) ) ) ;
600+ }
427601}
0 commit comments