Skip to main content

construct/sidecars/
python.rs

1//! Python interpreter detection for sidecar provisioning.
2//!
3//! Hard-fails with a download link when no usable Python ≥3.11 is found.
4//! Probes candidates with `-c "import sys"` to skip the Windows Store stub
5//! which exits 9009 and writes to stderr rather than actually running.
6
7use anyhow::{Result, anyhow};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11const MIN_MAJOR: u32 = 3;
12const MIN_MINOR: u32 = 11;
13
14const DOWNLOAD_URL: &str = "https://www.python.org/downloads/";
15
16/// Platform-native default name for the Python interpreter when spawning a
17/// sidecar launcher. On Windows there is no `python3` executable — installs
18/// from python.org expose `python.exe` (or `py.exe`), so hardcoding
19/// `python3` causes "program not found" at spawn time. On Unix `python3`
20/// remains the convention.
21pub fn default_python_command() -> &'static str {
22    if cfg!(windows) { "python" } else { "python3" }
23}
24
25/// Resolve a usable Python interpreter, honoring `explicit` if provided.
26///
27/// Returns the absolute path to the interpreter on success.
28pub fn detect_python(explicit: Option<&str>) -> Result<PathBuf> {
29    if let Some(path) = explicit {
30        let candidate = PathBuf::from(path);
31        return probe(&candidate).ok_or_else(|| missing_python_error(Some(path)));
32    }
33
34    let candidates: &[&str] = if cfg!(windows) {
35        &["py", "python3", "python"]
36    } else {
37        &["python3", "python"]
38    };
39
40    for name in candidates {
41        if let Some(resolved) = probe(Path::new(name)) {
42            return Ok(resolved);
43        }
44    }
45
46    Err(missing_python_error(None))
47}
48
49/// Probe a candidate interpreter. Returns its resolved absolute path if it
50/// runs, reports version ≥ MIN_MAJOR.MIN_MINOR, and is not a Store stub.
51fn probe(candidate: &Path) -> Option<PathBuf> {
52    let output = Command::new(candidate)
53        .args([
54            "-c",
55            "import sys; print(sys.executable); print(sys.version_info[0]); print(sys.version_info[1])",
56        ])
57        .output()
58        .ok()?;
59
60    if !output.status.success() {
61        return None;
62    }
63
64    let stdout = String::from_utf8_lossy(&output.stdout);
65    let mut lines = stdout.lines();
66    let exe = lines.next()?.trim();
67    let major: u32 = lines.next()?.trim().parse().ok()?;
68    let minor: u32 = lines.next()?.trim().parse().ok()?;
69
70    if exe.is_empty() {
71        return None;
72    }
73    if major < MIN_MAJOR || (major == MIN_MAJOR && minor < MIN_MINOR) {
74        return None;
75    }
76
77    Some(PathBuf::from(exe))
78}
79
80fn missing_python_error(tried: Option<&str>) -> anyhow::Error {
81    let prefix = match tried {
82        Some(p) => format!("Python interpreter `{p}` is unusable"),
83        None => format!("No usable Python ≥{MIN_MAJOR}.{MIN_MINOR} found on PATH"),
84    };
85    anyhow!(
86        "{prefix}.\n\n\
87         Construct requires Python {MIN_MAJOR}.{MIN_MINOR}+ to run its MCP sidecars.\n\
88         Install it from {DOWNLOAD_URL} and re-run `construct install --sidecars-only`.\n\n\
89         On Windows, avoid the Microsoft Store Python stub — install from python.org directly."
90    )
91}