Skip to content

Commit 7b8829b

Browse files
committed
move all po3 code into separate runner
1 parent a021df4 commit 7b8829b

File tree

3 files changed

+223
-183
lines changed

3 files changed

+223
-183
lines changed

src/lib.rs

Lines changed: 15 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
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;
149
use 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;
2012
use thiserror::Error;
2113
use 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

9543
fn 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)]
414248
mod 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

Comments
 (0)