r2x_python/
initialization.rs

1//! Python environment initialization and setup
2//!
3//! This module handles all Python interpreter initialization, virtual environment
4//! configuration, and environment setup required before the bridge can be used.
5
6use super::utils::{resolve_python_path, resolve_site_package_path};
7use crate::errors::BridgeError;
8use once_cell::sync::OnceCell;
9use pyo3::prelude::*;
10use pyo3::types::PyModule;
11use r2x_config::Config;
12use r2x_logger as logger;
13use std::env;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use which::which;
18
19#[cfg(windows)]
20const PYTHON_BIN_DIR_NAME: &str = "Scripts";
21#[cfg(not(windows))]
22const PYTHON_BIN_DIR_NAME: &str = "bin";
23#[cfg(windows)]
24const SYSTEM_PYTHON_CANDIDATES: &[&str] = &["python.exe", "py.exe"];
25#[cfg(not(windows))]
26const SYSTEM_PYTHON_CANDIDATES: &[&str] = &["python3", "python"];
27const REQUIRED_PYTHON_MAJOR: i32 = 3;
28const REQUIRED_PYTHON_MINOR: i32 = 12;
29
30pub struct Bridge {}
31
32#[derive(Debug, Clone)]
33pub struct PythonEnvironment {
34    pub interpreter: PathBuf,
35    pub python_home: Option<PathBuf>,
36}
37
38static BRIDGE_INSTANCE: OnceCell<Result<Bridge, BridgeError>> = OnceCell::new();
39
40impl Bridge {
41    /// Get or initialize the bridge singleton
42    pub fn get() -> Result<&'static Bridge, BridgeError> {
43        match BRIDGE_INSTANCE.get_or_init(Bridge::initialize) {
44            Ok(bridge) => Ok(bridge),
45            Err(e) => Err(BridgeError::Initialization(format!("{}", e))),
46        }
47    }
48
49    /// Initialize Python interpreter and configure environment
50    ///
51    /// This performs:
52    /// - Configure venv/python environment
53    /// - Add venv site-packages to sys.path
54    /// - Ensure r2x-core is installed
55    fn initialize() -> Result<Bridge, BridgeError> {
56        let start_time = std::time::Instant::now();
57
58        let python_env = configure_python_venv()?;
59        let python_path = python_env.interpreter.clone();
60
61        let mut config = Config::load()
62            .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
63        let cache_path = config.ensure_cache_path().map_err(|e| {
64            BridgeError::Initialization(format!("Failed to ensure cache path: {}", e))
65        })?;
66        let venv_path = PathBuf::from(config.get_venv_path());
67        let site_packages = resolve_site_package_path(&venv_path)?;
68        if let Some(ref home) = python_env.python_home {
69            configure_embedded_python_env(home, &site_packages);
70        } else {
71            logger::debug("Using default embedded Python search paths");
72        }
73
74        logger::debug(&format!(
75            "Initializing Python bridge with: {}",
76            python_path.display()
77        ));
78
79        let pyo3_start = std::time::Instant::now();
80        pyo3::Python::initialize();
81        logger::debug(&format!(
82            "pyo3::Python::initialize took: {:?}",
83            pyo3_start.elapsed()
84        ));
85
86        // Enable Python bytecode generation for faster subsequent imports
87        // This overrides PYTHONDONTWRITEBYTECODE if set in the environment
88        pyo3::Python::attach(|py| {
89            let sys = PyModule::import(py, "sys")
90                .map_err(|e| BridgeError::Python(format!("Failed to import sys module: {}", e)))?;
91            sys.setattr("dont_write_bytecode", false).map_err(|e| {
92                BridgeError::Python(format!("Failed to enable bytecode generation: {}", e))
93            })?;
94            Ok::<(), BridgeError>(())
95        })?;
96        logger::debug("Enabled Python bytecode generation");
97
98        // Add site-packages from venv to sys.path so imports work as expected
99        logger::debug(&format!(
100            "site_packages: {}, exists: {}",
101            site_packages.display(),
102            site_packages.exists()
103        ));
104
105        pyo3::Python::attach(|py| {
106            let site = PyModule::import(py, "site")
107                .map_err(|e| BridgeError::Python(format!("Failed to import site module: {}", e)))?;
108            site.call_method1("addsitedir", (site_packages.to_str().unwrap(),))
109                .map_err(|e| BridgeError::Python(format!("Failed to add site directory: {}", e)))?;
110            Ok::<(), BridgeError>(())
111        })?;
112
113        let sitedir_start = std::time::Instant::now();
114        logger::debug(&format!(
115            "Site packages setup completed in: {:?}",
116            sitedir_start.elapsed()
117        ));
118
119        // Detect and store the compiled Python version in config if not already set
120        let version_start = std::time::Instant::now();
121        detect_and_store_python_version()?;
122        logger::debug(&format!(
123            "Python version detection took: {:?}",
124            version_start.elapsed()
125        ));
126
127        configure_python_cache(&cache_path)?;
128
129        // r2x_core is now installed during venv creation, so no need to check here
130
131        // Configure Python loguru to write to the same log file as Rust
132        // Python logs always go to file, --log-python flag controls console output
133        logger::debug("Starting Python logging configuration...");
134        if let Err(e) = Self::configure_python_logging() {
135            logger::warn(&format!("Python logging configuration failed: {}", e));
136        }
137        logger::debug("Python logging configuration completed");
138
139        logger::debug(&format!(
140            "Total bridge initialization took: {:?}",
141            start_time.elapsed()
142        ));
143        Ok(Bridge {})
144    }
145
146    /// Configure Python loguru logging to integrate with Rust logger
147    fn configure_python_logging() -> Result<(), BridgeError> {
148        let log_file = logger::get_log_path_string();
149        let verbosity = logger::get_verbosity();
150        let log_level = match verbosity {
151            0 => "WARNING",
152            1 => "INFO",
153            2 => "DEBUG",
154            _ => "TRACE",
155        };
156
157        // Format to match Rust logger: [YYYY-MM-DD HH:MM:SS] [PYTHON] LEVEL message
158        let fmt = "[{time:YYYY-MM-DD HH:mm:ss}] [PYTHON] {level: <8} {message}";
159
160        // Check if Python logs should be shown on console
161        let enable_console = logger::get_log_python();
162
163        logger::debug(&format!(
164            "Configuring Python logging with level={}, file={}, enable_console={}",
165            log_level, log_file, enable_console
166        ));
167
168        pyo3::Python::attach(|py| {
169            let logger_module = PyModule::import(py, "r2x_core.logger").map_err(|e| {
170                logger::warn(&format!("Failed to import r2x_core.logger: {}", e));
171                BridgeError::Import("r2x_core.logger".to_string(), format!("{}", e))
172            })?;
173            let setup_logging = logger_module.getattr("setup_logging").map_err(|e| {
174                logger::warn(&format!("Failed to get setup_logging function: {}", e));
175                BridgeError::Python(format!("setup_logging not found: {}", e))
176            })?;
177            let kwargs = pyo3::types::PyDict::new(py);
178            kwargs.set_item("level", log_level)?;
179            kwargs.set_item("log_file", &log_file)?;
180            kwargs.set_item("fmt", fmt)?;
181            kwargs.set_item("enable_console_log", enable_console)?;
182            setup_logging.call((), Some(&kwargs))?;
183
184            // Explicitly enable logging for r2x modules
185            let loguru = PyModule::import(py, "loguru")?;
186            let logger = loguru.getattr("logger")?;
187            logger.call_method1("enable", ("r2x_core",))?;
188            logger.call_method1("enable", ("r2x_reeds",))?;
189            logger.call_method1("enable", ("r2x_plexos",))?;
190            logger.call_method1("enable", ("r2x_sienna",))?;
191
192            Ok::<(), BridgeError>(())
193        })
194    }
195}
196
197/// Helper: get python3.X directory inside venv lib/
198/// Detect the Python version from the embedded interpreter and store it in config
199///
200/// This function:
201/// 1. Gets the Python version from sys.version_info (the compiled/embedded version)
202/// 2. Compares it with what's stored in config
203/// 3. If missing or mismatched, updates config to the actual version
204/// 4. Logs warnings if there's a mismatch (indicates config was manually edited)
205fn detect_and_store_python_version() -> Result<(), BridgeError> {
206    let mut config = Config::load()
207        .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
208
209    // Get Python version from sys.version_info (the actual compiled version)
210    let version_str = pyo3::Python::attach(|py| {
211        let sys = PyModule::import(py, "sys")
212            .map_err(|e| BridgeError::Python(format!("Failed to import sys: {}", e)))?;
213        let version_info = sys
214            .getattr("version_info")
215            .map_err(|e| BridgeError::Python(format!("Failed to get version_info: {}", e)))?;
216
217        let major = version_info
218            .getattr("major")
219            .map_err(|e| BridgeError::Python(format!("Failed to get major: {}", e)))?
220            .extract::<i32>()
221            .map_err(|e| BridgeError::Python(format!("Failed to extract major: {}", e)))?;
222
223        let minor = version_info
224            .getattr("minor")
225            .map_err(|e| BridgeError::Python(format!("Failed to get minor: {}", e)))?
226            .extract::<i32>()
227            .map_err(|e| BridgeError::Python(format!("Failed to extract minor: {}", e)))?;
228
229        Ok::<String, BridgeError>(format!("{}.{}", major, minor))
230    })?;
231
232    logger::debug(&format!("Detected Python version: {}", version_str));
233
234    // Check if config version matches detected version
235    if let Some(ref config_version) = config.python_version {
236        if config_version == &version_str {
237            // Versions match, nothing to do
238            return Ok(());
239        } else {
240            // Mismatch detected - config was likely manually edited
241            logger::warn(&format!(
242                "Python version mismatch: binary was compiled with {}, but config shows {}. Updating config to match compiled version.",
243                version_str, config_version
244            ));
245        }
246    } else {
247        // First time detection
248        logger::debug("First time detecting Python version for this binary");
249    }
250
251    // Store/update the actual compiled version in config
252    config.python_version = Some(version_str.clone());
253    config
254        .save()
255        .map_err(|e| BridgeError::Initialization(format!("Failed to save config: {}", e)))?;
256
257    logger::info(&format!("Python version {} stored in config", version_str));
258
259    Ok(())
260}
261
262fn configure_python_cache(cache_path: &str) -> Result<(), BridgeError> {
263    std::fs::create_dir_all(cache_path).map_err(|e| {
264        BridgeError::Initialization(format!("Failed to create cache directory: {}", e))
265    })?;
266    std::env::set_var("R2X_CACHE_PATH", cache_path);
267
268    let cache_path_escaped = cache_path.replace('\\', "\\\\");
269    pyo3::Python::attach(|py| {
270        let patch_code = format!(
271            r#"from pathlib import Path
272_R2X_CACHE_PATH = Path(r"{cache}")
273
274def _r2x_cache_path_override():
275    return _R2X_CACHE_PATH
276"#,
277            cache = cache_path_escaped
278        );
279
280        let code_cstr = std::ffi::CString::new(patch_code).map_err(|e| {
281            BridgeError::Python(format!("Failed to prepare cache override script: {}", e))
282        })?;
283        let filename = std::ffi::CString::new("r2x_cache_patch.py").unwrap();
284        let module_name = std::ffi::CString::new("r2x_cache_patch").unwrap();
285        let patch_module = PyModule::from_code(
286            py,
287            code_cstr.as_c_str(),
288            filename.as_c_str(),
289            module_name.as_c_str(),
290        )
291        .map_err(|e| BridgeError::Python(format!("Failed to build cache override: {}", e)))?;
292
293        let override_fn = patch_module
294            .getattr("_r2x_cache_path_override")
295            .map_err(|e| {
296                BridgeError::Python(format!("Failed to obtain cache override function: {}", e))
297            })?;
298
299        let file_ops = PyModule::import(py, "r2x_core.utils.file_operations").map_err(|e| {
300            BridgeError::Python(format!(
301                "Failed to import r2x_core.utils.file_operations: {}",
302                e
303            ))
304        })?;
305
306        file_ops
307            .setattr("get_r2x_cache_path", override_fn)
308            .map_err(|e| BridgeError::Python(format!("Failed to override cache path: {}", e)))?;
309
310        Ok::<(), BridgeError>(())
311    })?;
312
313    Ok(())
314}
315
316/// Configure the Python virtual environment before PyO3 initialization
317pub fn configure_python_venv() -> Result<PythonEnvironment, BridgeError> {
318    let mut config = Config::load()
319        .map_err(|e| BridgeError::Initialization(format!("Failed to load config: {}", e)))?;
320
321    let venv_path = PathBuf::from(config.get_venv_path());
322
323    let python_path_result = resolve_python_path(&venv_path);
324
325    if python_path_result.is_err() {
326        logger::debug("Could not resolve Python path");
327    }
328
329    let mut python_path = python_path_result.unwrap_or_else(|_| PathBuf::new());
330
331    // Create venv only when it doesn't exist
332    if !venv_path.exists() {
333        logger::step(&format!(
334            "Creating Python virtual environment at: {}",
335            venv_path.display()
336        ));
337
338        let uv_path = config
339            .ensure_uv_path()
340            .map_err(|e| BridgeError::Initialization(format!("Failed to ensure uv: {}", e)))?;
341
342        // Use the Python version from config, or default to 3.12
343        let python_version = config.python_version.as_deref().unwrap_or("3.12");
344
345        let output = Command::new(&uv_path)
346            .arg("venv")
347            .arg(&venv_path)
348            .arg("--python")
349            .arg(python_version)
350            .output()?;
351
352        logger::capture_output(&format!("uv venv --python {}", python_version), &output);
353
354        if !output.status.success() {
355            return Err(BridgeError::Initialization(
356                "Failed to create Python virtual environment".to_string(),
357            ));
358        }
359
360        python_path = resolve_python_path(&venv_path).unwrap_or_else(|_| PathBuf::new());
361
362        if python_path.as_os_str().is_empty() || !python_path.exists() {
363            if let Ok(entries) = std::fs::read_dir(venv_path.join(PYTHON_BIN_DIR_NAME)) {
364                let names: Vec<String> = entries
365                    .filter_map(|e| e.ok())
366                    .filter_map(|e| e.file_name().into_string().ok())
367                    .collect();
368                logger::debug(&format!("Venv bin contents after creation: {:?}", names));
369            }
370            return Err(BridgeError::Initialization(
371                "Failed to locate Python executable after creating venv".to_string(),
372            ));
373        }
374    }
375
376    if python_path.as_os_str().is_empty() || !python_path.exists() {
377        logger::warn("Python binary not found in configured venv; attempting system fallback");
378        if let Some((fallback, home)) = find_system_python() {
379            return Ok(PythonEnvironment {
380                interpreter: fallback,
381                python_home: Some(home),
382            });
383        }
384
385        return Err(BridgeError::Initialization(
386            "Failed to locate a usable Python interpreter".to_string(),
387        ));
388    }
389
390    let python_home = resolve_python_home(&venv_path);
391
392    Ok(PythonEnvironment {
393        interpreter: python_path,
394        python_home,
395    })
396}
397
398fn configure_embedded_python_env(python_home: &Path, site_packages: &Path) {
399    let home = python_home.to_string_lossy().to_string();
400    env::set_var("PYTHONHOME", &home);
401    logger::debug(&format!("Set PYTHONHOME={}", home));
402
403    let mut paths = vec![site_packages.to_path_buf()];
404    if let Some(existing) = env::var_os("PYTHONPATH") {
405        if !existing.is_empty() {
406            paths.extend(env::split_paths(&existing));
407        }
408    }
409    if let Ok(joined) = env::join_paths(paths) {
410        env::set_var("PYTHONPATH", &joined);
411        logger::debug(&format!(
412            "Updated PYTHONPATH to include {}",
413            site_packages.display()
414        ));
415    }
416}
417
418fn resolve_python_home(venv_path: &Path) -> Option<PathBuf> {
419    let cfg_path = venv_path.join("pyvenv.cfg");
420    let contents = fs::read_to_string(&cfg_path).ok()?;
421    for line in contents.lines() {
422        let trimmed = line.trim();
423        if trimmed.starts_with("home") {
424            let parts: Vec<_> = trimmed.splitn(2, '=').collect();
425            if parts.len() == 2 {
426                let mut path = PathBuf::from(parts[1].trim());
427                if path.ends_with("bin") || path.ends_with("Scripts") {
428                    path = path.parent().map(PathBuf::from).unwrap_or(path);
429                }
430                logger::debug(&format!(
431                    "Resolved base Python home {} from {}",
432                    path.display(),
433                    cfg_path.display()
434                ));
435                return Some(path);
436            }
437        }
438    }
439    logger::debug(&format!(
440        "Failed to resolve base Python home from {}",
441        cfg_path.display()
442    ));
443    None
444}
445
446fn detect_python_runtime(
447    python_bin: &Path,
448) -> Option<(PathBuf, i32, i32)> {
449    let output = Command::new(python_bin)
450        .arg("-c")
451        .arg(
452            "import sys\nprint(sys.base_prefix)\nprint(sys.version_info.major)\nprint(sys.version_info.minor)",
453        )
454        .output()
455        .ok()?;
456    if !output.status.success() {
457        logger::debug(&format!(
458            "Failed to probe python runtime (status {:?})",
459            output.status.code()
460        ));
461        return None;
462    }
463    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
464    let mut lines = stdout
465        .lines()
466        .map(|s| s.trim().to_string())
467        .collect::<Vec<_>>();
468    if lines.len() < 3 {
469        return None;
470    }
471    let minor = lines
472        .pop()
473        .and_then(|v| v.parse::<i32>().ok())
474        .unwrap_or(0);
475    let major = lines
476        .pop()
477        .and_then(|v| v.parse::<i32>().ok())
478        .unwrap_or(0);
479    let prefix = PathBuf::from(lines.pop().unwrap_or_default());
480    Some((prefix, major, minor))
481}
482
483fn find_system_python() -> Option<(PathBuf, PathBuf)> {
484    for candidate in SYSTEM_PYTHON_CANDIDATES {
485        if let Ok(path) = which(candidate) {
486            if let Some((home, major, minor)) = detect_python_runtime(&path) {
487                if major == REQUIRED_PYTHON_MAJOR && minor == REQUIRED_PYTHON_MINOR {
488                    logger::warn(&format!(
489                        "Falling back to system Python at {}",
490                        path.display()
491                    ));
492                    return Some((path, home));
493                } else {
494                    logger::debug(&format!(
495                        "Skipping system python {} (version {}.{})",
496                        path.display(),
497                        major,
498                        minor
499                    ));
500                }
501            }
502        }
503    }
504    None
505}