"""UTF-8 stdio bootstrap tests (v0.40.1 Part A). Closes QA findings C1, C4, H1, N5, N8, G5 — all surface as Windows codepage (cp1251 / cp1252) UnicodeEncodeError when Rich/Typer prints non-ASCII (β, ✓, box-drawing, etc.). The fix is a single CLI-bootstrap call that reconfigures stdout/stderr to UTF-8 on Windows. POSIX is already UTF-8 so this is a no-op there. """ from __future__ import annotations import io import os from unittest.mock import patch from soup_cli.utils import encoding as enc def test_force_utf8_stdio_noop_on_posix(): """POSIX terminals are UTF-8 already; bootstrap should not raise.""" with patch.object(enc.sys, "platform", "linux"): # Should not raise even when stdout has no reconfigure. enc.force_utf8_stdio() def test_force_utf8_stdio_sets_pythonioencoding_on_windows(monkeypatch): monkeypatch.delenv("PYTHONIOENCODING", raising=False) monkeypatch.setattr(enc.sys, "platform", "win32") # Provide stdout/stderr with .reconfigure that records the call. calls: list[dict] = [] class FakeStream: def reconfigure(self, **kwargs): calls.append(kwargs) monkeypatch.setattr(enc.sys, "stdout", FakeStream()) monkeypatch.setattr(enc.sys, "stderr", FakeStream()) enc.force_utf8_stdio() assert os.environ.get("PYTHONIOENCODING") == "utf-8" assert any(c.get("encoding") == "utf-8" for c in calls) def test_force_utf8_stdio_does_not_override_existing_pythonioencoding(monkeypatch): """User-set PYTHONIOENCODING is preserved (we use setdefault).""" monkeypatch.setenv("PYTHONIOENCODING", "latin-1") monkeypatch.setattr(enc.sys, "platform", "win32") class FakeStream: def reconfigure(self, **kwargs): pass monkeypatch.setattr(enc.sys, "stdout", FakeStream()) monkeypatch.setattr(enc.sys, "stderr", FakeStream()) enc.force_utf8_stdio() assert os.environ.get("PYTHONIOENCODING") == "latin-1" def test_force_utf8_stdio_swallows_reconfigure_errors(monkeypatch): """Some streams (redirected to file objects without reconfigure) must not crash.""" monkeypatch.setattr(enc.sys, "platform", "win32") class BadStream: def reconfigure(self, **kwargs): raise OSError("not a tty") monkeypatch.setattr(enc.sys, "stdout", BadStream()) monkeypatch.setattr(enc.sys, "stderr", BadStream()) # Must not raise. enc.force_utf8_stdio() def test_force_utf8_stdio_handles_missing_reconfigure(monkeypatch): """Older Python or non-TextIO streams without .reconfigure must not crash.""" monkeypatch.setattr(enc.sys, "platform", "win32") monkeypatch.setattr(enc.sys, "stdout", io.BytesIO()) # no reconfigure attr monkeypatch.setattr(enc.sys, "stderr", io.BytesIO()) enc.force_utf8_stdio() def test_cli_bootstrap_calls_force_utf8(monkeypatch): """soup_cli.cli imports `force_utf8_stdio` so it runs at module load.""" import soup_cli.cli as cli_mod # Module-level guarantee: the symbol is imported. assert hasattr(cli_mod, "_utf8_bootstrap_done") or hasattr( cli_mod, "force_utf8_stdio" ) or "force_utf8_stdio" in dir(enc) def test_writers_use_utf8_encoding(): """Static check that hot-path writers pass encoding='utf-8' to open().""" import pathlib audited_files = [ "src/soup_cli/commands/quickstart.py", "src/soup_cli/commands/init.py", "src/soup_cli/commands/migrate.py", ] repo_root = pathlib.Path(__file__).resolve().parent.parent for rel in audited_files: path = repo_root / rel if not path.exists(): continue text = path.read_text(encoding="utf-8") # Every text-mode write should specify encoding. # We grep for `open(...,"w"` patterns and assert utf-8 nearby. # Permissive check: if file contains `open(` with `"w"` or `'w'`, it must # also reference encoding="utf-8" in the same call (rough heuristic — full # static verification is left to ruff + lint hooks). # Just ensure the file at least mentions utf-8 if it contains text writes. if 'open(' in text and (', "w"' in text or ", 'w'" in text): assert 'encoding="utf-8"' in text or "encoding='utf-8'" in text, ( f"{rel} writes text without explicit utf-8 encoding" )