Skip to main content

r2x_python/
python_bridge.rs

1//! Python bridge initialization with venv-based configuration
2//!
3//! This module handles lazy initialization of the Python bridge using
4//! the virtual environment's configuration. It uses OnceCell for
5//! thread-safe singleton initialization.
6//!
7//! ## PYTHONHOME Resolution
8//!
9//! PYTHONHOME is resolved from the venv's `pyvenv.cfg` file, which contains
10//! the `home` field pointing to the Python installation used to create the venv.
11//! This ensures PyO3 (linked at build time) uses a compatible Python environment.
12
13use crate::errors::BridgeError;
14use crate::utils::{resolve_python_path, resolve_site_package_path};
15use once_cell::sync::OnceCell;
16use pyo3::prelude::*;
17use pyo3::types::PyModule;
18use r2x_config::Config;
19use r2x_logger as logger;
20use std::env;
21use std::fs;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25/// The Python bridge for plugin execution
26pub struct Bridge {
27    /// Placeholder field for future extension
28    _marker: (),
29}
30
31/// Global bridge singleton
32static BRIDGE_INSTANCE: OnceCell<Result<Bridge, BridgeError>> = OnceCell::new();
33
34impl Bridge {
35    /// Get or initialize the bridge singleton
36    pub fn get() -> Result<&'static Bridge, BridgeError> {
37        match BRIDGE_INSTANCE.get_or_init(Bridge::initialize) {
38            Ok(bridge) => Ok(bridge),
39            Err(e) => Err(BridgeError::Initialization(format!("{}", e))),
40        }
41    }
42
43    /// Check if Python is available without initializing
44    pub fn is_python_available() -> bool {
45        let config = match Config::load() {
46            Ok(c) => c,
47            Err(_) => return false,
48        };
49
50        // Check if venv exists and has valid pyvenv.cfg
51        let venv_path = PathBuf::from(config.get_venv_path());
52        venv_path.join("pyvenv.cfg").exists()
53    }
54
55    /// Initialize Python interpreter and configure environment
56    ///
57    /// This performs:
58    /// 1. Ensure venv exists (create if needed)
59    /// 2. Resolve PYTHONHOME from venv's pyvenv.cfg
60    /// 3. Set PYTHONHOME and initialize PyO3
61    /// 4. Configure site-packages
62    fn initialize() -> Result<Bridge, BridgeError> {
63        let start_time = std::time::Instant::now();
64
65        let mut config = Config::load()
66            .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
67
68        // Ensure venv exists
69        let venv_path = PathBuf::from(config.get_venv_path());
70
71        if !venv_path.exists() {
72            // Create venv using the compiled Python version
73            Self::create_venv(&config, &venv_path)?;
74        }
75
76        // Resolve PYTHONHOME from venv's pyvenv.cfg
77        let python_home = resolve_python_home(&venv_path)?;
78        env::set_var("PYTHONHOME", &python_home);
79        logger::debug(&format!("Set PYTHONHOME={}", python_home.display()));
80
81        // Get site-packages path
82        let site_packages = resolve_site_package_path(&venv_path)?;
83
84        // Add site-packages to PYTHONPATH
85        Self::configure_python_path(&site_packages);
86
87        // Initialize PyO3
88        logger::debug("Initializing PyO3...");
89        let pyo3_start = std::time::Instant::now();
90        pyo3::Python::initialize();
91        logger::debug(&format!(
92            "pyo3::Python::initialize took: {:?}",
93            pyo3_start.elapsed()
94        ));
95
96        // Enable bytecode generation
97        pyo3::Python::attach(|py| {
98            let sys = PyModule::import(py, "sys")
99                .map_err(|e| BridgeError::Python(format!("Failed to import sys module: {}", e)))?;
100            sys.setattr("dont_write_bytecode", false).map_err(|e| {
101                BridgeError::Python(format!("Failed to enable bytecode generation: {}", e))
102            })?;
103            Ok::<(), BridgeError>(())
104        })?;
105        logger::debug("Enabled Python bytecode generation");
106
107        // Add venv site-packages to sys.path
108        pyo3::Python::attach(|py| {
109            let site = PyModule::import(py, "site")
110                .map_err(|e| BridgeError::Python(format!("Failed to import site module: {}", e)))?;
111            site.call_method1("addsitedir", (site_packages.to_str().unwrap(),))
112                .map_err(|e| BridgeError::Python(format!("Failed to add site directory: {}", e)))?;
113            Ok::<(), BridgeError>(())
114        })?;
115
116        // Configure cache path
117        let cache_path = config.ensure_cache_path().map_err(|e| {
118            BridgeError::Initialization(format!("Failed to ensure cache path: {}", e))
119        })?;
120        Self::configure_python_cache(&cache_path)?;
121
122        // Configure Python logging
123        if let Err(e) = Self::configure_python_logging() {
124            logger::warn(&format!("Python logging configuration failed: {}", e));
125        }
126
127        logger::debug(&format!(
128            "Total bridge initialization took: {:?}",
129            start_time.elapsed()
130        ));
131
132        Ok(Bridge { _marker: () })
133    }
134
135    /// Create a virtual environment
136    ///
137    /// Uses the compiled Python version to ensure compatibility with PyO3.
138    fn create_venv(config: &Config, venv_path: &PathBuf) -> Result<(), BridgeError> {
139        logger::step(&format!(
140            "Creating Python virtual environment at: {}",
141            venv_path.display()
142        ));
143
144        let python_version = get_compiled_python_version();
145
146        // Try uv first
147        if let Some(ref uv_path) = config.uv_path {
148            let output = Command::new(uv_path)
149                .arg("venv")
150                .arg(venv_path)
151                .arg("--python")
152                .arg(&python_version)
153                .output()?;
154
155            if output.status.success() {
156                logger::success("Virtual environment created successfully");
157                return Ok(());
158            }
159
160            let stderr = String::from_utf8_lossy(&output.stderr);
161            logger::debug(&format!("uv venv failed: {}", stderr));
162        }
163
164        // Fallback to python3 -m venv
165        let python_cmd = format!("python{}", python_version);
166        let output = Command::new(&python_cmd)
167            .args(["-m", "venv"])
168            .arg(venv_path)
169            .output();
170
171        if let Ok(output) = output {
172            if output.status.success() {
173                logger::success("Virtual environment created successfully");
174                return Ok(());
175            }
176        }
177
178        // Try generic python3
179        let output = Command::new("python3")
180            .args(["-m", "venv"])
181            .arg(venv_path)
182            .output()?;
183
184        if !output.status.success() {
185            let stderr = String::from_utf8_lossy(&output.stderr);
186            return Err(BridgeError::Initialization(format!(
187                "Failed to create virtual environment: {}",
188                stderr
189            )));
190        }
191
192        logger::success("Virtual environment created successfully");
193        Ok(())
194    }
195
196    /// Configure PYTHONPATH to include site-packages
197    fn configure_python_path(site_packages: &Path) {
198        let mut paths = vec![site_packages.to_path_buf()];
199        if let Some(existing) = env::var_os("PYTHONPATH") {
200            if !existing.is_empty() {
201                paths.extend(env::split_paths(&existing));
202            }
203        }
204        if let Ok(joined) = env::join_paths(paths) {
205            env::set_var("PYTHONPATH", &joined);
206            logger::debug(&format!(
207                "Updated PYTHONPATH to include {}",
208                site_packages.display()
209            ));
210        }
211    }
212
213    /// Configure Python cache path override
214    fn configure_python_cache(cache_path: &str) -> Result<(), BridgeError> {
215        std::fs::create_dir_all(cache_path).map_err(|e| {
216            BridgeError::Initialization(format!("Failed to create cache directory: {}", e))
217        })?;
218        env::set_var("R2X_CACHE_PATH", cache_path);
219
220        let cache_path_escaped = cache_path.replace('\\', "\\\\");
221        pyo3::Python::attach(|py| {
222            let patch_code = format!(
223                r#"from pathlib import Path
224_R2X_CACHE_PATH = Path(r"{cache}")
225
226def _r2x_cache_path_override():
227    return _R2X_CACHE_PATH
228"#,
229                cache = cache_path_escaped
230            );
231
232            let code_cstr = std::ffi::CString::new(patch_code).map_err(|e| {
233                BridgeError::Python(format!("Failed to prepare cache override script: {}", e))
234            })?;
235            let filename = std::ffi::CString::new("r2x_cache_patch.py").unwrap();
236            let module_name = std::ffi::CString::new("r2x_cache_patch").unwrap();
237            let patch_module = PyModule::from_code(
238                py,
239                code_cstr.as_c_str(),
240                filename.as_c_str(),
241                module_name.as_c_str(),
242            )
243            .map_err(|e| BridgeError::Python(format!("Failed to build cache override: {}", e)))?;
244
245            let override_fn = patch_module
246                .getattr("_r2x_cache_path_override")
247                .map_err(|e| {
248                    BridgeError::Python(format!("Failed to obtain cache override function: {}", e))
249                })?;
250
251            let file_ops = PyModule::import(py, "r2x_core.utils.file_operations").map_err(|e| {
252                BridgeError::Python(format!(
253                    "Failed to import r2x_core.utils.file_operations: {}",
254                    e
255                ))
256            })?;
257
258            file_ops
259                .setattr("get_r2x_cache_path", override_fn)
260                .map_err(|e| {
261                    BridgeError::Python(format!("Failed to override cache path: {}", e))
262                })?;
263
264            Ok::<(), BridgeError>(())
265        })?;
266
267        Ok(())
268    }
269
270    /// Configure Python loguru logging
271    fn configure_python_logging() -> Result<(), BridgeError> {
272        let log_python = logger::get_log_python();
273        if !log_python {
274            return Ok(());
275        }
276
277        let verbosity = logger::get_verbosity();
278        logger::debug(&format!(
279            "Configuring Python logging with verbosity={}",
280            verbosity
281        ));
282
283        pyo3::Python::attach(|py| {
284            let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| {
285                logger::warn(&format!("Failed to import r2x_core.logger: {}", e));
286                BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e))
287            })?;
288            let setup_logging = logger_module.getattr("setup_logging").map_err(|e| {
289                logger::warn(&format!("Failed to get setup_logging function: {}", e));
290                BridgeError::Python(format!("setup_logging not found: {}", e))
291            })?;
292            setup_logging.call1((verbosity,))?;
293
294            let loguru = PyModule::import(py, "loguru")?;
295            let logger_obj = loguru.getattr("logger")?;
296            logger_obj.call_method1("enable", ("r2x_core",))?;
297            logger_obj.call_method1("enable", ("r2x_reeds",))?;
298            logger_obj.call_method1("enable", ("r2x_plexos",))?;
299            logger_obj.call_method1("enable", ("r2x_sienna",))?;
300
301            Ok::<(), BridgeError>(())
302        })
303    }
304
305    /// Reconfigure Python logging for a specific plugin
306    pub fn reconfigure_logging_for_plugin(_plugin_name: &str) -> Result<(), BridgeError> {
307        Self::configure_python_logging()
308    }
309}
310
311/// Resolve PYTHONHOME from the venv's pyvenv.cfg file
312///
313/// The pyvenv.cfg file contains:
314/// ```text
315/// home = /path/to/python/installation
316/// include-system-site-packages = false
317/// version = 3.12.1
318/// ```
319///
320/// The `home` field points to the Python installation's bin directory,
321/// so we return its parent as PYTHONHOME.
322fn resolve_python_home(venv_path: &Path) -> Result<PathBuf, BridgeError> {
323    let pyvenv_cfg = venv_path.join("pyvenv.cfg");
324
325    if !pyvenv_cfg.exists() {
326        return Err(BridgeError::Initialization(format!(
327            "pyvenv.cfg not found in venv: {}",
328            venv_path.display()
329        )));
330    }
331
332    let content = fs::read_to_string(&pyvenv_cfg)
333        .map_err(|e| BridgeError::Initialization(format!("Failed to read pyvenv.cfg: {}", e)))?;
334
335    for line in content.lines() {
336        let line = line.trim();
337        if line.starts_with("home") {
338            if let Some((_key, value)) = line.split_once('=') {
339                let home_bin = PathBuf::from(value.trim());
340                // The 'home' field points to the bin directory, return its parent
341                if let Some(parent) = home_bin.parent() {
342                    logger::debug(&format!(
343                        "Resolved PYTHONHOME from pyvenv.cfg: {}",
344                        parent.display()
345                    ));
346                    return Ok(parent.to_path_buf());
347                }
348                // If no parent, use the path directly (unusual case)
349                return Ok(home_bin);
350            }
351        }
352    }
353
354    Err(BridgeError::Initialization(format!(
355        "Could not find 'home' in pyvenv.cfg: {}",
356        pyvenv_cfg.display()
357    )))
358}
359
360/// Get the Python version that PyO3 was compiled against
361///
362/// Returns the version string (e.g., "3.12") based on PyO3's abi3 feature.
363/// This should match the PYO3_PYTHON environment variable used during build.
364fn get_compiled_python_version() -> String {
365    // PyO3 with abi3-py311 is compatible with Python 3.11+
366    // The actual version depends on PYO3_PYTHON at build time
367    // Default to 3.12 which is the version in the justfile
368    "3.12".to_string()
369}
370
371/// Configure the Python virtual environment (legacy API compatibility)
372pub fn configure_python_venv() -> Result<PythonEnvCompat, BridgeError> {
373    let config = Config::load()
374        .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
375
376    let venv_path = PathBuf::from(config.get_venv_path());
377
378    let interpreter = resolve_python_path(&venv_path)?;
379    let python_home = resolve_python_home(&venv_path).ok();
380
381    Ok(PythonEnvCompat {
382        interpreter,
383        python_home,
384    })
385}
386
387/// Legacy compatibility struct for PythonEnvironment
388#[derive(Debug, Clone)]
389pub struct PythonEnvCompat {
390    pub interpreter: PathBuf,
391    pub python_home: Option<PathBuf>,
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn test_bridge_struct() {
400        // Test that Bridge can be created
401        let _bridge = Bridge { _marker: () };
402    }
403
404    #[test]
405    fn test_get_compiled_python_version() {
406        let version = get_compiled_python_version();
407        assert!(version.starts_with("3."));
408    }
409}