metrale_bench/
python.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Subprocess runner, Python preflight and venv provisioning.
4//! BFCL provisions its venv with these during `load()`; `run` is also used to
5//! call other tools.
6//!
7//! A `load()` error becomes the run's failed `setup` frame, so the failures
8//! of `find_python`, `ensure_venv` and `pip_install` name the missing piece
9//! and a fix.
10//!
11//! Owner: bench (provisioning).
12//! Invariants:
13//! - A command that `run` sees exit unsuccessfully is an error carrying at
14//!   most the last 12 lines of its stderr.
15
16use std::path::{Path, PathBuf};
17use std::process::Stdio;
18
19use anyhow::{Context, Result, anyhow, bail};
20use tokio::process::Command;
21
22/// 2026-09-26: Captured output of a command.
23#[derive(Debug)]
24pub struct Output {
25    pub stdout: String,
26    pub stderr: String,
27}
28
29/// 2026-09-26: Run a command to completion with stdin closed, capturing both
30/// streams. A failure carries the tail of stderr, since a bare exit code is
31/// not actionable.
32pub async fn run(program: &Path, args: &[&str], cwd: Option<&Path>) -> Result<Output> {
33    let mut cmd = Command::new(program);
34    cmd.args(args)
35        .stdin(Stdio::null())
36        .stdout(Stdio::piped())
37        .stderr(Stdio::piped());
38    if let Some(dir) = cwd {
39        cmd.current_dir(dir);
40    }
41    let out = cmd
42        .output()
43        .await
44        .with_context(|| format!("spawning {}", program.display()))?;
45    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
46    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
47    if !out.status.success() {
48        let tail: Vec<&str> = stderr.lines().rev().take(12).collect();
49        let tail: Vec<&str> = tail.into_iter().rev().collect();
50        bail!(
51            "{} {} failed ({}):\n{}",
52            program.display(),
53            args.join(" "),
54            out.status,
55            tail.join("\n")
56        );
57    }
58    Ok(Output { stdout, stderr })
59}
60
61/// 2026-09-26: Locate a Python at least `min_major.min_minor`: `python3`,
62/// then `python`.
63///
64/// `METRALE_PYTHON`, when set, is the only candidate: the box may keep the
65/// usable interpreter off PATH.
66pub async fn find_python(min_major: u32, min_minor: u32) -> Result<PathBuf> {
67    let candidates: Vec<PathBuf> = match std::env::var_os("METRALE_PYTHON") {
68        Some(p) => vec![PathBuf::from(p)],
69        None => vec![PathBuf::from("python3"), PathBuf::from("python")],
70    };
71    let mut tried = Vec::new();
72    for cand in candidates {
73        match run(&cand, &["--version"], None).await {
74            Ok(out) => {
75                let text = if out.stdout.trim().is_empty() {
76                    out.stderr
77                } else {
78                    out.stdout
79                };
80                let (major, minor) = parse_version(&text)
81                    .ok_or_else(|| anyhow!("could not parse {:?} from {}", text, cand.display()))?;
82                if (major, minor) >= (min_major, min_minor) {
83                    return Ok(cand);
84                }
85                tried.push(format!(
86                    "{} is {major}.{minor}, need >= {min_major}.{min_minor}",
87                    cand.display()
88                ));
89            }
90            Err(e) => tried.push(format!("{}: {e}", cand.display())),
91        }
92    }
93    bail!(
94        "no usable python found (need >= {min_major}.{min_minor}). Tried:\n  {}\n\
95         Install python3 or point METRALE_PYTHON at an interpreter.",
96        tried.join("\n  ")
97    )
98}
99
100fn parse_version(text: &str) -> Option<(u32, u32)> {
101    // 2026-09-26: "Python 3.12.3": take the first token whose leading
102    // dot-separated part is all digits; a missing minor reads as 0.
103    let token = text.split_whitespace().find(|t| {
104        t.split('.')
105            .next()
106            .is_some_and(|h| !h.is_empty() && h.chars().all(|c| c.is_ascii_digit()))
107    })?;
108    let mut parts = token.split('.');
109    let major = parts.next()?.parse().ok()?;
110    let minor = parts.next().unwrap_or("0").parse().ok()?;
111    Some((major, minor))
112}
113
114/// 2026-09-26: The interpreter inside a venv, per platform layout.
115pub fn venv_python(venv: &Path) -> PathBuf {
116    if cfg!(windows) {
117        venv.join("Scripts").join("python.exe")
118    } else {
119        venv.join("bin").join("python")
120    }
121}
122
123/// 2026-09-26: Create `venv` with `python` if its interpreter is not already
124/// there, and return the interpreter.
125pub async fn ensure_venv(python: &Path, venv: &Path) -> Result<PathBuf> {
126    let interpreter = venv_python(venv);
127    if interpreter.is_file() {
128        return Ok(interpreter);
129    }
130    // 2026-09-26: Probe the `venv` module first, so a box missing
131    // `python3-venv` is told which package to install.
132    run(python, &["-c", "import venv"], None)
133        .await
134        .context("python is present but the `venv` module is not — install python3-venv")?;
135    let venv_str = venv
136        .to_str()
137        .ok_or_else(|| anyhow!("venv path is not valid UTF-8: {}", venv.display()))?;
138    run(python, &["-m", "venv", venv_str], None)
139        .await
140        .with_context(|| format!("creating venv at {}", venv.display()))?;
141    if !interpreter.is_file() {
142        bail!(
143            "venv created at {} but {} is missing",
144            venv.display(),
145            interpreter.display()
146        );
147    }
148    Ok(interpreter)
149}
150
151/// 2026-09-26: `pip install -r <requirements>` with the venv's interpreter.
152pub async fn pip_install(interpreter: &Path, requirements: &Path) -> Result<()> {
153    let req = requirements
154        .to_str()
155        .ok_or_else(|| anyhow!("requirements path is not valid UTF-8"))?;
156    run(
157        interpreter,
158        &[
159            "-m",
160            "pip",
161            "install",
162            "--disable-pip-version-check",
163            "-r",
164            req,
165        ],
166        None,
167    )
168    .await
169    .context(
170        "pip install failed — this step needs network access to PyPI \
171         (air-gapped boxes must pre-populate ~/.metrale/artifacts)",
172    )?;
173    Ok(())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn version_parses_from_either_stream_format() {
182        assert_eq!(parse_version("Python 3.12.3\n"), Some((3, 12)));
183        assert_eq!(parse_version("Python 3.9"), Some((3, 9)));
184        assert_eq!(parse_version("no version here"), None);
185    }
186
187    #[test]
188    fn venv_interpreter_path_matches_the_platform_layout() {
189        let p = venv_python(Path::new("/tmp/v"));
190        if cfg!(windows) {
191            assert!(p.ends_with("Scripts/python.exe"));
192        } else {
193            assert_eq!(p, Path::new("/tmp/v/bin/python"));
194        }
195    }
196
197    #[tokio::test]
198    async fn a_failing_command_reports_stderr_not_just_the_code() {
199        let script =
200            "i=1; while [ $i -le 14 ]; do printf 'line-%02d\\n' $i >&2; i=$((i + 1)); done; exit 3";
201        let err = run(Path::new("sh"), &["-c", script], None)
202            .await
203            .unwrap_err()
204            .to_string();
205        assert!(err.contains("failed (exit status: 3)"), "{err}");
206        assert!(!err.contains("line-01\n"), "tail excludes line 1: {err}");
207        assert!(!err.contains("line-02\n"), "tail excludes line 2: {err}");
208        assert!(err.contains("line-03\n"), "tail begins at line 3: {err}");
209        assert!(err.ends_with("line-14"), "tail preserves order: {err}");
210    }
211
212    #[tokio::test]
213    async fn a_successful_command_captures_both_streams_and_honors_cwd() {
214        let dir = std::env::temp_dir().join(format!("metrale-python-run-{}", std::process::id()));
215        std::fs::create_dir_all(&dir).expect("scratch");
216        let out = run(
217            Path::new("sh"),
218            &["-c", "pwd; printf warning >&2"],
219            Some(&dir),
220        )
221        .await
222        .expect("runs");
223        assert_eq!(
224            PathBuf::from(out.stdout.trim()),
225            dir.canonicalize().expect("canonical scratch")
226        );
227        assert_eq!(out.stderr, "warning");
228        let _ = std::fs::remove_dir_all(dir);
229    }
230}