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