@@ -114,9 +114,18 @@ def read_stream(output):
114114 file_flags = fcntl .fcntl (file_descriptor , fcntl .F_GETFL )
115115 fcntl .fcntl (file_descriptor , fcntl .F_SETFL , file_flags | os .O_NONBLOCK )
116116 try :
117- return output .read ()
117+ data = output .read ()
118118 except (TypeError , BlockingIOError ):
119119 return ""
120+ if data is None :
121+ return ""
122+ # ``universal_newlines`` is intentionally not enabled on Popen so
123+ # that carriage returns survive intact (Python's universal newlines
124+ # mode otherwise rewrites every ``\r`` to ``\n``, which destroys
125+ # in-place progress updates like the ones ``pip`` and ``apt`` emit).
126+ # Decode here with ``errors="replace"`` so a stray non-UTF-8 byte
127+ # doesn't kill the whole run.
128+ return data .decode ("utf-8" , errors = "replace" )
120129
121130 # Allow running as a different user if we are root
122131 if self .is_root () and run_as_user is not None :
@@ -135,23 +144,44 @@ def preexec():
135144 preexec = None
136145
137146 full_output = ""
147+ # Per-stream "are we at the start of a new line?" state so the group
148+ # prefix is emitted exactly once per logical line, even when a chunk
149+ # arrives split across reads or contains in-place updates ending in
150+ # ``\r`` (e.g. download progress bars).
151+ stream_state = {"stdout" : True , "stderr" : True }
138152 with subprocess .Popen ( # pylint: disable=subprocess-popen-preexec-fn
139153 cmd ,
140154 shell = True ,
141155 stdout = subprocess .PIPE ,
142156 stderr = subprocess .PIPE ,
143- universal_newlines = True ,
144157 env = env ,
145158 preexec_fn = preexec ,
146159 ) as proc :
147160 while proc .poll () is None :
148161 err = read_stream (proc .stderr )
149162 if err != "" and not suppress_message :
150- self .error (err .strip (), end = "\n \r " )
163+ stream_state ["stderr" ] = self ._emit_stream_chunk (
164+ err , kind = "error" , at_line_start = stream_state ["stderr" ]
165+ )
151166 output = read_stream (proc .stdout )
152167 if output != "" and not suppress_message :
153- self .info (output .strip (), end = "\n \r " )
168+ stream_state ["stdout" ] = self ._emit_stream_chunk (
169+ output , kind = "info" , at_line_start = stream_state ["stdout" ]
170+ )
154171 full_output += output
172+ # Drain anything that arrived between the last read and the
173+ # process exit so short-lived commands don't lose their output.
174+ err = read_stream (proc .stderr )
175+ if err != "" and not suppress_message :
176+ stream_state ["stderr" ] = self ._emit_stream_chunk (
177+ err , kind = "error" , at_line_start = stream_state ["stderr" ]
178+ )
179+ output = read_stream (proc .stdout )
180+ if output != "" and not suppress_message :
181+ stream_state ["stdout" ] = self ._emit_stream_chunk (
182+ output , kind = "info" , at_line_start = stream_state ["stdout" ]
183+ )
184+ full_output += output
155185 return_code = proc .poll ()
156186 proc .stdout .close ()
157187 proc .stderr .close ()
@@ -161,6 +191,53 @@ def preexec():
161191 return False
162192 return True
163193
194+ def _emit_stream_chunk (self , chunk , * , kind , at_line_start ):
195+ """
196+ Write a chunk read from a subprocess stream to stdout, preserving the
197+ process's own line terminators (including ``\r ``-only progress updates)
198+ and prepending the colored group prefix at the start of each logical
199+ new line.
200+
201+ ``kind`` selects the color used for the group prefix
202+ (``"info"`` -> green, ``"error"`` -> red). The ``end="\n \r "`` that the
203+ old code hardcoded is *not* added here; whatever terminators the
204+ underlying process emitted are passed through unchanged so that
205+ carriage-return-based progress lines update in place instead of
206+ scrolling.
207+
208+ Returns the updated ``at_line_start`` state for the next call.
209+ """
210+ if not chunk :
211+ return at_line_start
212+
213+ # The original implementation funneled both info and error chunks
214+ # through ``print()`` (i.e. stdout). Preserve that routing here -- only
215+ # the prefix color differs between the two streams.
216+ if kind == "error" :
217+ colorize = colored .red
218+ else :
219+ colorize = colored .green
220+ stream = sys .stdout
221+
222+ prefix = colorize (self ._group ) + " " if self ._group is not None else ""
223+
224+ # Split on '\n' but keep the separator attached to each segment so
225+ # we can detect logical line boundaries. A bare '\r' inside a segment
226+ # is *not* a new logical line -- it's an in-place update of the
227+ # current line -- so we deliberately don't re-emit the prefix for it.
228+ parts = chunk .split ("\n " )
229+ for index , part in enumerate (parts ):
230+ is_last = index == len (parts ) - 1
231+ segment = part if is_last else part + "\n "
232+ if not segment :
233+ continue
234+ if at_line_start and prefix :
235+ stream .write (prefix )
236+ stream .write (segment )
237+ at_line_start = segment .endswith ("\n " )
238+ stream .flush ()
239+ return at_line_start
240+
164241 def write_templated_file (self , output_path , template , ** kwargs ):
165242 """
166243 Use a template file and render it with the given context and write it to the specified path.
0 commit comments