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
38pub fn process_exit(code: i32) -> ! {
40 if BRIDGE_INSTANCE.get().is_some() {
41 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 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 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 let venv_path = ensure_configured_venv(&mut config)?;
77
78 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 let site_packages = resolve_site_package_path(&venv_path)?;
85
86 Self::configure_python_path(&site_packages);
88
89 let python_version = runtime_python_version(&config)?;
91 check_python_library_available(&python_version)?;
92
93 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 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 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 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 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 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 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 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 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 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
322fn 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
407fn 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
414fn 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 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 if find_lib_in_paths(search_paths.iter().copied(), &lib_names) {
458 return Ok(());
459 }
460
461 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 logger::debug("Python library not found in standard locations, relying on rpath");
471 Ok(())
472 }
473
474 #[cfg(target_os = "windows")]
475 {
476 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 Ok(())
487 }
488}
489
490#[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#[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 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#[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#[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 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 let dll_path = parent.join(&dll_name);
579 if dll_path.exists() {
580 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 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
634pub 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#[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 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}