TIL: Python has a `cmd` Module

Today I Learned that Python’s standard library has a cmd module and it is awesome!

Interactive program using the cmd module. (Sped up 2x)
Interactive program using the cmd module. (Sped up 2x)

The cmd module contains a single class called Cmd which handles all the details of creating an application similar to Python’s REPL. All you need to do is to provide some command definitions and the Cmd class will handle the rest.

In an attempt to demonstrate why I think this is so cool I’m going to walk through the process of building the application you see in the screencast above.

The example application we’re going to create is a very basic REPL for a passion project of mine called stylo. Stylo is a Python library that allows you to draw images and create animations using code and some mathematics. The application will expose some of the basic shapes available and for the “Print” part of the REPL it will show a preview of your image.

My main focus for this post is the cmd module which means I’m not going to go into any of the specifics of stylo or how to use it. If you want to know more about it I will point you in the direction of the documentation (under construction :construction:) and the example gallery.

Setup

To start with we’re going to create a virtual environment and install stylo into it. This will also install matplotlib which we will be using later on. I’m using Python 3.7 but this application should work on all versions of Python ≥ 3.5.

$ python -m venv env
$ source env/bin/activate
(env) $ pip install stylo

Note: The cmd module is available for even older versions of Python. However we are limited by stylo which only supports Python 3.5 and newer.

With the dependencies out of the way we can create a file called stylo-cmd.py and start writing some code!

import cmd


class StyloPrompt(cmd.Cmd):
    pass


if __name__ == '__main__':
    prompt = StyloPrompt()
    prompt.cmdloop()

This is the bare minimum required to get something we can start playing with. If you were to run python stylo-cmd.py you would see the following prompt which comes with a single built-in command help.

(Cmd) help

Documented commands (type help <topic>):
========================================
help

Ctrl-C will exit the application. Obviously this is pretty useless right now so let’s look at adding in some commands of our own.

Adding Commands

Any method on our StyloPrompt class with a name of the form do_* is considered a command, with the command name given by whatever is after the underscore. To get ourselves warmed up let’s add two commands reset and save which will allow us to create a fresh image and save it to a file.

from stylo.image import LayeredImage

class StyloPrompt(cmd.Cmd):

    def __init__(self):
        super().__init__()
        self.image = LayeredImage()

    def do_reset(self, args):
        self.image = LayeredImage()

    def do_save(self, args):
        width, height, filename = args.split(" ")

        width = int(width)
        height = int(height)

        self.image(width, height, filename=filename)

As you can see each command receives its arguments as a single string and it is up to the method to handle them - including conversions to appropriate data types as is the case with the width and height arguments. For the sake of being brief proper error handling has been omitted.

Now if we were to fire up the application we would be able to produce an image!

(Cmd) reset
(Cmd) save 1920 1080 image.png

Of course this image is currently empty so next we should add the ability for the user to place shapes on the image. We’ll create two more commands circle and square.

from stylo.color import FillColor
from stylo.shape import Circle, Square

class StyloPrompt(cmd.Cmd):

    ...

    def do_circle(self, args):
        x, y, r, color = args.split(" ")

        circle = Circle(float(x), float(y), float(r), fill=True)
        self.image.add_layer(circle, FillColor(color))

    def do_square(self, args):
        x, y, size, color = args.split(" ")

        square = Square(float(x), float(y), float(size))
        self.image.add_layer(square, FillColor(color))

Now when we use the application we can create something a bit more interesting than a snowman in a blizzard! :smile:

Number 3 on a dice
Number 3 on a dice
(Cmd) square 0 0 1.75 000000
(Cmd) circle 0 0 0.3 ffffff
(Cmd) circle -0.5 0.5 0.3 ffffff
(Cmd) circle 0.5 -0.5 0.3 ffffff
(Cmd) save 1920 1080 image.png

Getting Help

Now that we have a few commands available we need to tell users how they can be used. If we were to use the help command we would see something like the following.

(Cmd) help

Documented commands (type help <topic>):
========================================
help

Undocumented commands:
======================
circle reset save square

Not very helpful.

Thankfully the default help system doesn’t require much to get started, all we have to do is add docstrings to our do_* methods!

def do_circle(self, args):
    """usage: circle <x> <y> <r> <color>

    This command will draw a circle centered at the coordinates (<x>, <y>)
    with radius given by <r>. The <color> argument is a 6 digit hex
    representing a color in RGB format.
    """
    ...

Now if we were to run help circle

(Cmd) help circle
circle <x> <y> <r> <color>

        This command will draw a circle centered at the coordinates (<x>, <y>)
        with radius given by <r>. The <color> argument is a 6 digit hex
        representing a color in RGB format.

Much better :smile:

Giving Feedback

Right now our program is… ok. The user can type in a few commands and they can create some images, but it’s not much of a step up from using the library directly as they still have to wait until they have saved their image before they can view it. Add in the fact that our program isn’t that flexible they may as well be using the library directly.

If only there was some way we could show the user their image as they build it up a command at a time…

Enter postcmd! This handy method is called each time our program has processed a command - we can use this to redraw the image each time. Then “all” we have to do if find a way to display the current image to the user.

After some searching and head scratching I was able to come up with the following matplotlib incantation to add our image to a figure and display it.

...
import matplotlib.pyplot as plt

class StyloPrompt(cmd.Cmd):

    def __init__(self):
        ...

        self.fig, self.ax = plt.subplots(1)
        self.ax.get_xaxis().set_visible(False)
        self.ax.get_yaxis().set_visible(False)

        self.update_image()

    ...

    def postcmd(self, stop, line):

        if stop:
            return True

        self.update_image()

    def update_image(self):

        # Re-render the image
        self.image(1920, 1080)

        # Update the preview
        self.ax.imshow(image.data)
        self.fig.show()

I won’t go into too much detail here but I will point out a few things.

  • The stop argument to postcmd indicates whether the previous command wanted to exit the program (by returning True). We have the option of overriding that by not returning True. But in our case we will just pass the message on.

  • Matplotlib is smart enough to use an existing window when calling show() on a figure so all we have to do is update the plot in the axis object

  • In the __init__ method we are disabling the scale on the axis so that the user doesn’t see something that looks like a graph.

Finishing Touches

With most of the functionality out of the way we can look at tweaking some things to make the overall experience nicer.

Exiting the Program

So far we don’t have a clean way to close the program, we can hit Ctrl-C to terminate the script but it results in Python printing a traceback and it looks like an error in our program more than anything.

Instead we can override the default method on our class. This method is called whenever the program doesn’t recogise the user’s input as a valid command and we can use it to look at all of the user’s input (not just the args) and decide what to do with it.

In this case we will say that the program will exit whenever the user types a q or we receive an EOF character (Ctrl-D).

class StyloPrompt(cmd.Cmd):
    ...

    def default(self, line):
        if line == "q" or line == "EOF":
            return True

        return super().default(line)

Changing the Prompt

We can change the default prompt (Cmd) by setting the prompt attribute on our class.

class StyloPromt(Cmd):
    prompt = "-> "
    ...

Greeting the User

Currently when our program starts it simply shows them the prompt, which if they are using it for the first time they probably won’t know where to start. To help them get started we can set the intro attribute to contain a welcome message.

...
from stylo import __version__

intro_text = """\
Interactive Shell for Stylo v{}
----------------------------------

Type `q` or `Ctrl-D` to quit.
Type `help` or `?` for an overview `help <command>` for more details.
"""

class StyloPrompt(cmd.Cmd):
    intro = intro_text.format(__version__)
    ...

Now when the user starts the program they should have enough information to continue from there.

Interactive Shell for Stylo v0.9.1
----------------------------------

Type `q` or `Ctrl-D` to quit.
Type `help` or `?` for an overview `help <command>` for more details.

->

There are also doc_header, misc_header and undoc_header that you can set to include even more information at different points in your program. You can refer to the documentation for more details.

Wrapping Up

I can’t believe I only just found out about this module. I hope you found this as useful as I did and I strongly encourage you to take a look at the documentation as there are features there that I didn’t get around to mentioning - such as completion!

For those interested the final version of this program (with a few minor tweaks) is available as a Gist on Github. I think what I like most about this module is that it requires very little code before you start seeing real results - Our entire application is only 155 lines of code!

Comments