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