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