Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/cli/src/repowise/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,14 @@
AI-generated documentation.
"""

from repowise.cli._stdio import _ensure_utf8_stdio

# Reconfigure stdout/stderr to UTF-8 with ``errors="replace"`` before any
# Rich Console is constructed downstream. Without this, Windows shells
# defaulting to cp1252 crash ``repowise init`` (and any other Rich-driven
# command) mid-pipeline when a non-ASCII glyph like ``↳`` or ``✓`` is
# printed — see issue #271. Safe and silent on Linux/macOS where stdio
# is already UTF-8.
_ensure_utf8_stdio()

__version__ = "0.13.0"
48 changes: 48 additions & 0 deletions packages/cli/src/repowise/cli/_stdio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Standard I/O hardening for the repowise CLI.

Imported at the top of :mod:`repowise.cli` so it runs before any Rich
``Console`` is built. Centralised here (rather than inlined in
``__init__.py``) so the behavior is unit-testable.
"""

from __future__ import annotations

import contextlib
import sys
from typing import IO


def _ensure_utf8_stdio() -> None:
"""Reconfigure ``sys.stdout``/``sys.stderr`` to UTF-8 with replacement.

Windows shells (``cmd.exe``, default PowerShell) ship with a cp1252
code page. Rich falls back to its legacy Windows renderer, which
encodes every printed line through the active code page — any
non-ASCII glyph in repowise's progress UI (``↳``, ``✓``) then raises
``UnicodeEncodeError`` and aborts the run mid-pipeline (issue #271).

Reconfiguring with ``errors="replace"`` means even if the underlying
console can't render a glyph, the write succeeds (substituting a
placeholder) and the pipeline keeps running. ``errors="replace"`` is
chosen over ``"backslashreplace"`` because the output is
user-visible — a single ``?`` is friendlier than ``\\u21b3``.

No-op on streams that lack ``reconfigure`` (e.g. when the CLI is
embedded and stdout has been swapped for an arbitrary writer) and
silently tolerant of any reconfigure failure — the original behavior
is preserved in that case rather than masked by an exception.
"""
for stream in (sys.stdout, sys.stderr):
_reconfigure(stream)


def _reconfigure(stream: IO[str] | None) -> None:
if stream is None:
return
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
return
# OSError: stream is detached or closed.
# ValueError: unsupported argument on a non-text wrapper.
with contextlib.suppress(OSError, ValueError):
reconfigure(encoding="utf-8", errors="replace")
98 changes: 98 additions & 0 deletions tests/unit/cli/test_stdio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Regression tests for stdio UTF-8 reconfiguration (issue #271).

The bug: Windows shells default to cp1252 and `repowise init` crashes with
`UnicodeEncodeError: 'charmap' codec can't encode character '↳'` when Rich
tries to render progress sub-step glyphs. The CLI now reconfigures stdout
and stderr to UTF-8 with `errors="replace"` at import time so the legacy
Windows renderer never raises.
"""

from __future__ import annotations

import io
from typing import Any

import pytest

from repowise.cli import _stdio


class _RecordingStream:
"""Minimal stand-in for sys.stdout with a `reconfigure` method."""

def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []

def reconfigure(self, **kwargs: Any) -> None:
self.calls.append(kwargs)


class _NoReconfigureStream:
"""Stream without `reconfigure` (e.g. a swapped-in StringIO)."""

def write(self, _data: str) -> int:
return 0


class _RaisingStream:
def reconfigure(self, **_kwargs: Any) -> None:
raise OSError("stream is detached")


def test_reconfigure_sets_utf8_with_replace() -> None:
stream = _RecordingStream()
_stdio._reconfigure(stream)
assert stream.calls == [{"encoding": "utf-8", "errors": "replace"}]


def test_reconfigure_tolerates_missing_method() -> None:
"""A StringIO has no `reconfigure` — must not raise."""
_stdio._reconfigure(_NoReconfigureStream())
_stdio._reconfigure(io.StringIO())


def test_reconfigure_swallows_oserror() -> None:
"""Detached or closed streams raise OSError — must be silent."""
_stdio._reconfigure(_RaisingStream())


def test_reconfigure_tolerates_none_stream() -> None:
_stdio._reconfigure(None)


def test_ensure_utf8_stdio_handles_swapped_streams(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`pytest -s` and `capsys` swap stdout for buffers without `reconfigure`.
The hardening function must not raise in either case — otherwise importing
the CLI under test would crash."""
monkeypatch.setattr("sys.stdout", io.StringIO())
monkeypatch.setattr("sys.stderr", io.StringIO())
_stdio._ensure_utf8_stdio()


def test_ensure_utf8_stdio_reconfigures_both_streams(
monkeypatch: pytest.MonkeyPatch,
) -> None:
out = _RecordingStream()
err = _RecordingStream()
monkeypatch.setattr("sys.stdout", out)
monkeypatch.setattr("sys.stderr", err)

_stdio._ensure_utf8_stdio()

assert out.calls == [{"encoding": "utf-8", "errors": "replace"}]
assert err.calls == [{"encoding": "utf-8", "errors": "replace"}]


def test_replace_errors_actually_survives_cp1252_glyph() -> None:
"""End-to-end: a TextIOWrapper around a BytesIO using cp1252+replace
must accept the `↳` glyph without raising — proving the chosen error
handler is the right one."""
raw = io.BytesIO()
wrapper = io.TextIOWrapper(raw, encoding="cp1252", errors="replace")
wrapper.write(" ↳ betweenness centrality ✓\n")
wrapper.flush()
# The exact replacement char varies, but the important property is
# no exception was raised and *some* bytes were written.
assert raw.getvalue()
Loading