From 1d651d91285a8300f3eeb86e9f58d6d43aac5756 Mon Sep 17 00:00:00 2001 From: Yunare Maia Date: Sun, 16 Aug 2026 01:19:57 +0000 Subject: [PATCH] fix(console): fail fast in select_with_arrows when stdin is not a TTY (#4152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specify init can hang indefinitely at arrow-key selection prompts (select_with_arrows) when stdin is not attached to a TTY — agent harnesses, CI, or piped input wait on readkey() forever with no timeout, no error, and no output. select_with_arrows now detects non-interactive stdin and: - resolves to default_key immediately when one is provided (making a fully scripted init expressible), or - raises ValueError naming the missing interactive session when no default exists, instead of blocking forever. Both call sites in init.py already guard with _stdin_is_interactive(), so this is defense in depth: any future caller of the selector cannot hang a non-interactive process again. 3 new tests: no-TTY without default raises, no-TTY with default resolves, TTY path still interactive. 8/8 console tests green; no regressions (test_cli failures are pre-existing in this environment, identical count with and without this change). Signed-off-by: Yunare Maia --- src/specify_cli/_console.py | 17 +++++++++ tests/test_console_non_interactive.py | 54 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/test_console_non_interactive.py diff --git a/src/specify_cli/_console.py b/src/specify_cli/_console.py index 0e448780ad..5a915c7da4 100644 --- a/src/specify_cli/_console.py +++ b/src/specify_cli/_console.py @@ -162,6 +162,12 @@ def select_with_arrows( Returns: Selected option key + + Raises: + ValueError: If stdin is not a TTY and no ``default_key`` is given — + the interactive loop would otherwise block forever waiting for + keypresses that will never arrive (agent harness, CI, piped + input). See issue #4152. """ if not options: raise ValueError("select_with_arrows() requires at least one option.") @@ -172,6 +178,17 @@ def select_with_arrows( else: selected_index = 0 + # Fail fast in non-interactive environments instead of hanging on + # readkey() forever. With a default, resolve to it immediately. + if not sys.stdin.isatty(): + if default_key and default_key in option_keys: + return default_key + raise ValueError( + "select_with_arrows() requires an interactive TTY, but stdin is " + "not a terminal. Pass an explicit option flag to pre-answer this " + "selection, or run in an interactive session." + ) + selected_key = None def create_selection_panel(): diff --git a/tests/test_console_non_interactive.py b/tests/test_console_non_interactive.py new file mode 100644 index 0000000000..6e32773afb --- /dev/null +++ b/tests/test_console_non_interactive.py @@ -0,0 +1,54 @@ +""" +Tests for non-interactive (no-TTY) behavior of select_with_arrows. + +When stdin is not a TTY (agent harness, CI, piped input), the interactive +arrow-key selector must fail fast with a clear error instead of blocking +forever on readkey(). See issue #4152. +""" + +import sys + +import pytest + +from specify_cli._console import select_with_arrows + + +class FakeStdin: + """Stdin-like object that reports not a TTY.""" + + def isatty(self) -> bool: + return False + + +def test_select_with_arrows_fails_fast_without_tty_and_no_default(monkeypatch): + monkeypatch.setattr("sys.stdin", FakeStdin()) + with pytest.raises(ValueError, match="not a terminal"): + select_with_arrows({"a": "option a", "b": "option b"}) + + +def test_select_with_arrows_uses_default_without_tty(monkeypatch): + monkeypatch.setattr("sys.stdin", FakeStdin()) + # With a default, no-TTY should resolve to the default (no hang). + result = select_with_arrows( + {"a": "option a", "b": "option b"}, + default_key="b", + ) + assert result == "b" + + +def test_select_with_arrows_still_interactive_with_tty(monkeypatch): + """With a real TTY, the interactive loop is untouched.""" + real_stdin = sys.stdin + + class RealStdin: + def isatty(self) -> bool: + return True + + monkeypatch.setattr("sys.stdin", RealStdin()) + # Simulate a single 'enter' keypress on the first get_key() call. + import specify_cli._console as console + + monkeypatch.setattr(console, "get_key", lambda: "enter") + result = select_with_arrows({"a": "option a"}) + assert result == "a" + monkeypatch.setattr("sys.stdin", real_stdin)