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)