Handle CTRL-C in Python cmd module
Asked Answered
O

1

9

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?

Obnoxious answered 22/5, 2016 at 18:44 Comment(3)
It would help to have a small runnable sample. Without running your code how you're currently going about it, would writing a '\n' or blank to stdout suffice? That is once signal handler is tripped tack a newline on the end of '^C'Stereoisomer
@Stereoisomer Edited the post by replacing the snippet with a runnable sample. I tried writing a newline to stdout (it is actually done by default by the print method), but it does't work, and why would it? It would require for stdin be fed from stdout, which is not the case, it's not piped.Obnoxious
Yeah, you're right I wasn't thinking in terms of command line stdin prompt. @DanGetz has an answer that will suit your needs I believe. Its cleaner too, no need to set signal handler or set flags. After your comment I started to think along the lines of redirecting stdin aswell, you can adjust to however you need.Stereoisomer
A
8

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.

Abohm answered 22/5, 2016 at 21:51 Comment(3)
Thanks for your solution. Unfortunately, I'm using readline and it is pretty important.Obnoxious
@Obnoxious yeah, I expected as much. Well, there's a slightly hackier way to handle that I guess, I'll add it.Abohm
I took option 3, just copied the cmdloop() code and changed. I think it is the least hacky solution... Thanks!Obnoxious

© 2022 - 2024 — McMap. All rights reserved.