diff --git a/crates/execution/assets/runners/python-runner.mjs b/crates/execution/assets/runners/python-runner.mjs index a8dfe850e9..9f822f14cb 100644 --- a/crates/execution/assets/runners/python-runner.mjs +++ b/crates/execution/assets/runners/python-runner.mjs @@ -7,6 +7,9 @@ import readline from 'node:readline'; import { URL } from 'node:url'; const ACCESS_DENIED_CODE = 'ERR_ACCESS_DENIED'; +// Package loads report progress on stdout by default, which would mix into +// guest output. Load errors still reach stderr. +const PYODIDE_LOAD_PACKAGE_OPTIONS = { checkIntegrity: true, messageCallback() {} }; const ASSET_ROOT_ENV = 'AGENTOS_NODE_IMPORT_CACHE_ASSET_ROOT'; const PYODIDE_INDEX_URL_ENV = 'AGENTOS_PYODIDE_INDEX_URL'; const PYODIDE_PACKAGE_BASE_URL_ENV = 'AGENTOS_PYODIDE_PACKAGE_BASE_URL'; @@ -304,28 +307,6 @@ function writeStream(stream, message) { stream.write(value.endsWith('\n') ? value : `${value}\n`); } -function writePyodideStdout(message) { - if (message == null) { - return; - } - - const value = typeof message === 'string' ? message : String(message); - const trimmed = value.trim(); - if (trimmed.length === 0) { - return; - } - if ( - trimmed.startsWith('Loading ') || - trimmed.startsWith('Loaded ') || - trimmed.startsWith("Didn't find package ") || - (trimmed.startsWith('Package ') && trimmed.includes(' loaded from ')) - ) { - return; - } - - writeStream(process.stdout, value); -} - function resolvePyodidePackageCacheDir() { const configured = readRunnerEnv(PYODIDE_PACKAGE_CACHE_DIR_ENV); if (typeof configured === 'string' && configured.trim() !== '') { @@ -1565,7 +1546,7 @@ function installPythonMicropipCompat(pyodide) { // Pyodide's loadPackage() logs a package that fails to load and resolves // anyway. Reject instead so micropip.install() does not report success. const loadPackage = async (names) => { - const loaded = await pyodide.loadPackage(names); + const loaded = await pyodide.loadPackage(names, PYODIDE_LOAD_PACKAGE_OPTIONS); const loadedNames = new Set(Object.keys(pyodide.loadedPackages).map(normalizePythonPackageName)); const missing = Array.from(names).filter( (name) => !loadedNames.has(normalizePythonPackageName(name)), @@ -2110,6 +2091,19 @@ async function readLockFileContents(indexPath, indexURL) { } } +// Pyodide's line-batched handlers drop a final line that has no newline and +// reframe the rest by line. Write raw bytes so guest output stays exact. +function installPythonStdout(pyodide) { + const rawWriter = (stream) => ({ + write(bytes) { + stream.write(bytes.slice()); + return bytes.length; + }, + }); + pyodide.setStdout(rawWriter(process.stdout)); + pyodide.setStderr(rawWriter(process.stderr)); +} + function installPythonStdin(pyodide) { if (typeof pyodide?.setStdin !== 'function') { return; @@ -2543,7 +2537,7 @@ try { packageBaseUrl, packageCacheDir, env: buildPythonRuntimeEnv(), - stdout: writePyodideStdout, + stdout: (message) => writeStream(process.stdout, message), stderr: (message) => writeStream(process.stderr, message), }).catch((error) => { throw wrapPythonStartupError( @@ -2562,13 +2556,14 @@ try { emitWarmupStage('after-load-pyodide'); const loadPyodideMs = realPerformance.now() - loadPyodideStarted; + installPythonStdout(pyodide); installPythonStdin(pyodide); installPythonWorkspaceFs(pyodide, pythonVfsRpcBridge); installPythonVfsSitePackages(pyodide); installPythonGuestLoaderHooks(); if (typeof pyodide?.loadPackage === 'function') { emitWarmupStage('before-load-micropip'); - await pyodide.loadPackage(['micropip']); + await pyodide.loadPackage(['micropip'], PYODIDE_LOAD_PACKAGE_OPTIONS); emitWarmupStage('after-load-micropip'); } const isBundledPackage = installPythonBundledPackages(pyodide, indexPath); diff --git a/crates/native-sidecar/tests/python.rs b/crates/native-sidecar/tests/python.rs index 56eeb4aee8..3790290e76 100644 --- a/crates/native-sidecar/tests/python.rs +++ b/crates/native-sidecar/tests/python.rs @@ -3564,6 +3564,46 @@ fn python_command_runs_inline_code() { assert_eq!(stdout, "2\n", "stderr: {stderr}"); } +fn python_command_keeps_output_without_trailing_newline() { + assert_node_available(); + let mut sidecar = new_sidecar("python-cli-partial-output"); + let cwd = temp_dir("python-cli-partial-output-cwd"); + let connection_id = authenticate_wire(&mut sidecar, "conn-python"); + let session_id = open_session_wire(&mut sidecar, 2, &connection_id); + let (vm_id, _) = create_vm_wire( + &mut sidecar, + 3, + &connection_id, + &session_id, + GuestRuntimeKind::Python, + &cwd, + ); + + execute_python_cli( + &mut sidecar, + 4, + &connection_id, + &session_id, + &vm_id, + "proc-py-partial", + "python3", + &[ + "-c", + "import sys; print('a'); print(); print('b', end=''); sys.stderr.write('err')", + ], + ); + let (stdout, stderr, exit_code) = collect_process_output( + &mut sidecar, + &connection_id, + &session_id, + &vm_id, + "proc-py-partial", + ); + assert_eq!(exit_code, 0, "stderr: {stderr}"); + assert_eq!(stdout, "a\n\nb"); + assert_eq!(stderr, "err"); +} + fn python_command_exits_with_cpython_status_and_traceback() { assert_node_available(); let mut sidecar = new_sidecar("python-cli-exit-status"); @@ -4155,6 +4195,7 @@ fn python_rootfs_suite() { fn python_cli_suite() { python_command_runs_inline_code(); python_command_exits_with_cpython_status_and_traceback(); + python_command_keeps_output_without_trailing_newline(); python_command_runs_script_with_argv(); python_command_runs_module_with_dash_m(); python_command_reads_program_from_stdin(); @@ -4264,6 +4305,7 @@ mod python_split { python_runtime_surfaces_subprocess_permission_errors, python_command_runs_inline_code, python_command_exits_with_cpython_status_and_traceback, + python_command_keeps_output_without_trailing_newline, python_command_runs_script_with_argv, python_command_runs_module_with_dash_m, python_command_reads_program_from_stdin,