Skip to content

Preserve carriage returns in run_command output (closes #18)#32

Merged
makermelissa merged 2 commits into
adafruit:mainfrom
makermelissa-piclaw:fix-run-command-preserve-cr
May 20, 2026
Merged

Preserve carriage returns in run_command output (closes #18)#32
makermelissa merged 2 commits into
adafruit:mainfrom
makermelissa-piclaw:fix-run-command-preserve-cr

Conversation

@makermelissa-piclaw
Copy link
Copy Markdown
Contributor

Closes #18.

What was broken

Output streamed through Shell.run_command lost any \r-based in-place updates (e.g. pip / apt download progress bars) and gained extra newlines. The reporter (Melissa) saw progress percentages scrolling on separate lines instead of updating in place.

There were actually two stacked bugs:

  1. Popen(..., universal_newlines=True) — Python's universal newlines mode rewrites every \r (and \r\n) to \n before the read ever returns. So the carriage returns were destroyed before any formatting layer ran. Verifiable in isolation:

    >>> subprocess.Popen("printf 'a\\rb\\rc\\n'", shell=True, stdout=subprocess.PIPE,
    ...                  universal_newlines=True).communicate()[0]
    'a\nb\nc\n'   # \r already gone
  2. self.info(output.strip(), end="\n\r") — Even if \r had survived (1), .strip() would have removed it, and the hardcoded end="\n\r" would have forced a newline on every chunk, with the group prefix re-emitted each time. So a real progress sequence rendered as separate prefixed lines.

The fix

  • Drop universal_newlines=True; read raw bytes and decode manually as utf-8 with errors="replace" so a stray non-UTF-8 byte doesn't kill the whole run.
  • Replace the per-chunk self.info / self.error calls with a small helper, _emit_stream_chunk, that writes chunks straight through and emits the colored group prefix exactly once per logical \n-terminated line. A bare \r mid-chunk is treated as an in-place update of the current line (no re-prefix).
  • Maintain a per-stream at_line_start flag across reads so a line split across chunk boundaries still gets exactly one prefix at its real start.
  • Add a post-loop drain so output from short-lived commands that exit between poll() calls isn't dropped.
  • Stderr coloring (red prefix) is preserved; both streams still route to sys.stdout exactly like the original print()-based path.

When self._group is None, the output is raw pass-through, matching the old behavior in that case.

Verification

Ran a small harness against the real Shell.run_command (captured sys.stdout to a StringIO to inspect bytes). Five scenarios, all assertions pass:

Scenario Captured (repr) Notes
Plain multi-line '[install] line one\n[install] line two\n[install] line three\n' Prefix once per line ✅
Progress with \r '[install] Downloading 12%\rDownloading 24%\rDownloading 100%\n[install] Done\n' \r preserved, prefix once at line start, not after each \r
Mixed stdout/stderr '[install] stdout line\n[install] stderr line\n' Both streams keep their own line-start state ✅
Line split across reads ('first half ' + sleep + 'second half\n') '[install] first half second half\n' Exactly one prefix at the real line start ✅
No group configured 'plain one\nplain two\n' Raw pass-through preserved ✅

Also verified return_output=True still returns a str (not bytes).

black clean, pylint 10/10 with the project's --disable=consider-using-f-string.

Downstream check

gh search code --owner=adafruit "run_command" confirms all downstream callers in Raspberry-Pi-Installer-Scripts (and elsewhere) use shell.run_command("...") for side effects; none parse the printed output for \n\r boundaries or the .strip() behavior, so this is safe to land.

Subprocess output passed through Shell.run_command used to lose its
in-place \r-based progress updates and gain spurious newlines.

Two stacked bugs caused this:

1. subprocess.Popen was opened with universal_newlines=True,
   which makes Python rewrite every \r (and \r\n) to \n
   before we ever see the bytes. That alone destroyed progress lines
   from tools like pip and apt before any formatting layer ran.
2. The read loop called output.strip() on every chunk and then
   printed with end="\n\r". Even if \r had survived (1), the
   strip + hardcoded newline would have turned each progress tick into
   a separate line with its own group prefix.

This change:

* Drops universal_newlines=True and decodes the raw bytes manually
  (utf-8 with errors='replace') so carriage returns survive.
* Replaces the per-chunk self.info/self.error calls with a small
  helper, _emit_stream_chunk, that writes chunks straight through
  while emitting the colored group prefix exactly once per logical
  \n-terminated line. Bare \r in the middle of a chunk is
  treated as an in-place update of the current line, not a new line,
  so progress percentages overwrite in place the way they do at a
  real terminal.
* Maintains a per-stream at_line_start flag across reads so a
  line split across chunk boundaries still gets exactly one prefix
  at its real start.
* Adds a post-loop drain so output from short-lived commands that
  exit between poll() calls isn't dropped.

Behavior with no group set is unchanged: raw pass-through of the
process's own bytes.
@makermelissa-piclaw
Copy link
Copy Markdown
Contributor Author

Visual verification

Built two identical debian:bookworm containers — one with the published adafruit-python-shell from PyPI, one with this branch — and ran the same harness through shell.run_command:

shell.run_command("apt-get update")
shell.run_command("script -qfec 'pip3 download --no-deps --dest /tmp/wheels --progress-bar on numpy' /dev/null")

(The script wrapper gives pip a PTY so it emits its real \r-driven progress bar — otherwise pip auto-downgrades to a single non-progress line on non-TTY stdout, which would mask the very bug we're testing.)

before/after comparison

Left (published lib): every progress tick became its own [demo]-prefixed line — 25+ duplicate "Downloading … X/16 MB" lines scrolling down.
Right (this PR): one prefix per logical line; the progress bar is a single line whose state updates in place.

Repro script and Dockerfiles are in ~/.openclaw/workspace/pitft-container/ on the build host — happy to push them somewhere shared if useful.


About the failing CI check

The failure isn't from anything this PR changed. black passes, pylint (library) passes, check-yaml / EOF / trailing-whitespace all pass. The failing hook is reuse v1.1.2, dying with:

File "…/site-packages/reuse/__init__.py", line 15, in <module>
    from pkg_resources import DistributionNotFound, get_distribution
ModuleNotFoundError: No module named 'pkg_resources'

pkg_resources ships with setuptools, which Python ≥ 3.12 no longer bundles by default. reuse-tool v1.x predates that change and assumes the import works. PR #31 (merged 2025-11) ran on the same workflow successfully, so this is upstream environment drift since then, not a regression introduced here.

Probably worth a separate small PR to bump .pre-commit-config.yaml's fsfe/reuse-tool rev (current is v6.x) — happy to put that up if it'd help unblock CI on this and other repos. Didn't bundle it into this PR to keep the scope clean.

Copy link
Copy Markdown
Collaborator

@makermelissa makermelissa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect. This has been bugging me for quite a while.

@makermelissa makermelissa merged commit 66701c5 into adafruit:main May 20, 2026
1 check passed
makermelissa-piclaw added a commit to makermelissa-piclaw/Adafruit_Python_Shell that referenced this pull request May 20, 2026
Follow-up to adafruit#32. While that fix preserved \r-based progress updates
end-to-end, two cosmetic glitches remained when running real apt /
pip installs through run_command:

1. apt's progress UI sometimes emits a stray padding space after a
   \n, e.g. 'Fetched 52.4 MB in 30s (1,729 kB/s)\n Selecting...'.
   The previous logic treated the trailing space as the start of a
   fresh logical line, wrote the group prefix, then the space, then
   the real content -- producing output like:

       PITFT Fetched 52.4 MB in 30s (1,729 kB/s)
       PITFT  Selecting previously unselected package ...
              ^ stray space, prefix pushed one column right

2. A bare \r-only line (e.g. apt's 'erase-progress-line' sequence)
   was being treated as 'still on the same logical line', so the
   next chunk's content would be concatenated onto the previous
   line's tail in pipe/log capture, and the prefix bookkeeping for
   the *next* \n boundary could drift.

This change:

* Splits chunks on either \n or runs of \r (\r+), so cursor-at-col-0
  is the actual boundary, not just newline.
* Treats only \n as a *prefix-re-emission* boundary. Bare \r is
  cursor-return: subsequent redraw frames are written without the
  prefix, which is how pip/apt progress UIs are designed to animate
  on a real terminal (each redraw overwrites the previous frame,
  including the prefix, so the terminal naturally shows the latest
  frame with no stale prefix dangling at column 0).
* Strips leading horizontal whitespace (spaces and tabs) immediately
  after a \n boundary, so padding from the source process doesn't
  push the prefix right.
* Suppresses the prefix entirely when a logical line is pure padding,
  but keeps the terminator (\r or \n) so the terminal still sees
  the cursor motion.

Verified end-to-end via a deterministic harness that simulates
terminal CR/LF cursor semantics against captured output, including
the exact apt+pip patterns Melissa saw during the PiTFT install.

ruff check + ruff format clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

For run_command, not all output ends in \n\r

2 participants