1use crate::errors::BridgeError;
14use crate::utils::{resolve_python_path, resolve_site_package_path};
15use once_cell::sync::OnceCell;
16use pyo3::prelude::*;
17use pyo3::types::{PyDict, PyModule};
18use r2x_config::Config;
19use r2x_logger as logger;
20use std::env;
21use std::fs;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25pub struct Bridge {
27 _marker: (),
29}
30
31static BRIDGE_INSTANCE: OnceCell<Result<Bridge, BridgeError>> = OnceCell::new();
33
34impl Bridge {
35 pub fn get() -> Result<&'static Bridge, BridgeError> {
37 match BRIDGE_INSTANCE.get_or_init(Bridge::initialize) {
38 Ok(bridge) => Ok(bridge),
39 Err(e) => Err(BridgeError::Initialization(format!("{}", e))),
40 }
41 }
42
43 pub fn is_python_available() -> bool {
45 let config = match Config::load() {
46 Ok(c) => c,
47 Err(_) => return false,
48 };
49
50 let venv_path = PathBuf::from(config.get_venv_path());
52 venv_path.join("pyvenv.cfg").exists()
53 }
54
55 fn initialize() -> Result<Bridge, BridgeError> {
63 let start_time = std::time::Instant::now();
64
65 let mut config = Config::load()
66 .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
67
68 let venv_path = PathBuf::from(config.get_venv_path());
70
71 if !venv_path.exists() {
72 Self::create_venv(&config, &venv_path)?;
74 }
75
76 let python_home = resolve_python_home(&venv_path)?;
78 env::set_var("PYTHONHOME", &python_home);
79 logger::debug(&format!("Set PYTHONHOME={}", python_home.display()));
80
81 let site_packages = resolve_site_package_path(&venv_path)?;
83
84 Self::configure_python_path(&site_packages);
86
87 check_python_library_available()?;
89
90 logger::debug("Initializing PyO3...");
92 let pyo3_start = std::time::Instant::now();
93 pyo3::Python::initialize();
94 logger::debug(&format!(
95 "pyo3::Python::initialize took: {:?}",
96 pyo3_start.elapsed()
97 ));
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(&format!(
131 "Total bridge initialization took: {:?}",
132 start_time.elapsed()
133 ));
134
135 Ok(Bridge { _marker: () })
136 }
137
138 fn create_venv(config: &Config, venv_path: &PathBuf) -> Result<(), BridgeError> {
142 logger::step(&format!(
143 "Creating Python virtual environment at: {}",
144 venv_path.display()
145 ));
146
147 let python_version = get_compiled_python_version();
148
149 if let Some(ref uv_path) = config.uv_path {
151 let output = Command::new(uv_path)
152 .arg("venv")
153 .arg(venv_path)
154 .arg("--python")
155 .arg(&python_version)
156 .output()?;
157
158 if output.status.success() {
159 logger::success("Virtual environment created successfully");
160 return Ok(());
161 }
162
163 let stderr = String::from_utf8_lossy(&output.stderr);
164 logger::debug(&format!("uv venv failed: {}", stderr));
165 }
166
167 let python_cmd = format!("python{}", python_version);
169 let output = Command::new(&python_cmd)
170 .args(["-m", "venv"])
171 .arg(venv_path)
172 .output();
173
174 if let Ok(output) = output {
175 if output.status.success() {
176 logger::success("Virtual environment created successfully");
177 return Ok(());
178 }
179 }
180
181 let output = Command::new("python3")
183 .args(["-m", "venv"])
184 .arg(venv_path)
185 .output()?;
186
187 if !output.status.success() {
188 let stderr = String::from_utf8_lossy(&output.stderr);
189 return Err(BridgeError::Initialization(format!(
190 "Failed to create virtual environment: {}",
191 stderr
192 )));
193 }
194
195 logger::success("Virtual environment created successfully");
196 Ok(())
197 }
198
199 fn configure_python_path(site_packages: &Path) {
201 let mut paths = vec![site_packages.to_path_buf()];
202 if let Some(existing) = env::var_os("PYTHONPATH") {
203 if !existing.is_empty() {
204 paths.extend(env::split_paths(&existing));
205 }
206 }
207 if let Ok(joined) = env::join_paths(paths) {
208 env::set_var("PYTHONPATH", &joined);
209 logger::debug(&format!(
210 "Updated PYTHONPATH to include {}",
211 site_packages.display()
212 ));
213 }
214 }
215
216 fn configure_python_cache(cache_path: &str) -> Result<(), BridgeError> {
218 std::fs::create_dir_all(cache_path).map_err(|e| {
219 BridgeError::Initialization(format!("Failed to create cache directory: {}", e))
220 })?;
221 env::set_var("R2X_CACHE_PATH", cache_path);
222
223 let cache_path_escaped = cache_path.replace('\\', "\\\\");
224 pyo3::Python::attach(|py| {
225 let patch_code = format!(
226 r#"from pathlib import Path
227_R2X_CACHE_PATH = Path(r"{cache}")
228
229def _r2x_cache_path_override():
230 return _R2X_CACHE_PATH
231"#,
232 cache = cache_path_escaped
233 );
234
235 let code_cstr = std::ffi::CString::new(patch_code).map_err(|e| {
236 BridgeError::Python(format!("Failed to prepare cache override script: {}", e))
237 })?;
238 let filename = std::ffi::CString::new("r2x_cache_patch.py")
239 .map_err(|e| BridgeError::Python(format!("Failed to create filename: {}", e)))?;
240 let module_name = std::ffi::CString::new("r2x_cache_patch")
241 .map_err(|e| BridgeError::Python(format!("Failed to create module name: {}", e)))?;
242 let patch_module = PyModule::from_code(
243 py,
244 code_cstr.as_c_str(),
245 filename.as_c_str(),
246 module_name.as_c_str(),
247 )
248 .map_err(|e| BridgeError::Python(format!("Failed to build cache override: {}", e)))?;
249
250 let override_fn = patch_module
251 .getattr("_r2x_cache_path_override")
252 .map_err(|e| {
253 BridgeError::Python(format!("Failed to obtain cache override function: {}", e))
254 })?;
255
256 let file_ops = PyModule::import(py, "r2x_core.utils.file_operations").map_err(|e| {
257 BridgeError::Python(format!(
258 "Failed to import r2x_core.utils.file_operations: {}",
259 e
260 ))
261 })?;
262
263 file_ops
264 .setattr("get_r2x_cache_path", override_fn)
265 .map_err(|e| {
266 BridgeError::Python(format!("Failed to override cache path: {}", e))
267 })?;
268
269 Ok::<(), BridgeError>(())
270 })?;
271
272 Ok(())
273 }
274
275 fn configure_python_logging() -> Result<(), BridgeError> {
280 let verbosity = logger::get_verbosity();
281 let log_python = logger::get_log_python();
282 let log_file = logger::get_log_path_string();
283
284 logger::debug(&format!(
285 "Configuring Python logging with verbosity={}, log_python={}, log_file={}",
286 verbosity, log_python, log_file
287 ));
288
289 pyo3::Python::attach(|py| {
290 let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| {
291 BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e))
292 })?;
293 let setup_logging = logger_module
294 .getattr("setup_logging")
295 .map_err(|e| BridgeError::Python(format!("setup_logging not found: {}", e)))?;
296
297 let kwargs = PyDict::new(py);
298 if !log_file.is_empty() {
299 kwargs.set_item("log_file", &log_file)?;
300 }
301 kwargs.set_item("log_to_console", log_python)?;
302 setup_logging.call((verbosity,), Some(&kwargs))?;
303
304 Self::enable_loguru_modules(
305 py,
306 &[
307 "r2x_core",
308 "r2x_reeds",
309 "r2x_plexos",
310 "r2x_sienna",
311 "r2x_nodal",
312 ],
313 )
314 })
315 }
316
317 pub(crate) fn enable_loguru_modules(py: Python, modules: &[&str]) -> Result<(), BridgeError> {
319 let loguru = PyModule::import(py, "loguru")?;
320 let logger_obj = loguru.getattr("logger")?;
321
322 for module in modules {
323 logger_obj.call_method1("enable", (module,))?;
324 }
325
326 Ok(())
327 }
328
329 pub fn reconfigure_logging_for_plugin(plugin_name: &str) -> Result<(), BridgeError> {
336 Self::configure_python_logging()?;
337
338 let package_part = plugin_name.split('.').next().unwrap_or(plugin_name);
342 let module_name = package_part.replace('-', "_");
343
344 pyo3::Python::attach(|py| Self::enable_loguru_modules(py, &[&module_name]))
345 }
346}
347
348fn resolve_python_home(venv_path: &Path) -> Result<PathBuf, BridgeError> {
354 let pyvenv_cfg = venv_path.join("pyvenv.cfg");
355
356 if !pyvenv_cfg.exists() {
357 return Err(BridgeError::Initialization(format!(
358 "pyvenv.cfg not found in venv: {}",
359 venv_path.display()
360 )));
361 }
362
363 let content = fs::read_to_string(&pyvenv_cfg)
364 .map_err(|e| BridgeError::Initialization(format!("Failed to read pyvenv.cfg: {}", e)))?;
365
366 for line in content.lines() {
367 let line = line.trim();
368 if let Some((key, value)) = line.split_once('=') {
369 if key.trim().eq_ignore_ascii_case("home") {
370 let home_value = PathBuf::from(value.trim());
371 let python_home = normalize_python_home(&home_value);
372 logger::debug(&format!(
373 "Resolved PYTHONHOME from pyvenv.cfg home={} -> {}",
374 home_value.display(),
375 python_home.display()
376 ));
377 return Ok(python_home);
378 }
379 }
380 }
381
382 Err(BridgeError::Initialization(format!(
383 "Could not find 'home' in pyvenv.cfg: {}",
384 pyvenv_cfg.display()
385 )))
386}
387
388fn normalize_python_home(home_value: &Path) -> PathBuf {
389 let Some(last_segment) = home_value.file_name().and_then(|name| name.to_str()) else {
390 return home_value.to_path_buf();
391 };
392
393 if is_python_executable_name(last_segment)
394 || last_segment.eq_ignore_ascii_case("bin")
395 || last_segment.eq_ignore_ascii_case("scripts")
396 {
397 if let Some(parent) = home_value.parent() {
398 return parent.to_path_buf();
399 }
400 }
401
402 home_value.to_path_buf()
403}
404
405fn is_python_executable_name(name: &str) -> bool {
406 if name.eq_ignore_ascii_case("python")
407 || name.eq_ignore_ascii_case("python.exe")
408 || name.eq_ignore_ascii_case("python3")
409 || name.eq_ignore_ascii_case("python3.exe")
410 {
411 return true;
412 }
413
414 let lower = name.to_ascii_lowercase();
415 if let Some(suffix) = lower.strip_prefix("python") {
416 let suffix = suffix.strip_suffix(".exe").unwrap_or(suffix);
417 if let Some(version) = suffix.strip_prefix('3') {
418 if version.is_empty() {
419 return true;
420 }
421 if let Some(dotless) = version.strip_prefix('.') {
422 return !dotless.is_empty() && dotless.chars().all(|ch| ch.is_ascii_digit());
423 }
424 return version.chars().all(|ch| ch.is_ascii_digit());
425 }
426 }
427
428 false
429}
430
431fn get_compiled_python_version() -> String {
436 "3.12".to_string()
440}
441
442fn check_python_library_available() -> Result<(), BridgeError> {
447 #[cfg(any(target_os = "macos", target_os = "linux"))]
448 {
449 let python_version = get_compiled_python_version();
450
451 #[cfg(target_os = "macos")]
452 let (lib_names, search_paths, env_var) = (
453 vec![format!("libpython{}.dylib", python_version)],
454 &[
455 "/opt/homebrew/lib",
456 "/usr/local/lib",
457 "/Library/Frameworks/Python.framework/Versions/Current/lib",
458 ][..],
459 "DYLD_LIBRARY_PATH",
460 );
461
462 #[cfg(target_os = "linux")]
463 let (lib_names, search_paths, env_var) = (
464 vec![
465 format!("libpython{}.so", python_version),
466 format!("libpython{}.so.1.0", python_version),
467 ],
468 &[
469 "/usr/lib",
470 "/usr/lib64",
471 "/usr/local/lib",
472 "/usr/local/lib64",
473 ][..],
474 "LD_LIBRARY_PATH",
475 );
476
477 if let Ok(paths) = env::var(env_var) {
479 if find_lib_in_paths(paths.split(':'), &lib_names) {
480 return Ok(());
481 }
482 }
483
484 if find_lib_in_paths(search_paths.iter().copied(), &lib_names) {
486 return Ok(());
487 }
488
489 if let Some(lib_dir) = find_python_lib_via_uv(&python_version, &lib_names) {
491 prepend_to_env_path(env_var, &lib_dir);
492 logger::debug(&format!(
493 "Set {} to include: {}",
494 env_var,
495 lib_dir.display()
496 ));
497 return Ok(());
498 }
499
500 logger::debug("Python library not found in standard locations, relying on rpath");
503 Ok(())
504 }
505
506 #[cfg(target_os = "windows")]
507 {
508 if let Err(e) = setup_windows_dll_path() {
510 logger::debug(&format!("Windows DLL path setup note: {}", e));
511 }
512 Ok(())
513 }
514
515 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
516 {
517 Ok(())
519 }
520}
521
522#[cfg(any(target_os = "macos", target_os = "linux"))]
525fn find_lib_in_paths<I, S>(paths: I, lib_names: &[String]) -> bool
526where
527 I: Iterator<Item = S>,
528 S: AsRef<str>,
529{
530 for path in paths {
531 for lib_name in lib_names {
532 let lib_path = PathBuf::from(path.as_ref()).join(lib_name);
533 if lib_path.exists() {
534 logger::debug(&format!("Found Python library at: {}", lib_path.display()));
535 return true;
536 }
537 }
538 }
539 false
540}
541
542#[cfg(any(target_os = "macos", target_os = "linux"))]
545fn find_python_lib_via_uv(python_version: &str, lib_names: &[String]) -> Option<PathBuf> {
546 let output = Command::new("uv")
547 .args(["python", "find", python_version])
548 .output()
549 .ok()?;
550
551 if !output.status.success() {
552 return None;
553 }
554
555 let python_path = String::from_utf8_lossy(&output.stdout);
556 let python_path = python_path.trim();
557
558 let lib_dir = PathBuf::from(python_path).parent()?.parent()?.join("lib");
560
561 for lib_name in lib_names {
562 let lib_path = lib_dir.join(lib_name);
563 if lib_path.exists() {
564 logger::debug(&format!(
565 "Found Python library via uv: {}",
566 lib_path.display()
567 ));
568 return Some(lib_dir);
569 }
570 }
571
572 None
573}
574
575#[cfg(any(target_os = "macos", target_os = "linux"))]
577fn prepend_to_env_path(env_var: &str, dir: &Path) {
578 if let Some(existing) = env::var_os(env_var) {
579 let mut paths = env::split_paths(&existing).collect::<Vec<_>>();
580 paths.insert(0, dir.to_path_buf());
581 if let Ok(new_path) = env::join_paths(&paths) {
582 env::set_var(env_var, new_path);
583 }
584 } else {
585 env::set_var(env_var, dir);
586 }
587}
588
589#[cfg(target_os = "windows")]
591fn setup_windows_dll_path() -> Result<(), BridgeError> {
592 let python_version = get_compiled_python_version();
593 let dll_name = format!("python{}.dll", python_version.replace(".", ""));
594
595 let output = Command::new("uv")
597 .args(["python", "find", &python_version])
598 .output();
599
600 if let Ok(output) = output {
601 if output.status.success() {
602 let python_path = String::from_utf8_lossy(&output.stdout);
603 let python_path = python_path.trim();
604 if let Some(parent) = PathBuf::from(python_path).parent() {
605 let dll_path = parent.join(&dll_name);
607 if dll_path.exists() {
608 if let Ok(current_path) = env::var("PATH") {
610 let new_path = format!("{};{}", parent.display(), current_path);
611 env::set_var("PATH", &new_path);
612 logger::debug(&format!(
613 "Added {} to PATH for Python DLL discovery",
614 parent.display()
615 ));
616 return Ok(());
617 }
618 }
619 }
620 }
621 }
622
623 if let Ok(output) = Command::new("where").arg("python").output() {
625 if output.status.success() {
626 let python_path = String::from_utf8_lossy(&output.stdout);
627 if let Some(first_line) = python_path.lines().next() {
628 if let Some(parent) = PathBuf::from(first_line.trim()).parent() {
629 let dll_path = parent.join(&dll_name);
630 if dll_path.exists() {
631 logger::debug(&format!("Found Python DLL at: {}", dll_path.display()));
632 return Ok(());
633 }
634 }
635 }
636 }
637 }
638
639 Err(BridgeError::PythonLibraryNotFound(format!(
640 "Could not find {}.\n\n\
641 This binary requires Python {} to be installed.\n\n\
642 To fix this on Windows:\n\
643 1. Install Python via uv: uv python install {}\n\
644 2. Or download from https://www.python.org/downloads/\n\
645 3. Ensure Python is in your PATH\n\n\
646 If you installed Python via uv, try running:\n\
647 uv python find {}",
648 dll_name, python_version, python_version, python_version
649 )))
650}
651
652pub fn configure_python_venv() -> Result<PythonEnvCompat, BridgeError> {
654 let config = Config::load()
655 .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
656
657 let venv_path = PathBuf::from(config.get_venv_path());
658
659 let interpreter = resolve_python_path(&venv_path)?;
660 let python_home = resolve_python_home(&venv_path).ok();
661
662 Ok(PythonEnvCompat {
663 interpreter,
664 python_home,
665 })
666}
667
668#[derive(Debug, Clone)]
670pub struct PythonEnvCompat {
671 pub interpreter: PathBuf,
672 pub python_home: Option<PathBuf>,
673}
674
675#[cfg(test)]
676mod tests {
677 use crate::python_bridge::*;
678 use std::fs;
679 use tempfile::TempDir;
680
681 #[test]
682 fn test_bridge_struct() {
683 let _bridge = Bridge { _marker: () };
685 }
686
687 #[test]
688 fn test_get_compiled_python_version() {
689 let version = get_compiled_python_version();
690 assert!(version.starts_with("3."));
691 }
692
693 #[test]
694 fn test_is_python_executable_name_variants() {
695 assert!(is_python_executable_name("python"));
696 assert!(is_python_executable_name("python.exe"));
697 assert!(is_python_executable_name("python3"));
698 assert!(is_python_executable_name("python3.exe"));
699 assert!(is_python_executable_name("python3.12"));
700 assert!(is_python_executable_name("python3.12.exe"));
701 assert!(is_python_executable_name("PYTHON3.13.EXE"));
702 assert!(!is_python_executable_name("pythonw.exe"));
703 assert!(!is_python_executable_name("python-3.12.exe"));
704 }
705
706 #[test]
707 fn test_normalize_python_home_bin_dir() {
708 let home = PathBuf::from("/opt/python/bin");
709 assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
710 }
711
712 #[test]
713 fn test_normalize_python_home_scripts_dir() {
714 let home = PathBuf::from("/opt/python/Scripts");
715 assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
716 }
717
718 #[test]
719 fn test_normalize_python_home_python_executable() {
720 let home = PathBuf::from("/opt/python/python3.12");
721 assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python"));
722 }
723
724 #[test]
725 fn test_normalize_python_home_prefix_value() {
726 let home = PathBuf::from("/opt/python/cpython-3.12.9-windows-x86_64-none");
727 assert_eq!(normalize_python_home(&home), home);
728 }
729
730 #[test]
731 fn test_resolve_python_home_preserves_prefix_from_pyvenv_cfg() {
732 let Ok(temp_dir) = TempDir::new() else {
733 return;
734 };
735 let venv_path = temp_dir.path().join(".venv");
736 if fs::create_dir_all(&venv_path).is_err() {
737 return;
738 }
739
740 let expected_prefix = temp_dir.path().join("uv-python-prefix");
741 let pyvenv_cfg = format!("home = {}\n", expected_prefix.to_string_lossy());
742 if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
743 return;
744 }
745
746 let result = resolve_python_home(&venv_path);
747 assert!(result.is_ok());
748 assert!(result.is_ok_and(|path| path == expected_prefix));
749 }
750
751 #[test]
752 fn test_resolve_python_home_converts_bin_home_to_prefix() {
753 let Ok(temp_dir) = TempDir::new() else {
754 return;
755 };
756 let venv_path = temp_dir.path().join(".venv");
757 if fs::create_dir_all(&venv_path).is_err() {
758 return;
759 }
760
761 let expected_prefix = temp_dir.path().join("python-prefix");
762 let home_bin = expected_prefix.join("bin");
763 let pyvenv_cfg = format!("home = {}\n", home_bin.to_string_lossy());
764 if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() {
765 return;
766 }
767
768 let result = resolve_python_home(&venv_path);
769 assert!(result.is_ok());
770 assert!(result.is_ok_and(|path| path == expected_prefix));
771 }
772}