From 82c60135b638a4ab38f6e77c276234f67c225d1d Mon Sep 17 00:00:00 2001 From: Zhang Yuyang Date: Mon, 24 Aug 2026 10:59:08 +0800 Subject: [PATCH 1/2] fix(core): keep run() from raising on invalid UTF-8 exercises run() documents a no-raise contract: the CLI and TUI rely on receiving a structured RunResult. An invalidly encoded learner exercise currently escapes that boundary as UnicodeDecodeError from read_text(). Catch the decode error and return a failing RunResult with an actionable stderr message instead. --- pythonlings/core/runner.py | 13 ++++++++++++- tests/unit/test_runner.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) 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") From 28073a06f011cf5b131bd5f3a14403796866b514 Mon Sep 17 00:00:00 2001 From: zhangyuyang Date: Wed, 2 Sep 2026 15:36:01 +0800 Subject: [PATCH 2/2] fix(tui): handle invalid UTF-8 exercises without crashing Render invalid encoding errors through the normal output panel, include the exercise name and path, and add TUI regression coverage. Signed-off-by: zhangyuyang --- pythonlings/core/runner.py | 5 +++- pythonlings/screens/track.py | 45 +++++++++++++++++++++++------ pythonlings/widgets/output_panel.py | 7 ++++- tests/tui/test_app_pilot.py | 20 +++++++++++++ tests/unit/test_runner.py | 2 ++ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/pythonlings/core/runner.py b/pythonlings/core/runner.py index 4aa694e..199860e 100644 --- a/pythonlings/core/runner.py +++ b/pythonlings/core/runner.py @@ -34,7 +34,10 @@ def run(exercise: Exercise, timeout_s: float = DEFAULT_TIMEOUT_S) -> RunResult: passed=False, exit_code=-1, stdout="", - stderr=f"pythonlings: exercise is not valid UTF-8: {e}", + stderr=( + f"pythonlings: exercise {exercise.name!r} at {exercise_path} " + f"is not valid UTF-8: {e}" + ), duration_s=0.0, timed_out=False, ) 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 fdb7b34..5a332fd 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -104,6 +104,8 @@ def test_invalid_utf8_exercise_returns_failure_not_raise(tmp_path: Path) -> None 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