blendtutor_core/runner/
python.rs

1//! Python execution mechanics: the Python-specific half of a language run —
2//! launching the interpreter through `uv` — over the shared subprocess core in
3//! [`super::subprocess`].
4//!
5//! This module owns *only* the Python specifics (ADR-0005, §4.1); callers depend
6//! on the [`Runner`](super::Runner) trait, not on [`PythonRunner`]. It mirrors
7//! the R runner: both are a small `Interpreter` descriptor over the shared
8//! spawn/timeout/temp-cwd dance (§4.2) — adding a language is a descriptor, not a
9//! re-implementation.
10//!
11//! When the lesson declares `packages` (ADR-0011), the runner injects
12//! `--with <pkg>` flags into the uv invocation so the submission can import
13//! them. The packages are constructor state — the [`Runner`] trait's
14//! `execute(&self, code, checks)` signature is unchanged (§3.4).
15
16use std::time::Duration;
17
18use super::subprocess::{self, Interpreter};
19use super::{ExecutionResult, Runner, RunnerError, Timeout};
20
21/// A [`Runner`] backed by a real Python subprocess spawned through `uv`.
22///
23/// Stores the lesson's `packages` at construction (ADR-0011); `execute` builds
24/// the `uv run --with <pkg>` invocation from them. The [`Runner`] trait stays
25/// clean of the packages concern — they are constructor state, not a per-call
26/// parameter (§3.4).
27#[derive(Debug, Clone)]
28pub struct PythonRunner {
29    timeout: Timeout,
30    packages: Vec<String>,
31}
32
33impl PythonRunner {
34    /// Build a runner that kills any execution exceeding `timeout`, spawning
35    /// `uv run --with <pkg>` for each package in `packages` (ADR-0011).
36    pub fn new(timeout: Timeout, packages: Vec<String>) -> Self {
37        Self { timeout, packages }
38    }
39
40    /// The lesson packages this runner will inject as `--with <pkg>` flags.
41    ///
42    /// Exposed `pub(crate)` so the grading join's tests can assert that
43    /// [`select_runner`](crate::grade::select_runner) threads packages through
44    /// to the runner, not just that it picks the `Python` variant (ADR-0011).
45    #[cfg(test)]
46    pub(crate) fn packages(&self) -> &[String] {
47        &self.packages
48    }
49
50    /// Build the interpreter descriptor: `uv run --no-project --quiet
51    /// [--with <pkg>...] python -I -c`. When `packages` is empty, no `--with`
52    /// flags are emitted — the invocation is `uv run --no-project --quiet
53    /// python -I -c`, identical to the pre-packages runner.
54    pub(crate) fn interpreter(&self) -> Interpreter {
55        let mut args: Vec<String> = vec!["run".into(), "--no-project".into(), "--quiet".into()];
56        for pkg in &self.packages {
57            args.push("--with".into());
58            args.push(pkg.clone());
59        }
60        args.extend(["python".into(), "-I".into(), "-c".into()]);
61        Interpreter {
62            program: "uv",
63            code_args: args,
64        }
65    }
66}
67
68impl Default for PythonRunner {
69    /// A 30-second bound — generous for a lesson exercise, finite for a runaway.
70    /// No packages (the pre-ADR-0011 default).
71    fn default() -> Self {
72        Self::new(Timeout(Duration::from_secs(30)), Vec::new())
73    }
74}
75
76impl Runner for PythonRunner {
77    async fn execute(
78        &self,
79        code: &str,
80        _checks: &[String],
81    ) -> Result<ExecutionResult, RunnerError> {
82        let interpreter = self.interpreter();
83        subprocess::run(&interpreter, code, self.timeout).await
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn interpreter_includes_with_flags_for_packages() {
93        // ADR-0011: each package must appear as `--with <pkg>` in the uv
94        // invocation, in declaration order. A regression that drops the loop
95        // (or reverses flag/package order) is caught here: the args must
96        // contain `--with`, `pandas`, `--with`, `numpy` in that sequence.
97        let runner = PythonRunner::new(
98            Timeout(Duration::from_secs(30)),
99            vec!["pandas".into(), "numpy".into()],
100        );
101        let interp = runner.interpreter();
102        assert_eq!(interp.program, "uv");
103        let args = &interp.code_args;
104        let with_idx = args
105            .iter()
106            .position(|a| a == "--with")
107            .expect("--with flag must be present when packages are declared");
108        // --with precedes each package name, in declaration order.
109        assert_eq!(
110            &args[with_idx..with_idx + 4],
111            &["--with", "pandas", "--with", "numpy"],
112            "packages must appear as --with <pkg> pairs in order"
113        );
114        // The tail after the packages is the Python invocation.
115        assert_eq!(
116            &args[args.len() - 3..],
117            &["python", "-I", "-c"],
118            "args must end with python -I -c"
119        );
120    }
121
122    #[test]
123    fn interpreter_omits_with_flags_when_packages_empty() {
124        // The pre-ADR-0011 baseline: no packages → no --with flags. The
125        // invocation is exactly `uv run --no-project --quiet python -I -c`.
126        // A regression that unconditionally emits `--with` would surface here.
127        let runner = PythonRunner::new(Timeout(Duration::from_secs(30)), Vec::new());
128        let interp = runner.interpreter();
129        assert_eq!(interp.program, "uv");
130        assert!(
131            !interp.code_args.iter().any(|a| a == "--with"),
132            "no --with flags when packages is empty"
133        );
134        assert_eq!(
135            interp.code_args,
136            vec!["run", "--no-project", "--quiet", "python", "-I", "-c"],
137            "empty-packages invocation must match pre-ADR-0011 baseline"
138        );
139    }
140}