handle raw input in interactive
This commit is contained in:
parent
7bae09e829
commit
18b0ce230e
|
|
@ -1,9 +1,10 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Module for running interactive subprocesses with output capture.
|
Module for running interactive subprocesses with output capture,
|
||||||
|
with full raw input passthrough for interactive commands.
|
||||||
|
|
||||||
It uses a pseudo-tty and integrates pyte's HistoryScreen to
|
It uses a pseudo-tty and integrates pyte's HistoryScreen to simulate
|
||||||
simulate a terminal and capture the final scrollback history (non-blank lines).
|
a terminal and capture the final scrollback history (non-blank lines).
|
||||||
The interface remains compatible with external callers expecting a tuple (output, return_code),
|
The interface remains compatible with external callers expecting a tuple (output, return_code),
|
||||||
where output is a bytes object (UTF-8 encoded).
|
where output is a bytes object (UTF-8 encoded).
|
||||||
"""
|
"""
|
||||||
|
|
@ -15,6 +16,9 @@ import errno
|
||||||
import sys
|
import sys
|
||||||
import io
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import select
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
import pyte
|
import pyte
|
||||||
|
|
@ -42,68 +46,97 @@ def run_interactive_command(cmd: List[str]) -> Tuple[bytes, int]:
|
||||||
FileNotFoundError: If the command is not found in PATH.
|
FileNotFoundError: If the command is not found in PATH.
|
||||||
RuntimeError: If an error occurs during execution.
|
RuntimeError: If an error occurs during execution.
|
||||||
"""
|
"""
|
||||||
# Fail early if cmd is empty.
|
|
||||||
if not cmd:
|
if not cmd:
|
||||||
raise ValueError("No command provided.")
|
raise ValueError("No command provided.")
|
||||||
|
|
||||||
# Check that the command exists.
|
|
||||||
if shutil.which(cmd[0]) is None:
|
if shutil.which(cmd[0]) is None:
|
||||||
raise FileNotFoundError(f"Command '{cmd[0]}' not found in PATH.")
|
raise FileNotFoundError(f"Command '{cmd[0]}' not found in PATH.")
|
||||||
|
|
||||||
# Determine terminal dimensions; use os.get_terminal_size if available.
|
|
||||||
try:
|
try:
|
||||||
term_size = os.get_terminal_size()
|
term_size = os.get_terminal_size()
|
||||||
cols, rows = term_size.columns, term_size.lines
|
cols, rows = term_size.columns, term_size.lines
|
||||||
except OSError:
|
except OSError:
|
||||||
cols, rows = 80, 24
|
cols, rows = 80, 24
|
||||||
|
|
||||||
# Instantiate HistoryScreen with a large history (scrollback) buffer.
|
# Set up pyte screen and stream to capture terminal output.
|
||||||
screen = HistoryScreen(cols, rows, history=2000, ratio=0.5)
|
screen = HistoryScreen(cols, rows, history=2000, ratio=0.5)
|
||||||
stream = pyte.Stream(screen)
|
stream = pyte.Stream(screen)
|
||||||
|
|
||||||
# Open a new pseudo-tty.
|
# Open a new pseudo-tty.
|
||||||
master_fd, slave_fd = os.openpty()
|
master_fd, slave_fd = os.openpty()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try to use real TTY stdin if available
|
|
||||||
stdin_fd = sys.stdin.fileno()
|
stdin_fd = sys.stdin.fileno()
|
||||||
except (AttributeError, io.UnsupportedOperation):
|
except (AttributeError, io.UnsupportedOperation):
|
||||||
# Fallback to pseudo-TTY for tests
|
stdin_fd = None
|
||||||
stdin_fd = slave_fd
|
|
||||||
|
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdin=stdin_fd,
|
stdin=slave_fd,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
bufsize=0,
|
bufsize=0,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
)
|
)
|
||||||
os.close(slave_fd) # Close slave in the parent.
|
os.close(slave_fd) # Close slave end in the parent process.
|
||||||
|
|
||||||
# Read output from the master file descriptor in real time.
|
captured_data = []
|
||||||
try:
|
|
||||||
while True:
|
# If we're in an interactive TTY, set raw mode and forward input.
|
||||||
try:
|
if stdin_fd is not None and sys.stdin.isatty():
|
||||||
data = os.read(master_fd, 1024)
|
old_settings = termios.tcgetattr(stdin_fd)
|
||||||
except OSError as e:
|
tty.setraw(stdin_fd)
|
||||||
if e.errno == errno.EIO:
|
try:
|
||||||
# Expected error when the slave side is closed.
|
while True:
|
||||||
|
rlist, _, _ = select.select([master_fd, stdin_fd], [], [])
|
||||||
|
if master_fd in rlist:
|
||||||
|
try:
|
||||||
|
data = os.read(master_fd, 1024)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.EIO:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
captured_data.append(data)
|
||||||
|
# Update pyte's screen state.
|
||||||
|
stream.feed(data.decode("utf-8", errors="ignore"))
|
||||||
|
# Write to stdout for live output.
|
||||||
|
os.write(1, data)
|
||||||
|
if stdin_fd in rlist:
|
||||||
|
try:
|
||||||
|
input_data = os.read(stdin_fd, 1024)
|
||||||
|
except OSError:
|
||||||
|
input_data = b""
|
||||||
|
if input_data:
|
||||||
|
# Forward raw keystrokes directly to the subprocess.
|
||||||
|
os.write(master_fd, input_data)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
proc.terminate()
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
|
||||||
|
else:
|
||||||
|
# Non-interactive mode (e.g., during unit tests).
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = os.read(master_fd, 1024)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.EIO:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
if not data:
|
||||||
break
|
break
|
||||||
else:
|
captured_data.append(data)
|
||||||
raise
|
stream.feed(data.decode("utf-8", errors="ignore"))
|
||||||
if not data:
|
os.write(1, data)
|
||||||
break
|
except KeyboardInterrupt:
|
||||||
# Feed the decoded data into pyte to update the screen and history.
|
proc.terminate()
|
||||||
stream.feed(data.decode("utf-8", errors="ignore"))
|
|
||||||
# Also write the raw data to stdout for live output.
|
os.close(master_fd)
|
||||||
os.write(1, data)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
proc.terminate()
|
|
||||||
finally:
|
|
||||||
os.close(master_fd)
|
|
||||||
proc.wait()
|
proc.wait()
|
||||||
|
|
||||||
# Assemble full scrollback: combine history.top, the current display, and history.bottom.
|
# Assemble full scrollback: combine history.top, the current display, and history.bottom.
|
||||||
top_lines = [render_line(line, cols) for line in screen.history.top]
|
top_lines = [render_line(line, cols) for line in screen.history.top]
|
||||||
bottom_lines = [render_line(line, cols) for line in screen.history.bottom]
|
bottom_lines = [render_line(line, cols) for line in screen.history.bottom]
|
||||||
|
|
@ -114,17 +147,12 @@ def run_interactive_command(cmd: List[str]) -> Tuple[bytes, int]:
|
||||||
trimmed_lines = [line for line in all_lines if line.strip()]
|
trimmed_lines = [line for line in all_lines if line.strip()]
|
||||||
final_output = "\n".join(trimmed_lines)
|
final_output = "\n".join(trimmed_lines)
|
||||||
|
|
||||||
# Return as bytes for compatibility.
|
|
||||||
return final_output.encode("utf-8"), proc.returncode
|
return final_output.encode("utf-8"), proc.returncode
|
||||||
|
|
||||||
# if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# # Test command: output 100 lines so that history goes beyond the screen height.
|
import sys
|
||||||
# test_cmd = [
|
if len(sys.argv) < 2:
|
||||||
# "bash",
|
print("Usage: interactive.py <command> [args...]")
|
||||||
# "-c",
|
sys.exit(1)
|
||||||
# "for i in $(seq 1 100); do echo \"Line $i\"; sleep 0.05; done"
|
output, return_code = run_interactive_command(sys.argv[1:])
|
||||||
# ]
|
sys.exit(return_code)
|
||||||
# output, ret = run_interactive_command(test_cmd)
|
|
||||||
# print("\n=== Captured Scrollback (trimmed history lines) ===")
|
|
||||||
# print(output.decode("utf-8"))
|
|
||||||
# print("Return code:", ret)
|
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,12 @@ def test_shell_pipeline():
|
||||||
|
|
||||||
def test_stderr_capture():
|
def test_stderr_capture():
|
||||||
"""Test that stderr is properly captured in combined output."""
|
"""Test that stderr is properly captured in combined output."""
|
||||||
# Use a command that definitely writes to stderr
|
# Use a command that definitely writes to stderr.
|
||||||
output, retcode = run_interactive_command(
|
output, retcode = run_interactive_command(
|
||||||
["/bin/bash", "-c", "ls /nonexistent/path"]
|
["/bin/bash", "-c", "ls /nonexistent/path"]
|
||||||
)
|
)
|
||||||
assert b"No such file or directory" in output
|
assert b"No such file or directory" in output
|
||||||
assert retcode != 0 # ls returns 0 upon success
|
assert retcode != 0 # ls returns non-zero on failure.
|
||||||
|
|
||||||
|
|
||||||
def test_command_not_found():
|
def test_command_not_found():
|
||||||
|
|
@ -49,9 +49,10 @@ def test_empty_command():
|
||||||
|
|
||||||
def test_interactive_command():
|
def test_interactive_command():
|
||||||
"""Test running an interactive command.
|
"""Test running an interactive command.
|
||||||
|
|
||||||
This test verifies that output appears in real-time using process substitution.
|
This test verifies that output appears in real-time using process substitution.
|
||||||
We use a command that prints to both stdout and stderr to verify capture."""
|
We use a command that prints to both stdout and stderr.
|
||||||
|
"""
|
||||||
output, retcode = run_interactive_command(
|
output, retcode = run_interactive_command(
|
||||||
["/bin/bash", "-c", "echo stdout; echo stderr >&2"]
|
["/bin/bash", "-c", "echo stdout; echo stderr >&2"]
|
||||||
)
|
)
|
||||||
|
|
@ -62,30 +63,23 @@ def test_interactive_command():
|
||||||
|
|
||||||
def test_large_output():
|
def test_large_output():
|
||||||
"""Test handling of commands that produce large output."""
|
"""Test handling of commands that produce large output."""
|
||||||
# Generate a large output with predictable content
|
# Generate a large output with predictable content.
|
||||||
cmd = 'for i in {1..3000}; do echo "Line $i of test output"; done'
|
cmd = 'for i in {1..3000}; do echo "Line $i of test output"; done'
|
||||||
output, retcode = run_interactive_command(["/bin/bash", "-c", cmd])
|
output, retcode = run_interactive_command(["/bin/bash", "-c", cmd])
|
||||||
|
# Clean up any leading artifacts.
|
||||||
# Clean up specific artifacts (e.g., ^D)
|
output_cleaned = output.lstrip(b"^D")
|
||||||
output_cleaned = output.lstrip(b"^D") # Remove the leading ^D if present
|
|
||||||
|
|
||||||
# Split and filter lines
|
|
||||||
lines = [
|
lines = [
|
||||||
line.strip()
|
line.strip()
|
||||||
for line in output_cleaned.splitlines()
|
for line in output_cleaned.splitlines()
|
||||||
if b"Script" not in line and line.strip()
|
if b"Script" not in line and line.strip()
|
||||||
]
|
]
|
||||||
|
# Expect roughly 2000 history lines plus the display lines.
|
||||||
# We expect around 2000 lines plus some additional lines from the current display
|
assert 2000 <= len(lines) <= 2050, (
|
||||||
# The exact number may vary slightly due to terminal buffering, but should be close to 2024
|
f"Expected between 2000-2050 lines due to history limit plus terminal display, but got {len(lines)}"
|
||||||
# (2000 history lines + 24 terminal lines)
|
)
|
||||||
assert 2000 <= len(lines) <= 2050, f"Expected between 2000-2050 lines due to history limit plus terminal display, but got {len(lines)}"
|
# Verify that we have the last line.
|
||||||
|
|
||||||
# Verify that we have the last lines (should include line 3000)
|
|
||||||
assert lines[-1] == b"Line 3000 of test output", f"Unexpected last line: {lines[-1]}"
|
assert lines[-1] == b"Line 3000 of test output", f"Unexpected last line: {lines[-1]}"
|
||||||
|
assert retcode == 0
|
||||||
# Verify return code
|
|
||||||
assert retcode == 0, f"Unexpected return code: {retcode}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unicode_handling():
|
def test_unicode_handling():
|
||||||
|
|
@ -110,7 +104,6 @@ def test_multiple_commands():
|
||||||
|
|
||||||
def test_cat_medium_file():
|
def test_cat_medium_file():
|
||||||
"""Test that cat command properly captures output for medium-length files."""
|
"""Test that cat command properly captures output for medium-length files."""
|
||||||
# Create a temporary file with known content
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
||||||
for i in range(500):
|
for i in range(500):
|
||||||
f.write(f"This is test line {i}\n")
|
f.write(f"This is test line {i}\n")
|
||||||
|
|
@ -120,7 +113,6 @@ def test_cat_medium_file():
|
||||||
output, retcode = run_interactive_command(
|
output, retcode = run_interactive_command(
|
||||||
["/bin/bash", "-c", f"cat {temp_path}"]
|
["/bin/bash", "-c", f"cat {temp_path}"]
|
||||||
)
|
)
|
||||||
# Split by newlines and filter out script header/footer lines
|
|
||||||
lines = [
|
lines = [
|
||||||
line
|
line
|
||||||
for line in output.splitlines()
|
for line in output.splitlines()
|
||||||
|
|
@ -129,7 +121,7 @@ def test_cat_medium_file():
|
||||||
assert len(lines) == 500
|
assert len(lines) == 500
|
||||||
assert retcode == 0
|
assert retcode == 0
|
||||||
|
|
||||||
# Verify content integrity by checking first and last lines
|
# Verify content integrity.
|
||||||
assert b"This is test line 0" in lines[0]
|
assert b"This is test line 0" in lines[0]
|
||||||
assert b"This is test line 499" in lines[-1]
|
assert b"This is test line 499" in lines[-1]
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -138,15 +130,14 @@ def test_cat_medium_file():
|
||||||
|
|
||||||
def test_realtime_output():
|
def test_realtime_output():
|
||||||
"""Test that output appears in real-time and is captured correctly."""
|
"""Test that output appears in real-time and is captured correctly."""
|
||||||
# Create a command that sleeps briefly between outputs
|
# Create a command that sleeps briefly between outputs.
|
||||||
cmd = "echo 'first'; sleep 0.1; echo 'second'; sleep 0.1; echo 'third'"
|
cmd = "echo 'first'; sleep 0.1; echo 'second'; sleep 0.1; echo 'third'"
|
||||||
output, retcode = run_interactive_command(["/bin/bash", "-c", cmd])
|
output, retcode = run_interactive_command(["/bin/bash", "-c", cmd])
|
||||||
|
|
||||||
# Filter out script header/footer lines
|
|
||||||
lines = [
|
lines = [
|
||||||
line for line in output.splitlines() if b"Script" not in line and line.strip()
|
line
|
||||||
|
for line in output.splitlines()
|
||||||
|
if b"Script" not in line and line.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
assert b"first" in lines[0]
|
assert b"first" in lines[0]
|
||||||
assert b"second" in lines[1]
|
assert b"second" in lines[1]
|
||||||
assert b"third" in lines[2]
|
assert b"third" in lines[2]
|
||||||
|
|
@ -155,41 +146,11 @@ def test_realtime_output():
|
||||||
|
|
||||||
def test_tty_available():
|
def test_tty_available():
|
||||||
"""Test that commands have access to a TTY."""
|
"""Test that commands have access to a TTY."""
|
||||||
# Run the tty command
|
|
||||||
output, retcode = run_interactive_command(["/bin/bash", "-c", "tty"])
|
output, retcode = run_interactive_command(["/bin/bash", "-c", "tty"])
|
||||||
|
output_cleaned = output.lstrip(b"^D")
|
||||||
# Clean up specific artifacts (e.g., ^D)
|
|
||||||
output_cleaned = output.lstrip(b"^D") # Remove leading ^D if present
|
|
||||||
|
|
||||||
# Debug: Print cleaned output
|
|
||||||
print(f"Cleaned TTY Output: {output_cleaned}")
|
print(f"Cleaned TTY Output: {output_cleaned}")
|
||||||
|
# Check if the output contains a valid TTY path.
|
||||||
# Check if the output contains a valid TTY path
|
|
||||||
assert (
|
assert (
|
||||||
b"/dev/pts/" in output_cleaned or b"/dev/ttys" in output_cleaned
|
b"/dev/pts/" in output_cleaned or b"/dev/ttys" in output_cleaned
|
||||||
), f"Unexpected TTY output: {output_cleaned}"
|
), f"Unexpected TTY output: {output_cleaned}"
|
||||||
assert retcode == 0
|
assert retcode == 0
|
||||||
|
|
||||||
|
|
||||||
def test_interactive_input():
|
|
||||||
"""Test that interactive input works properly with cat command."""
|
|
||||||
# Create a temporary file to store expected input
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
|
||||||
f.write("test input\n")
|
|
||||||
temp_path = f.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Redirect the temp file as stdin and run cat
|
|
||||||
with open(temp_path, 'rb') as stdin_file:
|
|
||||||
old_stdin = sys.stdin
|
|
||||||
sys.stdin = stdin_file
|
|
||||||
try:
|
|
||||||
output, retcode = run_interactive_command(["cat"])
|
|
||||||
finally:
|
|
||||||
sys.stdin = old_stdin
|
|
||||||
|
|
||||||
# Verify the output matches input
|
|
||||||
assert b"test input" in output
|
|
||||||
assert retcode == 0
|
|
||||||
finally:
|
|
||||||
os.unlink(temp_path)
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue