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