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