Skip to content

Commit a021df4

Browse files
committed
add run_file and set_venv
1 parent d12eb19 commit a021df4

File tree

1 file changed

+187
-13
lines changed

1 file changed

+187
-13
lines changed

src/lib.rs

Lines changed: 187 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
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
38
use pyo3::{
49
exceptions::PyKeyError,
5-
ffi::c_str,
610
prelude::*,
711
types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString},
812
IntoPyObjectExt,
913
};
1014
use serde_json::Value;
11-
use std::{ffi::CString, thread};
15+
use std::{
16+
ffi::CString,
17+
path::{Path, PathBuf},
18+
thread,
19+
};
1220
use thiserror::Error;
1321
use 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)]
85115
pub 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)]
303414
mod 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
323436
y = 20
324437
z = 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\nresult = 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

Comments
 (0)