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.files").map_err(|e| {
263                BridgeError::Python(format!("Failed to import r2x_core.utils.files: {}", e))
264            })?;
265
266            file_ops
267                .setattr("get_r2x_cache_path", override_fn)
268                .map_err(|e| {
269                    BridgeError::Python(format!("Failed to override cache path: {}", e))
270                })?;
271
272            Ok::<(), BridgeError>(())
273        })?;
274
275        Ok(())
276    }
277
278    /// Configure Python loguru logging
279    ///
280    /// Always configures a file sink pointing to the shared r2x.log.
281    /// Optionally adds a console sink when --log-python is active.
282    fn configure_python_logging() -> Result<(), BridgeError> {
283        let verbosity = logger::get_verbosity();
284        let log_python = logger::get_log_python();
285        let log_file = logger::get_log_path_string();
286
287        logger::debug_lazy(|| {
288            format!(
289                "Configuring Python logging with verbosity={}, log_python={}, log_file={}",
290                verbosity, log_python, log_file
291            )
292        });
293
294        pyo3::Python::attach(|py| {
295            let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| {
296                BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e))
297            })?;
298            let setup_logging = logger_module
299                .getattr("setup_logging")
300                .map_err(|e| BridgeError::Python(format!("setup_logging not found: {}", e)))?;
301
302            let kwargs = PyDict::new(py);
303            if !log_file.is_empty() {
304                kwargs.set_item("log_file", &log_file)?;
305            }
306            kwargs.set_item("log_to_console", log_python)?;
307            setup_logging.call((verbosity,), Some(&kwargs))?;
308
309            Self::enable_loguru_modules(
310                py,
311                &[
312                    "r2x_core",
313                    "r2x_reeds",
314                    "r2x_plexos",
315                    "r2x_sienna",
316                    "r2x_nodal",
317                ],
318            )
319        })
320    }
321
322    /// Enable loguru logging for a list of Python modules
323    pub(crate) fn enable_loguru_modules(py: Python, modules: &[&str]) -> Result<(), BridgeError> {
324        let loguru = PyModule::import(py, "loguru")?;
325        let logger_obj = loguru.getattr("logger")?;
326
327        for module in modules {
328            logger_obj.call_method1("enable", (module,))?;
329        }
330
331        Ok(())
332    }
333
334    /// Enable loguru for plugin modules after Python has imported them.
335    ///
336    /// Many plugin packages call `logger.disable(__name__)` from their
337    /// `__init__.py`; enabling before import can be overwritten. Once a package
338    /// has been imported and re-enabled, later plugin invocations from that same
339    /// package do not need to import loguru or call enable again.
340    pub(crate) fn enable_loguru_modules_after_import(
341        py: Python,
342        modules: &[&str],
343    ) -> Result<(), BridgeError> {
344        let pending = pending_post_import_log_modules(modules);
345
346        if pending.is_empty() {
347            return Ok(());
348        }
349
350        let pending_refs = pending.iter().map(String::as_str).collect::<Vec<_>>();
351        Self::enable_loguru_modules(py, &pending_refs)?;
352
353        mark_post_import_log_modules_enabled(&pending);
354
355        Ok(())
356    }
357}
358
359fn pending_post_import_log_modules(modules: &[&str]) -> Vec<String> {
360    let enabled = POST_IMPORT_LOG_MODULES_ENABLED
361        .lock()
362        .unwrap_or_else(std::sync::PoisonError::into_inner);
363    modules
364        .iter()
365        .copied()
366        .filter(|module| !enabled.contains(*module))
367        .map(str::to_string)
368        .collect()
369}
370
371fn mark_post_import_log_modules_enabled(modules: &[String]) {
372    let mut enabled = POST_IMPORT_LOG_MODULES_ENABLED
373        .lock()
374        .unwrap_or_else(std::sync::PoisonError::into_inner);
375    enabled.extend(modules.iter().cloned());
376}
377
378/// Resolve PYTHONHOME from the venv's pyvenv.cfg file.
379///
380/// `home` in `pyvenv.cfg` is not fully consistent across creators/platforms:
381/// it may point at a prefix, a launcher dir (`bin`/`Scripts`), or an executable.
382/// We normalize it into a stable Python prefix for embedded startup.
383fn resolve_python_home(venv_path: &Path) -> Result<PathBuf, BridgeError> {
384    let pyvenv_cfg = venv_path.join("pyvenv.cfg");
385
386    if !pyvenv_cfg.exists() {
387        return Err(BridgeError::Initialization(format!(
388            "pyvenv.cfg not found in venv: {}",
389            venv_path.display()
390        )));
391    }
392
393    let content = fs::read_to_string(&pyvenv_cfg)
394        .map_err(|e| BridgeError::Initialization(format!("Failed to read pyvenv.cfg: {}", e)))?;
395
396    for line in content.lines() {
397        let line = line.trim();
398        if let Some((key, value)) = line.split_once('=') {
399            if key.trim().eq_ignore_ascii_case("home") {
400                let home_value = PathBuf::from(value.trim());
401                let python_home = normalize_python_home(&home_value);
402                logger::debug_lazy(|| {
403                    format!(
404                        "Resolved PYTHONHOME from pyvenv.cfg home={} -> {}",
405                        home_value.display(),
406                        python_home.display()
407                    )
408                });
409                return Ok(python_home);
410            }
411        }
412    }
413
414    Err(BridgeError::Initialization(format!(
415        "Could not find 'home' in pyvenv.cfg: {}",
416        pyvenv_cfg.display()
417    )))
418}
419
420fn normalize_python_home(home_value: &Path) -> PathBuf {
421    let Some(last_segment) = home_value.file_name().and_then(|name| name.to_str()) else {
422        return home_value.to_path_buf();
423    };
424
425    if is_python_executable_name(last_segment)
426        || last_segment.eq_ignore_ascii_case("bin")
427        || last_segment.eq_ignore_ascii_case("scripts")
428    {
429        if let Some(parent) = home_value.parent() {
430            return parent.to_path_buf();
431        }
432    }
433
434    home_value.to_path_buf()
435}
436
437fn is_python_executable_name(name: &str) -> bool {
438    if name.eq_ignore_ascii_case("python")
439        || name.eq_ignore_ascii_case("python.exe")
440        || name.eq_ignore_ascii_case("python3")
441        || name.eq_ignore_ascii_case("python3.exe")
442    {
443        return true;
444    }
445
446    let lower = name.to_ascii_lowercase();
447    if let Some(suffix) = lower.strip_prefix("python") {
448        let suffix = suffix.strip_suffix(".exe").unwrap_or(suffix);
449        if let Some(version) = suffix.strip_prefix('3') {
450            if version.is_empty() {
451                return true;
452            }
453            if let Some(dotless) = version.strip_prefix('.') {
454                return !dotless.is_empty() && dotless.chars().all(|ch| ch.is_ascii_digit());
455            }
456            return version.chars().all(|ch| ch.is_ascii_digit());
457        }
458    }
459
460    false
461}
462
463/// Select the Python runtime version for venv creation and library discovery.
464fn runtime_python_version(config: &Config) -> Result<PythonRuntimeVersion, BridgeError> {
465    config.runtime_python_version().map_err(|error| {
466        BridgeError::Initialization(format!("Invalid configured Python version: {}", error))
467    })
468}
469
470/// Check if Python library is available before attempting to initialize PyO3.
471///
472/// This provides better error messages than the cryptic dyld errors on macOS
473/// or DLL loading errors on Windows.
474fn check_python_library_available(
475    python_version: &PythonRuntimeVersion,
476) -> Result<(), BridgeError> {
477    #[cfg(any(target_os = "macos", target_os = "linux"))]
478    {
479        #[cfg(target_os = "macos")]
480        let (lib_names, search_paths, env_var) = (
481            vec![format!("libpython{}.dylib", python_version.abi())],
482            &[
483                "/opt/homebrew/lib",
484                "/usr/local/lib",
485                "/Library/Frameworks/Python.framework/Versions/Current/lib",
486            ][..],
487            "DYLD_LIBRARY_PATH",
488        );
489
490        #[cfg(target_os = "linux")]
491        let (lib_names, search_paths, env_var) = (
492            vec![
493                format!("libpython{}.so", python_version.abi()),
494                format!("libpython{}.so.1.0", python_version.abi()),
495            ],
496            &[
497                "/usr/lib",
498                "/usr/lib64",
499                "/usr/local/lib",
500                "/usr/local/lib64",
501            ][..],
502            "LD_LIBRARY_PATH",
503        );
504
505        // Check environment variable paths first
506        if let Ok(paths) = env::var(env_var) {
507            if find_lib_in_paths(paths.split(':'), &lib_names) {
508                return Ok(());
509            }
510        }
511
512        // Check standard system locations
513        if find_lib_in_paths(search_paths.iter().copied(), &lib_names) {
514            return Ok(());
515        }
516
517        // Try to find Python via uv and set up the library path
518        if let Some(lib_dir) = find_python_lib_via_uv(python_version, &lib_names) {
519            prepend_to_env_path(env_var, &lib_dir);
520            logger::debug_lazy(|| format!("Set {} to include: {}", env_var, lib_dir.display()));
521            return Ok(());
522        }
523
524        // Library not found in expected locations, but don't fail -
525        // let PyO3 try to load it via rpath or other mechanisms.
526        logger::debug("Python library not found in standard locations, relying on rpath");
527        Ok(())
528    }
529
530    #[cfg(target_os = "windows")]
531    {
532        // On Windows, try to set up the DLL path (best effort)
533        if let Err(e) = setup_windows_dll_path(python_version) {
534            logger::debug_lazy(|| format!("Windows DLL path setup note: {}", e));
535        }
536        Ok(())
537    }
538
539    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
540    {
541        // For other platforms, just proceed and let PyO3 handle it
542        Ok(())
543    }
544}
545
546/// Search for any of the library names in the given paths.
547/// Returns true if found, logging the discovery.
548#[cfg(any(target_os = "macos", target_os = "linux"))]
549fn find_lib_in_paths<I, S>(paths: I, lib_names: &[String]) -> bool
550where
551    I: Iterator<Item = S>,
552    S: AsRef<str>,
553{
554    for path in paths {
555        for lib_name in lib_names {
556            let lib_path = PathBuf::from(path.as_ref()).join(lib_name);
557            if lib_path.exists() {
558                logger::debug_lazy(|| format!("Found Python library at: {}", lib_path.display()));
559                return true;
560            }
561        }
562    }
563    false
564}
565
566/// Try to find Python library via uv python find command.
567/// Returns the lib directory path if found.
568#[cfg(any(target_os = "macos", target_os = "linux"))]
569fn find_python_lib_via_uv(
570    python_version: &PythonRuntimeVersion,
571    lib_names: &[String],
572) -> Option<PathBuf> {
573    for python_query in python_version.query_candidates() {
574        let output = Command::new("uv")
575            .args(["python", "find", python_query])
576            .output()
577            .ok()?;
578
579        if !output.status.success() {
580            continue;
581        }
582
583        let python_path = String::from_utf8_lossy(&output.stdout);
584        let python_path = python_path.trim();
585
586        // Python binary is in bin/, lib is in ../lib/
587        let lib_dir = PathBuf::from(python_path).parent()?.parent()?.join("lib");
588
589        for lib_name in lib_names {
590            let lib_path = lib_dir.join(lib_name);
591            if lib_path.exists() {
592                logger::debug_lazy(|| {
593                    format!("Found Python library via uv: {}", lib_path.display())
594                });
595                return Some(lib_dir);
596            }
597        }
598    }
599
600    None
601}
602
603/// Prepend a directory to an environment path variable.
604#[cfg(any(target_os = "macos", target_os = "linux"))]
605fn prepend_to_env_path(env_var: &str, dir: &Path) {
606    if let Some(existing) = env::var_os(env_var) {
607        let mut paths = env::split_paths(&existing).collect::<Vec<_>>();
608        paths.insert(0, dir.to_path_buf());
609        if let Ok(new_path) = env::join_paths(&paths) {
610            env::set_var(env_var, new_path);
611        }
612    } else {
613        env::set_var(env_var, dir);
614    }
615}
616
617/// Setup Windows DLL search path for Python
618#[cfg(target_os = "windows")]
619fn setup_windows_dll_path(python_version: &PythonRuntimeVersion) -> Result<(), BridgeError> {
620    let dll_name = format!("python{}.dll", python_version.abi().replace('.', ""));
621
622    // Try to find Python via uv first
623    for python_query in python_version.query_candidates() {
624        let output = Command::new("uv")
625            .args(["python", "find", python_query])
626            .output();
627
628        if let Ok(output) = output {
629            if output.status.success() {
630                let python_path = String::from_utf8_lossy(&output.stdout);
631                let python_path = python_path.trim();
632                if let Some(parent) = PathBuf::from(python_path).parent() {
633                    // On Windows, Python DLL is usually in the same directory as python.exe
634                    let dll_path = parent.join(&dll_name);
635                    if dll_path.exists() {
636                        // Add the directory to PATH so Windows can find the DLL
637                        if let Ok(current_path) = env::var("PATH") {
638                            let new_path = format!("{};{}", parent.display(), current_path);
639                            env::set_var("PATH", &new_path);
640                            logger::debug_lazy(|| {
641                                format!(
642                                    "Added {} to PATH for Python DLL discovery",
643                                    parent.display()
644                                )
645                            });
646                            return Ok(());
647                        }
648                    }
649                }
650            }
651        }
652    }
653
654    // Try to find Python in PATH
655    if let Ok(output) = Command::new("where").arg("python").output() {
656        if output.status.success() {
657            let python_path = String::from_utf8_lossy(&output.stdout);
658            if let Some(first_line) = python_path.lines().next() {
659                if let Some(parent) = PathBuf::from(first_line.trim()).parent() {
660                    let dll_path = parent.join(&dll_name);
661                    if dll_path.exists() {
662                        logger::debug_lazy(|| {
663                            format!("Found Python DLL at: {}", dll_path.display())
664                        });
665                        return Ok(());
666                    }
667                }
668            }
669        }
670    }
671
672    let find_hint = python_version.find_hint();
673    let install_hint = python_version.install_hint();
674    Err(BridgeError::PythonLibraryNotFound(format!(
675        "Could not find {}.\n\n\
676        This binary requires Python {} to be installed.\n\n\
677        To fix this on Windows:\n\
678        1. Install Python via uv: {}\n\
679        2. Or download from https://www.python.org/downloads/\n\
680        3. Ensure Python is in your PATH\n\n\
681        If you installed Python via uv, try running:\n\
682           {}",
683        dll_name,
684        python_version.requested(),
685        install_hint,
686        find_hint
687    )))
688}
689
690/// Configure the Python virtual environment (legacy API compatibility)
691pub fn configure_python_venv() -> Result<PythonEnvCompat, BridgeError> {
692    let config = Config::load()
693        .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
694
695    let venv_path = PathBuf::from(config.get_venv_path());
696
697    let interpreter = resolve_python_path(&venv_path)?;
698    let python_home = resolve_python_home(&venv_path).ok();
699
700    Ok(PythonEnvCompat {
701        interpreter,
702        python_home,
703    })
704}
705
706/// Legacy compatibility struct for PythonEnvironment
707#[derive(Debug, Clone)]
708pub struct PythonEnvCompat {
709    pub interpreter: PathBuf,
710    pub python_home: Option<PathBuf>,
711}
712
713#[cfg(test)]
714mod tests {
715    use crate::python_bridge::*;
716    use r2x_config::{default_python_version, PythonRuntimeVersion};
717    #[cfg(unix)]
718    use std::env;
719    use std::fs;
720    #[cfg(unix)]
721    use std::os::unix::fs::PermissionsExt;
722    use tempfile::TempDir;
723
724    #[cfg(unix)]
725    static PATH_TEST_LOCK: once_cell::sync::Lazy<std::sync::Mutex<()>> =
726        once_cell::sync::Lazy::new(|| std::sync::Mutex::new(()));
727
728    #[test]
729    fn test_bridge_struct() {
730        // Test that Bridge can be created
731        let _bridge = Bridge { _marker: () };
732    }
733
734    #[test]
735    fn test_runtime_python_version_defaults_to_build_python_version() {
736        let config = Config::default();
737        assert_eq!(
738            runtime_python_version(&config)
739                .ok()
740                .map(|version| (version.requested().to_string(), version.abi().to_string())),
741            Some((
742                default_python_version().to_string(),
743                default_python_version().to_string()
744            ))
745        );
746    }
747
748    #[test]
749    fn test_runtime_python_version_uses_configured_version() {
750        let config = Config {
751            python_version: Some("3.13".to_string()),
752            ..Config::default()
753        };
754        assert_eq!(
755            runtime_python_version(&config)
756                .ok()
757                .map(|version| (version.requested().to_string(), version.abi().to_string())),
758            Some(("3.13".to_string(), "3.13".to_string()))
759        );
760    }
761
762    #[test]
763    fn test_runtime_python_version_keeps_patch_request_but_uses_minor_abi() {
764        let config = Config {
765            python_version: Some("3.13.1".to_string()),
766            ..Config::default()
767        };
768        assert_eq!(
769            runtime_python_version(&config)
770                .ok()
771                .map(|version| (version.requested().to_string(), version.abi().to_string())),
772            Some(("3.13.1".to_string(), "3.13".to_string()))
773        );
774    }
775
776    #[test]
777    fn test_runtime_python_version_ignores_blank_config_value() {
778        let config = Config {
779            python_version: Some("  ".to_string()),
780            ..Config::default()
781        };
782        assert_eq!(
783            runtime_python_version(&config)
784                .ok()
785                .map(|version| (version.requested().to_string(), version.abi().to_string())),
786            Some((
787                default_python_version().to_string(),
788                default_python_version().to_string()
789            ))
790        );
791    }
792
793    #[test]
794    fn test_runtime_python_version_rejects_unsupported_configured_version() {
795        let config = Config {
796            python_version: Some("3.10".to_string()),
797            ..Config::default()
798        };
799
800        assert!(runtime_python_version(&config).is_err());
801    }
802
803    #[test]
804    fn test_post_import_log_module_cache_skips_previously_enabled_modules() {
805        let module_a = "r2x_test_cache_alpha";
806        let module_b = "r2x_test_cache_beta";
807
808        let pending = pending_post_import_log_modules(&[module_a, module_b]);
809        assert!(pending.contains(&module_a.to_string()));
810        assert!(pending.contains(&module_b.to_string()));
811
812        mark_post_import_log_modules_enabled(&[module_a.to_string()]);
813
814        let pending = pending_post_import_log_modules(&[module_a, module_b]);
815        assert!(!pending.contains(&module_a.to_string()));
816        assert!(pending.contains(&module_b.to_string()));
817    }
818
819    #[test]
820    fn test_is_python_executable_name_variants() {
821        assert!(is_python_executable_name("python"));
822        assert!(is_python_executable_name("python.exe"));
823        assert!(is_python_executable_name("python3"));
824        assert!(is_python_executable_name("python3.exe"));
825        assert!(is_python_executable_name("python3.12"));
826        assert!(is_python_executable_name("python3.12.exe"));
827        assert!(is_python_executable_name("PYTHON3.13.EXE"));
828        assert!(!is_python_executable_name("pythonw.exe"));
829        assert!(!is_python_executable_name("python-3.12.exe"));
830    }
831
832    #[test]
833    fn test_normalize_python_home_bin_dir() {
834        let home = PathBuf::from("/opt/python/bin");
835        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
836    }
837
838    #[test]
839    fn test_normalize_python_home_scripts_dir() {
840        let home = PathBuf::from("/opt/python/Scripts");
841        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
842    }
843
844    #[test]
845    fn test_normalize_python_home_python_executable() {
846        let home = PathBuf::from("/opt/python/python3.12");
847        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
848    }
849
850    #[test]
851    fn test_normalize_python_home_prefix_value() {
852        let home = PathBuf::from("/opt/python/cpython-3.12.9-windows-x86_64-none");
853        assert_eq!(normalize_python_home(&home), home);
854    }
855
856    #[test]
857    fn test_resolve_python_home_preserves_prefix_from_pyvenv_cfg() {
858        let Ok(temp_dir) = TempDir::new() else {
859            return;
860        };
861        let venv_path = temp_dir.path().join(".venv");
862        if fs::create_dir_all(&venv_path).is_err() {
863            return;
864        }
865
866        let expected_prefix = temp_dir.path().join("uv-python-prefix");
867        let pyvenv_cfg = format!("home = {}\n", expected_prefix.to_string_lossy());
868        if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
869            return;
870        }
871
872        let result = resolve_python_home(&venv_path);
873        assert!(result.is_ok());
874        assert!(result.is_ok_and(|path| path == expected_prefix));
875    }
876
877    #[test]
878    fn test_resolve_python_home_converts_bin_home_to_prefix() {
879        let Ok(temp_dir) = TempDir::new() else {
880            return;
881        };
882        let venv_path = temp_dir.path().join(".venv");
883        if fs::create_dir_all(&venv_path).is_err() {
884            return;
885        }
886
887        let expected_prefix = temp_dir.path().join("python-prefix");
888        let home_bin = expected_prefix.join("bin");
889        let pyvenv_cfg = format!("home = {}\n", home_bin.to_string_lossy());
890        if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
891            return;
892        }
893
894        let result = resolve_python_home(&venv_path);
895        assert!(result.is_ok());
896        assert!(result.is_ok_and(|path| path == expected_prefix));
897    }
898
899    #[test]
900    #[cfg(any(target_os = "linux", target_os = "macos"))]
901    fn test_find_python_lib_via_uv_falls_back_from_patch_to_abi_query() {
902        let Ok(_lock) = PATH_TEST_LOCK.lock() else {
903            return;
904        };
905        let Ok(temp_dir) = TempDir::new() else {
906            return;
907        };
908
909        let python_prefix = temp_dir.path().join("cpython-3.13");
910        let python_bin = python_prefix.join("bin").join("python3.13");
911        let lib_dir = python_prefix.join("lib");
912        if fs::create_dir_all(python_bin.parent().unwrap_or(temp_dir.path())).is_err() {
913            return;
914        }
915        if fs::create_dir_all(&lib_dir).is_err() {
916            return;
917        }
918        if fs::write(&python_bin, "").is_err() {
919            return;
920        }
921
922        let lib_name = if cfg!(target_os = "macos") {
923            "libpython3.13.dylib"
924        } else {
925            "libpython3.13.so"
926        };
927        if fs::write(lib_dir.join(lib_name), "").is_err() {
928            return;
929        }
930
931        let uv = temp_dir.path().join("uv");
932        if fs::write(
933            &uv,
934            format!(
935                "#!/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",
936                python_bin.display()
937            ),
938        )
939        .is_err()
940        {
941            return;
942        }
943        let Ok(metadata) = fs::metadata(&uv) else {
944            return;
945        };
946        let mut permissions = metadata.permissions();
947        permissions.set_mode(0o755);
948        if fs::set_permissions(&uv, permissions).is_err() {
949            return;
950        }
951
952        let original_path = env::var_os("PATH");
953        let mut path_entries = vec![temp_dir.path().to_path_buf()];
954        if let Some(existing) = original_path.as_ref() {
955            path_entries.extend(env::split_paths(existing));
956        }
957        let Ok(new_path) = env::join_paths(path_entries) else {
958            return;
959        };
960        env::set_var("PATH", &new_path);
961
962        let Ok(version) = PythonRuntimeVersion::parse("3.13.1") else {
963            return;
964        };
965        let found = find_python_lib_via_uv(&version, &[lib_name.to_string()]);
966
967        if let Some(path) = original_path {
968            env::set_var("PATH", path);
969        } else {
970            env::remove_var("PATH");
971        }
972
973        assert_eq!(found, Some(lib_dir));
974    }
975}