11// Async Python
22// © Copyright 2025, by Marco Mengelkoch
33// Licensed under MIT License, see License file for more details
4- // git clone https://github.com/marcomq/tauri-plugin-python
4+ // git clone https://github.com/marcomq/async_py
55
66//! A library for calling Python code asynchronously from Rust.
77
8- use pyo3:: {
9- exceptions:: PyKeyError ,
10- prelude:: * ,
11- types:: { PyBool , PyDict , PyFloat , PyInt , PyList , PyString } ,
12- IntoPyObjectExt ,
13- } ;
8+ mod pyo3_runner;
149use serde_json:: Value ;
15- use std:: {
16- ffi:: CString ,
17- path:: { Path , PathBuf } ,
18- thread,
19- } ;
10+ use std:: path:: { Path , PathBuf } ;
11+ use std:: thread;
2012use thiserror:: Error ;
2113use tokio:: sync:: { mpsc, oneshot} ;
2214
23- enum CmdType {
15+ pub ( crate ) enum CmdType {
2416 RunCode ( String ) ,
2517 EvalCode ( String ) ,
2618 ReadVariable ( String ) ,
@@ -30,9 +22,9 @@ enum CmdType {
3022/// Represents a command to be sent to the Python execution thread. It includes the
3123/// command to execute and a one-shot channel sender to send the `serde_json::Value`
3224/// result back.
33- struct PyCommand {
25+ pub ( crate ) struct PyCommand {
3426 cmd_type : CmdType ,
35- responder : oneshot:: Sender < PyResult < Value > > ,
27+ responder : oneshot:: Sender < Result < Value , String > > ,
3628}
3729
3830/// Custom error types for the `PyRunner`.
@@ -44,52 +36,8 @@ pub enum PyRunnerError {
4436 #[ error( "Failed to receive result from Python thread. The thread may have panicked." ) ]
4537 ReceiveResultFailed ,
4638
47- #[ error( "Python execution error: {0}" ) ]
48- PyError ( #[ from] PyErr ) ,
49- }
50-
51- /// Resolves a potentially dot-separated Python object name from the globals dictionary.
52- fn get_py_object < ' py > (
53- globals : & pyo3:: Bound < ' py , PyDict > ,
54- name : & str ,
55- ) -> PyResult < pyo3:: Bound < ' py , pyo3:: PyAny > > {
56- let mut parts = name. split ( '.' ) ;
57- let first_part = parts. next ( ) . unwrap ( ) ; // split always yields at least one item
58-
59- let mut obj = globals
60- . get_item ( first_part) ?
61- . ok_or_else ( || PyErr :: new :: < PyKeyError , _ > ( format ! ( "'{}' not found" , first_part) ) ) ?;
62-
63- for part in parts {
64- obj = obj. getattr ( part) ?;
65- }
66-
67- Ok ( obj)
68- }
69-
70- /// Handles the `CallFunction` command.
71- fn handle_call_function (
72- py : Python ,
73- globals : & pyo3:: Bound < ' _ , PyDict > ,
74- name : String ,
75- args : Vec < Value > ,
76- ) -> PyResult < Value > {
77- let func = get_py_object ( globals, & name) ?;
78-
79- if !func. is_callable ( ) {
80- return Err ( PyErr :: new :: < PyKeyError , _ > ( format ! (
81- "'{}' is not a callable function" ,
82- name
83- ) ) ) ;
84- }
85-
86- let py_args = args
87- . into_iter ( )
88- . map ( |v| json_value_to_pyobject ( py, v) )
89- . collect :: < PyResult < Vec < _ > > > ( ) ?;
90- let t_args = pyo3:: types:: PyTuple :: new ( py, py_args) ?;
91- let result = func. call1 ( t_args) ?;
92- py_any_to_json ( py, & result)
39+ #[ error( "Python execution error: {0:?}" ) ]
40+ PyError ( String ) ,
9341}
9442
9543fn cleanup_path_for_python ( path : & PathBuf ) -> String {
@@ -127,50 +75,12 @@ impl PyRunner {
12775 /// which can happen if Python is already initialized in an incompatible way.
12876 pub fn new ( ) -> Self {
12977 // Create a multi-producer, single-consumer channel for sending commands.
130- let ( sender, mut receiver) = mpsc:: channel :: < PyCommand > ( 32 ) ;
78+ let ( sender, receiver) = mpsc:: channel :: < PyCommand > ( 32 ) ;
13179
13280 // Spawn a new OS thread to handle all Python-related work.
13381 // This is crucial to avoid blocking the async runtime and to manage the GIL correctly.
13482 thread:: spawn ( move || {
135- // Prepare the Python interpreter for use in a multi-threaded context.
136- // This must be called before any other pyo3 functions.
137- Python :: initialize ( ) ;
138- // Acquire the GIL and enter a Python context.
139- // The `py.allow_threads` call releases the GIL when we are waiting for commands,
140- // allowing other Python threads (if any) to run.
141- Python :: attach ( |py| {
142- // Loop indefinitely, waiting for commands from the channel.
143- let globals = PyDict :: new ( py) ;
144- while let Some ( cmd) = py. detach ( || receiver. blocking_recv ( ) ) {
145- match cmd. cmd_type {
146- CmdType :: RunCode ( code) => {
147- let c_code = CString :: new ( code) . expect ( "CString::new failed" ) ;
148- let result = py. run ( & c_code, Some ( & globals) , None ) ;
149- let _ = cmd. responder . send ( result. map ( |_| Value :: Null ) ) ;
150- }
151- CmdType :: EvalCode ( code) => {
152- let c_code = CString :: new ( code) . expect ( "CString::new failed" ) ;
153- let result = py. eval ( & c_code, Some ( & globals) , None ) ;
154- let _ = cmd
155- . responder
156- . send ( result. and_then ( |obj| py_any_to_json ( py, & obj) ) ) ;
157- }
158- CmdType :: ReadVariable ( var_name) => {
159- let result = get_py_object ( & globals, & var_name)
160- . and_then ( |obj| py_any_to_json ( py, & obj) ) ;
161- let _ = cmd. responder . send ( result) ;
162- }
163- CmdType :: CallFunction { name, args } => {
164- let result = handle_call_function ( py, & globals, name, args) ;
165- let _ = cmd. responder . send ( result) ;
166- }
167- CmdType :: Stop => {
168- let _ = cmd. responder . send ( Ok ( Value :: Null ) ) ;
169- break ;
170- }
171- } ;
172- }
173- } ) ;
83+ pyo3_runner:: python_thread_main ( receiver) ;
17484 } ) ;
17585
17686 Self { sender }
@@ -193,11 +103,10 @@ impl PyRunner {
193103 . map_err ( |_| PyRunnerError :: SendCommandFailed ) ?;
194104
195105 // Await the result from the Python thread.
196- let result = receiver
106+ receiver
197107 . await
198- . map_err ( |_| PyRunnerError :: ReceiveResultFailed ) ??;
199-
200- Ok ( result)
108+ . map_err ( |_| PyRunnerError :: ReceiveResultFailed ) ?
109+ . map_err ( PyRunnerError :: PyError )
201110 }
202111
203112 /// Asynchronously executes a block of Python code.
@@ -335,81 +244,6 @@ def add_venv_libs_to_syspath(venv_path):
335244 }
336245}
337246
338- /// Recursively converts a Python object to a `serde_json::Value`.
339- fn py_any_to_json ( py : Python , obj : & pyo3:: Bound < ' _ , pyo3:: PyAny > ) -> PyResult < Value > {
340- if obj. is_none ( ) {
341- return Ok ( Value :: Null ) ;
342- }
343- if let Ok ( b) = obj. cast :: < PyBool > ( ) {
344- return Ok ( Value :: Bool ( b. is_true ( ) ) ) ;
345- }
346- if let Ok ( i) = obj. cast :: < PyInt > ( ) {
347- return Ok ( Value :: Number ( i. extract :: < i64 > ( ) ?. into ( ) ) ) ;
348- }
349- if let Ok ( f) = obj. cast :: < PyFloat > ( ) {
350- // serde_json::Number does not support infinity or NaN
351- let val = f. value ( ) ;
352- if !val. is_finite ( ) {
353- return Ok ( Value :: Null ) ;
354- }
355- return Ok ( Value :: Number (
356- serde_json:: Number :: from_f64 ( val) . unwrap_or_else ( || serde_json:: Number :: from ( 0 ) ) ,
357- ) ) ;
358- }
359- if let Ok ( s) = obj. cast :: < PyString > ( ) {
360- return Ok ( Value :: String ( s. to_string ( ) ) ) ;
361- }
362- if let Ok ( list) = obj. cast :: < PyList > ( ) {
363- let items: PyResult < Vec < Value > > =
364- list. iter ( ) . map ( |item| py_any_to_json ( py, & item) ) . collect ( ) ;
365- return Ok ( Value :: Array ( items?) ) ;
366- }
367- if let Ok ( dict) = obj. cast :: < PyDict > ( ) {
368- let mut map = serde_json:: Map :: new ( ) ;
369- for ( key, value) in dict. iter ( ) {
370- map. insert ( key. to_string ( ) , py_any_to_json ( py, & value) ?) ;
371- }
372- return Ok ( Value :: Object ( map) ) ;
373- }
374-
375- // Fallback for other types: convert to string representation
376- Ok ( Value :: String ( obj. to_string ( ) ) )
377- }
378-
379- /// Converts a serde_json::Value to a Python object.
380- fn json_value_to_pyobject ( py : Python , value : Value ) -> PyResult < pyo3:: Py < pyo3:: PyAny > > {
381- match value {
382- Value :: Null => Ok ( py. None ( ) ) ,
383- Value :: Bool ( b) => b. into_py_any ( py) ,
384- Value :: Number ( n) => {
385- if let Some ( i) = n. as_i64 ( ) {
386- i. into_py_any ( py)
387- } else if let Some ( u) = n. as_u64 ( ) {
388- u. into_py_any ( py)
389- } else if let Some ( f) = n. as_f64 ( ) {
390- f. into_py_any ( py)
391- } else {
392- Ok ( py. None ( ) )
393- }
394- }
395- Value :: String ( s) => s. into_py_any ( py) ,
396- Value :: Array ( arr) => {
397- let py_list = pyo3:: types:: PyList :: empty ( py) ;
398- for v in arr {
399- py_list. append ( json_value_to_pyobject ( py, v) ?) ?;
400- }
401- py_list. into_py_any ( py)
402- }
403- Value :: Object ( obj) => {
404- let py_dict = pyo3:: types:: PyDict :: new ( py) ;
405- for ( k, v) in obj {
406- py_dict. set_item ( k, json_value_to_pyobject ( py, v) ?) ?;
407- }
408- py_dict. into_py_any ( py)
409- }
410- }
411- }
412-
413247#[ cfg( test) ]
414248mod tests {
415249 use super :: * ;
@@ -495,9 +329,7 @@ def add(a, b):
495329
496330 match result {
497331 Err ( PyRunnerError :: PyError ( py_err) ) => {
498- Python :: attach ( |py| {
499- assert ! ( py_err. is_instance_of:: <pyo3:: exceptions:: PyZeroDivisionError >( py) ) ;
500- } ) ;
332+ assert ! ( py_err. contains( "ZeroDivisionError: division by zero" ) ) ;
501333 }
502334 _ => panic ! ( "Expected a PyError" ) ,
503335 }
0 commit comments