1use 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
44const DEFAULT_MAX_RECURSION: usize = 200;
47const DISABLED_STDLIB_MODULES: &[&str] = &["re"];
50const VIRTUAL_NOW_UNIX_SECS: i64 = 1_704_067_200; #[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 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; const 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 || {
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#[derive(Debug, Clone)]
186pub struct PythonLimits {
187 pub common: RuntimeLimits,
189}
190
191impl Default for PythonLimits {
192 fn default() -> Self {
193 Self {
194 common: RuntimeLimits {
195 max_call_depth: DEFAULT_MAX_RECURSION,
197 ..RuntimeLimits::default()
198 },
199 }
200 }
201}
202
203impl PythonLimits {
204 #[must_use]
206 pub fn max_duration(mut self, d: Duration) -> Self {
207 self.common.max_duration = d;
208 self
209 }
210
211 #[must_use]
213 pub fn max_memory(mut self, bytes: usize) -> Self {
214 self.common.max_memory = bytes;
215 self
216 }
217
218 #[must_use]
220 pub fn max_recursion(mut self, depth: usize) -> Self {
221 self.common.max_call_depth = depth;
222 self
223 }
224}
225
226pub 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#[derive(Clone)]
249pub struct PythonExternalFns {
250 names: Vec<String>,
252 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
266pub struct Python {
284 pub limits: PythonLimits,
286 external_fns: Option<PythonExternalFns>,
288}
289
290impl Python {
291 pub fn new() -> Self {
293 Self {
294 limits: PythonLimits::default(),
295 external_fns: None,
296 }
297 }
298
299 pub fn with_limits(limits: PythonLimits) -> Self {
301 Self {
302 limits,
303 external_fns: None,
304 }
305 }
306
307 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 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 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 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 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 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 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 if input.is_empty() {
519 return Ok(ExecResult::ok(String::new()));
520 }
521 (input.to_string(), "<stdin>".to_string())
522 } else {
523 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 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 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 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
598async 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 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 let print_cap = Some(py_limits.common.max_memory);
648 let python_deadline = Instant::now().checked_add(py_limits.common.max_duration);
651
652 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 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 let result = if external_fns
722 .map(|ef| ef.names.contains(&lookup.name))
723 .unwrap_or(false)
724 {
725 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 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 !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 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
797async 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 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 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 ExtFunctionResult::Return(MontyObject::Path(path.to_string_lossy().to_string()))
1029 }
1030 _ => ExtFunctionResult::Error(MontyException::new(
1032 ExcType::OSError,
1033 Some(format!("{name} not supported in virtual mode")),
1034 )),
1035 }
1036}
1037
1038fn 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
1088fn 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
1098fn 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
1121fn 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
1136fn 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
1157fn 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
1171fn 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
1181fn 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 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 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
1230fn format_exception(e: MontyException) -> ExecResult {
1236 ExecResult::err(format!("{e}\n"), 1)
1237}
1238
1239fn 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 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 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 #[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 #[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 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 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 #[tokio::test]
1633 async fn test_custom_limits_tight_memory() {
1634 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 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 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 let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1884 Box::pin(async { ExtFunctionResult::Return(MontyObject::Int(1)) })
1885 });
1886 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 #[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 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 #[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 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 #[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}