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        // Check if Python library is available before initializing
88        check_python_library_available()?;
89
90        // Initialize PyO3
91        logger::debug("Initializing PyO3...");
92        let pyo3_start = std::time::Instant::now();
93        pyo3::Python::initialize();
94        logger::debug(&format!(
95            "pyo3::Python::initialize took: {:?}",
96            pyo3_start.elapsed()
97        ));
98
99        // Enable bytecode generation
100        pyo3::Python::attach(|py| {
101            let sys = PyModule::import(py, "sys")
102                .map_err(|e| BridgeError::Python(format!("Failed to import sys module: {}", e)))?;
103            sys.setattr("dont_write_bytecode", false).map_err(|e| {
104                BridgeError::Python(format!("Failed to enable bytecode generation: {}", e))
105            })?;
106            Ok::<(), BridgeError>(())
107        })?;
108        logger::debug("Enabled Python bytecode generation");
109
110        // Add venv site-packages to sys.path
111        pyo3::Python::attach(|py| {
112            let site = PyModule::import(py, "site")
113                .map_err(|e| BridgeError::Python(format!("Failed to import site module: {}", e)))?;
114            site.call_method1("addsitedir", (site_packages.to_str().unwrap(),))
115                .map_err(|e| BridgeError::Python(format!("Failed to add site directory: {}", e)))?;
116            Ok::<(), BridgeError>(())
117        })?;
118
119        // Configure cache path
120        let cache_path = config.ensure_cache_path().map_err(|e| {
121            BridgeError::Initialization(format!("Failed to ensure cache path: {}", e))
122        })?;
123        Self::configure_python_cache(&cache_path)?;
124
125        // Configure Python logging
126        if let Err(e) = Self::configure_python_logging() {
127            logger::warn(&format!("Python logging configuration failed: {}", e));
128        }
129
130        logger::debug(&format!(
131            "Total bridge initialization took: {:?}",
132            start_time.elapsed()
133        ));
134
135        Ok(Bridge { _marker: () })
136    }
137
138    /// Create a virtual environment
139    ///
140    /// Uses the compiled Python version to ensure compatibility with PyO3.
141    fn create_venv(config: &Config, venv_path: &PathBuf) -> Result<(), BridgeError> {
142        logger::step(&format!(
143            "Creating Python virtual environment at: {}",
144            venv_path.display()
145        ));
146
147        let python_version = get_compiled_python_version();
148
149        // Try uv first
150        if let Some(ref uv_path) = config.uv_path {
151            let output = Command::new(uv_path)
152                .arg("venv")
153                .arg(venv_path)
154                .arg("--python")
155                .arg(&python_version)
156                .output()?;
157
158            if output.status.success() {
159                logger::success("Virtual environment created successfully");
160                return Ok(());
161            }
162
163            let stderr = String::from_utf8_lossy(&output.stderr);
164            logger::debug(&format!("uv venv failed: {}", stderr));
165        }
166
167        // Fallback to python3 -m venv
168        let python_cmd = format!("python{}", python_version);
169        let output = Command::new(&python_cmd)
170            .args(["-m", "venv"])
171            .arg(venv_path)
172            .output();
173
174        if let Ok(output) = output {
175            if output.status.success() {
176                logger::success("Virtual environment created successfully");
177                return Ok(());
178            }
179        }
180
181        // Try generic python3
182        let output = Command::new("python3")
183            .args(["-m", "venv"])
184            .arg(venv_path)
185            .output()?;
186
187        if !output.status.success() {
188            let stderr = String::from_utf8_lossy(&output.stderr);
189            return Err(BridgeError::Initialization(format!(
190                "Failed to create virtual environment: {}",
191                stderr
192            )));
193        }
194
195        logger::success("Virtual environment created successfully");
196        Ok(())
197    }
198
199    /// Configure PYTHONPATH to include site-packages
200    fn configure_python_path(site_packages: &Path) {
201        let mut paths = vec![site_packages.to_path_buf()];
202        if let Some(existing) = env::var_os("PYTHONPATH") {
203            if !existing.is_empty() {
204                paths.extend(env::split_paths(&existing));
205            }
206        }
207        if let Ok(joined) = env::join_paths(paths) {
208            env::set_var("PYTHONPATH", &joined);
209            logger::debug(&format!(
210                "Updated PYTHONPATH to include {}",
211                site_packages.display()
212            ));
213        }
214    }
215
216    /// Configure Python cache path override
217    fn configure_python_cache(cache_path: &str) -> Result<(), BridgeError> {
218        std::fs::create_dir_all(cache_path).map_err(|e| {
219            BridgeError::Initialization(format!("Failed to create cache directory: {}", e))
220        })?;
221        env::set_var("R2X_CACHE_PATH", cache_path);
222
223        let cache_path_escaped = cache_path.replace('\\', "\\\\");
224        pyo3::Python::attach(|py| {
225            let patch_code = format!(
226                r#"from pathlib import Path
227_R2X_CACHE_PATH = Path(r"{cache}")
228
229def _r2x_cache_path_override():
230    return _R2X_CACHE_PATH
231"#,
232                cache = cache_path_escaped
233            );
234
235            let code_cstr = std::ffi::CString::new(patch_code).map_err(|e| {
236                BridgeError::Python(format!("Failed to prepare cache override script: {}", e))
237            })?;
238            let filename = std::ffi::CString::new("r2x_cache_patch.py").unwrap();
239            let module_name = std::ffi::CString::new("r2x_cache_patch").unwrap();
240            let patch_module = PyModule::from_code(
241                py,
242                code_cstr.as_c_str(),
243                filename.as_c_str(),
244                module_name.as_c_str(),
245            )
246            .map_err(|e| BridgeError::Python(format!("Failed to build cache override: {}", e)))?;
247
248            let override_fn = patch_module
249                .getattr("_r2x_cache_path_override")
250                .map_err(|e| {
251                    BridgeError::Python(format!("Failed to obtain cache override function: {}", e))
252                })?;
253
254            let file_ops = PyModule::import(py, "r2x_core.utils.file_operations").map_err(|e| {
255                BridgeError::Python(format!(
256                    "Failed to import r2x_core.utils.file_operations: {}",
257                    e
258                ))
259            })?;
260
261            file_ops
262                .setattr("get_r2x_cache_path", override_fn)
263                .map_err(|e| {
264                    BridgeError::Python(format!("Failed to override cache path: {}", e))
265                })?;
266
267            Ok::<(), BridgeError>(())
268        })?;
269
270        Ok(())
271    }
272
273    /// Configure Python loguru logging
274    fn configure_python_logging() -> Result<(), BridgeError> {
275        let log_python = logger::get_log_python();
276        if !log_python {
277            return Ok(());
278        }
279
280        let verbosity = logger::get_verbosity();
281        logger::debug(&format!(
282            "Configuring Python logging with verbosity={}",
283            verbosity
284        ));
285
286        pyo3::Python::attach(|py| {
287            let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| {
288                logger::warn(&format!("Failed to import r2x_core.logger: {}", e));
289                BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e))
290            })?;
291            let setup_logging = logger_module.getattr("setup_logging").map_err(|e| {
292                logger::warn(&format!("Failed to get setup_logging function: {}", e));
293                BridgeError::Python(format!("setup_logging not found: {}", e))
294            })?;
295            setup_logging.call1((verbosity,))?;
296
297            let loguru = PyModule::import(py, "loguru")?;
298            let logger_obj = loguru.getattr("logger")?;
299            logger_obj.call_method1("enable", ("r2x_core",))?;
300            logger_obj.call_method1("enable", ("r2x_reeds",))?;
301            logger_obj.call_method1("enable", ("r2x_plexos",))?;
302            logger_obj.call_method1("enable", ("r2x_sienna",))?;
303
304            Ok::<(), BridgeError>(())
305        })
306    }
307
308    /// Reconfigure Python logging for a specific plugin
309    pub fn reconfigure_logging_for_plugin(_plugin_name: &str) -> Result<(), BridgeError> {
310        Self::configure_python_logging()
311    }
312}
313
314/// Resolve PYTHONHOME from the venv's pyvenv.cfg file
315///
316/// The pyvenv.cfg file contains:
317/// ```text
318/// home = /path/to/python/installation
319/// include-system-site-packages = false
320/// version = 3.12.1
321/// ```
322///
323/// The `home` field points to the Python installation's bin directory,
324/// so we return its parent as PYTHONHOME.
325fn resolve_python_home(venv_path: &Path) -> Result<PathBuf, BridgeError> {
326    let pyvenv_cfg = venv_path.join("pyvenv.cfg");
327
328    if !pyvenv_cfg.exists() {
329        return Err(BridgeError::Initialization(format!(
330            "pyvenv.cfg not found in venv: {}",
331            venv_path.display()
332        )));
333    }
334
335    let content = fs::read_to_string(&pyvenv_cfg)
336        .map_err(|e| BridgeError::Initialization(format!("Failed to read pyvenv.cfg: {}", e)))?;
337
338    for line in content.lines() {
339        let line = line.trim();
340        if line.starts_with("home") {
341            if let Some((_key, value)) = line.split_once('=') {
342                let home_bin = PathBuf::from(value.trim());
343                // The 'home' field points to the bin directory, return its parent
344                if let Some(parent) = home_bin.parent() {
345                    logger::debug(&format!(
346                        "Resolved PYTHONHOME from pyvenv.cfg: {}",
347                        parent.display()
348                    ));
349                    return Ok(parent.to_path_buf());
350                }
351                // If no parent, use the path directly (unusual case)
352                return Ok(home_bin);
353            }
354        }
355    }
356
357    Err(BridgeError::Initialization(format!(
358        "Could not find 'home' in pyvenv.cfg: {}",
359        pyvenv_cfg.display()
360    )))
361}
362
363/// Get the Python version that PyO3 was compiled against
364///
365/// Returns the version string (e.g., "3.12") based on PyO3's abi3 feature.
366/// This should match the PYO3_PYTHON environment variable used during build.
367fn get_compiled_python_version() -> String {
368    // PyO3 with abi3-py311 is compatible with Python 3.11+
369    // The actual version depends on PYO3_PYTHON at build time
370    // Default to 3.12 which is the version in the justfile
371    "3.12".to_string()
372}
373
374/// Check if Python library is available before attempting to initialize PyO3.
375///
376/// This provides better error messages than the cryptic dyld errors on macOS
377/// or DLL loading errors on Windows.
378fn check_python_library_available() -> Result<(), BridgeError> {
379    #[cfg(target_os = "macos")]
380    {
381        // On macOS, try to find libpython in common locations
382        let python_version = get_compiled_python_version();
383        let lib_name = format!("libpython{}.dylib", python_version);
384
385        let search_paths = [
386            // Homebrew locations
387            "/opt/homebrew/lib",
388            "/usr/local/lib",
389            // Common Python framework locations
390            "/Library/Frameworks/Python.framework/Versions/Current/lib",
391        ];
392
393        // Check DYLD_LIBRARY_PATH first
394        if let Ok(paths) = env::var("DYLD_LIBRARY_PATH") {
395            for path in paths.split(':') {
396                let lib_path = PathBuf::from(path).join(&lib_name);
397                if lib_path.exists() {
398                    logger::debug(&format!("Found Python library at: {}", lib_path.display()));
399                    return Ok(());
400                }
401            }
402        }
403
404        // Check standard locations
405        for search_path in &search_paths {
406            let lib_path = PathBuf::from(search_path).join(&lib_name);
407            if lib_path.exists() {
408                logger::debug(&format!("Found Python library at: {}", lib_path.display()));
409                return Ok(());
410            }
411        }
412
413        // Try to find Python via uv (this works regardless of HOME setting)
414        if let Ok(output) = Command::new("uv")
415            .args(["python", "find", &python_version])
416            .output()
417        {
418            if output.status.success() {
419                let python_path = String::from_utf8_lossy(&output.stdout);
420                let python_path = python_path.trim();
421                if let Some(parent) = PathBuf::from(python_path).parent() {
422                    // Python binary is in bin/, lib is in ../lib/
423                    let lib_dir = parent.parent().map(|p| p.join("lib")).unwrap_or_default();
424                    let lib_path = lib_dir.join(&lib_name);
425                    if lib_path.exists() {
426                        logger::debug(&format!(
427                            "Found Python library via uv: {}",
428                            lib_path.display()
429                        ));
430                        // Set DYLD_LIBRARY_PATH to help the dynamic linker
431                        if let Some(existing) = env::var_os("DYLD_LIBRARY_PATH") {
432                            let mut paths = env::split_paths(&existing).collect::<Vec<_>>();
433                            paths.insert(0, lib_dir.clone());
434                            if let Ok(new_path) = env::join_paths(&paths) {
435                                env::set_var("DYLD_LIBRARY_PATH", new_path);
436                            }
437                        } else {
438                            env::set_var("DYLD_LIBRARY_PATH", &lib_dir);
439                        }
440                        logger::debug(&format!(
441                            "Set DYLD_LIBRARY_PATH to include: {}",
442                            lib_dir.display()
443                        ));
444                        return Ok(());
445                    }
446                }
447            }
448        }
449
450        // Library not found in expected locations, but don't fail -
451        // let PyO3 try to load it via rpath or other mechanisms.
452        // The binary may have been fixed with install_name_tool to use @rpath.
453        logger::debug(&format!(
454            "Python library {} not found in standard locations, relying on rpath",
455            lib_name
456        ));
457        Ok(())
458    }
459
460    #[cfg(target_os = "linux")]
461    {
462        // On Linux, check common locations
463        let python_version = get_compiled_python_version();
464        let lib_names = [
465            format!("libpython{}.so", python_version),
466            format!("libpython{}.so.1.0", python_version),
467        ];
468
469        let search_paths = [
470            "/usr/lib",
471            "/usr/lib64",
472            "/usr/local/lib",
473            "/usr/local/lib64",
474        ];
475
476        // Check LD_LIBRARY_PATH first
477        if let Ok(paths) = env::var("LD_LIBRARY_PATH") {
478            for path in paths.split(':') {
479                for lib_name in &lib_names {
480                    let lib_path = PathBuf::from(path).join(lib_name);
481                    if lib_path.exists() {
482                        logger::debug(&format!("Found Python library at: {}", lib_path.display()));
483                        return Ok(());
484                    }
485                }
486            }
487        }
488
489        // Check standard locations
490        for search_path in &search_paths {
491            for lib_name in &lib_names {
492                let lib_path = PathBuf::from(search_path).join(lib_name);
493                if lib_path.exists() {
494                    logger::debug(&format!("Found Python library at: {}", lib_path.display()));
495                    return Ok(());
496                }
497            }
498        }
499
500        // Try to find Python via uv (this works regardless of HOME setting)
501        if let Ok(output) = Command::new("uv")
502            .args(["python", "find", &python_version])
503            .output()
504        {
505            if output.status.success() {
506                let python_path = String::from_utf8_lossy(&output.stdout);
507                let python_path = python_path.trim();
508                if let Some(parent) = PathBuf::from(python_path).parent() {
509                    // Python binary is in bin/, lib is in ../lib/
510                    let lib_dir = parent.parent().map(|p| p.join("lib")).unwrap_or_default();
511                    for lib_name in &lib_names {
512                        let lib_path = lib_dir.join(lib_name);
513                        if lib_path.exists() {
514                            logger::debug(&format!(
515                                "Found Python library via uv: {}",
516                                lib_path.display()
517                            ));
518                            // Set LD_LIBRARY_PATH to help the dynamic linker
519                            if let Some(existing) = env::var_os("LD_LIBRARY_PATH") {
520                                let mut paths = env::split_paths(&existing).collect::<Vec<_>>();
521                                paths.insert(0, lib_dir.clone());
522                                if let Ok(new_path) = env::join_paths(&paths) {
523                                    env::set_var("LD_LIBRARY_PATH", new_path);
524                                }
525                            } else {
526                                env::set_var("LD_LIBRARY_PATH", &lib_dir);
527                            }
528                            logger::debug(&format!(
529                                "Set LD_LIBRARY_PATH to include: {}",
530                                lib_dir.display()
531                            ));
532                            return Ok(());
533                        }
534                    }
535                }
536            }
537        }
538
539        // Library not found in expected locations, but don't fail -
540        // let PyO3 try to load it via rpath or other mechanisms.
541        logger::debug("Python library not found in standard locations, relying on rpath");
542        Ok(())
543    }
544
545    #[cfg(target_os = "windows")]
546    {
547        // On Windows, try to set up the DLL path (best effort)
548        if let Err(e) = setup_windows_dll_path() {
549            logger::debug(&format!("Windows DLL path setup note: {}", e));
550        }
551        Ok(())
552    }
553
554    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
555    {
556        // For other platforms, just proceed and let PyO3 handle it
557        Ok(())
558    }
559}
560
561/// Setup Windows DLL search path for Python
562#[cfg(target_os = "windows")]
563fn setup_windows_dll_path() -> Result<(), BridgeError> {
564    use std::process::Command;
565
566    let python_version = get_compiled_python_version();
567    let dll_name = format!("python{}.dll", python_version.replace(".", ""));
568
569    // Try to find Python via uv first
570    let output = Command::new("uv")
571        .args(["python", "find", &python_version])
572        .output();
573
574    if let Ok(output) = output {
575        if output.status.success() {
576            let python_path = String::from_utf8_lossy(&output.stdout);
577            let python_path = python_path.trim();
578            if let Some(parent) = PathBuf::from(python_path).parent() {
579                // On Windows, Python DLL is usually in the same directory as python.exe
580                let dll_path = parent.join(&dll_name);
581                if dll_path.exists() {
582                    // Add the directory to PATH so Windows can find the DLL
583                    if let Ok(current_path) = env::var("PATH") {
584                        let new_path = format!("{};{}", parent.display(), current_path);
585                        env::set_var("PATH", &new_path);
586                        logger::debug(&format!(
587                            "Added {} to PATH for Python DLL discovery",
588                            parent.display()
589                        ));
590                        return Ok(());
591                    }
592                }
593            }
594        }
595    }
596
597    // Try to find Python in PATH
598    if let Ok(output) = Command::new("where").arg("python").output() {
599        if output.status.success() {
600            let python_path = String::from_utf8_lossy(&output.stdout);
601            if let Some(first_line) = python_path.lines().next() {
602                if let Some(parent) = PathBuf::from(first_line.trim()).parent() {
603                    let dll_path = parent.join(&dll_name);
604                    if dll_path.exists() {
605                        logger::debug(&format!("Found Python DLL at: {}", dll_path.display()));
606                        return Ok(());
607                    }
608                }
609            }
610        }
611    }
612
613    Err(BridgeError::PythonLibraryNotFound(format!(
614        "Could not find {}.\n\n\
615        This binary requires Python {} to be installed.\n\n\
616        To fix this on Windows:\n\
617        1. Install Python via uv: uv python install {}\n\
618        2. Or download from https://www.python.org/downloads/\n\
619        3. Ensure Python is in your PATH\n\n\
620        If you installed Python via uv, try running:\n\
621           uv python find {}",
622        dll_name, python_version, python_version, python_version
623    )))
624}
625
626/// Configure the Python virtual environment (legacy API compatibility)
627pub fn configure_python_venv() -> Result<PythonEnvCompat, BridgeError> {
628    let config = Config::load()
629        .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
630
631    let venv_path = PathBuf::from(config.get_venv_path());
632
633    let interpreter = resolve_python_path(&venv_path)?;
634    let python_home = resolve_python_home(&venv_path).ok();
635
636    Ok(PythonEnvCompat {
637        interpreter,
638        python_home,
639    })
640}
641
642/// Legacy compatibility struct for PythonEnvironment
643#[derive(Debug, Clone)]
644pub struct PythonEnvCompat {
645    pub interpreter: PathBuf,
646    pub python_home: Option<PathBuf>,
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    #[test]
654    fn test_bridge_struct() {
655        // Test that Bridge can be created
656        let _bridge = Bridge { _marker: () };
657    }
658
659    #[test]
660    fn test_get_compiled_python_version() {
661        let version = get_compiled_python_version();
662        assert!(version.starts_with("3."));
663    }
664}