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::OnceCell;
16use pyo3::prelude::*;
17use pyo3::types::{PyDict, PyModule};
18use r2x_config::Config;
19use r2x_logger as logger;
20use std::env;
21use std::fs;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25/// The Python bridge for plugin execution
26pub struct Bridge {
27    /// Placeholder field for future extension
28    _marker: (),
29}
30
31/// Global bridge singleton
32static BRIDGE_INSTANCE: OnceCell<Result<Bridge, BridgeError>> = OnceCell::new();
33
34impl Bridge {
35    /// Get or initialize the bridge singleton
36    pub fn get() -> Result<&'static Bridge, BridgeError> {
37        match BRIDGE_INSTANCE.get_or_init(Bridge::initialize) {
38            Ok(bridge) => Ok(bridge),
39            Err(e) => Err(BridgeError::Initialization(format!("{}", e))),
40        }
41    }
42
43    /// Check if Python is available without initializing
44    pub fn is_python_available() -> bool {
45        let config = match Config::load() {
46            Ok(c) => c,
47            Err(_) => return false,
48        };
49
50        // Check if venv exists and has valid pyvenv.cfg
51        let venv_path = PathBuf::from(config.get_venv_path());
52        venv_path.join("pyvenv.cfg").exists()
53    }
54
55    /// Initialize Python interpreter and configure environment
56    ///
57    /// This performs:
58    /// 1. Ensure venv exists (create if needed)
59    /// 2. Resolve PYTHONHOME from venv's pyvenv.cfg
60    /// 3. Set PYTHONHOME and initialize PyO3
61    /// 4. Configure site-packages
62    fn initialize() -> Result<Bridge, BridgeError> {
63        let start_time = std::time::Instant::now();
64
65        let mut config = Config::load()
66            .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
67
68        // Ensure venv exists
69        let venv_path = PathBuf::from(config.get_venv_path());
70
71        if !venv_path.exists() {
72            // Create venv using the compiled Python version
73            Self::create_venv(&config, &venv_path)?;
74        }
75
76        // Resolve PYTHONHOME from venv's pyvenv.cfg
77        let python_home = resolve_python_home(&venv_path)?;
78        env::set_var("PYTHONHOME", &python_home);
79        logger::debug(&format!("Set PYTHONHOME={}", python_home.display()));
80
81        // Get site-packages path
82        let site_packages = resolve_site_package_path(&venv_path)?;
83
84        // Add site-packages to PYTHONPATH
85        Self::configure_python_path(&site_packages);
86
87        // Check if Python library is available before initializing
88        check_python_library_available()?;
89
90        // Initialize PyO3
91        logger::debug("Initializing PyO3...");
92        let pyo3_start = std::time::Instant::now();
93        pyo3::Python::initialize();
94        logger::debug(&format!(
95            "pyo3::Python::initialize took: {:?}",
96            pyo3_start.elapsed()
97        ));
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(&format!(
131            "Total bridge initialization took: {:?}",
132            start_time.elapsed()
133        ));
134
135        Ok(Bridge { _marker: () })
136    }
137
138    /// Create a virtual environment
139    ///
140    /// Uses the compiled Python version to ensure compatibility with PyO3.
141    fn create_venv(config: &Config, venv_path: &PathBuf) -> Result<(), BridgeError> {
142        logger::step(&format!(
143            "Creating Python virtual environment at: {}",
144            venv_path.display()
145        ));
146
147        let python_version = get_compiled_python_version();
148
149        // Try uv first
150        if let Some(ref uv_path) = config.uv_path {
151            let output = Command::new(uv_path)
152                .arg("venv")
153                .arg(venv_path)
154                .arg("--python")
155                .arg(&python_version)
156                .output()?;
157
158            if output.status.success() {
159                logger::success("Virtual environment created successfully");
160                return Ok(());
161            }
162
163            let stderr = String::from_utf8_lossy(&output.stderr);
164            logger::debug(&format!("uv venv failed: {}", stderr));
165        }
166
167        // Fallback to python3 -m venv
168        let python_cmd = format!("python{}", python_version);
169        let output = Command::new(&python_cmd)
170            .args(["-m", "venv"])
171            .arg(venv_path)
172            .output();
173
174        if let Ok(output) = output {
175            if output.status.success() {
176                logger::success("Virtual environment created successfully");
177                return Ok(());
178            }
179        }
180
181        // Try generic python3
182        let output = Command::new("python3")
183            .args(["-m", "venv"])
184            .arg(venv_path)
185            .output()?;
186
187        if !output.status.success() {
188            let stderr = String::from_utf8_lossy(&output.stderr);
189            return Err(BridgeError::Initialization(format!(
190                "Failed to create virtual environment: {}",
191                stderr
192            )));
193        }
194
195        logger::success("Virtual environment created successfully");
196        Ok(())
197    }
198
199    /// Configure PYTHONPATH to include site-packages
200    fn configure_python_path(site_packages: &Path) {
201        let mut paths = vec![site_packages.to_path_buf()];
202        if let Some(existing) = env::var_os("PYTHONPATH") {
203            if !existing.is_empty() {
204                paths.extend(env::split_paths(&existing));
205            }
206        }
207        if let Ok(joined) = env::join_paths(paths) {
208            env::set_var("PYTHONPATH", &joined);
209            logger::debug(&format!(
210                "Updated PYTHONPATH to include {}",
211                site_packages.display()
212            ));
213        }
214    }
215
216    /// Configure Python cache path override
217    fn configure_python_cache(cache_path: &str) -> Result<(), BridgeError> {
218        std::fs::create_dir_all(cache_path).map_err(|e| {
219            BridgeError::Initialization(format!("Failed to create cache directory: {}", e))
220        })?;
221        env::set_var("R2X_CACHE_PATH", cache_path);
222
223        let cache_path_escaped = cache_path.replace('\\', "\\\\");
224        pyo3::Python::attach(|py| {
225            let patch_code = format!(
226                r#"from pathlib import Path
227_R2X_CACHE_PATH = Path(r"{cache}")
228
229def _r2x_cache_path_override():
230    return _R2X_CACHE_PATH
231"#,
232                cache = cache_path_escaped
233            );
234
235            let code_cstr = std::ffi::CString::new(patch_code).map_err(|e| {
236                BridgeError::Python(format!("Failed to prepare cache override script: {}", e))
237            })?;
238            let filename = std::ffi::CString::new("r2x_cache_patch.py")
239                .map_err(|e| BridgeError::Python(format!("Failed to create filename: {}", e)))?;
240            let module_name = std::ffi::CString::new("r2x_cache_patch")
241                .map_err(|e| BridgeError::Python(format!("Failed to create module name: {}", e)))?;
242            let patch_module = PyModule::from_code(
243                py,
244                code_cstr.as_c_str(),
245                filename.as_c_str(),
246                module_name.as_c_str(),
247            )
248            .map_err(|e| BridgeError::Python(format!("Failed to build cache override: {}", e)))?;
249
250            let override_fn = patch_module
251                .getattr("_r2x_cache_path_override")
252                .map_err(|e| {
253                    BridgeError::Python(format!("Failed to obtain cache override function: {}", e))
254                })?;
255
256            let file_ops = PyModule::import(py, "r2x_core.utils.file_operations").map_err(|e| {
257                BridgeError::Python(format!(
258                    "Failed to import r2x_core.utils.file_operations: {}",
259                    e
260                ))
261            })?;
262
263            file_ops
264                .setattr("get_r2x_cache_path", override_fn)
265                .map_err(|e| {
266                    BridgeError::Python(format!("Failed to override cache path: {}", e))
267                })?;
268
269            Ok::<(), BridgeError>(())
270        })?;
271
272        Ok(())
273    }
274
275    /// Configure Python loguru logging
276    ///
277    /// Always configures a file sink pointing to the shared r2x.log.
278    /// Optionally adds a console sink when --log-python is active.
279    fn configure_python_logging() -> Result<(), BridgeError> {
280        let verbosity = logger::get_verbosity();
281        let log_python = logger::get_log_python();
282        let log_file = logger::get_log_path_string();
283
284        logger::debug(&format!(
285            "Configuring Python logging with verbosity={}, log_python={}, log_file={}",
286            verbosity, log_python, log_file
287        ));
288
289        pyo3::Python::attach(|py| {
290            let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| {
291                BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e))
292            })?;
293            let setup_logging = logger_module
294                .getattr("setup_logging")
295                .map_err(|e| BridgeError::Python(format!("setup_logging not found: {}", e)))?;
296
297            let kwargs = PyDict::new(py);
298            if !log_file.is_empty() {
299                kwargs.set_item("log_file", &log_file)?;
300            }
301            kwargs.set_item("log_to_console", log_python)?;
302            setup_logging.call((verbosity,), Some(&kwargs))?;
303
304            Self::enable_loguru_modules(
305                py,
306                &[
307                    "r2x_core",
308                    "r2x_reeds",
309                    "r2x_plexos",
310                    "r2x_sienna",
311                    "r2x_nodal",
312                ],
313            )
314        })
315    }
316
317    /// Enable loguru logging for a list of Python modules
318    pub(crate) fn enable_loguru_modules(py: Python, modules: &[&str]) -> Result<(), BridgeError> {
319        let loguru = PyModule::import(py, "loguru")?;
320        let logger_obj = loguru.getattr("logger")?;
321
322        for module in modules {
323            logger_obj.call_method1("enable", (module,))?;
324        }
325
326        Ok(())
327    }
328
329    /// Reconfigure Python logging for a specific plugin.
330    ///
331    /// Enables loguru for the plugin's Python module in addition to
332    /// the base set of packages. The plugin_name can be either a
333    /// fully-qualified ref like "r2x-nodal.zonal-to-nodal" (the
334    /// package prefix before the dot is used) or a bare name.
335    pub fn reconfigure_logging_for_plugin(plugin_name: &str) -> Result<(), BridgeError> {
336        Self::configure_python_logging()?;
337
338        // Extract the package portion (before the first dot) and convert
339        // hyphens to underscores so "r2x-nodal.zonal-to-nodal" becomes
340        // "r2x_nodal", matching the Python module name.
341        let package_part = plugin_name.split('.').next().unwrap_or(plugin_name);
342        let module_name = package_part.replace('-', "_");
343
344        pyo3::Python::attach(|py| Self::enable_loguru_modules(py, &[&module_name]))
345    }
346}
347
348/// Resolve PYTHONHOME from the venv's pyvenv.cfg file.
349///
350/// `home` in `pyvenv.cfg` is not fully consistent across creators/platforms:
351/// it may point at a prefix, a launcher dir (`bin`/`Scripts`), or an executable.
352/// We normalize it into a stable Python prefix for embedded startup.
353fn resolve_python_home(venv_path: &Path) -> Result<PathBuf, BridgeError> {
354    let pyvenv_cfg = venv_path.join("pyvenv.cfg");
355
356    if !pyvenv_cfg.exists() {
357        return Err(BridgeError::Initialization(format!(
358            "pyvenv.cfg not found in venv: {}",
359            venv_path.display()
360        )));
361    }
362
363    let content = fs::read_to_string(&pyvenv_cfg)
364        .map_err(|e| BridgeError::Initialization(format!("Failed to read pyvenv.cfg: {}", e)))?;
365
366    for line in content.lines() {
367        let line = line.trim();
368        if let Some((key, value)) = line.split_once('=') {
369            if key.trim().eq_ignore_ascii_case("home") {
370                let home_value = PathBuf::from(value.trim());
371                let python_home = normalize_python_home(&home_value);
372                logger::debug(&format!(
373                    "Resolved PYTHONHOME from pyvenv.cfg home={} -> {}",
374                    home_value.display(),
375                    python_home.display()
376                ));
377                return Ok(python_home);
378            }
379        }
380    }
381
382    Err(BridgeError::Initialization(format!(
383        "Could not find 'home' in pyvenv.cfg: {}",
384        pyvenv_cfg.display()
385    )))
386}
387
388fn normalize_python_home(home_value: &Path) -> PathBuf {
389    let Some(last_segment) = home_value.file_name().and_then(|name| name.to_str()) else {
390        return home_value.to_path_buf();
391    };
392
393    if is_python_executable_name(last_segment)
394        || last_segment.eq_ignore_ascii_case("bin")
395        || last_segment.eq_ignore_ascii_case("scripts")
396    {
397        if let Some(parent) = home_value.parent() {
398            return parent.to_path_buf();
399        }
400    }
401
402    home_value.to_path_buf()
403}
404
405fn is_python_executable_name(name: &str) -> bool {
406    if name.eq_ignore_ascii_case("python")
407        || name.eq_ignore_ascii_case("python.exe")
408        || name.eq_ignore_ascii_case("python3")
409        || name.eq_ignore_ascii_case("python3.exe")
410    {
411        return true;
412    }
413
414    let lower = name.to_ascii_lowercase();
415    if let Some(suffix) = lower.strip_prefix("python") {
416        let suffix = suffix.strip_suffix(".exe").unwrap_or(suffix);
417        if let Some(version) = suffix.strip_prefix('3') {
418            if version.is_empty() {
419                return true;
420            }
421            if let Some(dotless) = version.strip_prefix('.') {
422                return !dotless.is_empty() && dotless.chars().all(|ch| ch.is_ascii_digit());
423            }
424            return version.chars().all(|ch| ch.is_ascii_digit());
425        }
426    }
427
428    false
429}
430
431/// Get the Python version that PyO3 was compiled against
432///
433/// Returns the version string (e.g., "3.12") based on PyO3's abi3 feature.
434/// This should match the PYO3_PYTHON environment variable used during build.
435fn get_compiled_python_version() -> String {
436    // PyO3 with abi3-py311 is compatible with Python 3.11+
437    // The actual version depends on PYO3_PYTHON at build time
438    // Default to 3.12 which is the version in the justfile
439    "3.12".to_string()
440}
441
442/// Check if Python library is available before attempting to initialize PyO3.
443///
444/// This provides better error messages than the cryptic dyld errors on macOS
445/// or DLL loading errors on Windows.
446fn check_python_library_available() -> Result<(), BridgeError> {
447    #[cfg(any(target_os = "macos", target_os = "linux"))]
448    {
449        let python_version = get_compiled_python_version();
450
451        #[cfg(target_os = "macos")]
452        let (lib_names, search_paths, env_var) = (
453            vec![format!("libpython{}.dylib", python_version)],
454            &[
455                "/opt/homebrew/lib",
456                "/usr/local/lib",
457                "/Library/Frameworks/Python.framework/Versions/Current/lib",
458            ][..],
459            "DYLD_LIBRARY_PATH",
460        );
461
462        #[cfg(target_os = "linux")]
463        let (lib_names, search_paths, env_var) = (
464            vec![
465                format!("libpython{}.so", python_version),
466                format!("libpython{}.so.1.0", python_version),
467            ],
468            &[
469                "/usr/lib",
470                "/usr/lib64",
471                "/usr/local/lib",
472                "/usr/local/lib64",
473            ][..],
474            "LD_LIBRARY_PATH",
475        );
476
477        // Check environment variable paths first
478        if let Ok(paths) = env::var(env_var) {
479            if find_lib_in_paths(paths.split(':'), &lib_names) {
480                return Ok(());
481            }
482        }
483
484        // Check standard system locations
485        if find_lib_in_paths(search_paths.iter().copied(), &lib_names) {
486            return Ok(());
487        }
488
489        // Try to find Python via uv and set up the library path
490        if let Some(lib_dir) = find_python_lib_via_uv(&python_version, &lib_names) {
491            prepend_to_env_path(env_var, &lib_dir);
492            logger::debug(&format!(
493                "Set {} to include: {}",
494                env_var,
495                lib_dir.display()
496            ));
497            return Ok(());
498        }
499
500        // Library not found in expected locations, but don't fail -
501        // let PyO3 try to load it via rpath or other mechanisms.
502        logger::debug("Python library not found in standard locations, relying on rpath");
503        Ok(())
504    }
505
506    #[cfg(target_os = "windows")]
507    {
508        // On Windows, try to set up the DLL path (best effort)
509        if let Err(e) = setup_windows_dll_path() {
510            logger::debug(&format!("Windows DLL path setup note: {}", e));
511        }
512        Ok(())
513    }
514
515    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
516    {
517        // For other platforms, just proceed and let PyO3 handle it
518        Ok(())
519    }
520}
521
522/// Search for any of the library names in the given paths.
523/// Returns true if found, logging the discovery.
524#[cfg(any(target_os = "macos", target_os = "linux"))]
525fn find_lib_in_paths<I, S>(paths: I, lib_names: &[String]) -> bool
526where
527    I: Iterator<Item = S>,
528    S: AsRef<str>,
529{
530    for path in paths {
531        for lib_name in lib_names {
532            let lib_path = PathBuf::from(path.as_ref()).join(lib_name);
533            if lib_path.exists() {
534                logger::debug(&format!("Found Python library at: {}", lib_path.display()));
535                return true;
536            }
537        }
538    }
539    false
540}
541
542/// Try to find Python library via uv python find command.
543/// Returns the lib directory path if found.
544#[cfg(any(target_os = "macos", target_os = "linux"))]
545fn find_python_lib_via_uv(python_version: &str, lib_names: &[String]) -> Option<PathBuf> {
546    let output = Command::new("uv")
547        .args(["python", "find", python_version])
548        .output()
549        .ok()?;
550
551    if !output.status.success() {
552        return None;
553    }
554
555    let python_path = String::from_utf8_lossy(&output.stdout);
556    let python_path = python_path.trim();
557
558    // Python binary is in bin/, lib is in ../lib/
559    let lib_dir = PathBuf::from(python_path).parent()?.parent()?.join("lib");
560
561    for lib_name in lib_names {
562        let lib_path = lib_dir.join(lib_name);
563        if lib_path.exists() {
564            logger::debug(&format!(
565                "Found Python library via uv: {}",
566                lib_path.display()
567            ));
568            return Some(lib_dir);
569        }
570    }
571
572    None
573}
574
575/// Prepend a directory to an environment path variable.
576#[cfg(any(target_os = "macos", target_os = "linux"))]
577fn prepend_to_env_path(env_var: &str, dir: &Path) {
578    if let Some(existing) = env::var_os(env_var) {
579        let mut paths = env::split_paths(&existing).collect::<Vec<_>>();
580        paths.insert(0, dir.to_path_buf());
581        if let Ok(new_path) = env::join_paths(&paths) {
582            env::set_var(env_var, new_path);
583        }
584    } else {
585        env::set_var(env_var, dir);
586    }
587}
588
589/// Setup Windows DLL search path for Python
590#[cfg(target_os = "windows")]
591fn setup_windows_dll_path() -> Result<(), BridgeError> {
592    let python_version = get_compiled_python_version();
593    let dll_name = format!("python{}.dll", python_version.replace(".", ""));
594
595    // Try to find Python via uv first
596    let output = Command::new("uv")
597        .args(["python", "find", &python_version])
598        .output();
599
600    if let Ok(output) = output {
601        if output.status.success() {
602            let python_path = String::from_utf8_lossy(&output.stdout);
603            let python_path = python_path.trim();
604            if let Some(parent) = PathBuf::from(python_path).parent() {
605                // On Windows, Python DLL is usually in the same directory as python.exe
606                let dll_path = parent.join(&dll_name);
607                if dll_path.exists() {
608                    // Add the directory to PATH so Windows can find the DLL
609                    if let Ok(current_path) = env::var("PATH") {
610                        let new_path = format!("{};{}", parent.display(), current_path);
611                        env::set_var("PATH", &new_path);
612                        logger::debug(&format!(
613                            "Added {} to PATH for Python DLL discovery",
614                            parent.display()
615                        ));
616                        return Ok(());
617                    }
618                }
619            }
620        }
621    }
622
623    // Try to find Python in PATH
624    if let Ok(output) = Command::new("where").arg("python").output() {
625        if output.status.success() {
626            let python_path = String::from_utf8_lossy(&output.stdout);
627            if let Some(first_line) = python_path.lines().next() {
628                if let Some(parent) = PathBuf::from(first_line.trim()).parent() {
629                    let dll_path = parent.join(&dll_name);
630                    if dll_path.exists() {
631                        logger::debug(&format!("Found Python DLL at: {}", dll_path.display()));
632                        return Ok(());
633                    }
634                }
635            }
636        }
637    }
638
639    Err(BridgeError::PythonLibraryNotFound(format!(
640        "Could not find {}.\n\n\
641        This binary requires Python {} to be installed.\n\n\
642        To fix this on Windows:\n\
643        1. Install Python via uv: uv python install {}\n\
644        2. Or download from https://www.python.org/downloads/\n\
645        3. Ensure Python is in your PATH\n\n\
646        If you installed Python via uv, try running:\n\
647           uv python find {}",
648        dll_name, python_version, python_version, python_version
649    )))
650}
651
652/// Configure the Python virtual environment (legacy API compatibility)
653pub fn configure_python_venv() -> Result<PythonEnvCompat, BridgeError> {
654    let config = Config::load()
655        .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
656
657    let venv_path = PathBuf::from(config.get_venv_path());
658
659    let interpreter = resolve_python_path(&venv_path)?;
660    let python_home = resolve_python_home(&venv_path).ok();
661
662    Ok(PythonEnvCompat {
663        interpreter,
664        python_home,
665    })
666}
667
668/// Legacy compatibility struct for PythonEnvironment
669#[derive(Debug, Clone)]
670pub struct PythonEnvCompat {
671    pub interpreter: PathBuf,
672    pub python_home: Option<PathBuf>,
673}
674
675#[cfg(test)]
676mod tests {
677    use crate::python_bridge::*;
678    use std::fs;
679    use tempfile::TempDir;
680
681    #[test]
682    fn test_bridge_struct() {
683        // Test that Bridge can be created
684        let _bridge = Bridge { _marker: () };
685    }
686
687    #[test]
688    fn test_get_compiled_python_version() {
689        let version = get_compiled_python_version();
690        assert!(version.starts_with("3."));
691    }
692
693    #[test]
694    fn test_is_python_executable_name_variants() {
695        assert!(is_python_executable_name("python"));
696        assert!(is_python_executable_name("python.exe"));
697        assert!(is_python_executable_name("python3"));
698        assert!(is_python_executable_name("python3.exe"));
699        assert!(is_python_executable_name("python3.12"));
700        assert!(is_python_executable_name("python3.12.exe"));
701        assert!(is_python_executable_name("PYTHON3.13.EXE"));
702        assert!(!is_python_executable_name("pythonw.exe"));
703        assert!(!is_python_executable_name("python-3.12.exe"));
704    }
705
706    #[test]
707    fn test_normalize_python_home_bin_dir() {
708        let home = PathBuf::from("/opt/python/bin");
709        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
710    }
711
712    #[test]
713    fn test_normalize_python_home_scripts_dir() {
714        let home = PathBuf::from("/opt/python/Scripts");
715        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
716    }
717
718    #[test]
719    fn test_normalize_python_home_python_executable() {
720        let home = PathBuf::from("/opt/python/python3.12");
721        assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
722    }
723
724    #[test]
725    fn test_normalize_python_home_prefix_value() {
726        let home = PathBuf::from("/opt/python/cpython-3.12.9-windows-x86_64-none");
727        assert_eq!(normalize_python_home(&home), home);
728    }
729
730    #[test]
731    fn test_resolve_python_home_preserves_prefix_from_pyvenv_cfg() {
732        let Ok(temp_dir) = TempDir::new() else {
733            return;
734        };
735        let venv_path = temp_dir.path().join(".venv");
736        if fs::create_dir_all(&venv_path).is_err() {
737            return;
738        }
739
740        let expected_prefix = temp_dir.path().join("uv-python-prefix");
741        let pyvenv_cfg = format!("home = {}\n", expected_prefix.to_string_lossy());
742        if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
743            return;
744        }
745
746        let result = resolve_python_home(&venv_path);
747        assert!(result.is_ok());
748        assert!(result.is_ok_and(|path| path == expected_prefix));
749    }
750
751    #[test]
752    fn test_resolve_python_home_converts_bin_home_to_prefix() {
753        let Ok(temp_dir) = TempDir::new() else {
754            return;
755        };
756        let venv_path = temp_dir.path().join(".venv");
757        if fs::create_dir_all(&venv_path).is_err() {
758            return;
759        }
760
761        let expected_prefix = temp_dir.path().join("python-prefix");
762        let home_bin = expected_prefix.join("bin");
763        let pyvenv_cfg = format!("home = {}\n", home_bin.to_string_lossy());
764        if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
765            return;
766        }
767
768        let result = resolve_python_home(&venv_path);
769        assert!(result.is_ok());
770        assert!(result.is_ok_and(|path| path == expected_prefix));
771    }
772}