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::{Lazy, OnceCell};
16use pyo3::prelude::*;
17use pyo3::types::{PyDict, PyModule};
18use r2x_config::{Config, PythonRuntimeVersion};
19use r2x_logger as logger;
20use std::collections::HashSet;
21use std::env;
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::process::Command;
25use std::sync::Mutex;
26
27/// The Python bridge for plugin execution
28pub struct Bridge {
29    /// Placeholder field for future extension
30    _marker: (),
31}
32
33/// Global bridge singleton
34static BRIDGE_INSTANCE: OnceCell<Result<Bridge, BridgeError>> = OnceCell::new();
35static POST_IMPORT_LOG_MODULES_ENABLED: Lazy<Mutex<HashSet<String>>> =
36    Lazy::new(|| Mutex::new(HashSet::new()));
37
38impl Bridge {
39    /// Get or initialize the bridge singleton
40    pub fn get() -> Result<&'static Bridge, BridgeError> {
41        match BRIDGE_INSTANCE.get_or_init(Bridge::initialize) {
42            Ok(bridge) => Ok(bridge),
43            Err(e) => Err(BridgeError::Initialization(format!("{}", e))),
44        }
45    }
46
47    /// Check if Python is available without initializing
48    pub fn is_python_available() -> bool {
49        let config = match Config::load() {
50            Ok(c) => c,
51            Err(_) => return false,
52        };
53
54        // Check if venv exists and has valid pyvenv.cfg
55        let venv_path = PathBuf::from(config.get_venv_path());
56        venv_path.join("pyvenv.cfg").exists()
57    }
58
59    /// Initialize Python interpreter and configure environment
60    ///
61    /// This performs:
62    /// 1. Ensure venv exists (create if needed)
63    /// 2. Resolve PYTHONHOME from venv's pyvenv.cfg
64    /// 3. Set PYTHONHOME and initialize PyO3
65    /// 4. Configure site-packages
66    fn initialize() -> Result<Bridge, BridgeError> {
67        let start_time = std::time::Instant::now();
68
69        let mut config = Config::load()
70            .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
71
72        // Ensure venv exists
73        let venv_path = PathBuf::from(config.get_venv_path());
74
75        if !venv_path.exists() {
76            // Create venv using the configured runtime Python version.
77            Self::create_venv(&config, &venv_path)?;
78        }
79
80        // Resolve PYTHONHOME from venv's pyvenv.cfg
81        let python_home = resolve_python_home(&venv_path)?;
82        env::set_var("PYTHONHOME", &python_home);
83        logger::debug_lazy(|| format!("Set PYTHONHOME={}", python_home.display()));
84
85        // Get site-packages path
86        let site_packages = resolve_site_package_path(&venv_path)?;
87
88        // Add site-packages to PYTHONPATH
89        Self::configure_python_path(&site_packages);
90
91        // Check if Python library is available before initializing
92        let python_version = runtime_python_version(&config)?;
93        check_python_library_available(&python_version)?;
94
95        // Initialize PyO3
96        logger::debug("Initializing PyO3...");
97        let pyo3_start = std::time::Instant::now();
98        pyo3::Python::initialize();
99        logger::debug_lazy(|| format!("pyo3::Python::initialize took: {:?}", pyo3_start.elapsed()));
100
101        // Enable bytecode generation
102        pyo3::Python::attach(|py| {
103            let sys = PyModule::import(py, "sys")
104                .map_err(|e| BridgeError::Python(format!("Failed to import sys module: {}", e)))?;
105            sys.setattr("dont_write_bytecode", false).map_err(|e| {
106                BridgeError::Python(format!("Failed to enable bytecode generation: {}", e))
107            })?;
108            Ok::<(), BridgeError>(())
109        })?;
110        logger::debug("Enabled Python bytecode generation");
111
112        // Add venv site-packages to sys.path
113        pyo3::Python::attach(|py| {
114            let site = PyModule::import(py, "site")
115                .map_err(|e| BridgeError::Python(format!("Failed to import site module: {}", e)))?;
116            site.call_method1("addsitedir", (site_packages.to_string_lossy().as_ref(),))
117                .map_err(|e| BridgeError::Python(format!("Failed to add site directory: {}", e)))?;
118            Ok::<(), BridgeError>(())
119        })?;
120
121        // Configure cache path
122        let cache_path = config.ensure_cache_path().map_err(|e| {
123            BridgeError::Initialization(format!("Failed to ensure cache path: {}", e))
124        })?;
125        Self::configure_python_cache(&cache_path)?;
126
127        // Configure Python logging
128        if let Err(e) = Self::configure_python_logging() {
129            logger::warn(&format!("Python logging configuration failed: {}", e));
130        }
131
132        logger::debug_lazy(|| {
133            format!(
134                "Total bridge initialization took: {:?}",
135                start_time.elapsed()
136            )
137        });
138
139        Ok(Bridge { _marker: () })
140    }
141
142    /// Create a virtual environment
143    ///
144    /// Uses the configured runtime Python version. PyO3 is built with abi3-py311,
145    /// so the embedded extension ABI supports Python 3.11 and newer runtimes.
146    fn create_venv(config: &Config, venv_path: &PathBuf) -> Result<(), BridgeError> {
147        logger::step(&format!(
148            "Creating Python virtual environment at: {}",
149            venv_path.display()
150        ));
151
152        let python_version = runtime_python_version(config)?;
153
154        // Try uv first
155        if let Some(ref uv_path) = config.uv_path {
156            for python_query in python_version.query_candidates() {
157                let output = Command::new(uv_path)
158                    .arg("venv")
159                    .arg(venv_path)
160                    .arg("--python")
161                    .arg(python_query)
162                    .output()?;
163
164                if output.status.success() {
165                    logger::success("Virtual environment created successfully");
166                    return Ok(());
167                }
168
169                let stderr = String::from_utf8_lossy(&output.stderr);
170                logger::debug_lazy(|| format!("uv venv failed for {python_query}: {}", stderr));
171            }
172        }
173
174        // Fallback to python3 -m venv
175        let python_cmd = format!("python{}", python_version.abi());
176        let output = Command::new(&python_cmd)
177            .args(["-m", "venv"])
178            .arg(venv_path)
179            .output();
180
181        if let Ok(output) = output {
182            if output.status.success() {
183                logger::success("Virtual environment created successfully");
184                return Ok(());
185            }
186        }
187
188        // Try generic python3
189        let output = Command::new("python3")
190            .args(["-m", "venv"])
191            .arg(venv_path)
192            .output()?;
193
194        if !output.status.success() {
195            let stderr = String::from_utf8_lossy(&output.stderr);
196            return Err(BridgeError::Initialization(format!(
197                "Failed to create virtual environment: {}",
198                stderr
199            )));
200        }
201
202        logger::success("Virtual environment created successfully");
203        Ok(())
204    }
205
206    /// Configure PYTHONPATH to include site-packages
207    fn configure_python_path(site_packages: &Path) {
208        let mut paths = vec![site_packages.to_path_buf()];
209        if let Some(existing) = env::var_os("PYTHONPATH") {
210            if !existing.is_empty() {
211                paths.extend(env::split_paths(&existing));
212            }
213        }
214        if let Ok(joined) = env::join_paths(paths) {
215            env::set_var("PYTHONPATH", &joined);
216            logger::debug_lazy(|| {
217                format!("Updated PYTHONPATH to include {}", site_packages.display())
218            });
219        }
220    }
221
222    /// Configure Python cache path override
223    fn configure_python_cache(cache_path: &str) -> Result<(), BridgeError> {
224        std::fs::create_dir_all(cache_path).map_err(|e| {
225            BridgeError::Initialization(format!("Failed to create cache directory: {}", e))
226        })?;
227        env::set_var("R2X_CACHE_PATH", cache_path);
228
229        let cache_path_escaped = cache_path.replace('\\', "\\\\");
230        pyo3::Python::attach(|py| {
231            let patch_code = format!(
232                r#"from pathlib import Path
233_R2X_CACHE_PATH = Path(r"{cache}")
234
235def _r2x_cache_path_override():
236    return _R2X_CACHE_PATH
237"#,
238                cache = cache_path_escaped
239            );
240
241            let code_cstr = std::ffi::CString::new(patch_code).map_err(|e| {
242                BridgeError::Python(format!("Failed to prepare cache override script: {}", e))
243            })?;
244            let filename = std::ffi::CString::new("r2x_cache_patch.py")
245                .map_err(|e| BridgeError::Python(format!("Failed to create filename: {}", e)))?;
246            let module_name = std::ffi::CString::new("r2x_cache_patch")
247                .map_err(|e| BridgeError::Python(format!("Failed to create module name: {}", e)))?;
248            let patch_module = PyModule::from_code(
249                py,
250                code_cstr.as_c_str(),
251                filename.as_c_str(),
252                module_name.as_c_str(),
253            )
254            .map_err(|e| BridgeError::Python(format!("Failed to build cache override: {}", e)))?;
255
256            let override_fn = patch_module
257                .getattr("_r2x_cache_path_override")
258                .map_err(|e| {
259                    BridgeError::Python(format!("Failed to obtain cache override function: {}", e))
260                })?;
261
262            let file_ops = PyModule::import(py, "r2x_core.utils.file_operations").map_err(|e| {
263                BridgeError::Python(format!(
264                    "Failed to import r2x_core.utils.file_operations: {}",
265                    e
266                ))
267            })?;
268
269            file_ops
270                .setattr("get_r2x_cache_path", override_fn)
271                .map_err(|e| {
272                    BridgeError::Python(format!("Failed to override cache path: {}", e))
273                })?;
274
275            Ok::<(), BridgeError>(())
276        })?;
277
278        Ok(())
279    }
280
281    /// Configure Python loguru logging
282    ///
283    /// Always configures a file sink pointing to the shared r2x.log.
284    /// Optionally adds a console sink when --log-python is active.
285    fn configure_python_logging() -> Result<(), BridgeError> {
286        let verbosity = logger::get_verbosity();
287        let log_python = logger::get_log_python();
288        let log_file = logger::get_log_path_string();
289
290        logger::debug_lazy(|| {
291            format!(
292                "Configuring Python logging with verbosity={}, log_python={}, log_file={}",
293                verbosity, log_python, log_file
294            )
295        });
296
297        pyo3::Python::attach(|py| {
298            let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| {
299                BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e))
300            })?;
301            let setup_logging = logger_module
302                .getattr("setup_logging")
303                .map_err(|e| BridgeError::Python(format!("setup_logging not found: {}", e)))?;
304
305            let kwargs = PyDict::new(py);
306            if !log_file.is_empty() {
307                kwargs.set_item("log_file", &log_file)?;
308            }
309            kwargs.set_item("log_to_console", log_python)?;
310            setup_logging.call((verbosity,), Some(&kwargs))?;
311
312            Self::enable_loguru_modules(
313                py,
314                &[
315                    "r2x_core",
316                    "r2x_reeds",
317                    "r2x_plexos",
318                    "r2x_sienna",
319                    "r2x_nodal",
320                ],
321            )
322        })
323    }
324
325    /// Enable loguru logging for a list of Python modules
326    pub(crate) fn enable_loguru_modules(py: Python, modules: &[&str]) -> Result<(), BridgeError> {
327        let loguru = PyModule::import(py, "loguru")?;
328        let logger_obj = loguru.getattr("logger")?;
329
330        for module in modules {
331            logger_obj.call_method1("enable", (module,))?;
332        }
333
334        Ok(())
335    }
336
337    /// Enable loguru for plugin modules after Python has imported them.
338    ///
339    /// Many plugin packages call `logger.disable(__name__)` from their
340    /// `__init__.py`; enabling before import can be overwritten. Once a package
341    /// has been imported and re-enabled, later plugin invocations from that same
342    /// package do not need to import loguru or call enable again.
343    pub(crate) fn enable_loguru_modules_after_import(
344        py: Python,
345        modules: &[&str],
346    ) -> Result<(), BridgeError> {
347        let pending = pending_post_import_log_modules(modules);
348
349        if pending.is_empty() {
350            return Ok(());
351        }
352
353        let pending_refs = pending.iter().map(String::as_str).collect::<Vec<_>>();
354        Self::enable_loguru_modules(py, &pending_refs)?;
355
356        mark_post_import_log_modules_enabled(&pending);
357
358        Ok(())
359    }
360}
361
362fn pending_post_import_log_modules(modules: &[&str]) -> Vec<String> {
363    let enabled = POST_IMPORT_LOG_MODULES_ENABLED
364        .lock()
365        .unwrap_or_else(std::sync::PoisonError::into_inner);
366    modules
367        .iter()
368        .copied()
369        .filter(|module| !enabled.contains(*module))
370        .map(str::to_string)
371        .collect()
372}
373
374fn mark_post_import_log_modules_enabled(modules: &[String]) {
375    let mut enabled = POST_IMPORT_LOG_MODULES_ENABLED
376        .lock()
377        .unwrap_or_else(std::sync::PoisonError::into_inner);
378    enabled.extend(modules.iter().cloned());
379}
380
381/// Resolve PYTHONHOME from the venv's pyvenv.cfg file.
382///
383/// `home` in `pyvenv.cfg` is not fully consistent across creators/platforms:
384/// it may point at a prefix, a launcher dir (`bin`/`Scripts`), or an executable.
385/// We normalize it into a stable Python prefix for embedded startup.
386fn resolve_python_home(venv_path: &Path) -> Result<PathBuf, BridgeError> {
387    let pyvenv_cfg = venv_path.join("pyvenv.cfg");
388
389    if !pyvenv_cfg.exists() {
390        return Err(BridgeError::Initialization(format!(
391            "pyvenv.cfg not found in venv: {}",
392            venv_path.display()
393        )));
394    }
395
396    let content = fs::read_to_string(&pyvenv_cfg)
397        .map_err(|e| BridgeError::Initialization(format!("Failed to read pyvenv.cfg: {}", e)))?;
398
399    for line in content.lines() {
400        let line = line.trim();
401        if let Some((key, value)) = line.split_once('=') {
402            if key.trim().eq_ignore_ascii_case("home") {
403                let home_value = PathBuf::from(value.trim());
404                let python_home = normalize_python_home(&home_value);
405                logger::debug_lazy(|| {
406                    format!(
407                        "Resolved PYTHONHOME from pyvenv.cfg home={} -> {}",
408                        home_value.display(),
409                        python_home.display()
410                    )
411                });
412                return Ok(python_home);
413            }
414        }
415    }
416
417    Err(BridgeError::Initialization(format!(
418        "Could not find 'home' in pyvenv.cfg: {}",
419        pyvenv_cfg.display()
420    )))
421}
422
423fn normalize_python_home(home_value: &Path) -> PathBuf {
424    let Some(last_segment) = home_value.file_name().and_then(|name| name.to_str()) else {
425        return home_value.to_path_buf();
426    };
427
428    if is_python_executable_name(last_segment)
429        || last_segment.eq_ignore_ascii_case("bin")
430        || last_segment.eq_ignore_ascii_case("scripts")
431    {
432        if let Some(parent) = home_value.parent() {
433            return parent.to_path_buf();
434        }
435    }
436
437    home_value.to_path_buf()
438}
439
440fn is_python_executable_name(name: &str) -> bool {
441    if name.eq_ignore_ascii_case("python")
442        || name.eq_ignore_ascii_case("python.exe")
443        || name.eq_ignore_ascii_case("python3")
444        || name.eq_ignore_ascii_case("python3.exe")
445    {
446        return true;
447    }
448
449    let lower = name.to_ascii_lowercase();
450    if let Some(suffix) = lower.strip_prefix("python") {
451        let suffix = suffix.strip_suffix(".exe").unwrap_or(suffix);
452        if let Some(version) = suffix.strip_prefix('3') {
453            if version.is_empty() {
454                return true;
455            }
456            if let Some(dotless) = version.strip_prefix('.') {
457                return !dotless.is_empty() && dotless.chars().all(|ch| ch.is_ascii_digit());
458            }
459            return version.chars().all(|ch| ch.is_ascii_digit());
460        }
461    }
462
463    false
464}
465
466/// Select the Python runtime version for venv creation and library discovery.
467fn runtime_python_version(config: &Config) -> Result<PythonRuntimeVersion, BridgeError> {
468    config.runtime_python_version().map_err(|error| {
469        BridgeError::Initialization(format!("Invalid configured Python version: {}", error))
470    })
471}
472
473/// Check if Python library is available before attempting to initialize PyO3.
474///
475/// This provides better error messages than the cryptic dyld errors on macOS
476/// or DLL loading errors on Windows.
477fn check_python_library_available(
478    python_version: &PythonRuntimeVersion,
479) -> Result<(), BridgeError> {
480    #[cfg(any(target_os = "macos", target_os = "linux"))]
481    {
482        #[cfg(target_os = "macos")]
483        let (lib_names, search_paths, env_var) = (
484            vec![format!("libpython{}.dylib", python_version.abi())],
485            &[
486                "/opt/homebrew/lib",
487                "/usr/local/lib",
488                "/Library/Frameworks/Python.framework/Versions/Current/lib",
489            ][..],
490            "DYLD_LIBRARY_PATH",
491        );
492
493        #[cfg(target_os = "linux")]
494        let (lib_names, search_paths, env_var) = (
495            vec![
496                format!("libpython{}.so", python_version.abi()),
497                format!("libpython{}.so.1.0", python_version.abi()),
498            ],
499            &[
500                "/usr/lib",
501                "/usr/lib64",
502                "/usr/local/lib",
503                "/usr/local/lib64",
504            ][..],
505            "LD_LIBRARY_PATH",
506        );
507
508        // Check environment variable paths first
509        if let Ok(paths) = env::var(env_var) {
510            if find_lib_in_paths(paths.split(':'), &lib_names) {
511                return Ok(());
512            }
513        }
514
515        // Check standard system locations
516        if find_lib_in_paths(search_paths.iter().copied(), &lib_names) {
517            return Ok(());
518        }
519
520        // Try to find Python via uv and set up the library path
521        if let Some(lib_dir) = find_python_lib_via_uv(python_version, &lib_names) {
522            prepend_to_env_path(env_var, &lib_dir);
523            logger::debug_lazy(|| format!("Set {} to include: {}", env_var, lib_dir.display()));
524            return Ok(());
525        }
526
527        // Library not found in expected locations, but don't fail -
528        // let PyO3 try to load it via rpath or other mechanisms.
529        logger::debug("Python library not found in standard locations, relying on rpath");
530        Ok(())
531    }
532
533    #[cfg(target_os = "windows")]
534    {
535        // On Windows, try to set up the DLL path (best effort)
536        if let Err(e) = setup_windows_dll_path(python_version) {
537            logger::debug_lazy(|| format!("Windows DLL path setup note: {}", e));
538        }
539        Ok(())
540    }
541
542    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
543    {
544        // For other platforms, just proceed and let PyO3 handle it
545        Ok(())
546    }
547}
548
549/// Search for any of the library names in the given paths.
550/// Returns true if found, logging the discovery.
551#[cfg(any(target_os = "macos", target_os = "linux"))]
552fn find_lib_in_paths<I, S>(paths: I, lib_names: &[String]) -> bool
553where
554    I: Iterator<Item = S>,
555    S: AsRef<str>,
556{
557    for path in paths {
558        for lib_name in lib_names {
559            let lib_path = PathBuf::from(path.as_ref()).join(lib_name);
560            if lib_path.exists() {
561                logger::debug_lazy(|| format!("Found Python library at: {}", lib_path.display()));
562                return true;
563            }
564        }
565    }
566    false
567}
568
569/// Try to find Python library via uv python find command.
570/// Returns the lib directory path if found.
571#[cfg(any(target_os = "macos", target_os = "linux"))]
572fn find_python_lib_via_uv(
573    python_version: &PythonRuntimeVersion,
574    lib_names: &[String],
575) -> Option<PathBuf> {
576    for python_query in python_version.query_candidates() {
577        let output = Command::new("uv")
578            .args(["python", "find", python_query])
579            .output()
580            .ok()?;
581
582        if !output.status.success() {
583            continue;
584        }
585
586        let python_path = String::from_utf8_lossy(&output.stdout);
587        let python_path = python_path.trim();
588
589        // Python binary is in bin/, lib is in ../lib/
590        let lib_dir = PathBuf::from(python_path).parent()?.parent()?.join("lib");
591
592        for lib_name in lib_names {
593            let lib_path = lib_dir.join(lib_name);
594            if lib_path.exists() {
595                logger::debug_lazy(|| {
596                    format!("Found Python library via uv: {}", lib_path.display())
597                });
598                return Some(lib_dir);
599            }
600        }
601    }
602
603    None
604}
605
606/// Prepend a directory to an environment path variable.
607#[cfg(any(target_os = "macos", target_os = "linux"))]
608fn prepend_to_env_path(env_var: &str, dir: &Path) {
609    if let Some(existing) = env::var_os(env_var) {
610        let mut paths = env::split_paths(&existing).collect::<Vec<_>>();
611        paths.insert(0, dir.to_path_buf());
612        if let Ok(new_path) = env::join_paths(&paths) {
613            env::set_var(env_var, new_path);
614        }
615    } else {
616        env::set_var(env_var, dir);
617    }
618}
619
620/// Setup Windows DLL search path for Python
621#[cfg(target_os = "windows")]
622fn setup_windows_dll_path(python_version: &PythonRuntimeVersion) -> Result<(), BridgeError> {
623    let dll_name = format!("python{}.dll", python_version.abi().replace('.', ""));
624
625    // Try to find Python via uv first
626    for python_query in python_version.query_candidates() {
627        let output = Command::new("uv")
628            .args(["python", "find", python_query])
629            .output();
630
631        if let Ok(output) = output {
632            if output.status.success() {
633                let python_path = String::from_utf8_lossy(&output.stdout);
634                let python_path = python_path.trim();
635                if let Some(parent) = PathBuf::from(python_path).parent() {
636                    // On Windows, Python DLL is usually in the same directory as python.exe
637                    let dll_path = parent.join(&dll_name);
638                    if dll_path.exists() {
639                        // Add the directory to PATH so Windows can find the DLL
640                        if let Ok(current_path) = env::var("PATH") {
641                            let new_path = format!("{};{}", parent.display(), current_path);
642                            env::set_var("PATH", &new_path);
643                            logger::debug_lazy(|| {
644                                format!(
645                                    "Added {} to PATH for Python DLL discovery",
646                                    parent.display()
647                                )
648                            });
649                            return Ok(());
650                        }
651                    }
652                }
653            }
654        }
655    }
656
657    // Try to find Python in PATH
658    if let Ok(output) = Command::new("where").arg("python").output() {
659        if output.status.success() {
660            let python_path = String::from_utf8_lossy(&output.stdout);
661            if let Some(first_line) = python_path.lines().next() {
662                if let Some(parent) = PathBuf::from(first_line.trim()).parent() {
663                    let dll_path = parent.join(&dll_name);
664                    if dll_path.exists() {
665                        logger::debug_lazy(|| {
666                            format!("Found Python DLL at: {}", dll_path.display())
667                        });
668                        return Ok(());
669                    }
670                }
671            }
672        }
673    }
674
675    let find_hint = python_version.find_hint();
676    let install_hint = python_version.install_hint();
677    Err(BridgeError::PythonLibraryNotFound(format!(
678        "Could not find {}.\n\n\
679        This binary requires Python {} to be installed.\n\n\
680        To fix this on Windows:\n\
681        1. Install Python via uv: {}\n\
682        2. Or download from https://www.python.org/downloads/\n\
683        3. Ensure Python is in your PATH\n\n\
684        If you installed Python via uv, try running:\n\
685           {}",
686        dll_name,
687        python_version.requested(),
688        install_hint,
689        find_hint
690    )))
691}
692
693/// Configure the Python virtual environment (legacy API compatibility)
694pub fn configure_python_venv() -> Result<PythonEnvCompat, BridgeError> {
695    let config = Config::load()
696        .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
697
698    let venv_path = PathBuf::from(config.get_venv_path());
699
700    let interpreter = resolve_python_path(&venv_path)?;
701    let python_home = resolve_python_home(&venv_path).ok();
702
703    Ok(PythonEnvCompat {
704        interpreter,
705        python_home,
706    })
707}
708
709/// Legacy compatibility struct for PythonEnvironment
710#[derive(Debug, Clone)]
711pub struct PythonEnvCompat {
712    pub interpreter: PathBuf,
713    pub python_home: Option<PathBuf>,
714}
715
716#[cfg(test)]
717mod tests {
718    use crate::python_bridge::*;
719    use r2x_config::{default_python_version, PythonRuntimeVersion};
720    #[cfg(unix)]
721    use std::env;
722    use std::fs;
723    #[cfg(unix)]
724    use std::os::unix::fs::PermissionsExt;
725    use tempfile::TempDir;
726
727    #[cfg(unix)]
728    static PATH_TEST_LOCK: once_cell::sync::Lazy<std::sync::Mutex<()>> =
729        once_cell::sync::Lazy::new(|| std::sync::Mutex::new(()));
730
731    #[test]
732    fn test_bridge_struct() {
733        // Test that Bridge can be created
734        let _bridge = Bridge { _marker: () };
735    }
736
737    #[test]
738    fn test_runtime_python_version_defaults_to_build_python_version() {
739        let config = Config::default();
740        assert_eq!(
741            runtime_python_version(&config)
742                .ok()
743                .map(|version| (version.requested().to_string(), version.abi().to_string())),
744            Some((
745                default_python_version().to_string(),
746                default_python_version().to_string()
747            ))
748        );
749    }
750
751    #[test]
752    fn test_runtime_python_version_uses_configured_version() {
753        let config = Config {
754            python_version: Some("3.13".to_string()),
755            ..Config::default()
756        };
757        assert_eq!(
758            runtime_python_version(&config)
759                .ok()
760                .map(|version| (version.requested().to_string(), version.abi().to_string())),
761            Some(("3.13".to_string(), "3.13".to_string()))
762        );
763    }
764
765    #[test]
766    fn test_runtime_python_version_keeps_patch_request_but_uses_minor_abi() {
767        let config = Config {
768            python_version: Some("3.13.1".to_string()),
769            ..Config::default()
770        };
771        assert_eq!(
772            runtime_python_version(&config)
773                .ok()
774                .map(|version| (version.requested().to_string(), version.abi().to_string())),
775            Some(("3.13.1".to_string(), "3.13".to_string()))
776        );
777    }
778
779    #[test]
780    fn test_runtime_python_version_ignores_blank_config_value() {
781        let config = Config {
782            python_version: Some("  ".to_string()),
783            ..Config::default()
784        };
785        assert_eq!(
786            runtime_python_version(&config)
787                .ok()
788                .map(|version| (version.requested().to_string(), version.abi().to_string())),
789            Some((
790                default_python_version().to_string(),
791                default_python_version().to_string()
792            ))
793        );
794    }
795
796    #[test]
797    fn test_runtime_python_version_rejects_unsupported_configured_version() {
798        let config = Config {
799            python_version: Some("3.10".to_string()),
800            ..Config::default()
801        };
802
803        assert!(runtime_python_version(&config).is_err());
804    }
805
806    #[test]
807    fn test_post_import_log_module_cache_skips_previously_enabled_modules() {
808        let module_a = "r2x_test_cache_alpha";
809        let module_b = "r2x_test_cache_beta";
810
811        let pending = pending_post_import_log_modules(&[module_a, module_b]);
812        assert!(pending.contains(&module_a.to_string()));
813        assert!(pending.contains(&module_b.to_string()));
814
815        mark_post_import_log_modules_enabled(&[module_a.to_string()]);
816
817        let pending = pending_post_import_log_modules(&[module_a, module_b]);
818        assert!(!pending.contains(&module_a.to_string()));
819        assert!(pending.contains(&module_b.to_string()));
820    }
821
822    #[test]
823    fn test_is_python_executable_name_variants() {
824        assert!(is_python_executable_name("python"));
825        assert!(is_python_executable_name("python.exe"));
826        assert!(is_python_executable_name("python3"));
827        assert!(is_python_executable_name("python3.exe"));
828        assert!(is_python_executable_name("python3.12"));
829        assert!(is_python_executable_name("python3.12.exe"));
830        assert!(is_python_executable_name("PYTHON3.13.EXE"));
831        assert!(!is_python_executable_name("pythonw.exe"));
832        assert!(!is_python_executable_name("python-3.12.exe"));
833    }
834
835    #[test]
836    fn test_normalize_python_home_bin_dir() {
837        let home = PathBuf::from("/opt/python/bin");
838        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
839    }
840
841    #[test]
842    fn test_normalize_python_home_scripts_dir() {
843        let home = PathBuf::from("/opt/python/Scripts");
844        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
845    }
846
847    #[test]
848    fn test_normalize_python_home_python_executable() {
849        let home = PathBuf::from("/opt/python/python3.12");
850        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
851    }
852
853    #[test]
854    fn test_normalize_python_home_prefix_value() {
855        let home = PathBuf::from("/opt/python/cpython-3.12.9-windows-x86_64-none");
856        assert_eq!(normalize_python_home(&home), home);
857    }
858
859    #[test]
860    fn test_resolve_python_home_preserves_prefix_from_pyvenv_cfg() {
861        let Ok(temp_dir) = TempDir::new() else {
862            return;
863        };
864        let venv_path = temp_dir.path().join(".venv");
865        if fs::create_dir_all(&venv_path).is_err() {
866            return;
867        }
868
869        let expected_prefix = temp_dir.path().join("uv-python-prefix");
870        let pyvenv_cfg = format!("home = {}\n", expected_prefix.to_string_lossy());
871        if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
872            return;
873        }
874
875        let result = resolve_python_home(&venv_path);
876        assert!(result.is_ok());
877        assert!(result.is_ok_and(|path| path == expected_prefix));
878    }
879
880    #[test]
881    fn test_resolve_python_home_converts_bin_home_to_prefix() {
882        let Ok(temp_dir) = TempDir::new() else {
883            return;
884        };
885        let venv_path = temp_dir.path().join(".venv");
886        if fs::create_dir_all(&venv_path).is_err() {
887            return;
888        }
889
890        let expected_prefix = temp_dir.path().join("python-prefix");
891        let home_bin = expected_prefix.join("bin");
892        let pyvenv_cfg = format!("home = {}\n", home_bin.to_string_lossy());
893        if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
894            return;
895        }
896
897        let result = resolve_python_home(&venv_path);
898        assert!(result.is_ok());
899        assert!(result.is_ok_and(|path| path == expected_prefix));
900    }
901
902    #[test]
903    #[cfg(any(target_os = "linux", target_os = "macos"))]
904    fn test_find_python_lib_via_uv_falls_back_from_patch_to_abi_query() {
905        let Ok(_lock) = PATH_TEST_LOCK.lock() else {
906            return;
907        };
908        let Ok(temp_dir) = TempDir::new() else {
909            return;
910        };
911
912        let python_prefix = temp_dir.path().join("cpython-3.13");
913        let python_bin = python_prefix.join("bin").join("python3.13");
914        let lib_dir = python_prefix.join("lib");
915        if fs::create_dir_all(python_bin.parent().unwrap_or(temp_dir.path())).is_err() {
916            return;
917        }
918        if fs::create_dir_all(&lib_dir).is_err() {
919            return;
920        }
921        if fs::write(&python_bin, "").is_err() {
922            return;
923        }
924
925        let lib_name = if cfg!(target_os = "macos") {
926            "libpython3.13.dylib"
927        } else {
928            "libpython3.13.so"
929        };
930        if fs::write(lib_dir.join(lib_name), "").is_err() {
931            return;
932        }
933
934        let uv = temp_dir.path().join("uv");
935        if fs::write(
936            &uv,
937            format!(
938                "#!/usr/bin/env sh\nif [ \"$1\" = \"python\" ] && [ \"$2\" = \"find\" ] && [ \"$3\" = \"3.13.1\" ]; then\n  exit 1\nfi\nif [ \"$1\" = \"python\" ] && [ \"$2\" = \"find\" ] && [ \"$3\" = \"3.13\" ]; then\n  printf '{}\\n'\n  exit 0\nfi\nexit 1\n",
939                python_bin.display()
940            ),
941        )
942        .is_err()
943        {
944            return;
945        }
946        let Ok(metadata) = fs::metadata(&uv) else {
947            return;
948        };
949        let mut permissions = metadata.permissions();
950        permissions.set_mode(0o755);
951        if fs::set_permissions(&uv, permissions).is_err() {
952            return;
953        }
954
955        let original_path = env::var_os("PATH");
956        let mut path_entries = vec![temp_dir.path().to_path_buf()];
957        if let Some(existing) = original_path.as_ref() {
958            path_entries.extend(env::split_paths(existing));
959        }
960        let Ok(new_path) = env::join_paths(path_entries) else {
961            return;
962        };
963        env::set_var("PATH", &new_path);
964
965        let Ok(version) = PythonRuntimeVersion::parse("3.13.1") else {
966            return;
967        };
968        let found = find_python_lib_via_uv(&version, &[lib_name.to_string()]);
969
970        if let Some(path) = original_path {
971            env::set_var("PATH", path);
972        } else {
973            env::remove_var("PATH");
974        }
975
976        assert_eq!(found, Some(lib_dir));
977    }
978}