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