diff --git a/pythonlings/core/runner.py b/pythonlings/core/runner.py index a39881d..4aa694e 100644 --- a/pythonlings/core/runner.py +++ b/pythonlings/core/runner.py @@ -27,7 +27,18 @@ def run(exercise: Exercise, timeout_s: float = DEFAULT_TIMEOUT_S) -> RunResult: "PYTHONDONTWRITEBYTECODE": "1", "PYTHONIOENCODING": "utf-8", } - exercise_src = exercise_path.read_text(encoding="utf-8") + try: + exercise_src = exercise_path.read_text(encoding="utf-8") + except UnicodeDecodeError as e: + return RunResult( + passed=False, + exit_code=-1, + stdout="", + stderr=f"pythonlings: exercise is not valid UTF-8: {e}", + duration_s=0.0, + timed_out=False, + ) + runner_src = ( "import sys\n" "from pathlib import Path\n" diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index 47f9ec8..fdb7b34 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -82,6 +82,31 @@ def test_utf8_output(tmp_path: Path) -> None: assert "héllo 🐍" in result.stdout +def test_invalid_utf8_exercise_returns_failure_not_raise(tmp_path: Path) -> None: + # Regression for #72: an invalidly encoded exercise must not escape + # run()'s no-raise contract as UnicodeDecodeError. + ex_path = tmp_path / "exercise.py" + check_path = tmp_path / "check.py" + ex_path.write_bytes(b'x = "\xff"\n') + check_path.write_text("assert True\n", encoding="utf-8") + + result = run( + Exercise( + name="invalid-utf8", + path=ex_path, + check_path=check_path, + topic="t", + hint="", + root=tmp_path, + ) + ) + + assert result.passed is False + assert result.exit_code != 0 + assert "not valid UTF-8" in result.stderr + assert result.timed_out is False + + def test_runner_uses_workspace_for_relative_files(tmp_path: Path) -> None: data_path = tmp_path / "data.txt" data_path.write_text("pythonlings\n", encoding="utf-8")