Search code examples
pythonpython-3.xlinuxselectstdin

Is it possible to do two consecutive, successful non-blocking reads of stdin in Python?


Apologies for the long code post, but I believe it is useful context.

I am playing around with parsing special keys in raw Python (without curses), but it seems that the select trick for doing non-blocking input is not working in this scenario. In particular, it looks like after reading the first character of input, select is returning that stdin is not readable despite there being more characters of input to read.

Steps to reproduce the problem:

  1. Run the code below.
  2. Press the Left-Arrow key (or any of the other named special keys).
  3. Observe that the output is ESC followed by the remainder of the escape sequence in individual lines. Expected behavior: Output ARROW_LEFT.

Is it possible to correctly read the full escape sequence for special keys, while still reading ESC itself correctly?

#!/usr/bin/env python3

import sys
from enum import Enum
import tty
import termios
import select
import signal

# Takes a given single-character string and returns the string control version
# of it. For example, it takes 'c' and returns the string representation of
# Control-C.  This can be used to check for control-x keys in the output of
# readKey.
def controlKey(c):
  return chr(ord(c) & 0x1f)

def nonblock_read(stream, limit=1):
  if select.select([stream,],[],[],0.1)[0]:
    return stream.read(limit)
  return None

# Read a key of input as a string. For special keys, it returns a
# representative string. For control keys, it returns the raw string.
# This function assumes that the caller has already put the terminal in raw mode.
def readKey():
  c = nonblock_read(sys.stdin, 1)
  if not c: return None
  # Handle special keys represented by escape sequences
  if c == "\x1b":
    seq = [None] * 3
    seq[0] = nonblock_read(sys.stdin, 1)
    if not seq[0]: return "ESC"
    seq[1] = nonblock_read(sys.stdin, 1)
    if not seq[1]: return "ESC"

    if seq[0] == '[':
      if seq[1] >= '0' and seq[1] <= '9':
        seq[2] = nonblock_read(sys.stdin, 1)
        if not seq[2]: return "ESC"

        if seq[2] == '~':
          if seq[1] == '1': return "HOME_KEY"
          if seq[1] == '3': return "DEL_KEY"
          if seq[1] == '4': return "END_KEY"
          if seq[1] == '5': return "PAGE_UP"
          if seq[1] == '6': return "PAGE_DOWN"
          if seq[1] == '7': return "HOME_KEY"
          if seq[1] == '8': return "END_KEY"
      else:
        if seq[1] == 'A': return "ARROW_UP"
        if seq[1] == 'B': return "ARROW_DOWN"
        if seq[1] == 'C': return "ARROW_RIGHT"
        if seq[1] == 'D': return "ARROW_LEFT"
        if seq[1] == 'H': return "HOME_KEY"
        if seq[1] == 'F': return "END_KEY"
    elif seq[0] == 'O':
      if seq[1] == 'H': return "HOME_KEY"
      if seq[1] == 'F': return "END_KEY"
    return 'ESC'
  return c

def main():
  # Save terminal settings
  fd = sys.stdin.fileno()
  old_tty_settings = termios.tcgetattr(fd)
  # Enter raw mode
  tty.setraw(sys.stdin)
  ################################################################################  
  interrupt = controlKey("c")
  while True:
    s = readKey()
    if s:
      print(f"{s}", end="\r\n")
    if s == interrupt:
      break
  ################################################################################  
  # Exit raw mode
  fd = sys.stdin.fileno()
  termios.tcsetattr(fd, termios.TCSADRAIN, old_tty_settings)

if __name__ == "__main__":
  main()

Solution

  • If you use low-level I/O, I think it works. select.select will accept numerical file descriptors. I haven't tried to integrate this with your program, but have a play with this. You should get a sequence of characters if you press e.g. left arrow. The original seems not to work with sys.stdin, but this is fine with fd 0. Note the os.read to read from numerical file descriptor.

    import os
    import sys
    import select
    import tty
    import termios
    
    def read_all_available(fd):
        "do a single blocking read plus non-blocking reads while any more data exists"
        if not select.select([fd],[],[], None)[0]:
            return None
        val = os.read(fd, 1)
        while select.select([fd],[],[], 0)[0]:
            val += os.read(fd, 1)
        return val
    
    
    data = None
    while data != b'\x03':
        old_settings = termios.tcgetattr(0)
        tty.setraw(sys.stdin)
        data = read_all_available(0)
    
        # reset settings here just to allow tidier printing to screen
        termios.tcsetattr(0, termios.TCSADRAIN, old_settings)
        print(data, len(data))