Keep forced color when stdout is redirected - #130
Merged
Conversation
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>
This was referenced Sep 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #128
Root cause
NullWriter.isatty()returnedTrue, so the module-scopeNULL_PRINTER = Printer(out_stream=NullWriter(), quiet=True)resolvedansi_colortoTrueand calledcolorama.init()whilegraphtagewas still being imported. That replacedsys.stdoutwith colorama'sStreamWrapper, which strips ANSI escapes from any stream that is not a terminal.main()then passed the already-wrappedsys.stdoutto the realPrinter,StatusWritercaptured it, and every escape sequence was stripped on a redirect regardless of--color.README.mddocuments the opposite: "If, for example, you would like to have Graphtage emit colorized output from a script or pipe, use the--coloror-cargument."On the reproducer from the issue,
graphtage --no-status --color a.json b.json > out.txtcontained 0 escape bytes before this change and 58 after. Default (unforced) redirected output still contains 0.Approach
Move
colorama.init()out ofPrinter.__init__and intomain(), behind a smallenable_ansi_support()helper inprinter.py, and correctNullWriter.isatty()to returnFalse.Three properties drove the choice:
sys.stdoutwhen it is imported.printer.pybuilds bothDEFAULT_PRINTERandNULL_PRINTERat module scope, so leavingcolorama.init()anywhere insidePrinter.__init__keeps an import-time global mutation: fixing onlyNullWriter.isatty()still wrapssys.stdoutat import whenever the process runs on a terminal, throughDEFAULT_PRINTER. Calling it frommain()puts the mutation in the application entry point, which is how colorama is meant to be used.Printercaptures its output stream inStatusWriter.__init__, so anything that wrapssys.stdouthas to run first. Callingcolorama.init()from insidePrinter.__init__, as the current code does, is already too late for that printer's own stream; it worked only becauseNULL_PRINTERhad wrapped the stream earlier, at import.main()callsenable_ansi_support()before it constructs any printer, which makes the ordering explicit instead of incidental.--colornow passesstrip=Falsetocolorama.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 runsubuntu-latestonly, so this part is reasoned rather than tested.The naive fix, returning
FalsefromNullWriter.isatty()and stopping there, would regress Windows.Printer.__init__readssys.stdoutand hands it toStatusWriterbefore it reaches thecolorama.init()call, so with no earlierinit()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()frommain()before the printer is constructed preserves the ordering that makes Windows work today. Walking the cases in colorama 0.4.6, whereneed_conversion = conversion_supported and not system_has_native_ansi,stripdefaults toneed_conversion or not have_tty,convertdefaults toneed_conversion and have_tty, and a stream is wrapped only whenconvert or strip or autoreset:--color:convert=True,strip=False. The stream is wrapped and escapes are converted to Win32 calls, as before.strip=Falsedoes not cause escapes to be written literally, becausewrite_and_convert()consumes each escape either way and only emits it as a Win32 call whenconvertis set.--color: unchanged from the current defaults,convert=Trueandstrip=True.need_conversionisFalse, so on a terminal nothing is wrapped and the escapes go through untouched.--color, redirected:convert=Falseandstrip=False, so nothing is wrapped and the raw escapes reach the file, which is what--colorasks for.--color:conversion_supportedisFalseandstrip=False, soshould_wrap()isFalseandsys.stdoutis left alone.Because
main()callsenable_ansi_support()unconditionally,sys.stderris prepared before the loggingPrintercaptures it, exactly as the import-time call used to do. Making the call conditional on--colorwould have left log messages on a Windows console untranslated whenever stdout was redirected.The trade-off is that a library user who constructs a
Printerdirectly on a legacy Windows console now has to callenable_ansi_support()themselves. The docstring says so. That seems better than a library that rewritessys.stdoutas a side effect ofimport.Tests
test/test_printer.pyadds four tests. AStringIOharness like the one intest_graphtage.pynever reaches colorama, so both regression tests drive the real command line in a subprocess whose stdout is a pipe.test_import_does_not_wrap_stdoutcomparestype(sys.stdout)acrossimport graphtagein a child process. Fails on master with'TextIOWrapper' != 'StreamWrapper'.test_forced_color_is_not_stripped_when_redirectedasserts that--colorwrites escapes to a pipe. Fails on master, which writes none.test_redirected_output_is_uncolored_by_defaultguards against over-correcting: no--colorstill means no escapes.test_html_output_is_colored_when_forcedguards theHTMLPrinterpath, which inherits this machinery. It passes both before and after;--html --coloremits HTML color spans rather than escape sequences, so colorama never had anything to strip there.Verified by stashing the
graphtage/changes and runningtest/test_printer.pyagainst 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_printingand the other exact-ANSI assertions are unaffected. Behavior on a real terminal is unchanged: measured over a pty, the default,--color, and--no-colorruns emit the same escape counts as master (59, 59, 1).ruff checkreports the same 16 findings on the touched files as master does, all pre-existing.flake8 --select=E9,F63,F7,F82is clean andcd docs && make htmlsucceeds 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-unicodeflag 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
<title>element in--htmloutput carries the leading indentation of the surrounding pretty-printer, so it reads<title> Graphtage Diff of a.json and b.json</title>.--htmlwithout--colorproduces no color spans when the output is redirected, sinceansi_colorstill auto-detects fromsys.stdout.isatty(). Arguably HTML output should default to color, but that is a separate decision.🤖 Generated with Claude Code