Skip to content

Bug: scope no_color color-stripping to the Outputter lifetime - #346

Merged
danielfromearth merged 4 commits into
developfrom
bugfix/issue-345-no-color-state-leak
Sep 3, 2026
Merged

Bug: scope no_color color-stripping to the Outputter lifetime#346
danielfromearth merged 4 commits into
developfrom
bugfix/issue-345-no-color-state-leak

Conversation

@danielfromearth

@danielfromearth danielfromearth commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

GitHub Issue: #345

Description

Outputter.__init__ blanked colorama's process-wide Fore/Style singletons
when no_color=True and never restored them, so the change leaked:

  • calling compare(..., no_color=True) then compare(...) in the same
    interpreter produced colorless output on the second call; and
  • any other in-process colorama user was silently de-colored.

The mutation is now bounded from both ends:

  1. __exit__ restores the per-instance snapshot taken in __init__, scoping
    the mutation to the Outputter's lifetime (compare() already uses it as a
    context manager). Restoring the per-instance values rather than pristine ones
    keeps a no-color Outputter nested inside another behaving correctly.
  2. Constructing a colorized Outputter restores a pristine snapshot taken at
    import, before any Outputter can touch the singletons. This covers callers
    that never use the context manager — tests/conftest.py builds bare
    Outputters, and a library user can too — for whom __exit__ never runs and
    the leak was still permanent.

In-block behavior is unchanged. Blanking the constants is load-bearing rather
than cosmetic: side_by_side pads the leading gutter by len(Fore.RED) to pay
for the escape sequence it prefixes onto the first column, and blanking makes
that compensation zero. The expanded comment in __init__ records this, and a new
test pins it.

Also drops self._no_color, which was added but never read.

A fuller fix (routing color through the instance instead of mutating globals at
all) remains the follow-up noted in #345.

Local test steps

uv run pytest48 passed, 1 deselected (baseline on develop is 44 passed,
1 deselected). uv run pre-commit run --all-files → all hooks pass, mypy included.

Four tests now cover no_color, three of them added in this revision:

  • test_no_color_state_is_restored_after_context_exit (tests/test_printing.py,
    from the earlier revision of this PR) — the color constants are blank inside
    with Outputter(no_color=True) and restored afterward.
  • test_no_color_leak_is_repaired_by_a_later_colorized_outputter (tests/test_printing.py,
    new) — builds a no-color Outputter without with, so __exit__ never runs,
    then asserts that constructing a colorized Outputter repairs the global state.
  • test_no_color_flag_suppresses_ansi_escape_sequences (tests/test_cli.py, new) —
    the --no-color CLI contract, which had no coverage at all. This one needs a
    pseudo-terminal: colorama strips escape sequences when stdout is not a
    terminal, so over a plain pipe a colorized run and a no-color run are
    byte-identical and the flag is untestable. Under a pty the default run emits
    escape sequences and --no-color emits none. Skipped on non-POSIX, since pty
    is Unix-only.
  • test_no_color_report_is_aligned_the_same_as_the_colorized_one (tests/test_cli.py,
    new) — pins the gutter arithmetic described above by asserting that stripping ANSI
    from a colorized report reproduces the --no-color report exactly.

Red-before-green, verified by swapping in each earlier version of
printing.py and re-running:

Version of printing.py ..._restored_after_context_exit ..._repaired_by_a_later_colorized_outputter
develop (no restore at all) fail fail
this PR's earlier __exit__-only restore pass fail
this PR as it now stands pass pass

test_no_color_report_is_aligned_the_same_as_the_colorized_one was likewise
confirmed to fail when extra_style_space = " " * len(default_color) is replaced
with "", i.e. it genuinely guards the compensation rather than passing by
construction.

Manual check of the API symptom in the issue, which no test exercises end to end:
compare(a, b, no_color=True) followed by compare(a, b) in one interpreter now
leaves Fore.RED at '\x1b[31m', and unrelated colorama use still colorizes.

Overview of integration done

[n/a] — unit-level only; no external services are involved. The comparison
report itself is unchanged, which the golden-file tests in
tests/test_complete_file_output.py confirm (txt/csv/xlsx all still match
tests/data/a-b_test_golden_file.*), as does the unchanged
EXPECTED_ATL06_DIFFERENCES = 4958.

Worth flagging for the reviewer: until this branch was brought up to date, it had
never been tested by CI at all.
The pull-request workflow filters on the base
branch, develop was only recently added to that filter (96a33e9), and a
pull_request run reads its workflow definition from the merge ref — which here was
both stale and conflicted, so no run could be scheduled. The failure mode is that
the check list stays short rather than turning red, which is easy to misread as
"tested".

Merging develop in fixed that. The suite has now run on this PR and passed on
Python 3.11, 3.12, 3.13 and 3.14 (Linux), collecting 48 selected / 1 deselected,
matching the local run — and that is the first Linux run these changes have had,
which matters for anything near the HDF5 stack. codecov/patch and
codecov/project both pass. Confirmed from the CI log rather than assumed: the
pseudo-terminal test executes on Linux rather than silently skipping.

PR Acceptance Checklist

  • Unit tests added/updated and passing — 48 passed, 1 deselected locally and on CI across Python 3.11–3.14; each new no-color test confirmed red on the pre-fix code.
  • [n/a] Integration testing — no external services; the ATL06 and golden-file regressions are unchanged.
  • CHANGELOG.md updated — one bullet under ### Fixed for Bug: no_color=True permanently mutates colorama's global Fore/Style state #345.
  • [n/a] Documentation updated (if needed) — no user-facing flag or API change; --no-color's documented behavior is unchanged, only its leakage.

📚 Documentation preview 📚: https://ncompare--346.org.readthedocs.build/en/346/

Saving and restoring colorama's global Fore/Style state around the
Outputter (init -> __exit__) prevents no_color=True from permanently
blanking the process-wide color singletons, which previously left later
compare() calls -- and any other in-process colorama users -- colorless.
In-block behavior (including column alignment) is unchanged.

Adds a regression test asserting the color constants are blanked inside
the context and restored on exit.

Closes #345

Co-Authored-By: Claude <noreply@anthropic.com>
@danielfromearth danielfromearth added the bug Something isn't working label Aug 22, 2026
…o-color-state-leak

# Conflicts:
#	CHANGELOG.md
@danielfromearth
danielfromearth marked this pull request as ready for review August 22, 2026 21:50
…o-color-state-leak

Brings the branch up to date with develop (1.15.0a10). All conflicts were
incidental additive churn, with no contested logic:

- CHANGELOG.md: both sides appended bullets under `### Fixed`; kept all of them.
- tests/test_printing.py: both sides added imports and appended tests; kept both
  imports and all three tests.

ncompare/printing.py auto-merged: develop's changes there (the column_widths
property, the ValueError for a wrong width count, the _add_to_history rewrite)
are in regions disjoint from this branch's __exit__ restore.

Merging onto current develop is also what finally lets CI test this branch. The
pull-request workflow filters on the base branch, and `develop` was only recently
added to that filter; because a pull_request run reads the workflow from the
merge ref, and this branch's merge ref was both stale and conflicted, the unit
suite had never run on this PR.
…r is built

The `__exit__` restore added earlier scopes the no-color mutation to the
Outputter's lifetime, but only for callers that use the context manager. Nothing
obliges them to: `tests/conftest.py` builds bare Outputters, and a library user
can too. Such an Outputter still blanked colorama's process-wide `Fore`/`Style`
permanently, which is the symptom issue #345 describes.

Snapshot the pristine color constants at import, before any Outputter can touch
them, and restore from that snapshot whenever an Outputter is constructed with
color enabled. The mutation is now bounded from both ends: `__exit__` undoes it
for context-manager users, and asking for color repairs it for everyone else.
`__exit__` keeps restoring the per-instance snapshot rather than the pristine one,
so a no-color Outputter nested inside another behaves correctly.

Blanking the singletons is still load-bearing rather than cosmetic, so in-block
behavior is untouched: `side_by_side` pads the leading gutter by `len(Fore.RED)`
to pay for the escape sequence it prefixes onto the first column, and blanking
makes that compensation zero. The expanded comment records why.

Also drops `self._no_color`, which was never read.

Tests:
- test_no_color_leak_is_repaired_by_a_later_colorized_outputter builds a no-color
  Outputter without `with`, then asserts a colorized one repairs the state. Red
  on pre-fix code and on the `__exit__`-only version; green now.
- test_no_color_flag_suppresses_ansi_escape_sequences covers the CLI contract,
  which had none. It needs a pseudo-terminal: colorama strips escapes when stdout
  is not a terminal, so over a pipe colorized and no-color runs are identical.
- test_no_color_report_is_aligned_the_same_as_the_colorized_one pins the gutter
  arithmetic above by asserting that stripping ANSI from a colorized report
  reproduces the no-color report exactly. Verified to fail when the compensation
  is removed.

48 passed, 1 deselected. pre-commit clean, mypy included.
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.61%. Comparing base (de22420) to head (643f163).
⚠️ Report is 1 commits behind head on develop.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #346      +/-   ##
===========================================
+ Coverage    95.81%   96.61%   +0.80%     
===========================================
  Files            9        9              
  Lines          550      562      +12     
===========================================
+ Hits           527      543      +16     
+ Misses          23       19       -4     
Flag Coverage Δ
python-3.11 96.61% <100.00%> (+0.80%) ⬆️
python-3.12 96.61% <100.00%> (+0.80%) ⬆️
python-3.13 96.61% <100.00%> (+0.80%) ⬆️
python-3.14 96.58% <100.00%> (+0.81%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@danielfromearth danielfromearth self-assigned this Sep 3, 2026
@danielfromearth
danielfromearth merged commit 9802b35 into develop Sep 3, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant