diff --git a/pythonlings/core/runner.py b/pythonlings/core/runner.py index a39881d..199860e 100644 --- a/pythonlings/core/runner.py +++ b/pythonlings/core/runner.py @@ -27,7 +27,21 @@ 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 {exercise.name!r} at {exercise_path} " + f"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/pythonlings/screens/track.py b/pythonlings/screens/track.py index fce440d..c1fa9e2 100644 --- a/pythonlings/screens/track.py +++ b/pythonlings/screens/track.py @@ -100,8 +100,8 @@ def on_mount(self) -> None: f"Topic '{self.topic}' complete." ) return - self._load_current() - self._run_current() + if self._load_current(): + self._run_current() self.query_one(EditorPane).focus_editor() # --- helpers --------------------------------------------------------- @@ -143,18 +143,45 @@ def _exercise(self, name: str) -> Exercise: return ex raise KeyError(name) - def _load_current(self) -> None: + def _load_current(self) -> bool: if self.current is None: - return + return False if self._save_timer is not None: self._save_timer.stop() self._save_timer = None self.query_one(OutputPanel).reset_hint() pane = self.query_one(EditorPane) - pane.load_exercise(self._exercise(self.current)) + exercise = self._exercise(self.current) + try: + pane.load_exercise(exercise) + except UnicodeDecodeError as error: + pane.query_one("#code", TextArea).text = "" + self._loaded_text = "" + self._failure_counts[self.current] = 1 + self._record_resume(self.current) + completed, total = self._progress_counts() + self.query_one(OutputPanel).render_result( + exercise, + RunResult( + passed=False, + exit_code=-1, + stdout="", + stderr=( + f"pythonlings: exercise {exercise.name!r} at " + f"{exercise.path} is not valid UTF-8: {error}" + ), + duration_s=0.0, + timed_out=False, + ), + failures=1, + completed=completed, + total=total, + ) + return False self._loaded_text = pane.text self._failure_counts[self.current] = 0 self._record_resume(self.current) + return True def _record_resume(self, exercise: str | None) -> None: self.app.state.record_resume(self.topic, exercise) @@ -230,8 +257,8 @@ def _apply_result(self, exercise: Exercise, result: RunResult) -> None: f"Topic '{self.topic}' complete — press F4 for topics." ) return - self._load_current() - self._run_current() + if self._load_current(): + self._run_current() # --- actions --------------------------------------------------------- @@ -249,8 +276,8 @@ def action_reset(self) -> None: self._save_timer.stop() self._save_timer = None restore(self.app.root, self._exercise(self.current)) - self._load_current() - self._run_current() + if self._load_current(): + self._run_current() def action_toggle_list(self) -> None: tree = self.query_one(ExerciseTree) diff --git a/pythonlings/widgets/output_panel.py b/pythonlings/widgets/output_panel.py index 2e0c573..ebcbd19 100644 --- a/pythonlings/widgets/output_panel.py +++ b/pythonlings/widgets/output_panel.py @@ -145,7 +145,12 @@ def _render_header( ) def _goal_from(self, exercise: Exercise) -> str: - for line in exercise.path.read_text(encoding="utf-8").splitlines()[:12]: + try: + lines = exercise.path.read_text(encoding="utf-8").splitlines()[:12] + except UnicodeDecodeError: + return exercise.name + + for line in lines: stripped = line.strip() if stripped.startswith("# Goal:"): return stripped.removeprefix("# Goal:").strip() diff --git a/tests/tui/test_app_pilot.py b/tests/tui/test_app_pilot.py index f055980..0d3c3ed 100644 --- a/tests/tui/test_app_pilot.py +++ b/tests/tui/test_app_pilot.py @@ -45,6 +45,26 @@ async def test_default_launch_opens_first_pending_exercise(tmp_path: Path) -> No assert app.screen.current == "a1" +@pytest.mark.asyncio +async def test_invalid_utf8_exercise_stays_open_and_shows_error( + tmp_path: Path, +) -> None: + work = _work_copy(tmp_path) + exercise_path = work / "exercises" / "alpha" / "a1.py" + exercise_path.write_bytes(b'x = "\xff"\n') + + app = PythonlingsApp(root=work) + async with app.run_test() as pilot: + await _settle(pilot) + assert isinstance(app.screen, TrackScreen) + assert app.screen.current == "a1" + assert app.screen.query_one("#code", TextArea).text == "" + rendered = app.screen.query_one(OutputPanel).renderable_text() + assert "not valid UTF-8" in rendered + assert "a1" in rendered + assert str(exercise_path) in rendered + + @pytest.mark.asyncio async def test_picker_lists_topics_with_progress(tmp_path: Path) -> None: app = PythonlingsApp(root=_work_copy(tmp_path), force_picker=True) diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index 47f9ec8..5a332fd 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -82,6 +82,33 @@ 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 "invalid-utf8" in result.stderr + assert str(ex_path) 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")