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
60 changes: 50 additions & 10 deletions pythonlings/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}")
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
43 changes: 43 additions & 0 deletions tests/integration/test_cli_topics.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# tests/integration/test_cli_topics.py
import os
import subprocess
import sys
from pathlib import Path
Expand All @@ -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
Expand Down Expand Up @@ -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
78 changes: 78 additions & 0 deletions tests/integration/test_cli_verify.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# tests/integration/test_cli_verify.py
import os
import subprocess
import sys
from pathlib import Path
Expand All @@ -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")
Expand Down Expand Up @@ -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 "<name> ✓", 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
Loading