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}