Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/specify_cli/_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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():
Expand Down
54 changes: 54 additions & 0 deletions tests/test_console_non_interactive.py
Original file line number Diff line number Diff line change
@@ -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)