How to Read Interactive User Input via /dev/tty in Python Without Breaking Pipes
The Challenge: Interactive Prompts in Unix Pipelines
When building command-line filters in Python that participate in Unix pipelines (e.g., cat data.txt | python filter.py > output.txt), standard I/O streams serve a dedicated purpose: sys.stdin ingests raw input data, and sys.stdout emits the transformed output.
Using Python's built-in input(prompt) in this scenario fails because it writes the prompt string to sys.stdout (corrupting pipeline output) and attempts to read the response from sys.stdin (consuming the data stream instead of awaiting user keystrokes).
To prompt the user interactively without interfering with standard streams, your program needs to read from and write to the controlling terminal directly—analogous to how Perl's Term::ReadLine or standard Unix tools like git add -p and gpg operate.
The Core Solution: Direct Access to /dev/tty
On Unix-like systems, /dev/tty represents the controlling terminal for the current process, regardless of how standard input and output are redirected. Opening /dev/tty allows you to present prompts and capture user keyboard responses explicitly.
def user_answers_ok_on_tty(prompt, line):
try:
# Open the controlling terminal for reading and writing
with open('/dev/tty', 'r+', encoding='utf-8') as tty:
tty.write(f"{prompt} ({line.strip()}): ")
tty.flush() # Ensure prompt appears immediately
response = tty.readline().strip().lower()
return response in ('y', 'yes')
except OSError as e:
# Handle headless or non-interactive environments
raise RuntimeError("Cannot prompt user: No controlling terminal available") from e
Implementing the Filter Loop
Once the TTY interaction helper is in place, you can consume sys.stdin and produce transformed lines to sys.stdout without cross-contamination:
import sys
def frobnicate(line):
return line.upper()
def main():
# Process data from piped stdin
for line in sys.stdin:
if user_answers_ok_on_tty("Process this line?", line):
sys.stdout.write(frobnicate(line))
sys.stdout.flush()
if __name__ == '__main__':
main()
Key Considerations and Best Practices
- Always Flush Streams: Terminal file handles and standard output streams are often buffered. Calling
tty.flush()guarantees the prompt displays before the program waits for input. - Handle Non-Interactive Environments: When run inside automated scripts, cron jobs, or CI/CD pipelines,
/dev/ttydoes not exist, raising anOSError: [Errno 6] No such device or address. Always provide a fallback flag (such as-yor--assume-yes) to bypass interactive prompts in automated runs. - Cross-Platform Compatibility:
/dev/ttyis specific to POSIX/Unix environments. If Windows support is required, consider using themsvcrtmodule (e.g.,msvcrt.getch()) or accessingCONIN$andCONOUT$. - Alternative for Simple Prompts: If you only need to display messages without reading keyboard input, writing directly to
sys.stderr.write(...)will bypassstdoutredirection without requiring a raw TTY handle.