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