Skip to content

Commit bdc05ab

Browse files
bjoaquincclaude
andcommitted
fix(cli): install venv in non-interactive terminals instead of crashing
When stdin is not a TTY (piped input, CI, coding agents) and neither --yes nor --no-sync is passed, the venv/uv confirmation prompts called beaupy.select, which raises `termios.error: (25, 'Inappropriate ioctl for device')`. That error fell through to the generic handler and the run failed with "Unexpected error" and exit code 1, leaving no venv. confirm() now detects a non-interactive stdin and returns the prompt's default (Yes) instead of invoking beaupy, so a non-TTY run installs uv if missing and runs `uv sync` unattended. --yes and --no-sync behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a5ef32e commit bdc05ab

2 files changed

Lines changed: 34 additions & 0 deletions

File tree

src/dlthub_init/prompts.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import sys
56
from typing import cast
67

78
import beaupy
@@ -17,8 +18,21 @@ def _echo_selection(value: str) -> None:
1718
console.print(f"[{CURSOR_STYLE}]{TICK_CHAR}[/{CURSOR_STYLE}] [bold]{value}[/bold]")
1819

1920

21+
def _stdin_is_interactive() -> bool:
22+
stream = sys.stdin
23+
try:
24+
return stream is not None and stream.isatty()
25+
except (OSError, ValueError):
26+
return False
27+
28+
2029
def confirm(message: str, *, default: bool = True) -> bool:
2130
console.print(f"\n[bold]{message}[/bold]")
31+
if not _stdin_is_interactive():
32+
# No TTY to read a selection from (piped stdin, CI, agents). beaupy would
33+
# crash with a termios error, so fall back to the default answer instead.
34+
_echo_selection("Yes" if default else "No")
35+
return default
2236
choice = cast(
2337
str,
2438
beaupy.select(

tests/test_prompts.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
class ConfirmTest(unittest.TestCase):
99
def setUp(self):
1010
display.console.quiet = True
11+
interactive = patch("dlthub_init.prompts._stdin_is_interactive", return_value=True)
12+
interactive.start()
13+
self.addCleanup(interactive.stop)
1114

1215
def tearDown(self):
1316
display.console.quiet = False
@@ -26,5 +29,22 @@ def test_default_controls_initial_cursor(self, select):
2629
self.assertEqual(select.call_args.kwargs["cursor_index"], 1)
2730

2831

32+
class NonInteractiveConfirmTest(unittest.TestCase):
33+
def setUp(self):
34+
display.console.quiet = True
35+
non_interactive = patch("dlthub_init.prompts._stdin_is_interactive", return_value=False)
36+
non_interactive.start()
37+
self.addCleanup(non_interactive.stop)
38+
39+
def tearDown(self):
40+
display.console.quiet = False
41+
42+
@patch("dlthub_init.prompts.beaupy.select")
43+
def test_returns_default_without_prompting(self, select):
44+
self.assertTrue(confirm("Proceed?", default=True))
45+
self.assertFalse(confirm("Proceed?", default=False))
46+
select.assert_not_called()
47+
48+
2949
if __name__ == "__main__":
3050
unittest.main()

0 commit comments

Comments
 (0)