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"]; 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 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}