From 977841dbdb7287e78ea0afc2e4163173e8d69d55 Mon Sep 17 00:00:00 2001 From: Abhik Sarkar Date: Fri, 21 Aug 2026 01:50:00 +0530 Subject: [PATCH] fix: keep CLI status output usable under ASCII locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #73. The CLI rendered Unicode status glyphs unconditionally, so verify and list crashed with UnicodeEncodeError on any console whose encoding cannot represent them -- LC_ALL=C, or a Windows code page. Status symbols now fall back to one-column ASCII stand-ins (+ pass, x fail, > current, - locked) when the stream cannot encode them, and keep the Unicode glyphs when it can. The issue recorded verify and list, but solution and run crash on a separate path: they write the check file's captured output, and every check ends with print(" ✓"). That glyph comes from the curriculum rather than from our own formatting, so degrading the CLI's symbols alone would not have fixed it. All captured subprocess output now goes through a writer that replaces characters the encoding cannot represent rather than raising. This surfaced as a win-64 failure while packaging for conda-forge, where the test console is a pipe using cp1252. Integration coverage pins PYTHONIOENCODING explicitly so the regression is deterministic rather than dependent on the locale the suite runs under, and covers both the ASCII fallback and the UTF-8 path. --- pythonlings/cli.py | 60 +++++++++++++++++---- tests/integration/test_cli_topics.py | 43 +++++++++++++++ tests/integration/test_cli_verify.py | 78 ++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 10 deletions(-) diff --git a/pythonlings/cli.py b/pythonlings/cli.py index 0fcdb33..d00aa14 100644 --- a/pythonlings/cli.py +++ b/pythonlings/cli.py @@ -14,6 +14,44 @@ __version__ = "0.0.0+unknown" +# Status glyphs, with ASCII stand-ins for consoles whose encoding cannot +# represent them (a Windows code page, or LC_ALL=C). The fallbacks stay one +# column wide so aligned output keeps its shape. +_ASCII_FALLBACK = {"\u2713": "+", "\u2717": "x", "\u25cf": ">", "\U0001f512": "-"} + + +def _encodable(stream: object, text: str) -> bool: + encoding = getattr(stream, "encoding", None) + if not encoding: + return True + try: + text.encode(encoding) + except (UnicodeEncodeError, LookupError): + return False + return True + + +def _symbol(stream: object, glyph: str) -> str: + """Return `glyph`, or an ASCII stand-in the stream can actually encode.""" + if _encodable(stream, glyph): + return glyph + return _ASCII_FALLBACK.get(glyph, "?") + + +def _write(stream, text: str) -> None: + """Write captured output, degrading characters the encoding cannot represent. + + Check files print their own status line (`print("variables1 \u2713")`), so + curriculum output reaches us containing glyphs a strict-ASCII console + cannot encode. Losing a glyph is better than a traceback. + """ + try: + stream.write(text) + except UnicodeEncodeError: + encoding = getattr(stream, "encoding", None) or "ascii" + stream.write(text.encode(encoding, "replace").decode(encoding, "replace")) + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="pythonlings") parser.add_argument("--version", action="version", version=f"pythonlings {__version__}") @@ -137,10 +175,10 @@ def _cmd_verify(root: Path, topic: str | None) -> int: exercises = manifest.exercises for ex in exercises: result = run_verify(ex) - status = "✓" if result.passed else "✗" + status = _symbol(sys.stdout, "✓" if result.passed else "✗") print(f"{status} {ex.name}") if not result.passed: - sys.stderr.write(result.stderr or result.stdout) + _write(sys.stderr, result.stderr or result.stdout) return 1 return 0 @@ -156,7 +194,9 @@ def _cmd_list(root: Path, topic: str | None) -> int: for name in manifest.topics(): exs = manifest.exercises_in(name) done = sum(1 for ex in exs if ex.name in state.completed) - mark = "✓" if done == len(exs) else ("●" if done else " ") + mark = _symbol(sys.stdout, "✓") if done == len(exs) else ( + _symbol(sys.stdout, "●") if done else " " + ) print(f" {mark} {name} {done}/{len(exs)}") return 0 @@ -166,11 +206,11 @@ def _cmd_list(root: Path, topic: str | None) -> int: current = next_pending(exs, state.completed) for ex in exs: if ex.name in state.completed: - marker = "✓" + marker = _symbol(sys.stdout, "✓") elif ex.name == current: - marker = "●" + marker = _symbol(sys.stdout, "●") else: - marker = "🔒" + marker = _symbol(sys.stdout, "🔒") print(f" {marker} {ex.name}") return 0 @@ -203,9 +243,9 @@ def _cmd_run(root: Path, name: str) -> int: result = run_exercise(ex) if result.stdout: - sys.stdout.write(result.stdout) + _write(sys.stdout, result.stdout) if result.stderr: - sys.stderr.write(result.stderr) + _write(sys.stderr, result.stderr) if result.timed_out: sys.stderr.write(f"pythonlings: {name} timed out after {result.duration_s:.1f}s\n") return 1 @@ -236,9 +276,9 @@ def _cmd_solution(root: Path, name: str) -> int: result = run_verify(ex) if result.stdout: - sys.stdout.write(result.stdout) + _write(sys.stdout, result.stdout) if result.stderr: - sys.stderr.write(result.stderr) + _write(sys.stderr, result.stderr) return 0 if result.passed else 1 diff --git a/tests/integration/test_cli_topics.py b/tests/integration/test_cli_topics.py index e7ece28..af23bed 100644 --- a/tests/integration/test_cli_topics.py +++ b/tests/integration/test_cli_topics.py @@ -1,4 +1,5 @@ # tests/integration/test_cli_topics.py +import os import subprocess import sys from pathlib import Path @@ -14,6 +15,28 @@ def _run(*args: str) -> subprocess.CompletedProcess[str]: ) +def _run_ascii(*args: str) -> subprocess.CompletedProcess[str]: + """Run the CLI with stdout/stderr pinned to a strict ASCII encoding. + + Setting the encoding explicitly keeps the regression deterministic rather + than depending on the locale the suite happens to run under. + """ + env = { + **os.environ, + "PYTHONIOENCODING": "ascii", + "PYTHONUTF8": "0", + "PYTHONCOERCECLOCALE": "0", + "LC_ALL": "C", + "LANG": "C", + } + return subprocess.run( + [sys.executable, "-m", "pythonlings", *args], + capture_output=True, + text=True, + env=env, + ) + + def test_list_shows_topics_with_progress() -> None: result = _run("--root", str(FIXTURES), "list") assert result.returncode == 0 @@ -58,3 +81,23 @@ def test_start_unknown_topic_errors() -> None: def test_topics_subcommand_parses() -> None: args = _build_parser().parse_args(["topics"]) assert args.command == "topics" + + +def test_list_completes_under_ascii_encoding() -> None: + result = _run_ascii("--root", str(FIXTURES), "list") + + assert result.returncode == 0, result.stderr + assert "Traceback" not in result.stderr + assert "exercises" in result.stdout + + +def test_list_topic_completes_under_ascii_encoding() -> None: + # Per-exercise output renders the current and locked markers, which are the + # glyphs a strict-ASCII console cannot encode. + result = _run_ascii("--root", str(FIXTURES), "list", "exercises") + + assert result.returncode == 0, result.stderr + assert "Traceback" not in result.stderr + # Current and locked states stay distinguishable through their stand-ins. + assert ">" in result.stdout + assert "-" in result.stdout diff --git a/tests/integration/test_cli_verify.py b/tests/integration/test_cli_verify.py index af525ba..def55e8 100644 --- a/tests/integration/test_cli_verify.py +++ b/tests/integration/test_cli_verify.py @@ -1,4 +1,5 @@ # tests/integration/test_cli_verify.py +import os import subprocess import sys from pathlib import Path @@ -14,6 +15,28 @@ def _run(*args: str) -> subprocess.CompletedProcess[str]: ) +def _run_ascii(*args: str) -> subprocess.CompletedProcess[str]: + """Run the CLI with stdout/stderr pinned to a strict ASCII encoding. + + Setting the encoding explicitly keeps the regression deterministic rather + than depending on the locale the suite happens to run under. + """ + env = { + **os.environ, + "PYTHONIOENCODING": "ascii", + "PYTHONUTF8": "0", + "PYTHONCOERCECLOCALE": "0", + "LC_ALL": "C", + "LANG": "C", + } + return subprocess.run( + [sys.executable, "-m", "pythonlings", *args], + capture_output=True, + text=True, + env=env, + ) + + def test_verify_fails_on_first_failure() -> None: # passing.py passes, asserts.py fails → verify exits non-zero. result = _run("--root", str(FIXTURES), "verify") @@ -141,3 +164,58 @@ def test_hint_non_string_field_exits_2_without_traceback(tmp_path: Path) -> None assert result.returncode == 2 assert "hint" in result.stderr assert "Traceback" not in result.stderr + + +def test_verify_completes_under_ascii_encoding(tmp_path: Path) -> None: + # A check prints " ✓", so verify writes a non-ASCII glyph it captured + # from the curriculum as well as its own status symbol. Neither may crash a + # strict-ASCII console. + info = tmp_path / "info.toml" + info.write_text( + 'format_version = 1\n' + '[[exercises]]\n' + 'name = "ok"\n' + 'path = "exercises/ok.py"\n', + encoding="utf-8", + ) + (tmp_path / "exercises").mkdir() + (tmp_path / "exercises" / "ok.py").write_text("value = 1\n", encoding="utf-8") + (tmp_path / "checks").mkdir() + (tmp_path / "checks" / "ok.py").write_text( + 'assert value == 1, "value should be 1"\nprint("ok \u2713")\n', + encoding="utf-8", + ) + + result = _run_ascii("--root", str(tmp_path), "verify") + + assert result.returncode == 0, result.stderr + assert "Traceback" not in result.stderr + assert "UnicodeEncodeError" not in result.stderr + # The pass state stays visible through its ASCII stand-in. + assert "+ ok" in result.stdout + + +def test_verify_keeps_unicode_symbol_under_utf8(tmp_path: Path) -> None: + info = tmp_path / "info.toml" + info.write_text( + 'format_version = 1\n' + '[[exercises]]\n' + 'name = "ok"\n' + 'path = "exercises/ok.py"\n', + encoding="utf-8", + ) + (tmp_path / "exercises").mkdir() + (tmp_path / "exercises" / "ok.py").write_text("value = 1\n", encoding="utf-8") + (tmp_path / "checks").mkdir() + (tmp_path / "checks" / "ok.py").write_text("assert value == 1\n", encoding="utf-8") + + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + result = subprocess.run( + [sys.executable, "-m", "pythonlings", "--root", str(tmp_path), "verify"], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert "\u2713 ok" in result.stdout