Skip to content

Keep forced color when stdout is redirected - #130

Merged
ESultanik merged 2 commits into
masterfrom
128-colorama-strips-forced-color
Sep 7, 2026
Merged

Keep forced color when stdout is redirected#130
ESultanik merged 2 commits into
masterfrom
128-colorama-strips-forced-color

Conversation

@ESultanik

Copy link
Copy Markdown
Collaborator

Closes #128

Root cause

NullWriter.isatty() returned True, so the module-scope NULL_PRINTER = Printer(out_stream=NullWriter(), quiet=True) resolved ansi_color to True and called colorama.init() while graphtage was still being imported. That replaced sys.stdout with colorama's StreamWrapper, which strips ANSI escapes from any stream that is not a terminal.

main() then passed the already-wrapped sys.stdout to the real Printer, StatusWriter captured it, and every escape sequence was stripped on a redirect regardless of --color. README.md documents the opposite: "If, for example, you would like to have Graphtage emit colorized output from a script or pipe, use the --color or -c argument."

On the reproducer from the issue, graphtage --no-status --color a.json b.json > out.txt contained 0 escape bytes before this change and 58 after. Default (unforced) redirected output still contains 0.

Approach

Move colorama.init() out of Printer.__init__ and into main(), behind a small enable_ansi_support() helper in printer.py, and correct NullWriter.isatty() to return False.

Three properties drove the choice:

  • A library must not mutate sys.stdout when it is imported. printer.py builds both DEFAULT_PRINTER and NULL_PRINTER at module scope, so leaving colorama.init() anywhere inside Printer.__init__ keeps an import-time global mutation: fixing only NullWriter.isatty() still wraps sys.stdout at import whenever the process runs on a terminal, through DEFAULT_PRINTER. Calling it from main() puts the mutation in the application entry point, which is how colorama is meant to be used.
  • Ordering. A Printer captures its output stream in StatusWriter.__init__, so anything that wraps sys.stdout has to run first. Calling colorama.init() from inside Printer.__init__, as the current code does, is already too late for that printer's own stream; it worked only because NULL_PRINTER had wrapped the stream earlier, at import. main() calls enable_ansi_support() before it constructs any printer, which makes the ordering explicit instead of incidental.
  • Forced color. --color now passes strip=False to colorama.init(), so colorama keeps the escapes on a stream that is not a terminal.

Windows

colorama.init() exists to translate ANSI escapes into Win32 console calls on legacy Windows consoles, and that translation only applies to writes that pass through colorama's wrapper. CI runs ubuntu-latest only, so this part is reasoned rather than tested.

The naive fix, returning False from NullWriter.isatty() and stopping there, would regress Windows. Printer.__init__ reads sys.stdout and hands it to StatusWriter before it reaches the colorama.init() call, so with no earlier init() the printer holds the raw, pre-wrap stream. Graphtage's own writes would bypass colorama entirely: correct on POSIX, silently uncolored on a legacy Windows console.

Calling enable_ansi_support() from main() before the printer is constructed preserves the ordering that makes Windows work today. Walking the cases in colorama 0.4.6, where need_conversion = conversion_supported and not system_has_native_ansi, strip defaults to need_conversion or not have_tty, convert defaults to need_conversion and have_tty, and a stream is wrapped only when convert or strip or autoreset:

  • Legacy Windows console, --color: convert=True, strip=False. The stream is wrapped and escapes are converted to Win32 calls, as before. strip=False does not cause escapes to be written literally, because write_and_convert() consumes each escape either way and only emits it as a Win32 call when convert is set.
  • Legacy Windows console, no --color: unchanged from the current defaults, convert=True and strip=True.
  • Windows with virtual terminal processing, or any modern console: need_conversion is False, so on a terminal nothing is wrapped and the escapes go through untouched.
  • Windows, --color, redirected: convert=False and strip=False, so nothing is wrapped and the raw escapes reach the file, which is what --color asks for.
  • POSIX, --color: conversion_supported is False and strip=False, so should_wrap() is False and sys.stdout is left alone.

Because main() calls enable_ansi_support() unconditionally, sys.stderr is prepared before the logging Printer captures it, exactly as the import-time call used to do. Making the call conditional on --color would have left log messages on a Windows console untranslated whenever stdout was redirected.

The trade-off is that a library user who constructs a Printer directly on a legacy Windows console now has to call enable_ansi_support() themselves. The docstring says so. That seems better than a library that rewrites sys.stdout as a side effect of import.

Tests

test/test_printer.py adds four tests. A StringIO harness like the one in test_graphtage.py never reaches colorama, so both regression tests drive the real command line in a subprocess whose stdout is a pipe.

  • test_import_does_not_wrap_stdout compares type(sys.stdout) across import graphtage in a child process. Fails on master with 'TextIOWrapper' != 'StreamWrapper'.
  • test_forced_color_is_not_stripped_when_redirected asserts that --color writes escapes to a pipe. Fails on master, which writes none.
  • test_redirected_output_is_uncolored_by_default guards against over-correcting: no --color still means no escapes.
  • test_html_output_is_colored_when_forced guards the HTMLPrinter path, which inherits this machinery. It passes both before and after; --html --color emits HTML color spans rather than escape sequences, so colorama never had anything to strip there.

Verified by stashing the graphtage/ changes and running test/test_printer.py against the unpatched tree: 2 failed, 2 passed. With the fix: 4 passed.

Full suite: 71 passed, up from 67, on Python 3.14 and on Python 3.8. test_string_diff_printing and the other exact-ANSI assertions are unaffected. Behavior on a real terminal is unchanged: measured over a pty, the default, --color, and --no-color runs emit the same escape counts as master (59, 59, 1). ruff check reports the same 16 findings on the touched files as master does, all pre-existing. flake8 --select=E9,F63,F7,F82 is clean and cd docs && make html succeeds with the same 5 pre-existing warnings.

Relationship to #35 and PR #105

#35 asks for the ANSI colors to be kept while the Unicode combining marks are dropped, so that captured output can be converted to HTML or LaTeX, and PR #105 adds the --no-unicode flag for it. Today the combining marks are the only thing that distinguishes an insertion from a removal in redirected output, because the escapes are stripped, so disabling them would produce output with no way to tell the two apart. This change is what makes that flag usable. Nothing here touches PR #105's branch.

Noticed but not changed

  • The <title> element in --html output carries the leading indentation of the surrounding pretty-printer, so it reads <title> Graphtage Diff of a.json and b.json</title>.
  • --html without --color produces no color spans when the output is redirected, since ansi_color still auto-detects from sys.stdout.isatty(). Arguably HTML output should default to color, but that is a separate decision.

🤖 Generated with Claude Code

ESultanik and others added 2 commits September 7, 2026 16:27
Constructing `NULL_PRINTER` at module scope called `colorama.init()` during
`import graphtage`, because `NullWriter.isatty()` claimed to be a terminal.
That replaced `sys.stdout` with colorama's wrapper, which strips ANSI escapes
from any stream that is not a terminal, so `--color` had no effect on
redirected or piped output.

Move `colorama.init()` out of `Printer.__init__` and into `main()`, where it
runs before the printer captures `sys.stdout`. On a legacy Windows console the
captured stream is therefore still colorama's wrapper, so escape sequences are
translated into Win32 console calls as before. Pass `strip=False` when the user
forces `--color` so the escapes survive redirection; colorama then leaves the
stream unwrapped on platforms that need no translation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both new tests exercise the real command line in a subprocess, because a
`StringIO` harness never reaches colorama and so cannot observe the stripping.
They fail on the unpatched tree: `import graphtage` turns `sys.stdout` from a
`TextIOWrapper` into colorama's `StreamWrapper`, and `--color` writes no escape
sequences to a pipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ESultanik
ESultanik merged commit 4c11c33 into master Sep 7, 2026
11 checks passed
@ESultanik
ESultanik deleted the 128-colorama-strips-forced-color branch September 7, 2026 21:10
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

1 participant