Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Handle CTRL-C in Python cmd module

I wrote a Python 3.5 application using the cmd module. The last thing I would like to implement is proper handling of the CTRL-C (sigint) signal. I would like it to behave more or less the way Bash does it:

  • print ^C at the point the cursor is
  • clear the buffer so that the input text is deleted
  • skip to the next line, print the prompt, and wait for input

Basically:

/test $ bla bla bla|
# user types CTRL-C
/test $ bla bla bla^C
/test $ 

Here is simplified code as a runnable sample:

import cmd
import signal


class TestShell(cmd.Cmd):
    def __init__(self):
        super().__init__()

        self.prompt = '$ '

        signal.signal(signal.SIGINT, handler=self._ctrl_c_handler)
        self._interrupted = False

    def _ctrl_c_handler(self, signal, frame):
        print('^C')
        self._interrupted = True

    def precmd(self, line):
        if self._interrupted:
            self._interrupted = False
            return ''

        if line == 'EOF':
            return 'exit'

        return line

    def emptyline(self):
        pass

    def do_exit(self, line):
        return True


TestShell().cmdloop()

This almost works. When I press CTRL-C, ^C is printed at the cursor, but I still have to press enter. Then, the precmd method notices its self._interrupted flag set by the handler, and returns an empty line. This is as far as I could take it, but I would like to somehow not have to press that enter.

I guess I somehow need to force the input() to return, does anybody have ideas?

like image 372
wujek Avatar asked May 22 '16 18:05

wujek


1 Answers

I found some hacky ways to achieve the behavior you want with Ctrl-C.

Set use_rawinput=False and replace stdin

This one sticks (more or less…) to the public interface of cmd.Cmd. Unfortunately, it disables readline support.

You can set use_rawinput to false and pass a different file-like object to replace stdin in Cmd.__init__(). In practice, only readline() is called on this object. So you can create a wrapper for stdin that catches the KeyboardInterrupt and executes the behavior you want instead:

class _Wrapper:

    def __init__(self, fd):
        self.fd = fd

    def readline(self, *args):
        try:
            return self.fd.readline(*args)
        except KeyboardInterrupt:
            print()
            return '\n'


class TestShell(cmd.Cmd):

    def __init__(self):
        super().__init__(stdin=_Wrapper(sys.stdin))
        self.use_rawinput = False
        self.prompt = '$ '

    def precmd(self, line):
        if line == 'EOF':
            return 'exit'
        return line

    def emptyline(self):
        pass

    def do_exit(self, line):
        return True


TestShell().cmdloop()

When I run this on my terminal, Ctrl-C shows ^C and switches to a new line.

Monkey-patch input()

If you want the results of input(), except you want different behavior for Ctrl-C, one way to do that would be to use a different function instead of input():

def my_input(*args):   # input() takes either no args or one non-keyword arg
    try:
        return input(*args)
    except KeyboardInterrupt:
        print('^C')   # on my system, input() doesn't show the ^C
        return '\n'

However, if you just blindly set input = my_input, you get infinite recursion because my_input() will call input(), which is now itself. But that's fixable, and you can patch the __builtins__ dictionary in the cmd module to use your modified input() method during Cmd.cmdloop():

def input_swallowing_interrupt(_input):
    def _input_swallowing_interrupt(*args):
        try:
            return _input(*args)
        except KeyboardInterrupt:
            print('^C')
            return '\n'
    return _input_swallowing_interrupt


class TestShell(cmd.Cmd):

    def cmdloop(self, *args, **kwargs):
        old_input_fn = cmd.__builtins__['input']
        cmd.__builtins__['input'] = input_swallowing_interrupt(old_input_fn)
        try:
            super().cmdloop(*args, **kwargs)
        finally:
            cmd.__builtins__['input'] = old_input_fn

    # ...

Note that this changes input() for all Cmd objects, not just TestShell objects. If this isn't acceptable to you, you could…

Copy the Cmd.cmdloop() source and modify it

Since you're subclassing it, you can make cmdloop() do anything you want. "Anything you want" could include copying parts of Cmd.cmdloop() and rewriting others. Either replace the call to input() with a call to another function, or catch and handle KeyboardInterrupt right there in your rewritten cmdloop().

If you're afraid of the underlying implementation changing with new versions of Python, you could copy the whole cmd module into a new module, and change what you want.

like image 140
Dan Getz Avatar answered Oct 29 '22 17:10

Dan Getz