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/tty does not exist, raising an OSError: [Errno 6] No such device or address. Always provide a fallback flag (such as -y or --assume-yes) to bypass interactive prompts in automated runs.
  • Cross-Platform Compatibility: /dev/tty is specific to POSIX/Unix environments. If Windows support is required, consider using the msvcrt module (e.g., msvcrt.getch()) or accessing CONIN$ and CONOUT$.
  • Alternative for Simple Prompts: If you only need to display messages without reading keyboard input, writing directly to sys.stderr.write(...) will bypass stdout redirection without requiring a raw TTY handle.