1+ import asyncio
12import os
23import subprocess
34from dataclasses import dataclass , field
5+ from typing import AsyncGenerator
46
57from jumpstarter .driver import Driver , export
68
@@ -27,41 +29,38 @@ def get_methods(self) -> list[str]:
2729 return methods
2830
2931 @export
30- def call_method (self , method : str , env , * args ):
32+ async def call_method (self , method : str , env , * args ) -> AsyncGenerator [tuple [str , str , int | None ], None ]:
33+ """
34+ Execute a shell method with live streaming output.
35+ Yields (stdout_chunk, stderr_chunk, returncode) tuples.
36+ returncode is None until the process completes, then it's the final return code.
37+ """
3138 self .logger .info (f"calling { method } with args: { args } and kwargs as env: { env } " )
3239 if method not in self .methods :
3340 raise ValueError (f"Method '{ method } ' not found in available methods: { list (self .methods .keys ())} " )
3441 script = self .methods [method ]
3542 self .logger .debug (f"running script: { script } " )
43+
3644 try :
37- result = self ._run_inline_shell_script (method , script , * args , env_vars = env )
38- if result .returncode != 0 :
39- self .logger .info (f"{ method } return code: { result .returncode } " )
40- if result .stderr != "" :
41- stderr = result .stderr .rstrip ("\n " )
42- self .logger .debug (f"{ method } stderr:\n { stderr } " )
43- if result .stdout != "" :
44- stdout = result .stdout .rstrip ("\n " )
45- self .logger .debug (f"{ method } stdout:\n { stdout } " )
46- return result .stdout , result .stderr , result .returncode
45+ async for stdout_chunk , stderr_chunk , returncode in self ._run_inline_shell_script (
46+ method , script , * args , env_vars = env
47+ ):
48+ if stdout_chunk :
49+ self .logger .debug (f"{ method } stdout:\n { stdout_chunk .rstrip ()} " )
50+ if stderr_chunk :
51+ self .logger .debug (f"{ method } stderr:\n { stderr_chunk .rstrip ()} " )
52+
53+ if returncode is not None and returncode != 0 :
54+ self .logger .info (f"{ method } return code: { returncode } " )
55+
56+ yield stdout_chunk , stderr_chunk , returncode
4757 except subprocess .TimeoutExpired as e :
4858 self .logger .error (f"Timeout expired while running { method } : { e } " )
49- return "" , f"Timeout expired while running { method } : { e } " , 199
50-
51- def _run_inline_shell_script (self , method , script , * args , env_vars = None ):
52- """
53- Run the given shell script (as a string) with optional arguments and
54- environment variables. Returns a CompletedProcess with stdout, stderr, and returncode.
55-
56- :param script: The shell script contents as a string.
57- :param args: Arguments to pass to the script (mapped to $1, $2, etc. in the script).
58- :param env_vars: A dict of environment variables to make available to the script.
59-
60- :return: A subprocess.CompletedProcess object (Python 3.5+).
61- """
59+ yield "" , f"\n Timeout expired while running { method } : { e } \n " , 199
6260
61+ def _validate_script_params (self , script , args , env_vars ):
62+ """Validate script parameters and return combined environment."""
6363 # Merge parent environment with the user-supplied env_vars
64- # so that we don't lose existing environment variables.
6564 combined_env = os .environ .copy ()
6665 if env_vars :
6766 # Validate environment variable names
@@ -82,16 +81,94 @@ def _run_inline_shell_script(self, method, script, *args, env_vars=None):
8281 if self .cwd and not os .path .isdir (self .cwd ):
8382 raise ValueError (f"Working directory does not exist: { self .cwd } " )
8483
84+ return combined_env
85+
86+ async def _read_process_output (self , process , read_all = False ):
87+ """Read data from stdout and stderr streams.
88+
89+ :param process: The subprocess to read from
90+ :param read_all: If True, read all remaining data. If False, read with timeout.
91+ :return: Tuple of (stdout_data, stderr_data)
92+ """
93+ stdout_data = ""
94+ stderr_data = ""
95+
96+ # Read from stdout
97+ if process .stdout :
98+ try :
99+ if read_all :
100+ chunk = await process .stdout .read ()
101+ else :
102+ chunk = await asyncio .wait_for (process .stdout .read (1024 ), timeout = 0.01 )
103+ if chunk :
104+ stdout_data = chunk .decode ('utf-8' , errors = 'replace' )
105+ except (asyncio .TimeoutError , Exception ):
106+ pass
107+
108+ # Read from stderr
109+ if process .stderr :
110+ try :
111+ if read_all :
112+ chunk = await process .stderr .read ()
113+ else :
114+ chunk = await asyncio .wait_for (process .stderr .read (1024 ), timeout = 0.01 )
115+ if chunk :
116+ stderr_data = chunk .decode ('utf-8' , errors = 'replace' )
117+ except (asyncio .TimeoutError , Exception ):
118+ pass
119+
120+ return stdout_data , stderr_data
121+
122+ async def _run_inline_shell_script (
123+ self , method , script , * args , env_vars = None
124+ ) -> AsyncGenerator [tuple [str , str , int | None ], None ]:
125+ """
126+ Run the given shell script with live streaming output.
127+
128+ :param method: The method name (for logging).
129+ :param script: The shell script contents as a string.
130+ :param args: Arguments to pass to the script (mapped to $1, $2, etc. in the script).
131+ :param env_vars: A dict of environment variables to make available to the script.
132+
133+ :yields: Tuples of (stdout_chunk, stderr_chunk, returncode).
134+ returncode is None until the process completes.
135+ """
136+ combined_env = self ._validate_script_params (script , args , env_vars )
85137 cmd = self .shell + [script , method ] + list (args )
86138
87- # Run the command
88- result = subprocess .run (
89- cmd ,
90- capture_output = True , # Captures stdout and stderr
91- text = True , # Returns stdout/stderr as strings (not bytes)
92- env = combined_env , # Pass our merged environment
93- cwd = self .cwd , # Run in the working directory (if set)
94- timeout = self .timeout ,
139+ # Start the process with pipes for streaming
140+ process = await asyncio .create_subprocess_exec (
141+ * cmd ,
142+ stdout = asyncio .subprocess .PIPE ,
143+ stderr = asyncio .subprocess .PIPE ,
144+ env = combined_env ,
145+ cwd = self .cwd ,
95146 )
96147
97- return result
148+ # Create a task to monitor the process timeout
149+ start_time = asyncio .get_event_loop ().time ()
150+
151+ # Read output in real-time
152+ while process .returncode is None :
153+ if asyncio .get_event_loop ().time () - start_time > self .timeout :
154+ process .kill ()
155+ await process .wait ()
156+ raise subprocess .TimeoutExpired (cmd , self .timeout ) from None
157+
158+ try :
159+ stdout_data , stderr_data = await self ._read_process_output (process , read_all = False )
160+
161+ # Yield any data we got
162+ if stdout_data or stderr_data :
163+ yield stdout_data , stderr_data , None
164+
165+ # Small delay to prevent busy waiting
166+ await asyncio .sleep (0.1 )
167+
168+ except Exception :
169+ break
170+
171+ # Process completed, get return code and final output
172+ returncode = process .returncode
173+ remaining_stdout , remaining_stderr = await self ._read_process_output (process , read_all = True )
174+ yield remaining_stdout , remaining_stderr , returncode
0 commit comments