Skip to content

Commit 66701c5

Browse files
authored
Merge pull request #32 from makermelissa-piclaw/fix-run-command-preserve-cr
Preserve carriage returns in run_command output (closes #18)
2 parents eca5d14 + fbf4b31 commit 66701c5

1 file changed

Lines changed: 81 additions & 4 deletions

File tree

adafruit_shell.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)