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.file_operations").map_err(|e| {
263 BridgeError::Python(format!(
264 "Failed to import r2x_core.utils.file_operations: {}",
265 e
266 ))
267 })?;
268
269 file_ops
270 .setattr("get_r2x_cache_path", override_fn)
271 .map_err(|e| {
272 BridgeError::Python(format!("Failed to override cache path: {}", e))
273 })?;
274
275 Ok::<(), BridgeError>(())
276 })?;
277
278 Ok(())
279 }
280
281 fn configure_python_logging() -> Result<(), BridgeError> {
286 let verbosity = logger::get_verbosity();
287 let log_python = logger::get_log_python();
288 let log_file = logger::get_log_path_string();
289
290 logger::debug_lazy(|| {
291 format!(
292 "Configuring Python logging with verbosity={}, log_python={}, log_file={}",
293 verbosity, log_python, log_file
294 )
295 });
296
297 pyo3::Python::attach(|py| {
298 let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| {
299 BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e))
300 })?;
301 let setup_logging = logger_module
302 .getattr("setup_logging")
303 .map_err(|e| BridgeError::Python(format!("setup_logging not found: {}", e)))?;
304
305 let kwargs = PyDict::new(py);
306 if !log_file.is_empty() {
307 kwargs.set_item("log_file", &log_file)?;
308 }
309 kwargs.set_item("log_to_console", log_python)?;
310 setup_logging.call((verbosity,), Some(&kwargs))?;
311
312 Self::enable_loguru_modules(
313 py,
314 &[
315 "r2x_core",
316 "r2x_reeds",
317 "r2x_plexos",
318 "r2x_sienna",
319 "r2x_nodal",
320 ],
321 )
322 })
323 }
324
325 pub(crate) fn enable_loguru_modules(py: Python, modules: &[&str]) -> Result<(), BridgeError> {
327 let loguru = PyModule::import(py, "loguru")?;
328 let logger_obj = loguru.getattr("logger")?;
329
330 for module in modules {
331 logger_obj.call_method1("enable", (module,))?;
332 }
333
334 Ok(())
335 }
336
337 pub(crate) fn enable_loguru_modules_after_import(
344 py: Python,
345 modules: &[&str],
346 ) -> Result<(), BridgeError> {
347 let pending = pending_post_import_log_modules(modules);
348
349 if pending.is_empty() {
350 return Ok(());
351 }
352
353 let pending_refs = pending.iter().map(String::as_str).collect::<Vec<_>>();
354 Self::enable_loguru_modules(py, &pending_refs)?;
355
356 mark_post_import_log_modules_enabled(&pending);
357
358 Ok(())
359 }
360}
361
362fn pending_post_import_log_modules(modules: &[&str]) -> Vec<String> {
363 let enabled = POST_IMPORT_LOG_MODULES_ENABLED
364 .lock()
365 .unwrap_or_else(std::sync::PoisonError::into_inner);
366 modules
367 .iter()
368 .copied()
369 .filter(|module| !enabled.contains(*module))
370 .map(str::to_string)
371 .collect()
372}
373
374fn mark_post_import_log_modules_enabled(modules: &[String]) {
375 let mut enabled = POST_IMPORT_LOG_MODULES_ENABLED
376 .lock()
377 .unwrap_or_else(std::sync::PoisonError::into_inner);
378 enabled.extend(modules.iter().cloned());
379}
380
381fn resolve_python_home(venv_path: &Path) -> Result<PathBuf, BridgeError> {
387 let pyvenv_cfg = venv_path.join("pyvenv.cfg");
388
389 if !pyvenv_cfg.exists() {
390 return Err(BridgeError::Initialization(format!(
391 "pyvenv.cfg not found in venv: {}",
392 venv_path.display()
393 )));
394 }
395
396 let content = fs::read_to_string(&pyvenv_cfg)
397 .map_err(|e| BridgeError::Initialization(format!("Failed to read pyvenv.cfg: {}", e)))?;
398
399 for line in content.lines() {
400 let line = line.trim();
401 if let Some((key, value)) = line.split_once('=') {
402 if key.trim().eq_ignore_ascii_case("home") {
403 let home_value = PathBuf::from(value.trim());
404 let python_home = normalize_python_home(&home_value);
405 logger::debug_lazy(|| {
406 format!(
407 "Resolved PYTHONHOME from pyvenv.cfg home={} -> {}",
408 home_value.display(),
409 python_home.display()
410 )
411 });
412 return Ok(python_home);
413 }
414 }
415 }
416
417 Err(BridgeError::Initialization(format!(
418 "Could not find 'home' in pyvenv.cfg: {}",
419 pyvenv_cfg.display()
420 )))
421}
422
423fn normalize_python_home(home_value: &Path) -> PathBuf {
424 let Some(last_segment) = home_value.file_name().and_then(|name| name.to_str()) else {
425 return home_value.to_path_buf();
426 };
427
428 if is_python_executable_name(last_segment)
429 || last_segment.eq_ignore_ascii_case("bin")
430 || last_segment.eq_ignore_ascii_case("scripts")
431 {
432 if let Some(parent) = home_value.parent() {
433 return parent.to_path_buf();
434 }
435 }
436
437 home_value.to_path_buf()
438}
439
440fn is_python_executable_name(name: &str) -> bool {
441 if name.eq_ignore_ascii_case("python")
442 || name.eq_ignore_ascii_case("python.exe")
443 || name.eq_ignore_ascii_case("python3")
444 || name.eq_ignore_ascii_case("python3.exe")
445 {
446 return true;
447 }
448
449 let lower = name.to_ascii_lowercase();
450 if let Some(suffix) = lower.strip_prefix("python") {
451 let suffix = suffix.strip_suffix(".exe").unwrap_or(suffix);
452 if let Some(version) = suffix.strip_prefix('3') {
453 if version.is_empty() {
454 return true;
455 }
456 if let Some(dotless) = version.strip_prefix('.') {
457 return !dotless.is_empty() && dotless.chars().all(|ch| ch.is_ascii_digit());
458 }
459 return version.chars().all(|ch| ch.is_ascii_digit());
460 }
461 }
462
463 false
464}
465
466fn runtime_python_version(config: &Config) -> Result<PythonRuntimeVersion, BridgeError> {
468 config.runtime_python_version().map_err(|error| {
469 BridgeError::Initialization(format!("Invalid configured Python version: {}", error))
470 })
471}
472
473fn check_python_library_available(
478 python_version: &PythonRuntimeVersion,
479) -> Result<(), BridgeError> {
480 #[cfg(any(target_os = "macos", target_os = "linux"))]
481 {
482 #[cfg(target_os = "macos")]
483 let (lib_names, search_paths, env_var) = (
484 vec![format!("libpython{}.dylib", python_version.abi())],
485 &[
486 "/opt/homebrew/lib",
487 "/usr/local/lib",
488 "/Library/Frameworks/Python.framework/Versions/Current/lib",
489 ][..],
490 "DYLD_LIBRARY_PATH",
491 );
492
493 #[cfg(target_os = "linux")]
494 let (lib_names, search_paths, env_var) = (
495 vec![
496 format!("libpython{}.so", python_version.abi()),
497 format!("libpython{}.so.1.0", python_version.abi()),
498 ],
499 &[
500 "/usr/lib",
501 "/usr/lib64",
502 "/usr/local/lib",
503 "/usr/local/lib64",
504 ][..],
505 "LD_LIBRARY_PATH",
506 );
507
508 if let Ok(paths) = env::var(env_var) {
510 if find_lib_in_paths(paths.split(':'), &lib_names) {
511 return Ok(());
512 }
513 }
514
515 if find_lib_in_paths(search_paths.iter().copied(), &lib_names) {
517 return Ok(());
518 }
519
520 if let Some(lib_dir) = find_python_lib_via_uv(python_version, &lib_names) {
522 prepend_to_env_path(env_var, &lib_dir);
523 logger::debug_lazy(|| format!("Set {} to include: {}", env_var, lib_dir.display()));
524 return Ok(());
525 }
526
527 logger::debug("Python library not found in standard locations, relying on rpath");
530 Ok(())
531 }
532
533 #[cfg(target_os = "windows")]
534 {
535 if let Err(e) = setup_windows_dll_path(python_version) {
537 logger::debug_lazy(|| format!("Windows DLL path setup note: {}", e));
538 }
539 Ok(())
540 }
541
542 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
543 {
544 Ok(())
546 }
547}
548
549#[cfg(any(target_os = "macos", target_os = "linux"))]
552fn find_lib_in_paths<I, S>(paths: I, lib_names: &[String]) -> bool
553where
554 I: Iterator<Item = S>,
555 S: AsRef<str>,
556{
557 for path in paths {
558 for lib_name in lib_names {
559 let lib_path = PathBuf::from(path.as_ref()).join(lib_name);
560 if lib_path.exists() {
561 logger::debug_lazy(|| format!("Found Python library at: {}", lib_path.display()));
562 return true;
563 }
564 }
565 }
566 false
567}
568
569#[cfg(any(target_os = "macos", target_os = "linux"))]
572fn find_python_lib_via_uv(
573 python_version: &PythonRuntimeVersion,
574 lib_names: &[String],
575) -> Option<PathBuf> {
576 for python_query in python_version.query_candidates() {
577 let output = Command::new("uv")
578 .args(["python", "find", python_query])
579 .output()
580 .ok()?;
581
582 if !output.status.success() {
583 continue;
584 }
585
586 let python_path = String::from_utf8_lossy(&output.stdout);
587 let python_path = python_path.trim();
588
589 let lib_dir = PathBuf::from(python_path).parent()?.parent()?.join("lib");
591
592 for lib_name in lib_names {
593 let lib_path = lib_dir.join(lib_name);
594 if lib_path.exists() {
595 logger::debug_lazy(|| {
596 format!("Found Python library via uv: {}", lib_path.display())
597 });
598 return Some(lib_dir);
599 }
600 }
601 }
602
603 None
604}
605
606#[cfg(any(target_os = "macos", target_os = "linux"))]
608fn prepend_to_env_path(env_var: &str, dir: &Path) {
609 if let Some(existing) = env::var_os(env_var) {
610 let mut paths = env::split_paths(&existing).collect::<Vec<_>>();
611 paths.insert(0, dir.to_path_buf());
612 if let Ok(new_path) = env::join_paths(&paths) {
613 env::set_var(env_var, new_path);
614 }
615 } else {
616 env::set_var(env_var, dir);
617 }
618}
619
620#[cfg(target_os = "windows")]
622fn setup_windows_dll_path(python_version: &PythonRuntimeVersion) -> Result<(), BridgeError> {
623 let dll_name = format!("python{}.dll", python_version.abi().replace('.', ""));
624
625 for python_query in python_version.query_candidates() {
627 let output = Command::new("uv")
628 .args(["python", "find", python_query])
629 .output();
630
631 if let Ok(output) = output {
632 if output.status.success() {
633 let python_path = String::from_utf8_lossy(&output.stdout);
634 let python_path = python_path.trim();
635 if let Some(parent) = PathBuf::from(python_path).parent() {
636 let dll_path = parent.join(&dll_name);
638 if dll_path.exists() {
639 if let Ok(current_path) = env::var("PATH") {
641 let new_path = format!("{};{}", parent.display(), current_path);
642 env::set_var("PATH", &new_path);
643 logger::debug_lazy(|| {
644 format!(
645 "Added {} to PATH for Python DLL discovery",
646 parent.display()
647 )
648 });
649 return Ok(());
650 }
651 }
652 }
653 }
654 }
655 }
656
657 if let Ok(output) = Command::new("where").arg("python").output() {
659 if output.status.success() {
660 let python_path = String::from_utf8_lossy(&output.stdout);
661 if let Some(first_line) = python_path.lines().next() {
662 if let Some(parent) = PathBuf::from(first_line.trim()).parent() {
663 let dll_path = parent.join(&dll_name);
664 if dll_path.exists() {
665 logger::debug_lazy(|| {
666 format!("Found Python DLL at: {}", dll_path.display())
667 });
668 return Ok(());
669 }
670 }
671 }
672 }
673 }
674
675 let find_hint = python_version.find_hint();
676 let install_hint = python_version.install_hint();
677 Err(BridgeError::PythonLibraryNotFound(format!(
678 "Could not find {}.\n\n\
679 This binary requires Python {} to be installed.\n\n\
680 To fix this on Windows:\n\
681 1. Install Python via uv: {}\n\
682 2. Or download from https://www.python.org/downloads/\n\
683 3. Ensure Python is in your PATH\n\n\
684 If you installed Python via uv, try running:\n\
685 {}",
686 dll_name,
687 python_version.requested(),
688 install_hint,
689 find_hint
690 )))
691}
692
693pub fn configure_python_venv() -> Result<PythonEnvCompat, BridgeError> {
695 let config = Config::load()
696 .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
697
698 let venv_path = PathBuf::from(config.get_venv_path());
699
700 let interpreter = resolve_python_path(&venv_path)?;
701 let python_home = resolve_python_home(&venv_path).ok();
702
703 Ok(PythonEnvCompat {
704 interpreter,
705 python_home,
706 })
707}
708
709#[derive(Debug, Clone)]
711pub struct PythonEnvCompat {
712 pub interpreter: PathBuf,
713 pub python_home: Option<PathBuf>,
714}
715
716#[cfg(test)]
717mod tests {
718 use crate::python_bridge::*;
719 use r2x_config::{default_python_version, PythonRuntimeVersion};
720 #[cfg(unix)]
721 use std::env;
722 use std::fs;
723 #[cfg(unix)]
724 use std::os::unix::fs::PermissionsExt;
725 use tempfile::TempDir;
726
727 #[cfg(unix)]
728 static PATH_TEST_LOCK: once_cell::sync::Lazy<std::sync::Mutex<()>> =
729 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(()));
730
731 #[test]
732 fn test_bridge_struct() {
733 let _bridge = Bridge { _marker: () };
735 }
736
737 #[test]
738 fn test_runtime_python_version_defaults_to_build_python_version() {
739 let config = Config::default();
740 assert_eq!(
741 runtime_python_version(&config)
742 .ok()
743 .map(|version| (version.requested().to_string(), version.abi().to_string())),
744 Some((
745 default_python_version().to_string(),
746 default_python_version().to_string()
747 ))
748 );
749 }
750
751 #[test]
752 fn test_runtime_python_version_uses_configured_version() {
753 let config = Config {
754 python_version: Some("3.13".to_string()),
755 ..Config::default()
756 };
757 assert_eq!(
758 runtime_python_version(&config)
759 .ok()
760 .map(|version| (version.requested().to_string(), version.abi().to_string())),
761 Some(("3.13".to_string(), "3.13".to_string()))
762 );
763 }
764
765 #[test]
766 fn test_runtime_python_version_keeps_patch_request_but_uses_minor_abi() {
767 let config = Config {
768 python_version: Some("3.13.1".to_string()),
769 ..Config::default()
770 };
771 assert_eq!(
772 runtime_python_version(&config)
773 .ok()
774 .map(|version| (version.requested().to_string(), version.abi().to_string())),
775 Some(("3.13.1".to_string(), "3.13".to_string()))
776 );
777 }
778
779 #[test]
780 fn test_runtime_python_version_ignores_blank_config_value() {
781 let config = Config {
782 python_version: Some(" ".to_string()),
783 ..Config::default()
784 };
785 assert_eq!(
786 runtime_python_version(&config)
787 .ok()
788 .map(|version| (version.requested().to_string(), version.abi().to_string())),
789 Some((
790 default_python_version().to_string(),
791 default_python_version().to_string()
792 ))
793 );
794 }
795
796 #[test]
797 fn test_runtime_python_version_rejects_unsupported_configured_version() {
798 let config = Config {
799 python_version: Some("3.10".to_string()),
800 ..Config::default()
801 };
802
803 assert!(runtime_python_version(&config).is_err());
804 }
805
806 #[test]
807 fn test_post_import_log_module_cache_skips_previously_enabled_modules() {
808 let module_a = "r2x_test_cache_alpha";
809 let module_b = "r2x_test_cache_beta";
810
811 let pending = pending_post_import_log_modules(&[module_a, module_b]);
812 assert!(pending.contains(&module_a.to_string()));
813 assert!(pending.contains(&module_b.to_string()));
814
815 mark_post_import_log_modules_enabled(&[module_a.to_string()]);
816
817 let pending = pending_post_import_log_modules(&[module_a, module_b]);
818 assert!(!pending.contains(&module_a.to_string()));
819 assert!(pending.contains(&module_b.to_string()));
820 }
821
822 #[test]
823 fn test_is_python_executable_name_variants() {
824 assert!(is_python_executable_name("python"));
825 assert!(is_python_executable_name("python.exe"));
826 assert!(is_python_executable_name("python3"));
827 assert!(is_python_executable_name("python3.exe"));
828 assert!(is_python_executable_name("python3.12"));
829 assert!(is_python_executable_name("python3.12.exe"));
830 assert!(is_python_executable_name("PYTHON3.13.EXE"));
831 assert!(!is_python_executable_name("pythonw.exe"));
832 assert!(!is_python_executable_name("python-3.12.exe"));
833 }
834
835 #[test]
836 fn test_normalize_python_home_bin_dir() {
837 let home = PathBuf::from("/opt/python/bin");
838 assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
839 }
840
841 #[test]
842 fn test_normalize_python_home_scripts_dir() {
843 let home = PathBuf::from("/opt/python/Scripts");
844 assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
845 }
846
847 #[test]
848 fn test_normalize_python_home_python_executable() {
849 let home = PathBuf::from("/opt/python/python3.12");
850 assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
851 }
852
853 #[test]
854 fn test_normalize_python_home_prefix_value() {
855 let home = PathBuf::from("/opt/python/cpython-3.12.9-windows-x86_64-none");
856 assert_eq!(normalize_python_home(&home), home);
857 }
858
859 #[test]
860 fn test_resolve_python_home_preserves_prefix_from_pyvenv_cfg() {
861 let Ok(temp_dir) = TempDir::new() else {
862 return;
863 };
864 let venv_path = temp_dir.path().join(".venv");
865 if fs::create_dir_all(&venv_path).is_err() {
866 return;
867 }
868
869 let expected_prefix = temp_dir.path().join("uv-python-prefix");
870 let pyvenv_cfg = format!("home = {}\n", expected_prefix.to_string_lossy());
871 if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
872 return;
873 }
874
875 let result = resolve_python_home(&venv_path);
876 assert!(result.is_ok());
877 assert!(result.is_ok_and(|path| path == expected_prefix));
878 }
879
880 #[test]
881 fn test_resolve_python_home_converts_bin_home_to_prefix() {
882 let Ok(temp_dir) = TempDir::new() else {
883 return;
884 };
885 let venv_path = temp_dir.path().join(".venv");
886 if fs::create_dir_all(&venv_path).is_err() {
887 return;
888 }
889
890 let expected_prefix = temp_dir.path().join("python-prefix");
891 let home_bin = expected_prefix.join("bin");
892 let pyvenv_cfg = format!("home = {}\n", home_bin.to_string_lossy());
893 if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
894 return;
895 }
896
897 let result = resolve_python_home(&venv_path);
898 assert!(result.is_ok());
899 assert!(result.is_ok_and(|path| path == expected_prefix));
900 }
901
902 #[test]
903 #[cfg(any(target_os = "linux", target_os = "macos"))]
904 fn test_find_python_lib_via_uv_falls_back_from_patch_to_abi_query() {
905 let Ok(_lock) = PATH_TEST_LOCK.lock() else {
906 return;
907 };
908 let Ok(temp_dir) = TempDir::new() else {
909 return;
910 };
911
912 let python_prefix = temp_dir.path().join("cpython-3.13");
913 let python_bin = python_prefix.join("bin").join("python3.13");
914 let lib_dir = python_prefix.join("lib");
915 if fs::create_dir_all(python_bin.parent().unwrap_or(temp_dir.path())).is_err() {
916 return;
917 }
918 if fs::create_dir_all(&lib_dir).is_err() {
919 return;
920 }
921 if fs::write(&python_bin, "").is_err() {
922 return;
923 }
924
925 let lib_name = if cfg!(target_os = "macos") {
926 "libpython3.13.dylib"
927 } else {
928 "libpython3.13.so"
929 };
930 if fs::write(lib_dir.join(lib_name), "").is_err() {
931 return;
932 }
933
934 let uv = temp_dir.path().join("uv");
935 if fs::write(
936 &uv,
937 format!(
938 "#!/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",
939 python_bin.display()
940 ),
941 )
942 .is_err()
943 {
944 return;
945 }
946 let Ok(metadata) = fs::metadata(&uv) else {
947 return;
948 };
949 let mut permissions = metadata.permissions();
950 permissions.set_mode(0o755);
951 if fs::set_permissions(&uv, permissions).is_err() {
952 return;
953 }
954
955 let original_path = env::var_os("PATH");
956 let mut path_entries = vec![temp_dir.path().to_path_buf()];
957 if let Some(existing) = original_path.as_ref() {
958 path_entries.extend(env::split_paths(existing));
959 }
960 let Ok(new_path) = env::join_paths(path_entries) else {
961 return;
962 };
963 env::set_var("PATH", &new_path);
964
965 let Ok(version) = PythonRuntimeVersion::parse("3.13.1") else {
966 return;
967 };
968 let found = find_python_lib_via_uv(&version, &[lib_name.to_string()]);
969
970 if let Some(path) = original_path {
971 env::set_var("PATH", path);
972 } else {
973 env::remove_var("PATH");
974 }
975
976 assert_eq!(found, Some(lib_dir));
977 }
978}