Skip to content

Fix --color being silently stripped on redirected/piped output - #192

Closed
agu2347 wants to merge 1 commit into
trailofbits:masterfrom
agu2347:fix-color-flag-stripped-by-import-time-colorama-init
Closed

agu2347 wants to merge 1 commit into
trailofbits:masterfrom
agu2347:fix-color-flag-stripped-by-import-time-colorama-init

Conversation

@agu2347

@agu2347 agu2347 commented Sep 15, 2026

Copy link
Copy Markdown

Root cause

NullWriter.isatty() returned True. NullWriter discards everything written to it, so it has no terminal to color in the first place -- it should always report False.

That mattered because NULL_PRINTER is built at import time:

NULL_PRINTER: Printer = Printer(out_stream=NullWriter(), quiet=True)

Printer.__init__ defaults ansi_color to out_stream.isatty() when ansi_color is omitted, and calls colorama.init() as a side effect whenever ansi_color ends up True. With the old isatty() returning True, simply import graphtage.printer unconditionally triggered colorama.init() before __main__.py ever got a chance to parse --color.

colorama.init() replaces sys.stdout/sys.stderr with its own stripping wrapper. Any later, real Printer built by main() on top of that already-wrapped stdout inherits a stream that strips ANSI escapes whenever the destination isn't a real terminal -- exactly the redirected/piped case this issue reports, and exactly the case where a user would pass --color to force it. The flag was silently swallowed by an import-order side effect it had no way to know about.

Fix

NullWriter.isatty() now returns False. NULL_PRINTER.ansi_color is then False, colorama.init() is no longer called at import time, and sys.stdout is left alone for main() to wrap correctly once --color is actually parsed.

Testing

Added test/test_printer.py (4 tests):

  • NullWriter().isatty() is False, and NULL_PRINTER.ansi_color is False.
  • Importing graphtage.printer in a fresh subprocess leaves sys.stdout unmodified (before is after).
  • End-to-end repro of the issue's own steps: python -m graphtage --no-status --color a.json b.json with stdout captured now emits ANSI escapes (\x1b), where it emitted none before the fix.

Verified the new tests fail with the expected AssertionErrors against the unmodified code (git stash) and pass against the fix, so they're not tautological. Also ran the full existing test/ suite -- all passing, no regressions.

Fixes #128.


Disclosure: this PR was prepared with AI assistance (Claude), used under my direction as the account owner. I reviewed the root-cause analysis, diff, and tests before opening it.

NullWriter.isatty() returned True. NullWriter discards everything
written to it, so it has no terminal to color in the first place --
it should always report False.

That mattered because NULL_PRINTER is built at import time as
Printer(out_stream=NullWriter(), quiet=True). Printer.__init__
defaults ansi_color to out_stream.isatty() and calls colorama.init()
as a side effect whenever ansi_color ends up True. With the old
isatty() returning True, importing graphtage.printer unconditionally
triggered colorama.init() at import time, before __main__.py ever
parsed --color.

colorama.init() replaces sys.stdout/sys.stderr with its own
stripping wrapper. Any later, real Printer built by main() on top of
that already-wrapped stdout inherits a stream that strips ANSI
escapes whenever the destination isn't a real terminal -- exactly
the redirected/piped case the issue reports, and exactly the case
where someone would pass --color to force it. The flag was silently
swallowed by import-order side effects it had no way to know about.

Fix: NullWriter.isatty() now returns False. NULL_PRINTER.ansi_color
is then False, colorama.init() is no longer called at import time,
and sys.stdout is left alone for main() to wrap correctly when
--color is passed.

Testing:
- New test/test_printer.py (4 tests): NullWriter.isatty() is False
  and NULL_PRINTER.ansi_color is False; importing graphtage.printer
  in a fresh subprocess leaves sys.stdout unmodified; an end-to-end
  repro of the issue (graphtage --no-status --color a.json b.json
  with stdout captured/redirected) now emits ANSI escapes, where it
  emitted none before the fix.
- Confirmed the new tests fail with the expected AssertionErrors
  against the unmodified code (git stash) and pass against the fix.
- Ran the full existing test suite (test/), all passing, no
  regressions.

Fixes trailofbits#128.

This PR was prepared with AI assistance (Claude) under my direction:
I reviewed the root-cause analysis and the diff before opening it.

Signed-off-by: agu2347 <agu2347@users.noreply.github.com>
@agu2347
agu2347 requested a review from ESultanik as a code owner September 15, 2026 16:31
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@agu2347

agu2347 commented Sep 16, 2026

Copy link
Copy Markdown
Author

Heads up: I'm not able to sign the CLA on behalf of the account here (that's a legal agreement tied to the individual, not something an automated contribution workflow should click through). If a maintainer is fine merging without it, or if the account holder wants to sign it themselves via the linked page, this can move forward -- otherwise no worries, feel free to close it.

@ESultanik

Copy link
Copy Markdown
Collaborator

Thank you for the careful write-up — the root-cause analysis here is good, and the mechanism you describe was real.

It was also already fixed. colorama.init() running at import time and replacing sys.stdout with a stripping
wrapper was filed as #128 and fixed by #130, which shipped in v0.4.0. On current master:

$ graphtage --no-status --color a.json b.json > out.txt
$ grep -c $'\x1b' out.txt
8

NullWriter.isatty() already returns False on master (graphtage/printer.py:703), and Printer.__init__ passes
strip=False to colorama.init() when color is forced (printer.py:458).

The branch here is based on a much older printer.py than the one it would merge into, so applying it would undo a
fair amount of work: it reintroduces typing_extensions as a library dependency, reverts the PEP 585/604 annotation
spellings the project standardized on, removes get_default_printer()/set_default_printer() (added in #135 so
--quiet is honored), and adds sys.version_info < (3, 7) branches that are dead under our 3.10 floor.

So I'm closing this one — not because the analysis was wrong, but because the bug it targets is already gone and the
diff would regress the module. I saw your note that you aren't able to sign the CLA; that's understood, and no issue
either way here.

@ESultanik ESultanik closed this Sep 16, 2026
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.

--color has no effect on redirected output; colorama.init() runs at import time

3 participants