Skip to main content

run/engine/
python.rs

1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::{Duration, Instant};
6
7use anyhow::{Context, Result};
8use tempfile::{Builder, TempDir};
9
10use super::{
11    ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession, execution_timeout,
12    run_version_command, wait_with_timeout,
13};
14
15pub struct PythonEngine {
16    executable: PathBuf,
17}
18
19impl Default for PythonEngine {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl PythonEngine {
26    pub fn new() -> Self {
27        let executable = resolve_python_binary();
28        Self { executable }
29    }
30
31    fn binary(&self) -> &Path {
32        &self.executable
33    }
34
35    fn run_command(&self) -> Command {
36        Command::new(self.binary())
37    }
38}
39
40impl LanguageEngine for PythonEngine {
41    fn id(&self) -> &'static str {
42        "python"
43    }
44
45    fn display_name(&self) -> &'static str {
46        "Python"
47    }
48
49    fn aliases(&self) -> &[&'static str] {
50        &["py", "python3", "py3"]
51    }
52
53    fn supports_sessions(&self) -> bool {
54        true
55    }
56
57    fn validate(&self) -> Result<()> {
58        let mut cmd = self.run_command();
59        cmd.arg("--version")
60            .stdout(Stdio::null())
61            .stderr(Stdio::null());
62        cmd.status()
63            .with_context(|| format!("failed to invoke {}", self.binary().display()))?
64            .success()
65            .then_some(())
66            .ok_or_else(|| anyhow::anyhow!("{} is not executable", self.binary().display()))
67    }
68
69    fn toolchain_version(&self) -> Result<Option<String>> {
70        let mut cmd = self.run_command();
71        cmd.arg("--version");
72        let context = format!("{}", self.binary().display());
73        run_version_command(cmd, &context)
74    }
75
76    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
77        let start = Instant::now();
78        let timeout = execution_timeout();
79        let mut cmd = self.run_command();
80        let args = payload.args();
81        let output = match payload {
82            ExecutionPayload::Inline { code, .. } => {
83                cmd.arg("-c")
84                    .arg(code)
85                    .args(args)
86                    .stdin(Stdio::inherit())
87                    .stdout(Stdio::piped())
88                    .stderr(Stdio::piped());
89                let child = cmd
90                    .spawn()
91                    .with_context(|| format!("failed to start {}", self.binary().display()))?;
92                wait_with_timeout(child, timeout)?
93            }
94            ExecutionPayload::File { path, .. } => {
95                cmd.arg(path)
96                    .args(args)
97                    .stdin(Stdio::inherit())
98                    .stdout(Stdio::piped())
99                    .stderr(Stdio::piped());
100                let child = cmd
101                    .spawn()
102                    .with_context(|| format!("failed to start {}", self.binary().display()))?;
103                wait_with_timeout(child, timeout)?
104            }
105            ExecutionPayload::Stdin { code, .. } => {
106                cmd.arg("-")
107                    .args(args)
108                    .stdin(Stdio::piped())
109                    .stdout(Stdio::piped())
110                    .stderr(Stdio::piped());
111                let mut child = cmd.spawn().with_context(|| {
112                    format!(
113                        "failed to start {} for stdin execution",
114                        self.binary().display()
115                    )
116                })?;
117                if let Some(mut stdin) = child.stdin.take() {
118                    stdin.write_all(code.as_bytes())?;
119                    if !code.ends_with('\n') {
120                        stdin.write_all(b"\n")?;
121                    }
122                    stdin.flush()?;
123                }
124                wait_with_timeout(child, timeout)?
125            }
126        };
127
128        Ok(ExecutionOutcome {
129            language: self.id().to_string(),
130            exit_code: output.status.code(),
131            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
132            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
133            duration: start.elapsed(),
134        })
135    }
136
137    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
138        Ok(Box::new(PythonSession::new(self.executable.clone())?))
139    }
140}
141
142struct PythonSession {
143    executable: PathBuf,
144    dir: TempDir,
145    source_path: PathBuf,
146    statements: Vec<String>,
147    previous_stdout: String,
148    previous_stderr: String,
149}
150
151impl PythonSession {
152    fn new(executable: PathBuf) -> Result<Self> {
153        let dir = Builder::new()
154            .prefix("run-python-repl")
155            .tempdir()
156            .context("failed to create temporary directory for python repl")?;
157        let source_path = dir.path().join("session.py");
158        fs::write(&source_path, "# Python REPL session\n")
159            .with_context(|| format!("failed to initialize {}", source_path.display()))?;
160
161        Ok(Self {
162            executable,
163            dir,
164            source_path,
165            statements: Vec::new(),
166            previous_stdout: String::new(),
167            previous_stderr: String::new(),
168        })
169    }
170
171    fn render_source(&self) -> String {
172        let mut source = String::from("import sys\nfrom math import *\n\n");
173        for snippet in &self.statements {
174            source.push_str(snippet);
175            if !snippet.ends_with('\n') {
176                source.push('\n');
177            }
178        }
179        source
180    }
181
182    fn write_source(&self, contents: &str) -> Result<()> {
183        fs::write(&self.source_path, contents).with_context(|| {
184            format!(
185                "failed to write generated Python REPL source to {}",
186                self.source_path.display()
187            )
188        })
189    }
190
191    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
192        let source = self.render_source();
193        self.write_source(&source)?;
194
195        let output = self.run_script()?;
196        let stdout_full = normalize_output(&output.stdout);
197        let stderr_full = normalize_output(&output.stderr);
198
199        let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
200        let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
201
202        let success = output.status.success();
203        if success {
204            self.previous_stdout = stdout_full;
205            self.previous_stderr = stderr_full;
206        }
207
208        let outcome = ExecutionOutcome {
209            language: "python".to_string(),
210            exit_code: output.status.code(),
211            stdout: stdout_delta,
212            stderr: stderr_delta,
213            duration: start.elapsed(),
214        };
215
216        Ok((outcome, success))
217    }
218
219    fn run_script(&self) -> Result<std::process::Output> {
220        let mut cmd = Command::new(&self.executable);
221        cmd.arg(&self.source_path)
222            .stdout(Stdio::piped())
223            .stderr(Stdio::piped())
224            .current_dir(self.dir.path());
225        cmd.output().with_context(|| {
226            format!(
227                "failed to run python session script {} with {}",
228                self.source_path.display(),
229                self.executable.display()
230            )
231        })
232    }
233
234    fn run_snippet(&mut self, snippet: String) -> Result<ExecutionOutcome> {
235        self.statements.push(snippet);
236        let start = Instant::now();
237        let (outcome, success) = self.run_current(start)?;
238        if !success {
239            let _ = self.statements.pop();
240            let source = self.render_source();
241            self.write_source(&source)?;
242        }
243        Ok(outcome)
244    }
245
246    fn reset_state(&mut self) -> Result<()> {
247        self.statements.clear();
248        self.previous_stdout.clear();
249        self.previous_stderr.clear();
250        let source = self.render_source();
251        self.write_source(&source)
252    }
253}
254
255impl LanguageSession for PythonSession {
256    fn language_id(&self) -> &str {
257        "python"
258    }
259
260    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
261        let trimmed = code.trim();
262        if trimmed.is_empty() {
263            return Ok(ExecutionOutcome {
264                language: self.language_id().to_string(),
265                exit_code: None,
266                stdout: String::new(),
267                stderr: String::new(),
268                duration: Duration::default(),
269            });
270        }
271
272        if trimmed.eq_ignore_ascii_case(":reset") {
273            self.reset_state()?;
274            return Ok(ExecutionOutcome {
275                language: self.language_id().to_string(),
276                exit_code: None,
277                stdout: String::new(),
278                stderr: String::new(),
279                duration: Duration::default(),
280            });
281        }
282
283        if trimmed.eq_ignore_ascii_case(":help") {
284            return Ok(ExecutionOutcome {
285                language: self.language_id().to_string(),
286                exit_code: None,
287                stdout:
288                    "Python commands:\n  :reset - clear session state\n  :help  - show this message\n"
289                        .to_string(),
290                stderr: String::new(),
291                duration: Duration::default(),
292            });
293        }
294
295        if should_treat_as_expression(trimmed) {
296            let snippet = wrap_expression(trimmed, self.statements.len());
297            let outcome = self.run_snippet(snippet)?;
298            if outcome.exit_code.unwrap_or(0) == 0 {
299                return Ok(outcome);
300            }
301        }
302
303        let snippet = ensure_trailing_newline(code);
304        self.run_snippet(snippet)
305    }
306
307    fn shutdown(&mut self) -> Result<()> {
308        Ok(())
309    }
310}
311
312pub(super) fn resolve_python_binary() -> PathBuf {
313    let candidates = ["python3", "python", "py"]; // windows py launcher
314    for name in candidates {
315        if let Ok(path) = which::which(name) {
316            return path;
317        }
318    }
319    PathBuf::from("python3")
320}
321
322fn ensure_trailing_newline(code: &str) -> String {
323    let mut owned = code.to_string();
324    if !owned.ends_with('\n') {
325        owned.push('\n');
326    }
327    owned
328}
329
330fn wrap_expression(code: &str, index: usize) -> String {
331    // Store result in both a unique var and `_` for last-result access
332    format!(
333        "__run_value_{index} = ({code})\n_ = __run_value_{index}\nprint(repr(__run_value_{index}), flush=True)\n"
334    )
335}
336
337fn diff_output(previous: &str, current: &str) -> String {
338    if let Some(stripped) = current.strip_prefix(previous) {
339        stripped.to_string()
340    } else {
341        current.to_string()
342    }
343}
344
345fn normalize_output(bytes: &[u8]) -> String {
346    String::from_utf8_lossy(bytes)
347        .replace("\r\n", "\n")
348        .replace('\r', "")
349}
350
351fn should_treat_as_expression(code: &str) -> bool {
352    let trimmed = code.trim();
353    if trimmed.is_empty() {
354        return false;
355    }
356    if trimmed.contains('\n') {
357        return false;
358    }
359    if trimmed.ends_with(':') {
360        return false;
361    }
362
363    let lowered = trimmed.to_ascii_lowercase();
364    const STATEMENT_PREFIXES: [&str; 21] = [
365        "import ",
366        "from ",
367        "def ",
368        "class ",
369        "if ",
370        "for ",
371        "while ",
372        "try",
373        "except",
374        "finally",
375        "with ",
376        "return ",
377        "raise ",
378        "yield",
379        "async ",
380        "await ",
381        "assert ",
382        "del ",
383        "global ",
384        "nonlocal ",
385        "pass",
386    ];
387    if STATEMENT_PREFIXES
388        .iter()
389        .any(|prefix| lowered.starts_with(prefix))
390    {
391        return false;
392    }
393
394    if lowered.starts_with("print(") || lowered.starts_with("print ") {
395        return false;
396    }
397
398    if trimmed.starts_with("#") {
399        return false;
400    }
401
402    if trimmed.contains('=')
403        && !trimmed.contains("==")
404        && !trimmed.contains("!=")
405        && !trimmed.contains(">=")
406        && !trimmed.contains("<=")
407        && !trimmed.contains("=>")
408    {
409        return false;
410    }
411
412    true
413}