Skip to main content

bashkit/builtins/
python.rs

1//! python/python3 builtin via embedded Monty interpreter (pydantic/monty)
2//!
3//! # Direct Integration
4//!
5//! Monty runs directly in the host process. No subprocess, no IPC.
6//! Resource limits (memory, time, recursion) are enforced by Monty's own
7//! runtime, not by process isolation.
8//!
9//! # Overview
10//!
11//! Virtual Python execution with resource limits and VFS access.
12//! Python `pathlib.Path` operations and `open()` file handles are bridged to
13//! Bashkit's virtual filesystem via Monty's OsCall pause/resume mechanism.
14//! No real filesystem or network access.
15//!
16//! Decision: `open()` performs only VFS open-time effects and returns Monty's
17//! virtual file handle. Bashkit never holds host/native Python file handles.
18//!
19//! Supports: `python -c "code"`, `python script.py`, stdin piping.
20
21use crate::time_compat::Instant;
22use async_trait::async_trait;
23use chrono::{Datelike, Timelike};
24use monty::{MontyRun, RunProgress};
25use monty_types::{
26    CompileOptions, ExcType, ExtFunctionResult, FileMode, LimitedTracker, MontyDate, MontyDateTime,
27    MontyException, MontyFileHandle, MontyObject, NameLookupResult, OsFunctionCall, PrintWriter,
28    ResourceError, ResourceLimits, ResourceTracker, dir_stat, file_stat, symlink_stat,
29};
30use std::cell::Cell;
31use std::collections::HashMap;
32use std::future::Future;
33use std::path::{Path, PathBuf};
34use std::pin::Pin;
35use std::sync::Arc;
36use std::time::Duration;
37
38use super::{Builtin, Context, ExecutionDeadline, RuntimeLimits, resolve_path};
39use crate::error::Result;
40use crate::fs::vfs_join;
41use crate::fs::{FileSystem, FileType};
42use crate::interpreter::ExecResult;
43
44/// Python's default recursion-depth cap. The other VM limits (duration,
45/// memory) default via [`RuntimeLimits::default`].
46const DEFAULT_MAX_RECURSION: usize = 200;
47// Security hard-stop: catastrophic regex backtracking can bypass cooperative
48// interpreter budget checks, so disable regex stdlib module in untrusted code.
49const DISABLED_STDLIB_MODULES: &[&str] = &["re"];
50// Security decision: virtual Python datetime uses a fixed UTC instant so
51// sandboxed code cannot fingerprint host clock/timezone state.
52const VIRTUAL_NOW_UNIX_SECS: i64 = 1_704_067_200; // 2024-01-01T00:00:00Z
53
54/// Bridges Monty's statement/allocation checkpoints into the shared request
55/// budget while retaining Monty's own memory/time/recursion ceilings.
56#[derive(Debug)]
57struct BudgetTracker {
58    runtime: LimitedTracker,
59    execution: Option<crate::limits::ExecutionBudget>,
60    vm_checkpoints: Cell<u64>,
61}
62
63impl BudgetTracker {
64    fn budget_error(err: crate::limits::LimitExceeded) -> ResourceError {
65        // Monty's tracker error type has no host-defined variant. The shared
66        // budget retains the precise poisoned reason; use an uncatchable
67        // memory error only to stop the VM at this checkpoint.
68        let _ = err;
69        ResourceError::Memory { limit: 0, used: 1 }
70    }
71
72    fn consume_work(&self) -> std::result::Result<(), ResourceError> {
73        if let Some(budget) = &self.execution {
74            budget.consume_work(1).map_err(Self::budget_error)?;
75        }
76        Ok(())
77    }
78
79    fn check_vm(&self) -> std::result::Result<(), ResourceError> {
80        if let Some(budget) = &self.execution {
81            budget.check().map_err(Self::budget_error)?;
82            let checkpoints = self.vm_checkpoints.get().wrapping_add(1);
83            self.vm_checkpoints.set(checkpoints);
84            if checkpoints.is_multiple_of(64) {
85                budget.consume_work(1).map_err(Self::budget_error)?;
86            }
87        }
88        Ok(())
89    }
90}
91
92impl ResourceTracker for BudgetTracker {
93    fn on_free(&self, get_size: impl FnOnce() -> usize) {
94        self.runtime.on_free(get_size);
95    }
96
97    fn check_time(&self) -> std::result::Result<(), ResourceError> {
98        self.check_vm()?;
99        self.runtime.check_time()
100    }
101
102    fn check_recursion_depth(
103        &self,
104        current_depth: usize,
105    ) -> std::result::Result<(), ResourceError> {
106        self.runtime.check_recursion_depth(current_depth)
107    }
108
109    fn check_large_result(&self, estimated_bytes: usize) -> std::result::Result<(), ResourceError> {
110        self.runtime.check_large_result(estimated_bytes)
111    }
112
113    fn on_grow(
114        &self,
115        additional_bytes: impl FnOnce() -> usize,
116    ) -> std::result::Result<(), ResourceError> {
117        self.consume_work()?;
118        self.runtime.on_grow(additional_bytes)
119    }
120
121    fn gc_interval(&self) -> Option<usize> {
122        self.runtime.gc_interval()
123    }
124
125    fn on_execution_start(&self) {
126        self.runtime.on_execution_start();
127    }
128
129    fn on_execution_stop(&self) {
130        self.runtime.on_execution_stop();
131    }
132}
133const VIRTUAL_NOW_NANOS: u32 = 123_456_000; // 123456 µs for deterministic microseconds
134
135const PYTHON_INPROCESS_OPT_IN_ENV: &str = "BASHKIT_ALLOW_INPROCESS_PYTHON";
136
137#[derive(Debug, Clone, Copy)]
138pub(crate) struct PythonInprocessOptIn(pub bool);
139
140fn python_inprocess_enabled(ctx: &Context<'_>) -> bool {
141    ctx.execution_extension::<PythonInprocessOptIn>()
142        .is_some_and(|opt_in| opt_in.try_with(|opt_in| opt_in.0).unwrap_or(false))
143        // Unit tests in this module build Context::new_for_test without
144        // execution extensions. Keep local test ergonomics only under cfg(test).
145        || {
146            #[cfg(test)]
147            {
148                let is_enabled = |v: &str| matches!(v, "1" | "true" | "TRUE" | "yes" | "YES");
149                ctx.env
150                    .get(PYTHON_INPROCESS_OPT_IN_ENV)
151                    .is_some_and(|v| is_enabled(v))
152            }
153            #[cfg(not(test))]
154            {
155                false
156            }
157        }
158}
159
160/// Resource limits for the embedded Python (Monty) interpreter.
161///
162/// Use the builder pattern to customize, or `Default` for the standard virtual execution limits:
163/// - 30 second timeout
164/// - 64 MB memory (also caps collected `print` output)
165/// - 200 recursion depth
166///
167/// There is no allocation-count knob: Monty removed `max_allocations` from its
168/// resource limits in 0.0.19, so allocation bombs are contained by `max_memory`
169/// and `max_duration` instead.
170///
171/// # Example
172///
173/// ```rust,ignore
174/// use bashkit::PythonLimits;
175///
176/// let limits = PythonLimits::default()
177///     .max_duration(Duration::from_secs(5))
178///     .max_memory(16 * 1024 * 1024);
179///
180/// let bash = Bash::builder().python_with_limits(limits).build();
181/// ```
182/// The individual limit axes live on [`common`](Self::common) (a shared
183/// [`RuntimeLimits`]); read them as e.g. `limits.common.max_memory`. The fluent
184/// setters below configure them directly.
185#[derive(Debug, Clone)]
186pub struct PythonLimits {
187    /// Shared VM resource limits (duration, memory, call depth).
188    pub common: RuntimeLimits,
189}
190
191impl Default for PythonLimits {
192    fn default() -> Self {
193        Self {
194            common: RuntimeLimits {
195                // Python defaults to a recursion depth of 200.
196                max_call_depth: DEFAULT_MAX_RECURSION,
197                ..RuntimeLimits::default()
198            },
199        }
200    }
201}
202
203impl PythonLimits {
204    /// Set max execution duration.
205    #[must_use]
206    pub fn max_duration(mut self, d: Duration) -> Self {
207        self.common.max_duration = d;
208        self
209    }
210
211    /// Set max memory in bytes.
212    #[must_use]
213    pub fn max_memory(mut self, bytes: usize) -> Self {
214        self.common.max_memory = bytes;
215        self
216    }
217
218    /// Set max recursion depth.
219    #[must_use]
220    pub fn max_recursion(mut self, depth: usize) -> Self {
221        self.common.max_call_depth = depth;
222        self
223    }
224}
225
226/// Async handler for external Python function calls.
227///
228/// Receives `(function_name, positional_args, keyword_args)` directly from monty.
229/// Return `ExtFunctionResult::Return(value)` for success or `ExtFunctionResult::Error(exc)` for failure.
230///
231/// Important security decision: the Python builtin wraps each awaited handler
232/// in the remaining [`PythonLimits::max_duration`] budget. Host handlers are
233/// trusted code, but should still enforce their own I/O and service limits.
234pub type PythonExternalFnHandler = Arc<
235    dyn Fn(
236            String,
237            Vec<MontyObject>,
238            Vec<(MontyObject, MontyObject)>,
239        ) -> Pin<Box<dyn Future<Output = ExtFunctionResult> + Send>>
240        + Send
241        + Sync,
242>;
243
244/// External function configuration for the Python builtin.
245///
246/// Groups function names and their async handler together.
247/// Configure via [`BashBuilder::python_with_external_handler`](crate::BashBuilder::python_with_external_handler).
248#[derive(Clone)]
249pub struct PythonExternalFns {
250    /// Function names callable from Python (e.g., `"call_tool"`).
251    names: Vec<String>,
252    /// Async handler invoked when Python calls one of these functions.
253    handler: PythonExternalFnHandler,
254    prelude: Option<String>,
255}
256
257impl std::fmt::Debug for PythonExternalFns {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.debug_struct("PythonExternalFns")
260            .field("names", &self.names)
261            .field("handler", &"<fn>")
262            .finish()
263    }
264}
265
266/// The python/python3 builtin command.
267///
268/// Executes Python code using the embedded Monty interpreter (pydantic/monty).
269/// Python `pathlib.Path` and `open()` operations are bridged to Bashkit's VFS
270/// — files created by bash (`cat > file`) are readable from Python, and vice
271/// versa.
272///
273/// # Usage
274///
275/// ```bash
276/// python3 -c "print('hello')"
277/// python3 script.py
278/// echo "print('hello')" | python3
279/// python3 -c "2 + 2"              # expression result printed
280/// python3 --version
281/// python3 -c "print(open('/tmp/f.txt').read())"
282/// ```
283pub struct Python {
284    /// Resource limits for the Monty interpreter.
285    pub limits: PythonLimits,
286    /// Optional external function configuration.
287    external_fns: Option<PythonExternalFns>,
288}
289
290impl Python {
291    /// Create with default limits.
292    pub fn new() -> Self {
293        Self {
294            limits: PythonLimits::default(),
295            external_fns: None,
296        }
297    }
298
299    /// Create with custom limits.
300    pub fn with_limits(limits: PythonLimits) -> Self {
301        Self {
302            limits,
303            external_fns: None,
304        }
305    }
306
307    /// Set external function names and handler.
308    ///
309    /// External functions are callable from Python by name.
310    /// When called, execution pauses and the handler is invoked with the raw monty arguments.
311    pub fn with_external_handler(
312        mut self,
313        names: Vec<String>,
314        handler: PythonExternalFnHandler,
315    ) -> Self {
316        self.external_fns = Some(PythonExternalFns {
317            names,
318            handler,
319            prelude: None,
320        });
321        self
322    }
323
324    /// Like [`Self::with_external_handler`], plus a Python prelude evaluated
325    /// before user code (e.g. classes that route `tools.get_user(...)` to an
326    /// external function). Lets embedders expose namespaced host APIs
327    /// without core knowing their shape.
328    pub fn with_external_handler_and_prelude(
329        mut self,
330        names: Vec<String>,
331        handler: PythonExternalFnHandler,
332        prelude: String,
333    ) -> Self {
334        self.external_fns = Some(PythonExternalFns {
335            names,
336            handler,
337            prelude: Some(prelude),
338        });
339        self
340    }
341}
342
343impl Default for Python {
344    fn default() -> Self {
345        Self::new()
346    }
347}
348
349fn is_disabled_import(code: &str) -> Option<&'static str> {
350    let is_disabled_module = |module: &str| {
351        DISABLED_STDLIB_MODULES.iter().any(|disabled| {
352            module == *disabled
353                || (module.starts_with(disabled)
354                    && module
355                        .as_bytes()
356                        .get(disabled.len())
357                        .is_some_and(|next| *next == b'.'))
358        })
359    };
360
361    for raw_line in code.lines() {
362        let line = raw_line.split('#').next().unwrap_or("").trim();
363        if line.is_empty() {
364            continue;
365        }
366
367        if let Some(imports) = line.strip_prefix("import ") {
368            for import in imports.split(',') {
369                let module = import
370                    .split_whitespace()
371                    .next()
372                    .unwrap_or("")
373                    .trim()
374                    .trim_matches(|c: char| c == '\'' || c == '"')
375                    .trim_end_matches(|c: char| {
376                        !(c.is_ascii_alphanumeric() || c == '_' || c == '.')
377                    })
378                    .to_string();
379                if is_disabled_module(&module) {
380                    return Some("re");
381                }
382            }
383        }
384
385        if let Some(from_stmt) = line.strip_prefix("from ") {
386            let module = from_stmt
387                .split_whitespace()
388                .next()
389                .unwrap_or("")
390                .trim()
391                .trim_matches(|c: char| c == '\'' || c == '"')
392                .trim_end_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
393                .to_string();
394            if is_disabled_module(&module) {
395                return Some("re");
396            }
397        }
398
399        if line.contains("__import__('re'") || line.contains("__import__(\"re\"") {
400            return Some("re");
401        }
402        if line.contains("importlib.import_module('re'")
403            || line.contains("importlib.import_module(\"re\"")
404        {
405            return Some("re");
406        }
407    }
408    None
409}
410
411#[async_trait]
412impl Builtin for Python {
413    fn llm_hint(&self) -> Option<&'static str> {
414        Some(
415            "python/python3: Embedded Python (Monty). \
416             Stdlib: math, pathlib, os.getenv, sys, typing. \
417             File I/O via pathlib.Path and open() against the VFS. \
418             No HTTP/network. No classes. No third-party imports.",
419        )
420    }
421
422    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
423        let args = ctx.args;
424
425        // python --version / python -V
426        if args.first().map(|s| s.as_str()) == Some("--version")
427            || args.first().map(|s| s.as_str()) == Some("-V")
428        {
429            return Ok(ExecResult::ok("Python 3.12.0 (monty)\n".to_string()));
430        }
431
432        // python --help / python -h
433        if args.first().map(|s| s.as_str()) == Some("--help")
434            || args.first().map(|s| s.as_str()) == Some("-h")
435        {
436            return Ok(ExecResult::ok(
437                "usage: python3 [-c cmd | file | -] [arg ...]\n\
438                 Options:\n  \
439                 -c cmd : execute code from string\n  \
440                 file   : execute code from file (VFS)\n  \
441                 -      : read code from stdin\n  \
442                 -V     : print version\n"
443                    .to_string(),
444            ));
445        }
446
447        if !python_inprocess_enabled(&ctx) {
448            return Ok(ExecResult::err(
449                format!(
450                    "python3: in-process Python disabled by default for security; set {}=1 to enable\n",
451                    PYTHON_INPROCESS_OPT_IN_ENV
452                ),
453                1,
454            ));
455        }
456
457        let (code, filename) = if let Some(first) = args.first() {
458            match first.as_str() {
459                "-c" => {
460                    // python -c "code"
461                    let code = args.get(1).map(|s| s.as_str()).unwrap_or("");
462                    if code.is_empty() {
463                        return Ok(ExecResult::err(
464                            "python3: option -c requires argument\n".to_string(),
465                            2,
466                        ));
467                    }
468                    (code.to_string(), "<string>".to_string())
469                }
470                "-" => {
471                    // python - : read from stdin
472                    match ctx.stdin {
473                        Some(input) if !input.is_empty() => {
474                            (input.to_string(), "<stdin>".to_string())
475                        }
476                        _ => {
477                            return Ok(ExecResult::err(
478                                "python3: no input from stdin\n".to_string(),
479                                1,
480                            ));
481                        }
482                    }
483                }
484                arg if arg.starts_with('-') => {
485                    return Ok(ExecResult::err(
486                        format!("python3: unknown option: {arg}\n"),
487                        2,
488                    ));
489                }
490                script_path => {
491                    // python script.py
492                    let path = resolve_path(ctx.cwd, script_path);
493                    match ctx.fs.read_file(&path).await {
494                        Ok(bytes) => match String::from_utf8(bytes) {
495                            Ok(code) => (code, script_path.to_string()),
496                            Err(_) => {
497                                return Ok(ExecResult::err(
498                                    format!(
499                                        "python3: can't decode file '{script_path}': not UTF-8\n"
500                                    ),
501                                    1,
502                                ));
503                            }
504                        },
505                        Err(_) => {
506                            return Ok(ExecResult::err(
507                                format!(
508                                    "python3: can't open file '{script_path}': No such file or directory\n"
509                                ),
510                                2,
511                            ));
512                        }
513                    }
514                }
515            }
516        } else if let Some(input) = ctx.stdin {
517            // Piped input without arguments
518            if input.is_empty() {
519                return Ok(ExecResult::ok(String::new()));
520            }
521            (input.to_string(), "<stdin>".to_string())
522        } else {
523            // No args, no stdin — interactive mode not supported
524            return Ok(ExecResult::err(
525                "python3: interactive mode not supported in virtual mode\n".to_string(),
526                1,
527            ));
528        };
529
530        if let Some(module) = is_disabled_import(&code) {
531            return Ok(ExecResult::err(
532                format!(
533                    "python3: importing module '{module}' is disabled for security reasons (regex DoS risk)\n"
534                ),
535                1,
536            ));
537        }
538        ctx.consume_budget_input(code.len())?;
539        ctx.consume_budget_work(u64::try_from(code.len().div_ceil(64)).unwrap_or(u64::MAX))?;
540
541        // THREAT[TM-INF]: Python environment access is intentionally scoped
542        // to exported variables only (`ctx.env`). Shell-local variables in
543        // `ctx.variables` may contain wrapper secrets or internal markers.
544
545        // Clamp Monty's wall-clock budget to the caller's remaining execution
546        // deadline (like the TypeScript builtin) so a CPU-bound script cannot
547        // overrun a tighter bash timeout — the async timeout cannot preempt
548        // Monty's synchronous start/resume sections.
549        let mut limits = self.limits.clone();
550        if let Some(remaining) = ctx
551            .execution_extension::<ExecutionDeadline>()
552            .and_then(|deadline| deadline.try_with(ExecutionDeadline::remaining).ok())
553        {
554            limits.common.max_duration = limits.common.max_duration.min(remaining);
555        }
556        // Monty exposes an independent VM ceiling. Reserve a conservative
557        // share so repeated runtime invocations cannot each claim a fresh full
558        // memory/allocation allowance from one host request.
559        ctx.consume_budget_work(
560            u64::try_from(limits.common.max_memory.div_ceil(64)).unwrap_or(u64::MAX),
561        )?;
562
563        let code = if let Some(prelude) = self
564            .external_fns
565            .as_ref()
566            .and_then(|external| external.prelude.as_ref())
567        {
568            format!("{prelude}\n{code}")
569        } else {
570            code
571        };
572        let policy = PythonExecutionPolicy {
573            limits: &limits,
574            external_fns: self.external_fns.as_ref(),
575            execution_budget: ctx
576                .execution_budget()
577                .and_then(|budget| budget.try_with(Clone::clone).ok()),
578        };
579        let execution_budget = ctx
580            .execution_budget()
581            .and_then(|budget| budget.try_with(Clone::clone).ok());
582        let future = run_python(&code, &filename, ctx.fs.clone(), ctx.cwd, ctx.env, policy);
583        // Host functions read the request through RuntimeCallContext::current().
584        let future = crate::runtime_call::scope(&ctx, future);
585        match execution_budget {
586            Some(budget) => budget.run(future).await?,
587            None => future.await,
588        }
589    }
590}
591
592struct PythonExecutionPolicy<'a> {
593    limits: &'a PythonLimits,
594    external_fns: Option<&'a PythonExternalFns>,
595    execution_budget: Option<crate::limits::ExecutionBudget>,
596}
597
598/// Execute Python code via Monty with resource limits and VFS bridging.
599///
600/// Uses Monty's start/resume API: execution pauses at filesystem operations
601/// (OsCall), we bridge them to Bashkit's VFS, then resume.
602async fn run_python(
603    code: &str,
604    filename: &str,
605    fs: Arc<dyn FileSystem>,
606    cwd: &Path,
607    env: &HashMap<String, String>,
608    policy: PythonExecutionPolicy<'_>,
609) -> Result<ExecResult> {
610    let PythonExecutionPolicy {
611        limits: py_limits,
612        external_fns,
613        execution_budget,
614    } = policy;
615    // Strip shebang if present
616    let code = if code.starts_with("#!") {
617        match code.find('\n') {
618            Some(pos) => &code[pos + 1..],
619            None => "",
620        }
621    } else {
622        code
623    };
624
625    let runner = match MontyRun::new(code.to_owned(), filename, vec![], CompileOptions::default()) {
626        Ok(r) => r,
627        Err(e) => return Ok(format_exception(e)),
628    };
629
630    let limits = ResourceLimits::new()
631        .max_duration(py_limits.common.max_duration)
632        .max_memory(py_limits.common.max_memory)
633        .max_recursion_depth(Some(py_limits.common.max_call_depth));
634
635    let tracker = BudgetTracker {
636        runtime: LimitedTracker::new(limits),
637        execution: execution_budget.clone(),
638        vm_checkpoints: Cell::new(0),
639    };
640    // Important security decision: cap collected print output at the same
641    // memory budget as the VM heap. Monty 0.0.19 added a byte cap on
642    // `PrintWriter::CollectString` because a `while True: print(...)` loop
643    // grows the *host* buffer without touching the VM heap, so an uncapped
644    // collector OOMs the host while `max_memory` stays happy. Reusing
645    // `max_memory` keeps one number to reason about: tightening the memory
646    // limit tightens the output cap with it.
647    let print_cap = Some(py_limits.common.max_memory);
648    // Important security decision: cap awaited host callbacks with the same wall-clock
649    // budget as Monty so external functions cannot pin execution between VM steps.
650    let python_deadline = Instant::now().checked_add(py_limits.common.max_duration);
651
652    // Run the synchronous start() phase, then extract collected output.
653    // PrintWriter::CollectString is not Send, so we scope it to avoid holding across .await.
654    let (mut progress, mut buf) = {
655        let mut buf = String::new();
656        match runner.start(
657            vec![],
658            tracker,
659            PrintWriter::CollectString(&mut buf, print_cap),
660        ) {
661            Ok(p) => (p, buf),
662            Err(e) => {
663                return Ok(format_exception_with_output(e, &buf));
664            }
665        }
666    };
667
668    loop {
669        if let Some(budget) = &execution_budget {
670            budget.consume_work(1)?;
671        }
672        match progress {
673            RunProgress::OsCall(os_call) => {
674                let function_call = os_call.function_call.clone();
675                let result = handle_os_call(function_call, &fs, cwd, env).await;
676                match os_call.resume(result, PrintWriter::CollectString(&mut buf, print_cap)) {
677                    Ok(next) => {
678                        progress = next;
679                    }
680                    Err(e) => {
681                        return Ok(format_exception_with_output(e, &buf));
682                    }
683                }
684            }
685            RunProgress::FunctionCall(call) => {
686                if let Some(budget) = &execution_budget {
687                    budget.consume_work(100)?;
688                }
689                let result = if let Some(ef) = external_fns {
690                    call_external_with_deadline(
691                        ef,
692                        call.function_name.clone(),
693                        call.args.clone(),
694                        call.kwargs.clone(),
695                        python_deadline,
696                    )
697                    .await
698                } else {
699                    // No external functions registered; return error
700                    ExtFunctionResult::Error(MontyException::new(
701                        ExcType::RuntimeError,
702                        Some(
703                            "no external function handler configured (external functions not enabled)".into(),
704                        ),
705                    ))
706                };
707
708                match call.resume(result, PrintWriter::CollectString(&mut buf, print_cap)) {
709                    Ok(next) => {
710                        progress = next;
711                    }
712                    Err(e) => {
713                        return Ok(format_exception_with_output(e, &buf));
714                    }
715                }
716            }
717            RunProgress::NameLookup(lookup) => {
718                // External functions are now auto-detected via NameLookup.
719                // If the name matches one of our registered external function names,
720                // resolve it as a callable; otherwise let Python raise NameError.
721                let result = if external_fns
722                    .map(|ef| ef.names.contains(&lookup.name))
723                    .unwrap_or(false)
724                {
725                    // Return a callable marker — monty will pause again with
726                    // FunctionCall when it's actually invoked.
727                    NameLookupResult::Value(MontyObject::Function {
728                        name: lookup.name.clone(),
729                        docstring: None,
730                    })
731                } else {
732                    NameLookupResult::Undefined
733                };
734
735                match lookup.resume(result, PrintWriter::CollectString(&mut buf, print_cap)) {
736                    Ok(next) => {
737                        progress = next;
738                    }
739                    Err(e) => {
740                        return Ok(format_exception_with_output(e, &buf));
741                    }
742                }
743            }
744            RunProgress::ResolveFutures(_) => {
745                // Async futures not supported in virtual mode
746                let err = MontyException::new(
747                    ExcType::RuntimeError,
748                    Some("async operations not supported in virtual mode".into()),
749                );
750                return Ok(format_exception_with_output(err, &buf));
751            }
752            RunProgress::Complete(result) => {
753                // If the result is not None and there was no print output,
754                // display the result (like Python REPL behavior for expressions)
755                if !matches!(result, MontyObject::None) && buf.is_empty() {
756                    buf = format!("{}\n", result.py_repr());
757                }
758
759                return Ok(ExecResult::ok(buf));
760            }
761        }
762    }
763}
764
765fn python_external_timeout_error() -> ExtFunctionResult {
766    ExtFunctionResult::Error(MontyException::new(
767        ExcType::RuntimeError,
768        Some("Python external function exceeded max_duration".into()),
769    ))
770}
771
772async fn call_external_with_deadline(
773    external_fns: &PythonExternalFns,
774    function_name: String,
775    args: Vec<MontyObject>,
776    kwargs: Vec<(MontyObject, MontyObject)>,
777    deadline: Option<Instant>,
778) -> ExtFunctionResult {
779    let Some(deadline) = deadline else {
780        // Instant::checked_add overflowed (very large max_duration); run uncapped.
781        return (external_fns.handler)(function_name, args, kwargs).await;
782    };
783    let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
784        return python_external_timeout_error();
785    };
786    match crate::time_compat::timeout(
787        remaining,
788        (external_fns.handler)(function_name, args, kwargs),
789    )
790    .await
791    {
792        Ok(result) => result,
793        Err(_) => python_external_timeout_error(),
794    }
795}
796
797// ---------------------------------------------------------------------------
798// VFS bridging: Monty OsCall → Bashkit FileSystem
799// ---------------------------------------------------------------------------
800
801/// Monty's `OsFunctionCall` is a tagged enum carrying typed args. We project it
802/// to the generic `(positional, keyword)` `MontyObject` view via the public
803/// `to_args()` and dispatch on the stable `name()` string (kept byte-identical
804/// across monty releases for snapshot compatibility), keeping the per-op VFS
805/// logic unchanged.
806async fn handle_os_call(
807    function: OsFunctionCall,
808    fs: &Arc<dyn FileSystem>,
809    cwd: &Path,
810    env: &HashMap<String, String>,
811) -> ExtFunctionResult {
812    let name = function.name();
813    let (args, kwargs) = function.to_args();
814    let args = args.as_slice();
815    let kwargs = kwargs.as_slice();
816
817    // Non-filesystem operations: env access, date/time
818    match name {
819        "os.getenv" => return handle_getenv(args, env),
820        "os.environ" => return handle_get_environ(env),
821        "date.today" => return handle_date_today(),
822        "datetime.now" => return handle_datetime_now(args),
823        _ => {}
824    }
825
826    // All other ops need a path as first arg
827    let path = match extract_path(args, cwd) {
828        Some(p) => p,
829        None => {
830            return ExtFunctionResult::Error(MontyException::new(
831                ExcType::TypeError,
832                Some("expected path argument".into()),
833            ));
834        }
835    };
836
837    match name {
838        "open" => {
839            let mode = match parse_open_mode(args) {
840                Ok(mode) => mode,
841                Err(err) => return err,
842            };
843            match open_vfs_file(&path, mode, fs).await {
844                Ok(()) => ExtFunctionResult::Return(MontyObject::FileHandle(MontyFileHandle {
845                    path: path.to_string_lossy().to_string(),
846                    mode,
847                    position: 0,
848                })),
849                Err(e) => map_vfs_error(e, &path),
850            }
851        }
852        "Path.exists" => {
853            let exists = fs.exists(&path).await.unwrap_or(false);
854            ExtFunctionResult::Return(MontyObject::Bool(exists))
855        }
856        "Path.is_file" => match fs.stat(&path).await {
857            Ok(meta) => ExtFunctionResult::Return(MontyObject::Bool(meta.file_type.is_file())),
858            Err(_) => ExtFunctionResult::Return(MontyObject::Bool(false)),
859        },
860        "Path.is_dir" => match fs.stat(&path).await {
861            Ok(meta) => ExtFunctionResult::Return(MontyObject::Bool(meta.file_type.is_dir())),
862            Err(_) => ExtFunctionResult::Return(MontyObject::Bool(false)),
863        },
864        "Path.is_symlink" => match fs.lstat(&path).await {
865            Ok(meta) => ExtFunctionResult::Return(MontyObject::Bool(meta.file_type.is_symlink())),
866            Err(_) => ExtFunctionResult::Return(MontyObject::Bool(false)),
867        },
868        "Path.read_text" => match fs.read_file(&path).await {
869            Ok(bytes) => match String::from_utf8(bytes) {
870                Ok(s) => ExtFunctionResult::Return(MontyObject::String(s)),
871                Err(_) => ExtFunctionResult::Error(MontyException::new(
872                    ExcType::OSError,
873                    Some(format!(
874                        "can't decode '{}': not valid UTF-8",
875                        path.display()
876                    )),
877                )),
878            },
879            Err(e) => map_vfs_error(e, &path),
880        },
881        "Path.read_bytes" => match fs.read_file(&path).await {
882            Ok(bytes) => ExtFunctionResult::Return(MontyObject::Bytes(bytes)),
883            Err(e) => map_vfs_error(e, &path),
884        },
885        "Path.write_text" => {
886            let content = match args.get(1) {
887                Some(MontyObject::String(s)) => s.as_bytes().to_vec(),
888                _ => {
889                    return ExtFunctionResult::Error(MontyException::new(
890                        ExcType::TypeError,
891                        Some("write_text() requires a string argument".into()),
892                    ));
893                }
894            };
895            let len = match args.get(1) {
896                Some(MontyObject::String(s)) => s.chars().count(),
897                _ => 0,
898            };
899            match fs.write_file(&path, &content).await {
900                Ok(()) => ExtFunctionResult::Return(MontyObject::Int(len as i64)),
901                Err(e) => map_vfs_error(e, &path),
902            }
903        }
904        "Path.write_bytes" => {
905            let content = match args.get(1) {
906                Some(MontyObject::Bytes(b)) => b.clone(),
907                _ => {
908                    return ExtFunctionResult::Error(MontyException::new(
909                        ExcType::TypeError,
910                        Some("write_bytes() requires a bytes argument".into()),
911                    ));
912                }
913            };
914            let len = content.len();
915            match fs.write_file(&path, &content).await {
916                Ok(()) => ExtFunctionResult::Return(MontyObject::Int(len as i64)),
917                Err(e) => map_vfs_error(e, &path),
918            }
919        }
920        "Path.append_text" => {
921            let content = match args.get(1) {
922                Some(MontyObject::String(s)) => s.as_bytes().to_vec(),
923                _ => {
924                    return ExtFunctionResult::Error(MontyException::new(
925                        ExcType::TypeError,
926                        Some("append_text() requires a string argument".into()),
927                    ));
928                }
929            };
930            let len = match args.get(1) {
931                Some(MontyObject::String(s)) => s.chars().count(),
932                _ => 0,
933            };
934            match fs.append_file(&path, &content).await {
935                Ok(()) => ExtFunctionResult::Return(MontyObject::Int(len as i64)),
936                Err(e) => map_vfs_error(e, &path),
937            }
938        }
939        "Path.append_bytes" => {
940            let content = match args.get(1) {
941                Some(MontyObject::Bytes(b)) => b.clone(),
942                _ => {
943                    return ExtFunctionResult::Error(MontyException::new(
944                        ExcType::TypeError,
945                        Some("append_bytes() requires a bytes argument".into()),
946                    ));
947                }
948            };
949            let len = content.len();
950            match fs.append_file(&path, &content).await {
951                Ok(()) => ExtFunctionResult::Return(MontyObject::Int(len as i64)),
952                Err(e) => map_vfs_error(e, &path),
953            }
954        }
955        "Path.mkdir" => {
956            let parents = get_bool_kwarg(kwargs, "parents").unwrap_or(false);
957            let exist_ok = get_bool_kwarg(kwargs, "exist_ok").unwrap_or(false);
958            match fs.mkdir(&path, parents).await {
959                Ok(()) => ExtFunctionResult::Return(MontyObject::None),
960                Err(e) => {
961                    let msg = e.to_string();
962                    if exist_ok && msg.contains("already exists") {
963                        ExtFunctionResult::Return(MontyObject::None)
964                    } else {
965                        map_vfs_error(e, &path)
966                    }
967                }
968            }
969        }
970        "Path.unlink" => match fs.remove(&path, false).await {
971            Ok(()) => ExtFunctionResult::Return(MontyObject::None),
972            Err(e) => map_vfs_error(e, &path),
973        },
974        "Path.rmdir" => match fs.remove(&path, false).await {
975            Ok(()) => ExtFunctionResult::Return(MontyObject::None),
976            Err(e) => map_vfs_error(e, &path),
977        },
978        "Path.iterdir" => match fs.read_dir(&path).await {
979            Ok(entries) => {
980                let items: Vec<MontyObject> = entries
981                    .into_iter()
982                    .map(|e| {
983                        let child = vfs_join(&path, &e.name);
984                        MontyObject::Path(child.to_string_lossy().to_string())
985                    })
986                    .collect();
987                ExtFunctionResult::Return(MontyObject::List(items))
988            }
989            Err(e) => map_vfs_error(e, &path),
990        },
991        "Path.stat" => match fs.stat(&path).await {
992            Ok(meta) => {
993                let mtime = meta
994                    .modified
995                    .duration_since(crate::time_compat::UNIX_EPOCH)
996                    .map(|d| d.as_secs_f64())
997                    .unwrap_or(0.0);
998                let stat_obj = match meta.file_type {
999                    FileType::Directory => dir_stat(meta.mode as i64, mtime),
1000                    FileType::Symlink => symlink_stat(meta.mode as i64, mtime),
1001                    _ => file_stat(meta.mode as i64, meta.size as i64, mtime),
1002                };
1003                ExtFunctionResult::Return(stat_obj)
1004            }
1005            Err(e) => map_vfs_error(e, &path),
1006        },
1007        "Path.rename" => {
1008            let target = match args.get(1) {
1009                Some(MontyObject::Path(p)) | Some(MontyObject::String(p)) => {
1010                    resolve_python_path(p, cwd)
1011                }
1012                _ => {
1013                    return ExtFunctionResult::Error(MontyException::new(
1014                        ExcType::TypeError,
1015                        Some("rename() requires a target path".into()),
1016                    ));
1017                }
1018            };
1019            match fs.rename(&path, &target).await {
1020                Ok(()) => ExtFunctionResult::Return(MontyObject::Path(
1021                    target.to_string_lossy().to_string(),
1022                )),
1023                Err(e) => map_vfs_error(e, &path),
1024            }
1025        }
1026        "Path.resolve" | "Path.absolute" => {
1027            // No symlink resolution in Bashkit VFS; just return absolute path
1028            ExtFunctionResult::Return(MontyObject::Path(path.to_string_lossy().to_string()))
1029        }
1030        // Getenv/GetEnviron handled above
1031        _ => ExtFunctionResult::Error(MontyException::new(
1032            ExcType::OSError,
1033            Some(format!("{name} not supported in virtual mode")),
1034        )),
1035    }
1036}
1037
1038/// Extract a path from the first OsCall arg and resolve relative to cwd.
1039fn extract_path(args: &[MontyObject], cwd: &Path) -> Option<PathBuf> {
1040    match args.first()? {
1041        MontyObject::Path(s) | MontyObject::String(s) => Some(resolve_python_path(s, cwd)),
1042        MontyObject::FileHandle(handle) => Some(resolve_python_path(&handle.path, cwd)),
1043        _ => None,
1044    }
1045}
1046
1047fn parse_open_mode(args: &[MontyObject]) -> std::result::Result<FileMode, ExtFunctionResult> {
1048    let Some(MontyObject::String(mode)) = args.get(1) else {
1049        return Err(ExtFunctionResult::Error(MontyException::new(
1050            ExcType::TypeError,
1051            Some("open() missing mode argument".into()),
1052        )));
1053    };
1054
1055    mode.parse::<FileMode>().map_err(|msg| {
1056        ExtFunctionResult::Error(MontyException::new(
1057            ExcType::ValueError,
1058            Some(msg.into_owned()),
1059        ))
1060    })
1061}
1062
1063async fn open_vfs_file(path: &Path, mode: FileMode, fs: &Arc<dyn FileSystem>) -> Result<()> {
1064    match mode {
1065        FileMode::Read(_) | FileMode::ReadUpdate(_) => {
1066            let meta = fs.stat(path).await?;
1067            if meta.file_type.is_dir() {
1068                return Err(std::io::Error::other("is a directory").into());
1069            }
1070        }
1071        FileMode::Write(_) | FileMode::WriteUpdate(_) => {
1072            fs.write_file(path, b"").await?;
1073        }
1074        FileMode::Append(_) | FileMode::AppendUpdate(_) => {
1075            if fs.exists(path).await.unwrap_or(false) {
1076                let meta = fs.stat(path).await?;
1077                if meta.file_type.is_dir() {
1078                    return Err(std::io::Error::other("is a directory").into());
1079                }
1080            } else {
1081                fs.write_file(path, b"").await?;
1082            }
1083        }
1084    }
1085    Ok(())
1086}
1087
1088/// Resolve a Python path string against cwd if relative.
1089fn resolve_python_path(path_str: &str, cwd: &Path) -> PathBuf {
1090    let p = Path::new(path_str);
1091    if p.is_absolute() {
1092        p.to_owned()
1093    } else {
1094        vfs_join(cwd, p)
1095    }
1096}
1097
1098/// Map a Bashkit VFS error to a Python exception via ExtFunctionResult.
1099fn map_vfs_error(e: crate::Error, path: &Path) -> ExtFunctionResult {
1100    let msg = e.to_string();
1101    let path_str = path.display().to_string();
1102
1103    let (exc_type, errno) = if msg.contains("not found") || msg.contains("No such file") {
1104        (ExcType::FileNotFoundError, 2)
1105    } else if msg.contains("is a directory") {
1106        (ExcType::IsADirectoryError, 21)
1107    } else if msg.contains("not a directory") {
1108        (ExcType::NotADirectoryError, 20)
1109    } else if msg.contains("already exists") {
1110        (ExcType::FileExistsError, 17)
1111    } else {
1112        (ExcType::OSError, 0)
1113    };
1114
1115    ExtFunctionResult::Error(MontyException::new(
1116        exc_type,
1117        Some(format!("[Errno {errno}] {msg}: '{path_str}'")),
1118    ))
1119}
1120
1121/// Extract a bool kwarg by name from the kwargs list.
1122fn get_bool_kwarg(kwargs: &[(MontyObject, MontyObject)], name: &str) -> Option<bool> {
1123    for (key, val) in kwargs {
1124        if let MontyObject::String(k) = key
1125            && k == name
1126        {
1127            return match val {
1128                MontyObject::Bool(b) => Some(*b),
1129                _ => None,
1130            };
1131        }
1132    }
1133    None
1134}
1135
1136/// Handle os.getenv(key, default=None).
1137fn handle_getenv(args: &[MontyObject], env: &HashMap<String, String>) -> ExtFunctionResult {
1138    let key = match args.first() {
1139        Some(MontyObject::String(s)) => s.as_str(),
1140        _ => {
1141            return ExtFunctionResult::Error(MontyException::new(
1142                ExcType::TypeError,
1143                Some("getenv() requires a string argument".into()),
1144            ));
1145        }
1146    };
1147    let default = match args.get(1) {
1148        Some(MontyObject::None) | None => MontyObject::None,
1149        Some(other) => other.clone(),
1150    };
1151    match env.get(key) {
1152        Some(val) => ExtFunctionResult::Return(MontyObject::String(val.clone())),
1153        None => ExtFunctionResult::Return(default),
1154    }
1155}
1156
1157/// Handle os.environ → dict of all env vars.
1158fn handle_get_environ(env: &HashMap<String, String>) -> ExtFunctionResult {
1159    let pairs: Vec<(MontyObject, MontyObject)> = env
1160        .iter()
1161        .map(|(k, v)| {
1162            (
1163                MontyObject::String(k.clone()),
1164                MontyObject::String(v.clone()),
1165            )
1166        })
1167        .collect();
1168    ExtFunctionResult::Return(MontyObject::dict(pairs))
1169}
1170
1171/// Handle datetime.date.today() using the sandbox virtual clock.
1172fn handle_date_today() -> ExtFunctionResult {
1173    let now = virtual_now_utc();
1174    ExtFunctionResult::Return(MontyObject::Date(MontyDate {
1175        year: now.year(),
1176        month: now.month() as u8,
1177        day: now.day() as u8,
1178    }))
1179}
1180
1181/// Handle datetime.datetime.now(tz=None) using the sandbox virtual clock.
1182///
1183/// If tz is None, returns a naive datetime (no timezone info).
1184/// If tz is a TimeZone, returns an aware datetime at that offset.
1185fn handle_datetime_now(args: &[MontyObject]) -> ExtFunctionResult {
1186    let base_utc = virtual_now_utc();
1187    let tz = match args.first() {
1188        Some(MontyObject::TimeZone(tz)) => Some(tz),
1189        _ => None,
1190    };
1191
1192    if let Some(tz) = tz {
1193        // Aware datetime at the requested offset
1194        let offset = chrono::FixedOffset::east_opt(tz.offset_seconds)
1195            .unwrap_or(chrono::FixedOffset::east_opt(0).expect("UTC offset is always valid"));
1196        let dt = base_utc.with_timezone(&offset);
1197        ExtFunctionResult::Return(MontyObject::DateTime(MontyDateTime {
1198            year: dt.year(),
1199            month: dt.month() as u8,
1200            day: dt.day() as u8,
1201            hour: dt.hour() as u8,
1202            minute: dt.minute() as u8,
1203            second: dt.second() as u8,
1204            microsecond: dt.nanosecond() / 1000,
1205            offset_seconds: Some(tz.offset_seconds),
1206            timezone_name: tz.name.clone(),
1207        }))
1208    } else {
1209        // No timezone → naive UTC datetime (no timezone metadata)
1210        let dt = base_utc.naive_utc();
1211        ExtFunctionResult::Return(MontyObject::DateTime(MontyDateTime {
1212            year: dt.year(),
1213            month: dt.month() as u8,
1214            day: dt.day() as u8,
1215            hour: dt.hour() as u8,
1216            minute: dt.minute() as u8,
1217            second: dt.second() as u8,
1218            microsecond: dt.nanosecond() / 1000,
1219            offset_seconds: None,
1220            timezone_name: None,
1221        }))
1222    }
1223}
1224
1225fn virtual_now_utc() -> chrono::DateTime<chrono::Utc> {
1226    chrono::DateTime::<chrono::Utc>::from_timestamp(VIRTUAL_NOW_UNIX_SECS, VIRTUAL_NOW_NANOS)
1227        .expect("virtual timestamp constant must be valid")
1228}
1229
1230// ---------------------------------------------------------------------------
1231// Error formatting
1232// ---------------------------------------------------------------------------
1233
1234/// Format a MontyException into an ExecResult with exit code 1.
1235fn format_exception(e: MontyException) -> ExecResult {
1236    ExecResult::err(format!("{e}\n"), 1)
1237}
1238
1239/// Format exception, preserving any output produced before the error.
1240fn format_exception_with_output(e: MontyException, printed: &str) -> ExecResult {
1241    let stderr = format!("{e}\n");
1242    let mut result = ExecResult::err(stderr, 1);
1243    if !printed.is_empty() {
1244        result.stdout = printed.to_string().into();
1245    }
1246    result
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251    use super::*;
1252    use crate::builtins::Context;
1253    use crate::fs::InMemoryFs;
1254    use std::collections::HashMap;
1255    use std::path::PathBuf;
1256    use std::sync::Arc;
1257
1258    fn opt_in_env() -> HashMap<String, String> {
1259        let mut env = HashMap::new();
1260        env.insert(PYTHON_INPROCESS_OPT_IN_ENV.to_string(), "1".to_string());
1261        env
1262    }
1263
1264    async fn run(args: &[&str], stdin: Option<&str>) -> ExecResult {
1265        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
1266        let env = opt_in_env();
1267        let mut variables = HashMap::new();
1268        let mut cwd = PathBuf::from("/home/user");
1269        let fs = Arc::new(InMemoryFs::new());
1270        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, stdin);
1271        Python::new().execute(ctx).await.unwrap()
1272    }
1273
1274    async fn run_with_file(args: &[&str], file_path: &str, content: &str) -> ExecResult {
1275        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
1276        let env = opt_in_env();
1277        let mut variables = HashMap::new();
1278        let mut cwd = PathBuf::from("/home/user");
1279        let fs = Arc::new(InMemoryFs::new());
1280        fs.write_file(std::path::Path::new(file_path), content.as_bytes())
1281            .await
1282            .unwrap();
1283        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
1284        Python::new().execute(ctx).await.unwrap()
1285    }
1286
1287    /// Helper: run Python with pre-populated VFS files and env vars.
1288    async fn run_with_vfs(
1289        args: &[&str],
1290        files: &[(&str, &str)],
1291        env_vars: &[(&str, &str)],
1292    ) -> ExecResult {
1293        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
1294        let mut env: HashMap<String, String> = env_vars
1295            .iter()
1296            .map(|(k, v)| (k.to_string(), v.to_string()))
1297            .collect();
1298        env.insert(PYTHON_INPROCESS_OPT_IN_ENV.to_string(), "1".to_string());
1299        let mut variables = HashMap::new();
1300        let mut cwd = PathBuf::from("/home/user");
1301        let fs = Arc::new(InMemoryFs::new());
1302        for (path, content) in files {
1303            // Ensure parent dirs exist
1304            let p = std::path::Path::new(path);
1305            if let Some(parent) = p.parent() {
1306                let _ = fs.mkdir(parent, true).await;
1307            }
1308            fs.write_file(p, content.as_bytes()).await.unwrap();
1309        }
1310        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
1311        Python::new().execute(ctx).await.unwrap()
1312    }
1313
1314    // --- Basic functionality tests ---
1315
1316    #[tokio::test]
1317    async fn test_version() {
1318        let r = run(&["--version"], None).await;
1319        assert_eq!(r.exit_code, 0);
1320        assert!(r.stdout.contains("Python 3.12.0"));
1321    }
1322
1323    #[tokio::test]
1324    async fn test_inline_print() {
1325        let r = run(&["-c", "print('hello world')"], None).await;
1326        assert_eq!(r.exit_code, 0);
1327        assert_eq!(r.stdout, "hello world\n");
1328    }
1329
1330    #[tokio::test]
1331    async fn test_inline_expression() {
1332        let r = run(&["-c", "2 + 3"], None).await;
1333        assert_eq!(r.exit_code, 0);
1334        assert_eq!(r.stdout, "5\n");
1335    }
1336
1337    #[tokio::test]
1338    async fn test_inline_multiline() {
1339        let r = run(&["-c", "x = 10\ny = 20\nprint(x + y)"], None).await;
1340        assert_eq!(r.exit_code, 0);
1341        assert_eq!(r.stdout, "30\n");
1342    }
1343
1344    #[tokio::test]
1345    async fn test_syntax_error() {
1346        let r = run(&["-c", "def"], None).await;
1347        assert_eq!(r.exit_code, 1);
1348        assert!(r.stderr.contains("SyntaxError") || r.stderr.contains("Error"));
1349    }
1350
1351    #[tokio::test]
1352    async fn test_runtime_error() {
1353        let r = run(&["-c", "1/0"], None).await;
1354        assert_eq!(r.exit_code, 1);
1355        assert!(r.stderr.contains("ZeroDivisionError"));
1356    }
1357
1358    #[tokio::test]
1359    async fn test_stdin_code() {
1360        let r = run(&["-"], Some("print('from stdin')")).await;
1361        assert_eq!(r.exit_code, 0);
1362        assert_eq!(r.stdout, "from stdin\n");
1363    }
1364
1365    #[tokio::test]
1366    async fn test_piped_stdin() {
1367        let r = run(&[], Some("print('piped')")).await;
1368        assert_eq!(r.exit_code, 0);
1369        assert_eq!(r.stdout, "piped\n");
1370    }
1371
1372    #[tokio::test]
1373    async fn test_file_execution() {
1374        let r = run_with_file(&["script.py"], "/home/user/script.py", "print('from file')").await;
1375        assert_eq!(r.exit_code, 0);
1376        assert_eq!(r.stdout, "from file\n");
1377    }
1378
1379    #[tokio::test]
1380    async fn test_file_not_found() {
1381        let r = run(&["missing.py"], None).await;
1382        assert_eq!(r.exit_code, 2);
1383        assert!(r.stderr.contains("can't open file"));
1384    }
1385
1386    #[tokio::test]
1387    async fn test_no_args_no_stdin() {
1388        let r = run(&[], None).await;
1389        assert_eq!(r.exit_code, 1);
1390        assert!(r.stderr.contains("interactive mode not supported"));
1391    }
1392
1393    #[tokio::test]
1394    async fn test_c_flag_missing_arg() {
1395        let r = run(&["-c"], None).await;
1396        assert_eq!(r.exit_code, 2);
1397        assert!(r.stderr.contains("requires argument"));
1398    }
1399
1400    #[tokio::test]
1401    async fn test_unknown_option() {
1402        let r = run(&["-x"], None).await;
1403        assert_eq!(r.exit_code, 2);
1404        assert!(r.stderr.contains("unknown option"));
1405    }
1406
1407    #[tokio::test]
1408    async fn test_help() {
1409        let r = run(&["--help"], None).await;
1410        assert_eq!(r.exit_code, 0);
1411        assert!(r.stdout.contains("usage:"));
1412    }
1413
1414    #[tokio::test]
1415    async fn test_dict_access() {
1416        let r = run(&["-c", "d = dict()\nd['a'] = 1\nprint(d['a'])"], None).await;
1417        assert_eq!(r.exit_code, 0);
1418        assert_eq!(r.stdout, "1\n");
1419    }
1420
1421    #[tokio::test]
1422    async fn test_list_comprehension() {
1423        let r = run(&["-c", "[x*2 for x in range(3)]"], None).await;
1424        assert_eq!(r.exit_code, 0);
1425        assert_eq!(r.stdout, "[0, 2, 4]\n");
1426    }
1427
1428    #[tokio::test]
1429    async fn test_fstring() {
1430        let r = run(&["-c", "x = 42\nprint(f'value={x}')"], None).await;
1431        assert_eq!(r.exit_code, 0);
1432        assert_eq!(r.stdout, "value=42\n");
1433    }
1434
1435    #[tokio::test]
1436    async fn test_recursion_limit() {
1437        let r = run(&["-c", "def r(): r()\nr()"], None).await;
1438        assert_eq!(r.exit_code, 1);
1439        assert!(r.stderr.contains("RecursionError") || r.stderr.contains("recursion"));
1440    }
1441
1442    #[tokio::test]
1443    async fn test_shebang_stripped() {
1444        let r = run_with_file(
1445            &["script.py"],
1446            "/home/user/script.py",
1447            "#!/usr/bin/env python3\nprint('shebang ok')",
1448        )
1449        .await;
1450        assert_eq!(r.exit_code, 0);
1451        assert_eq!(r.stdout, "shebang ok\n");
1452    }
1453
1454    #[tokio::test]
1455    async fn test_name_error() {
1456        let r = run(&["-c", "print(undefined_var)"], None).await;
1457        assert_eq!(r.exit_code, 1);
1458        assert!(r.stderr.contains("NameError"));
1459    }
1460
1461    #[tokio::test]
1462    async fn test_type_error() {
1463        let r = run(&["-c", "1 + 'a'"], None).await;
1464        assert_eq!(r.exit_code, 1);
1465        assert!(r.stderr.contains("TypeError"));
1466    }
1467
1468    #[tokio::test]
1469    async fn test_index_error() {
1470        let r = run(&["-c", "lst = [1, 2]\nprint(lst[10])"], None).await;
1471        assert_eq!(r.exit_code, 1);
1472        assert!(r.stderr.contains("IndexError"));
1473    }
1474
1475    #[tokio::test]
1476    async fn test_empty_stdin() {
1477        let r = run(&["-"], Some("")).await;
1478        assert_eq!(r.exit_code, 1);
1479    }
1480
1481    #[tokio::test]
1482    async fn test_output_before_error() {
1483        let r = run(&["-c", "print('before')\n1/0"], None).await;
1484        assert_eq!(r.exit_code, 1);
1485        assert_eq!(r.stdout, "before\n");
1486        assert!(r.stderr.contains("ZeroDivisionError"));
1487    }
1488
1489    // --- VFS bridging tests ---
1490
1491    #[tokio::test]
1492    async fn test_vfs_read_text() {
1493        let r = run_with_vfs(
1494            &[
1495                "-c",
1496                "from pathlib import Path\nprint(Path('/tmp/hello.txt').read_text())",
1497            ],
1498            &[("/tmp/hello.txt", "hello from vfs")],
1499            &[],
1500        )
1501        .await;
1502        assert_eq!(r.exit_code, 0);
1503        assert_eq!(r.stdout, "hello from vfs\n");
1504    }
1505
1506    #[tokio::test]
1507    async fn test_vfs_write_text() {
1508        // Write via Python, then read via Python to verify
1509        let r = run_with_vfs(
1510            &[
1511                "-c",
1512                "from pathlib import Path\nPath('/tmp/out.txt').write_text('written by python')\nprint(Path('/tmp/out.txt').read_text())",
1513            ],
1514            &[],
1515            &[],
1516        )
1517        .await;
1518        assert_eq!(r.exit_code, 0);
1519        assert_eq!(r.stdout, "written by python\n");
1520    }
1521
1522    #[tokio::test]
1523    async fn test_vfs_exists() {
1524        let r = run_with_vfs(
1525            &[
1526                "-c",
1527                "from pathlib import Path\nprint(Path('/tmp/hello.txt').exists())\nprint(Path('/tmp/nope.txt').exists())",
1528            ],
1529            &[("/tmp/hello.txt", "content")],
1530            &[],
1531        )
1532        .await;
1533        assert_eq!(r.exit_code, 0);
1534        assert_eq!(r.stdout, "True\nFalse\n");
1535    }
1536
1537    #[tokio::test]
1538    async fn test_vfs_is_file_is_dir() {
1539        let r = run_with_vfs(
1540            &[
1541                "-c",
1542                "from pathlib import Path\nprint(Path('/tmp/f.txt').is_file())\nprint(Path('/tmp').is_dir())",
1543            ],
1544            &[("/tmp/f.txt", "data")],
1545            &[],
1546        )
1547        .await;
1548        assert_eq!(r.exit_code, 0);
1549        assert_eq!(r.stdout, "True\nTrue\n");
1550    }
1551
1552    #[tokio::test]
1553    async fn test_vfs_read_not_found() {
1554        let r = run_with_vfs(
1555            &[
1556                "-c",
1557                "from pathlib import Path\ntry:\n    Path('/no/such/file').read_text()\nexcept FileNotFoundError as e:\n    print('caught:', e)",
1558            ],
1559            &[],
1560            &[],
1561        )
1562        .await;
1563        assert_eq!(r.exit_code, 0);
1564        assert!(r.stdout.contains("caught:"));
1565        assert!(r.stdout.contains("not found") || r.stdout.contains("No such file"));
1566    }
1567
1568    #[tokio::test]
1569    async fn test_vfs_mkdir() {
1570        let r = run_with_vfs(
1571            &[
1572                "-c",
1573                "from pathlib import Path\nPath('/tmp/newdir').mkdir()\nprint(Path('/tmp/newdir').is_dir())",
1574            ],
1575            &[],
1576            &[],
1577        )
1578        .await;
1579        assert_eq!(r.exit_code, 0);
1580        assert_eq!(r.stdout, "True\n");
1581    }
1582
1583    #[tokio::test]
1584    async fn test_vfs_iterdir() {
1585        let r = run_with_vfs(
1586            &[
1587                "-c",
1588                "from pathlib import Path\nfor p in Path('/data').iterdir():\n    print(p.name)",
1589            ],
1590            &[("/data/a.txt", "a"), ("/data/b.txt", "b")],
1591            &[],
1592        )
1593        .await;
1594        assert_eq!(r.exit_code, 0);
1595        // Order from VFS may vary, check both entries present
1596        assert!(r.stdout.contains("a.txt"));
1597        assert!(r.stdout.contains("b.txt"));
1598    }
1599
1600    #[tokio::test]
1601    async fn test_vfs_getenv() {
1602        let r = run_with_vfs(
1603            &[
1604                "-c",
1605                "import os\nprint(os.getenv('MY_VAR'))\nprint(os.getenv('MISSING', 'default'))",
1606            ],
1607            &[],
1608            &[("MY_VAR", "hello")],
1609        )
1610        .await;
1611        assert_eq!(r.exit_code, 0);
1612        assert_eq!(r.stdout, "hello\ndefault\n");
1613    }
1614
1615    #[tokio::test]
1616    async fn test_vfs_stat() {
1617        let r = run_with_vfs(
1618            &[
1619                "-c",
1620                "from pathlib import Path\ninfo = Path('/tmp/f.txt').stat()\nprint(info.st_size)",
1621            ],
1622            &[("/tmp/f.txt", "12345")],
1623            &[],
1624        )
1625        .await;
1626        assert_eq!(r.exit_code, 0);
1627        assert_eq!(r.stdout, "5\n");
1628    }
1629
1630    // --- PythonLimits tests ---
1631
1632    #[tokio::test]
1633    async fn test_custom_limits_tight_memory() {
1634        // Very tight memory limit should cause failure for large allocations
1635        let limits = PythonLimits::default().max_memory(1024);
1636        let py = Python::with_limits(limits);
1637        let args = vec!["-c".to_string(), "x = list(range(100000))".to_string()];
1638        let env = opt_in_env();
1639        let mut variables = HashMap::new();
1640        let mut cwd = PathBuf::from("/home/user");
1641        let fs = Arc::new(InMemoryFs::new());
1642        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
1643        let r = py.execute(ctx).await.unwrap();
1644        assert_ne!(r.exit_code, 0, "Tight memory limit should cause failure");
1645    }
1646
1647    #[tokio::test]
1648    async fn test_custom_limits_generous() {
1649        // Generous limits should succeed
1650        let limits = PythonLimits::default().max_memory(128 * 1024 * 1024);
1651        let py = Python::with_limits(limits);
1652        let args = vec!["-c".to_string(), "print(sum(range(100)))".to_string()];
1653        let env = opt_in_env();
1654        let mut variables = HashMap::new();
1655        let mut cwd = PathBuf::from("/home/user");
1656        let fs = Arc::new(InMemoryFs::new());
1657        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
1658        let r = py.execute(ctx).await.unwrap();
1659        assert_eq!(r.exit_code, 0);
1660        assert_eq!(r.stdout, "4950\n");
1661    }
1662
1663    #[test]
1664    fn test_python_limits_builder() {
1665        let limits = PythonLimits::default()
1666            .max_duration(Duration::from_secs(10))
1667            .max_memory(1024)
1668            .max_recursion(50);
1669        assert_eq!(limits.common.max_duration, Duration::from_secs(10));
1670        assert_eq!(limits.common.max_memory, 1024);
1671        assert_eq!(limits.common.max_call_depth, 50);
1672    }
1673
1674    #[test]
1675    fn test_python_limits_default() {
1676        let limits = PythonLimits::default();
1677        assert_eq!(limits.common.max_duration, Duration::from_secs(30));
1678        assert_eq!(limits.common.max_memory, 64 * 1024 * 1024);
1679        assert_eq!(limits.common.max_call_depth, 200);
1680    }
1681
1682    // --- External function tests ---
1683
1684    /// Helper: run Python with an external function handler.
1685    async fn run_with_external(
1686        code: &str,
1687        fn_names: &[&str],
1688        handler: PythonExternalFnHandler,
1689    ) -> ExecResult {
1690        run_with_external_and_limits(code, fn_names, handler, PythonLimits::default()).await
1691    }
1692
1693    async fn run_with_external_and_limits(
1694        code: &str,
1695        fn_names: &[&str],
1696        handler: PythonExternalFnHandler,
1697        limits: PythonLimits,
1698    ) -> ExecResult {
1699        let args = vec!["-c".to_string(), code.to_string()];
1700        let env = opt_in_env();
1701        let mut variables = HashMap::new();
1702        let mut cwd = PathBuf::from("/home/user");
1703        let fs = Arc::new(InMemoryFs::new());
1704        let py = Python::with_limits(limits)
1705            .with_external_handler(fn_names.iter().map(|s| s.to_string()).collect(), handler);
1706        let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
1707        py.execute(ctx).await.unwrap()
1708    }
1709
1710    #[tokio::test]
1711    async fn test_external_fn_return_value() {
1712        let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1713            Box::pin(async { ExtFunctionResult::Return(MontyObject::Int(42)) })
1714        });
1715        let r = run_with_external("print(get_answer())", &["get_answer"], handler).await;
1716        assert_eq!(r.exit_code, 0);
1717        assert_eq!(r.stdout, "42\n");
1718    }
1719
1720    #[tokio::test]
1721    async fn test_external_fn_handler_timeout_uses_python_deadline() {
1722        let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1723            Box::pin(async {
1724                tokio::time::sleep(Duration::from_secs(1)).await;
1725                ExtFunctionResult::Return(MontyObject::Int(1))
1726            })
1727        });
1728        let started = Instant::now();
1729        let r = tokio::time::timeout(
1730            Duration::from_secs(1),
1731            run_with_external_and_limits(
1732                "print(slow())",
1733                &["slow"],
1734                handler,
1735                PythonLimits::default().max_duration(Duration::from_millis(25)),
1736            ),
1737        )
1738        .await
1739        .expect("Python external call should observe max_duration");
1740
1741        assert_eq!(r.exit_code, 1);
1742        assert!(r.stderr.contains("RuntimeError"));
1743        assert!(r.stderr.contains("exceeded max_duration"));
1744        assert!(started.elapsed() < Duration::from_millis(500));
1745    }
1746
1747    #[tokio::test]
1748    async fn test_external_fn_with_args() {
1749        let handler: PythonExternalFnHandler = Arc::new(|_name, args, _kwargs| {
1750            Box::pin(async move {
1751                let a = match &args[0] {
1752                    MontyObject::Int(i) => *i,
1753                    _ => 0,
1754                };
1755                let b = match &args[1] {
1756                    MontyObject::Int(i) => *i,
1757                    _ => 0,
1758                };
1759                ExtFunctionResult::Return(MontyObject::Int(a + b))
1760            })
1761        });
1762        let r = run_with_external("print(add(3, 4))", &["add"], handler).await;
1763        assert_eq!(r.exit_code, 0);
1764        assert_eq!(r.stdout, "7\n");
1765    }
1766
1767    #[tokio::test]
1768    async fn test_external_fn_with_kwargs() {
1769        let handler: PythonExternalFnHandler = Arc::new(|_name, _args, kwargs| {
1770            Box::pin(async move {
1771                for (k, v) in &kwargs {
1772                    if let (MontyObject::String(key), MontyObject::String(val)) = (k, v)
1773                        && key == "name"
1774                    {
1775                        return ExtFunctionResult::Return(MontyObject::String(format!(
1776                            "hello {val}"
1777                        )));
1778                    }
1779                }
1780                ExtFunctionResult::Return(MontyObject::String("hello unknown".into()))
1781            })
1782        });
1783        let r = run_with_external("print(greet(name='world'))", &["greet"], handler).await;
1784        assert_eq!(r.exit_code, 0);
1785        assert_eq!(r.stdout, "hello world\n");
1786    }
1787
1788    #[tokio::test]
1789    async fn test_external_fn_error() {
1790        let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1791            Box::pin(async {
1792                ExtFunctionResult::Error(MontyException::new(
1793                    ExcType::RuntimeError,
1794                    Some("something went wrong".into()),
1795                ))
1796            })
1797        });
1798        let r = run_with_external("fail()", &["fail"], handler).await;
1799        assert_eq!(r.exit_code, 1);
1800        assert!(r.stderr.contains("RuntimeError"));
1801        assert!(r.stderr.contains("something went wrong"));
1802    }
1803
1804    #[tokio::test]
1805    async fn test_external_fn_caught_error() {
1806        let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1807            Box::pin(async {
1808                ExtFunctionResult::Error(MontyException::new(
1809                    ExcType::ValueError,
1810                    Some("bad value".into()),
1811                ))
1812            })
1813        });
1814        let r = run_with_external(
1815            "try:\n    fail()\nexcept ValueError as e:\n    print(f'caught: {e}')",
1816            &["fail"],
1817            handler,
1818        )
1819        .await;
1820        assert_eq!(r.exit_code, 0);
1821        assert!(r.stdout.contains("caught:"));
1822        assert!(r.stdout.contains("bad value"));
1823    }
1824
1825    #[tokio::test]
1826    async fn test_external_fn_multiple_calls() {
1827        let counter = Arc::new(std::sync::atomic::AtomicI64::new(0));
1828        let counter_clone = counter.clone();
1829        let handler: PythonExternalFnHandler = Arc::new(move |_name, _args, _kwargs| {
1830            let c = counter_clone.clone();
1831            Box::pin(async move {
1832                let val = c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1833                ExtFunctionResult::Return(MontyObject::Int(val))
1834            })
1835        });
1836        let r = run_with_external(
1837            "a = next_id()\nb = next_id()\nc = next_id()\nprint(a, b, c)",
1838            &["next_id"],
1839            handler,
1840        )
1841        .await;
1842        assert_eq!(r.exit_code, 0);
1843        assert_eq!(r.stdout, "0 1 2\n");
1844    }
1845
1846    #[tokio::test]
1847    async fn test_external_fn_returns_string() {
1848        let handler: PythonExternalFnHandler = Arc::new(|_name, args, _kwargs| {
1849            Box::pin(async move {
1850                let input = match &args[0] {
1851                    MontyObject::String(s) => s.clone(),
1852                    _ => String::new(),
1853                };
1854                ExtFunctionResult::Return(MontyObject::String(input.to_uppercase()))
1855            })
1856        });
1857        let r = run_with_external("print(upper('hello'))", &["upper"], handler).await;
1858        assert_eq!(r.exit_code, 0);
1859        assert_eq!(r.stdout, "HELLO\n");
1860    }
1861
1862    #[tokio::test]
1863    async fn test_external_fn_dispatches_by_name() {
1864        let handler: PythonExternalFnHandler = Arc::new(|name, _args, _kwargs| {
1865            Box::pin(async move {
1866                let result = match name.as_str() {
1867                    "get_x" => MontyObject::Int(10),
1868                    "get_y" => MontyObject::Int(20),
1869                    _ => MontyObject::None,
1870                };
1871                ExtFunctionResult::Return(result)
1872            })
1873        });
1874        let r = run_with_external("print(get_x() + get_y())", &["get_x", "get_y"], handler).await;
1875        assert_eq!(r.exit_code, 0);
1876        assert_eq!(r.stdout, "30\n");
1877    }
1878
1879    #[tokio::test]
1880    async fn test_unregistered_name_reference_raises_name_error() {
1881        // Referencing a name (not as a call) that is NOT in the registered
1882        // external function list should raise NameError via NameLookup → Undefined.
1883        let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1884            Box::pin(async { ExtFunctionResult::Return(MontyObject::Int(1)) })
1885        });
1886        // Register "registered_fn" but reference "unknown_var" (not a call)
1887        let r = run_with_external("x = unknown_var", &["registered_fn"], handler).await;
1888        assert_eq!(r.exit_code, 1);
1889        assert!(r.stderr.contains("NameError"));
1890    }
1891
1892    // --- Monty 0.0.8 feature tests ---
1893
1894    #[tokio::test]
1895    async fn test_math_module() {
1896        let r = run(&["-c", "import math; print(math.sqrt(144))"], None).await;
1897        assert_eq!(r.exit_code, 0);
1898        assert_eq!(r.stdout.trim(), "12.0");
1899    }
1900
1901    #[tokio::test]
1902    async fn test_re_module_is_blocked() {
1903        let r = run(
1904            &[
1905                "-c",
1906                "import re; m = re.search(r'(\\d+)', 'abc123def'); print(m.group(1))",
1907            ],
1908            None,
1909        )
1910        .await;
1911        assert_eq!(r.exit_code, 1);
1912        assert!(r.stderr.contains("importing module 're' is disabled"));
1913    }
1914
1915    #[tokio::test]
1916    async fn test_re_module_dynamic_import_is_blocked() {
1917        let r = run(&["-c", "m = __import__('re')"], None).await;
1918        assert_eq!(r.exit_code, 1);
1919        assert!(r.stderr.contains("importing module 're' is disabled"));
1920    }
1921
1922    #[tokio::test]
1923    async fn test_filter_builtin() {
1924        let r = run(
1925            &["-c", "print(list(filter(lambda x: x > 2, [1, 2, 3, 4])))"],
1926            None,
1927        )
1928        .await;
1929        assert_eq!(r.exit_code, 0);
1930        assert_eq!(r.stdout.trim(), "[3, 4]");
1931    }
1932
1933    #[tokio::test]
1934    async fn test_getattr_builtin() {
1935        // getattr with default value fallback
1936        let r = run(
1937            &["-c", "print(getattr('hello', 'missing_attr', 'default'))"],
1938            None,
1939        )
1940        .await;
1941        assert_eq!(r.exit_code, 0);
1942        assert_eq!(r.stdout.trim(), "default");
1943    }
1944
1945    #[tokio::test]
1946    async fn test_tuple_comparison() {
1947        let r = run(&["-c", "print((1, 2) < (1, 3))"], None).await;
1948        assert_eq!(r.exit_code, 0);
1949        assert_eq!(r.stdout.trim(), "True");
1950    }
1951
1952    #[tokio::test]
1953    async fn test_pep448_unpacking() {
1954        let r = run(&["-c", "a = [1, 2]; b = [3, 4]; print([*a, *b])"], None).await;
1955        assert_eq!(r.exit_code, 0);
1956        assert_eq!(r.stdout.trim(), "[1, 2, 3, 4]");
1957    }
1958
1959    #[tokio::test]
1960    async fn test_dict_constructor_from_iterable() {
1961        let r = run(
1962            &[
1963                "-c",
1964                "d = dict([('a', 1), ('b', 2)]); print(d['a'], d['b'])",
1965            ],
1966            None,
1967        )
1968        .await;
1969        assert_eq!(r.exit_code, 0);
1970        assert_eq!(r.stdout.trim(), "1 2");
1971    }
1972
1973    // --- datetime tests (Monty 0.0.11+) ---
1974
1975    #[tokio::test]
1976    async fn test_date_today() {
1977        let r = run(
1978            &[
1979                "-c",
1980                "from datetime import date\nd = date.today()\nprint(f'{d.year}-{d.month}-{d.day}')",
1981            ],
1982            None,
1983        )
1984        .await;
1985        assert_eq!(r.exit_code, 0);
1986        assert_eq!(r.stdout.trim(), "2024-1-1");
1987    }
1988
1989    #[tokio::test]
1990    async fn test_datetime_now_naive() {
1991        let r = run(
1992            &[
1993                "-c",
1994                "from datetime import datetime\ndt = datetime.now()\nprint(f'{dt.year}-{dt.month}-{dt.day} {dt.hour}:{dt.minute}:{dt.second}.{dt.microsecond}')",
1995            ],
1996            None,
1997        )
1998        .await;
1999        assert_eq!(r.exit_code, 0);
2000        assert_eq!(r.stdout.trim(), "2024-1-1 0:0:0.123456");
2001    }
2002
2003    #[tokio::test]
2004    async fn test_datetime_now_utc() {
2005        let r = run(
2006            &[
2007                "-c",
2008                "from datetime import datetime, timezone\ndt = datetime.now(timezone.utc)\nprint(f'{dt.year}-{dt.month}-{dt.day} {dt.hour}:{dt.minute}:{dt.second}.{dt.microsecond}')",
2009            ],
2010            None,
2011        )
2012        .await;
2013        assert_eq!(r.exit_code, 0);
2014        assert_eq!(r.stdout.trim(), "2024-1-1 0:0:0.123456");
2015    }
2016
2017    #[tokio::test]
2018    async fn test_datetime_attributes() {
2019        // Verify datetime components are populated correctly
2020        let r = run(
2021            &[
2022                "-c",
2023                "from datetime import datetime\ndt = datetime.now()\nassert 1 <= dt.month <= 12\nassert 1 <= dt.day <= 31\nassert 0 <= dt.hour <= 23\nprint('ok')",
2024            ],
2025            None,
2026        )
2027        .await;
2028        assert_eq!(r.exit_code, 0);
2029        assert_eq!(r.stdout.trim(), "ok");
2030    }
2031
2032    // --- json tests (Monty 0.0.9+) ---
2033
2034    #[tokio::test]
2035    async fn test_json_dumps_loads() {
2036        let r = run(
2037            &[
2038                "-c",
2039                "import json\nd = {'a': 1, 'b': [2, 3]}\ns = json.dumps(d)\nprint(json.loads(s) == d)",
2040            ],
2041            None,
2042        )
2043        .await;
2044        assert_eq!(r.exit_code, 0);
2045        assert_eq!(r.stdout.trim(), "True");
2046    }
2047}