pythonsigintpython-cmd

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:

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?


Solution

  • 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.