diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index dd10bed5..157c6af9 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -19,7 +19,8 @@ usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] [--logging-config-path LOGGING_CONFIG_PATH] - [--headless] [--status] [--version] + [--headless] [--exit-when-done | --no-exit-when-done] + [--status] [--version] [filename] Render plain code to target code. Path arguments resolve based on where they @@ -131,6 +132,14 @@ options: specification text itself stays in the log file only. Used automatically when stdin, stdout or stderr is not a terminal. + --exit-when-done, --no-exit-when-done + Close the terminal interface as soon as rendering + stops, instead of keeping it open until you press + Enter. The render summary is printed to the terminal + as usual. Use --no-exit-when-done to keep the + interface open when config.yaml turns this on. + Headless rendering always ends by itself, so the + option changes nothing there. --status Display account status including user information, API key label, and rendering credits. Does not render any code. diff --git a/plain2code.py b/plain2code.py index 93842b8d..126b354a 100644 --- a/plain2code.py +++ b/plain2code.py @@ -453,6 +453,7 @@ def run_render(): enter_pause_event=enter_pause_event, on_cancel=run_state.set_render_cancelled, default_log_level=default_log_level, + exit_when_done=args.exit_when_done, css_path="styles.css", ) app.run() @@ -591,7 +592,7 @@ def main(): # noqa: C901 print_exit_summary( run_state, args.filename, - error_message=error_message, + error_message=error_message or run_state.render_error_message, ) # Remove any scratch extractions created for archive-only (".module") modules. for module in plain_module.all_required_modules + [plain_module]: diff --git a/plain2code_arguments.py b/plain2code_arguments.py index ff7b9355..e9e783f2 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -511,6 +511,17 @@ def create_parser(): "Used automatically when stdin, stdout or stderr is not a terminal.", ) + _add_arg( + parser, + "--exit-when-done", + action=argparse.BooleanOptionalAction, + default=False, + help="Close the terminal interface as soon as rendering stops, instead of keeping it open " + "until you press Enter. The render summary is printed to the terminal as usual. " + "Use --no-exit-when-done to keep the interface open when config.yaml turns this on. " + "Headless rendering always ends by itself, so the option changes nothing there.", + ) + parser.add_argument( "--status", action="store_true", diff --git a/plain2code_state.py b/plain2code_state.py index a295cb97..8a49634b 100644 --- a/plain2code_state.py +++ b/plain2code_state.py @@ -12,6 +12,8 @@ def __init__(self, spec_filename: str, replay_with: Optional[str] = None, job_ta self.replay: bool = replay_with is not None self.render_succeeded: bool = False self.render_cancelled: bool = False + # Why the render failed, as the TUI showed it; printed in the summary after the TUI closes. + self.render_error_message: Optional[str] = None self.render_generated_code_path: Optional[str] = None self.rendered_functionalities: int = 0 if replay_with: @@ -39,6 +41,9 @@ def increment_unittest_batch_id(self): def set_render_succeeded(self, succeeded: bool): self.render_succeeded = succeeded + def set_render_error_message(self, error_message: str): + self.render_error_message = error_message + def set_render_cancelled(self): self.render_cancelled = True diff --git a/tests/test_exit_when_done.py b/tests/test_exit_when_done.py new file mode 100644 index 00000000..f331fcb5 --- /dev/null +++ b/tests/test_exit_when_done.py @@ -0,0 +1,151 @@ +"""Tests for --exit-when-done: the TUI closes by itself once rendering has stopped.""" + +import asyncio +import threading + +import pytest + +from event_bus import EventBus +from plain2code_arguments import create_parser, parse_arguments +from plain2code_events import LogMessageEmitted, RenderCompleted, RenderFailed +from plain2code_state import RunState +from tui.plain2code_tui import Plain2CodeTUI + + +def _make_app(run_state: RunState, event_bus: EventBus, exit_when_done: bool, on_ready=lambda: None) -> Plain2CodeTUI: + return Plain2CodeTUI( + event_bus=event_bus, + run_state=run_state, + on_ready=on_ready, + render_id="test-render-id", + unittests_script=None, + conformance_tests_script=None, + prepare_environment_script=None, + state_machine_version="0.0.0", + exit_when_done=exit_when_done, + css_path="styles.css", + ) + + +def _run(exit_when_done: bool, event) -> bool: + """Publish ``event`` to a mounted TUI and report whether the TUI closed itself.""" + + async def scenario() -> bool: + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus, exit_when_done) + async with app.run_test() as pilot: + event_bus.publish(event) + await pilot.pause() + return app._exit + + return asyncio.run(scenario()) + + +def test_tui_closes_itself_when_rendering_completes(): + assert _run(True, RenderCompleted(rendered_code_path="plain_modules/x/")) is True + + +def test_tui_closes_itself_when_rendering_fails(): + assert _run(True, RenderFailed(error_message="Conformance tests failed")) is True + + +def test_tui_keeps_the_failure_reason_for_the_summary(): + """The TUI closes right away, so the reason it showed must survive on the run state.""" + + async def scenario() -> RunState: + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus, True) + async with app.run_test() as pilot: + event_bus.publish(RenderFailed(error_message="Conformance tests failed")) + await pilot.pause() + return run_state + + assert asyncio.run(scenario()).render_error_message == "Conformance tests failed" + + +def test_tui_waits_for_the_user_without_the_option(): + assert _run(False, RenderCompleted(rendered_code_path="plain_modules/x/")) is False + assert _run(False, RenderFailed(error_message="Conformance tests failed")) is False + + +def test_tui_closes_itself_when_the_render_thread_reports_completion(): + """The render runs in its own thread, so the completion event reaches the TUI from there.""" + + async def scenario() -> bool: + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus, True) + async with app.run_test() as pilot: + thread = threading.Thread( + target=lambda: event_bus.publish(RenderCompleted(rendered_code_path="plain_modules/x/")) + ) + thread.start() + thread.join() + await pilot.pause() + return app._exit + + assert asyncio.run(scenario()) is True + + +def _log(message: str) -> LogMessageEmitted: + return LogMessageEmitted(logger_name="codeplain", level="INFO", message=message, timestamp="00:00:01") + + +@pytest.mark.parametrize( + "event", [RenderCompleted(rendered_code_path="plain_modules/x/"), RenderFailed(error_message="x")] +) +def test_tui_closes_cleanly_while_log_lines_are_still_arriving(event): + """Log lines reach the TUI from the render thread right up to (and past) the final event. + + Entries still waiting to be drawn when the TUI closes must not crash it: before the fix, + mounting them into the torn-down log view raised MountError, which run_test re-raises. + """ + + async def scenario() -> bool: + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + + def render(): + for i in range(20): + event_bus.publish(_log(f"step {i}")) + event_bus.publish(event) + for i in range(20): + event_bus.publish(_log(f"after {i}")) + + # Started from on_ready, exactly as plain2code.render() starts the render thread. + render_thread = threading.Thread(target=render, daemon=True) + app = _make_app(run_state, event_bus, True, on_ready=render_thread.start) + async with app.run_test() as pilot: + for _ in range(100): + if app._exit and not render_thread.is_alive(): + break + await pilot.pause(0.01) + return app._exit + + assert asyncio.run(scenario()) is True + + +def test_option_is_off_by_default(): + args = create_parser().parse_args(["x.plain"]) + assert args.exit_when_done is False + + +def test_option_turns_on_from_the_command_line(): + args = create_parser().parse_args(["x.plain", "--exit-when-done"]) + assert args.exit_when_done is True + + +def test_option_turns_off_from_the_command_line(): + args = create_parser().parse_args(["x.plain", "--no-exit-when-done"]) + assert args.exit_when_done is False + + +def test_command_line_turns_off_an_exit_configured_in_config_yaml(tmp_path, monkeypatch): + (tmp_path / "x.plain").write_text("") + (tmp_path / "config.yaml").write_text("exit-when-done: true\n") + monkeypatch.chdir(tmp_path) + + assert parse_arguments([str(tmp_path / "x.plain")]).exit_when_done is True + assert parse_arguments([str(tmp_path / "x.plain"), "--no-exit-when-done"]).exit_when_done is False diff --git a/tests/test_render_finished_report.py b/tests/test_render_finished_report.py index 98baf38d..63c27a2b 100644 --- a/tests/test_render_finished_report.py +++ b/tests/test_render_finished_report.py @@ -108,7 +108,7 @@ def make_args(api_key="test-key", headless=False): ) -def run_main(render_side_effect=None, api_key="test-key"): +def run_main(render_side_effect=None, api_key="test-key", print_exit_summary=None): """Drive main() with everything but the reporting path stubbed out. render() is replaced, so the stub is what decides how the render "ended" - @@ -134,7 +134,7 @@ def fake_render(plain_module, codeplainAPI, args, run_state, event_bus, default_ patch("plain2code.setup_logging", return_value="INFO"), patch("plain2code.initialize_telemetry"), patch("plain2code.stop_narrating"), - patch("plain2code.print_exit_summary"), + patch("plain2code.print_exit_summary", print_exit_summary or MagicMock()), patch("plain2code.dump_crash_logs"), patch("plain2code.capture_crash"), patch("plain2code.codeplain_api.CodeplainAPI", return_value=api), @@ -204,3 +204,38 @@ def test_a_missing_api_key_reports_nothing(self): api = run_main(api_key=None) api.render_finished.assert_not_called() + + +class TestSummaryShowsWhyTheRenderFailed: + """A failure inside the render machine reaches main() only through the run state. + + The state machine publishes RenderFailed without raising, so render() returns normally + and main() has no error of its own. The TUI keeps the reason on the run state; with + --exit-when-done the summary is the only place the user still sees it. + """ + + def test_the_reason_the_tui_kept_is_printed(self): + def fail_inside_the_render_machine(run_state): + run_state.set_render_succeeded(False) + run_state.set_render_error_message("Conformance tests still fail after 20 attempts.") + + summary = MagicMock() + run_main(fail_inside_the_render_machine, print_exit_summary=summary) + + assert summary.call_args.kwargs["error_message"] == "Conformance tests still fail after 20 attempts." + + def test_a_raised_error_still_wins(self): + def fail(run_state): + run_state.set_render_error_message("reason the TUI kept") + raise Exception("rendering blew up") + + summary = MagicMock() + run_main(fail, print_exit_summary=summary) + + assert summary.call_args.kwargs["error_message"] == "rendering blew up" + + def test_a_successful_render_has_no_error_message(self): + summary = MagicMock() + run_main(lambda run_state: run_state.set_render_succeeded(True), print_exit_summary=summary) + + assert summary.call_args.kwargs["error_message"] is None diff --git a/tests/test_terminal_attachment.py b/tests/test_terminal_attachment.py index 641f6bd0..f1bca60a 100644 --- a/tests/test_terminal_attachment.py +++ b/tests/test_terminal_attachment.py @@ -84,6 +84,7 @@ def make_args(): render_from=None, force_render=False, headless=False, + exit_when_done=False, replay_with=None, job_tag=None, log_to_file=False, diff --git a/tui/components.py b/tui/components.py index 58c4ebaa..841d2241 100644 --- a/tui/components.py +++ b/tui/components.py @@ -624,12 +624,20 @@ def _should_show_log(self, level: str) -> bool: async def add_log( self, logger_name: str, level: str, message: str, timestamp: str = "", log_color: Optional[str] = None ): - """Add a new log entry.""" + """Add a new log entry. + + Log entries are scheduled from the render thread, so one can still be pending when the + TUI closes (e.g. with --exit-when-done). Once the view is detached from the DOM the entry + is dropped instead of mounted, since mounting into a detached widget raises MountError. + The check runs right before each mount because the view can detach while awaiting. + """ # Check if this is a success message that should have spacing before it is_success_message = any(keyword in message.lower() for keyword in LogEntry.SUCCESS_KEYWORDS) # Add empty line before success messages if is_success_message: + if not self.is_attached: + return spacer = Static("", classes="log-spacer") await self.mount(spacer) @@ -639,6 +647,8 @@ async def add_log( if not self._should_show_log(level): entry.display = False + if not self.is_attached: + return await self.mount(entry) # Auto-scroll to bottom to show latest logs self.scroll_end(animate=False) diff --git a/tui/plain2code_tui.py b/tui/plain2code_tui.py index cb492d46..dcd992a7 100644 --- a/tui/plain2code_tui.py +++ b/tui/plain2code_tui.py @@ -84,6 +84,7 @@ def __init__( enter_pause_event: threading.Event | None = None, on_cancel: Callable[[], None] | None = None, default_log_level: str = "INFO", + exit_when_done: bool = False, **kwargs, ): super().__init__(**kwargs) @@ -104,6 +105,7 @@ def __init__( self.enter_pause_event = enter_pause_event self._on_cancel = on_cancel self.default_log_level = default_log_level + self.exit_when_done = exit_when_done self._render_finished = False # Initialize state handlers @@ -336,10 +338,14 @@ def on_render_completed(self, event: RenderCompleted): footer.update_footer_state("finished") except NoMatches: pass + self._exit_when_done() def on_render_failed(self, event: RenderFailed): """Handle render failure.""" self._render_error_handler.handle(event.error_message) + # A failure inside the render machine is not raised, so main() has no error message of + # its own; keep the reason so the summary printed after the TUI closes can show it. + self.run_state.set_render_error_message(event.error_message) self._render_finished = True self._finalize_usage_summary() try: @@ -347,6 +353,18 @@ def on_render_failed(self, event: RenderFailed): footer.update_footer_state("finished") except NoMatches: pass + self._exit_when_done() + + def _exit_when_done(self) -> None: + """Close the TUI once rendering has stopped, when --exit-when-done asked for it. + + Both render outcomes are reported on the terminal after the TUI closes, so nothing + the user needs is lost. The events arrive on the render thread. The exit is queued + behind the log entries the render thread has already scheduled, so those are drawn + before the TUI starts shutting down; entries scheduled later are dropped by the log view. + """ + if self.exit_when_done: + self.call_later(self.exit) async def action_copy_selection(self) -> None: """Handle ctrl+c: copy selected text if any.