10
\$\begingroup\$

Rags.


Introduction

All too often I find myself wanting to allow only a certain list of characters to be written to stdin, and only recently did I actually bother to implement it. In Python, of all languages!

Essentially, this module provides a few APIs that allow a very tailored approach to reading characters from a terminal. By intercepting individual keypresses at the instant they occur, we can make cool decisions about closing the input stream whenever we want -- we dictate who says what in our terminal!

The standard input stream, after being opened, can be closed after a number of characters, or, at caller's option, any combination of a number of characters and allowed inputs.

API Overview

Outward interface

  • func read_single_keypress() -> string — cross-platform function that gets exactly one keypress, and returns it after processing.

  • func thismany(count: int = -1) -> string — get exactly count characters from stdin. if count is -1, then sys.maxsize chars will be read.

  • func until(chars: string || list, count: int = -1) -> string — get characters from stdin until char is read, or until count chars have been read. if count is -1, sys.maxsize chars will be read.

  • func until_not(chars: string || list, count: int = -1) -> string — get characters from stdin until any of chars is not read. if count is -1, then sys.maxsize chars will be read.

  • func pretty_press() -> string — read a char from stdin, and send it through the same processing as the other functions here do — write it, then if it's a backspace write a backspace, etc

Inward interface

  • class _Getch: determines system platform and calls one of _GetchUnix or _GetchWindows appropriately

  • class _GetchUnix: get a raw character from stdin, on any *nx box

  • class _GetchWindows: get a raw character from stdin, on any Windows box

  • func nbsp: do stuff accordingly for certain chars of input; handles backspace, etc

  • func parsenum: return a number, or sys.maxsize if number is -1

The Code

import sys

class _Getch:
    """Gets a single character from standard input."""
    def __init__(self):
        try:
            self.impl = _GetchWindows()
        except ImportError:
            self.impl = _GetchUnix()

    def __call__(self):
        return self.impl()


class _GetchUnix:
    def __init__(self):
        import tty

    def __call__(self):
        import tty, termios
        fd = sys.stdin.fileno()
        old_settings = termios.tcgetattr(fd)
        try:
            tty.setraw(sys.stdin.fileno())
            ch = sys.stdin.read(1)
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
        return ch


class _GetchWindows:
    def __init__(self):
        import msvcrt

    def __call__(self):
        import msvcrt
        return msvcrt.getch()


parsenum = (lambda num:
            (sys.maxsize if 0 > num else num))


def read_single_keypress():
    """interface for _Getch that interprets backspace and DEL properly"""
    getch = _Getch()
    x = getch.__call__()
    ox = ord(x)
    if ox == 27 or ox == 127:
        sys.stdout.write(chr(8))
        sys.stdout.write(chr(32))  # hacky? indeed. does it *work*? hell yeah!
        sys.stdout.write(chr(8))

    elif ox == 3: raise KeyboardInterrupt
    elif ox == 4: raise EOFError
    return x


def nbsp(x, y):
    """append x to y as long as x is not DEL or backspace"""
    if ord(x) == 27 or ord(x) == 127:
        try:
            y.pop()
        except IndexError:
            pass
        return y
    y.append(x)
    return y


def thismany(count=-1) -> str:
    """get exactly count chars of stdin"""
    y = []
    count = parsenum(count)
    while len(y) <= count:
        i = read_single_keypress()
        _ = sys.stdout.write(i)
        sys.stdout.flush()
        y = nbsp(i, y)
    return "".join(y)


def until(chars, count=-1) -> str:
    """get chars of stdin until any of chars is read,
    or until count chars have been read, whichever comes first"""
    y = []
    chars = list(chars)
    count = parsenum(count)
    while len(y) <= count:
        i = read_single_keypress()
        _ = sys.stdout.write(i)
        sys.stdout.flush()
        if i in chars:
            break
        y = nbsp(i, y)
    return "".join(y)


def until_not(chars, count=-1) -> str:
    """read stdin until any of chars stop being read,
    or until count chars have been read; whichever comes first"""
    y = []
    chars = list(chars)
    count = parsenum(count)
    while len(y) <= count:
        i = read_single_keypress()
        _ = sys.stdout.write(i)
        sys.stdout.flush()
        if i not in chars:
            break
        y = nbsp(i, y)
    return "".join(y)


def pretty_press() -> str:
    """literally just read any fancy char from stdin let caller do whatever"""
    i = read_single_keypress()
    _ = sys.stdout.write(i)
    sys.stdout.flush()
    return nbsp(i, y)

Short & sweet. No bugs. Anywhere. 1

Notes / etc

  1. There's a lot of duplication here, and I hate it. I turned the whole of the outward functions into one big function earlier today, with lots of optional args and a lot of nested if's and it was bad. I want to avoid duplication as much as possible, because there's a one keyword difference between some of these functions and I can't see a clean way to fix this.

  2. Yeah, I know all of the "inward interface" functions should begin with an underscore. Who knows, maybe caller will want them if caller implements pretty_press() or read_single_keypress() from scratch.

  3. Backspaces work! That was a lot of headache to finally get compliant. Arrow keys, on the other hand, do not work. In fact, they are so badly broken that how well they work has wrapped the scale. Try it!

  4. To extend the last point, backspaces work too well in that you can put the cursor anywhere you want on the terminal grid, and backspace whatever you like. Buggy? Heck yeah! It's probably fine, because I'm not building readline here (unfortunately).

  5. I have not tested this much on Py2, but it should work fine. If it blows up your cat, I am not responsible for damages because this software is released under the GPL.

  6. Naming. My names for things are terrible. They say the two hardest things in programming are cache invalidation and naming things. I don't have to worry about cache invalidation in Python, so I screwed up naming instead.

  7. You'll probably want some way to... y'know, use this API without having to write code. Understandable, so here you go: some example functions. Look how simple my API is!

    def _until_demo() -> None:
        """demonstrate the until function"""
        print("get until what?")
        char = read_single_keypress()
        _ = sys.stdout.write(char + "\n")
        sys.stdout.flush()
        y = until(char)
        print("\n" + y)
    
    
    def _thismany_demo() -> None:
        """demonstrate the thismany function"""
        print("get how many chars?")
        kps = input()
        try:
            kps = int(kps)
        except ValueError:
            print("not a number, sorry")
            return
        print("getting", str(kps))
        y = thismany(kps)
        print("\n" + y)
    
    
    def _can_you_vote() -> str:
        """a practical example:
        test if a user can vote based purely on keypresses"""
        _ = sys.stdout.write("can you vote? age : ")
        sys.stdout.flush()
        x = int("0" + until_not("0123456789"))
        if not x:
            print("\nsorry, age can only consist of digits.")
            return
        print(
            "your age is", x, "\nYou can vote!"
            if x >= 18
            else "Sorry! you can't vote"
        )
    
    
    def _forth_syntax_test() -> str:
        """
        in the programming language Forth,
        `function` definitons start at the beginning of a line with a `:` colon
        and go until the next semicolon.
    
        this is an example of how this module can be used
        in a Forth REPL to compile statements specially;
        it's implemented in catb0t/microcat as well.
        """
        sys.stdout.write("demo FORTH repl \n> ")
        firstchar = read_single_keypress()
        _ = sys.stdout.write(firstchar)
        if firstchar != ":":
            return print("first char wasn't ':'")
        defn = firstchar + until(";") + ";"
        sys.stdout.write("\nrepl got:\n" + defn + "\n")
    

If a review should focus on any one thing, it should be duplication, because it's really bad and it makes me sad :(


1unit testing hand crafted python stdin in python is hard :(

\$\endgroup\$
8
\$\begingroup\$

I really don't like that you detect which version of _GetchX to use via an ImportError - that isn't obvious to me at all. I also don't like that you keep importing things locally. I think you can solve this like so:

import platform
system = platform.system()    
import sys

if system == "Windows":
    import msvcrt

    class _Getch:
        """Gets a single character from standard input."""

        def __call__(self):
            return msvcrt.getch()

else:
    import tty, termios

    class _Getch: 
        """Gets a single character from standard input."""

        def __call__(self):
            fd = sys.stdin.fileno()
            old_settings = termios.tcgetattr(fd)
            try:
                tty.setraw(sys.stdin.fileno())
                ch = sys.stdin.read(1)
            finally:
                termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
            return ch

If that level of repetition is also upsetting, move the if inside of the __call__ implementation, but that seems like too much. You could try this instead:

class _Getch:
    """Gets a single character from standard input."""

if system == "Windows":
    import msvcrt

    def _call(self):
        return msvcrt.getch()

else:
    import tty, termios

    def _call(self):
       fd = sys.stdin.fileno()
       old_settings = termios.tcgetattr(fd)
       try:
           tty.setraw(sys.stdin.fileno())
           ch = sys.stdin.read(1)
       finally:
           termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
           return ch

_Getch.__call__ = _call

More generally, however, I don't understand why this is a class. Just make it a function.

if system == "Windows":
    import msvcrt

    def getch():
        return msvcrt.getch()

else:
    import tty, termios

    def getch():
       fd = sys.stdin.fileno()
       old_settings = termios.tcgetattr(fd)
       try:
           tty.setraw(sys.stdin.fileno())
           ch = sys.stdin.read(1)
       finally:
           termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
           return ch

Why is this a lambda?

parsenum = (lambda num:
        (sys.maxsize if 0 > num else num))

Just make a normal function, for readability if nothing else.

def parsenum(num):
    return sys.maxsize if 0 > num else num

General thoughts on the rest of your code:

Why are you explicitly calling __call__()? Just call it normally (or just use the function, as above). You also have a lot of magic numbers, which to someone who is not immediately familiar with their ASCII codes (I have literally never been able to remember any ASCII code off the top of my head) isn't helpful - prefer named constants here. Instead of ox == 27 or ox == 127 just do ox in [27, 127] (and replace that with some constant). You shouldn't need to explicitly turn chars into a list, unless you expect that it is some bizarre iterable type that doesn't implement __getitem__.

You mentioned in the comments that you assign the result of sys.stdout.write(i) because it will append the result to the output otherwise. I had no idea that happens, and it seems pretty odd, but it looks like a code smell to anyone (like myself) who doesn't know that it happens. You could put a comment everywhere you do that, but that is annoying and you might forget if you add it in the future. I'd write a simple helper method

def write_to_stdout(i):
    # Have to assign or it will append the result to the output
    _ = sys.stdout.write(i)
    sys.stdout.flush()

until and until_not are basically identical - they could be condensed to this:

def _until_condition(chars, condition, count) -> str:
    y = []
    count = parsenum(count)
    while len(y) <= count:
        i = read_single_keypress()
        write_to_stdout(i)
        if condition(i, chars):
            break
        y = nbsp(i, y)
    return "".join(y)

def until(chars, count=-1) -> str:
    """get chars of stdin until any of chars is read,
    or until count chars have been read, whichever comes first"""

    return _until_condition(chars, lambda i, chars: i in chars, count)

def until_not(chars, count=-1) -> str:
    """read stdin until any of chars stop being read,
    or until count chars have been read; whichever comes first"""

    return _until_condition(chars, lambda i, chars: i not in chars, count)
\$\endgroup\$
  • \$\begingroup\$ Wow, thanks for the comprehensive review! The reason I assign stdout writes is because if I don't, the number of chars written is appended to the output. \$\endgroup\$ – cat Feb 3 '16 at 4:50
  • 1
    \$\begingroup\$ @cat that seems bizarre. See my edit (in like 2 minutes - haven't written it yet). \$\endgroup\$ – Dannnno Feb 3 '16 at 4:54
  • \$\begingroup\$ @cat I've edited my answer to address that factoid (also, is that documented somewhere? I've never heard/seen that before, but I also almost never use sys.stdout.write directly). \$\endgroup\$ – Dannnno Feb 3 '16 at 4:57
  • \$\begingroup\$ I can't find any info about it but it seems like it only affects Python 3.4 and 3.5 on my box -- I didn't realise it was abnormal, now I'm curious. \$\endgroup\$ – cat Feb 3 '16 at 4:59
  • 3
    \$\begingroup\$ @cat Maybe you tried it out in the interactive prompt? It prints out the result of the expression you give. \$\endgroup\$ – Janne Karila Feb 3 '16 at 10:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.