Building a modern CLI with cliff
History
Most pythoners probably heard about Openstack, the famous open-source software platform for cloud computing. The Openstack ecosystem is composed of various components, all written in Python. This ensures interoperability, coherency and accessibility for contributors within the Openstack foundation. With mature projects like Nova, Neutron, Keystone, Swift, Cinder and many others, redundant technical matters appear. This is the moment one chooses to write a generic solution. From time to time shiny diamonds are carved out of the former technical issue. Sadly, some lack visibility and are forgotten.
One of them is cliff
. It stands for Command Line
Interface Formulation Framework. This is an easy and elegant way to implement
an interactive command line interface in your project. It is used within
Openstack to implement a unique CLI for all
openstack services (previously each component was respectively implementing its
own that’s why you will find legacy packages as
python-novaclient
, python-swiftclient
, python-keystoneclient
and so on).
This effort gave birth to a well-designed framework with an interesting set of
features.
I’ve used it to write a python CLI for Confluence and would like to share key directions to people willing to write elegant command line interfaces.
Features
It mainly provides :
- An interactive shell.
- Bash command completion.
- Command suggestion.
- Command result formatting and filtering with support for JSON, CSV, YAML and others.
Philosophy
Using cliff
is a matter of :
- Declaring a mapping between commands and classes in your package entry points.
- Initializing an application that uses a command manager with this mapping.
- Implementing commands by defining argument parsing, defining what action should take place and how the action result should be formatted if any.
Hands-on
We will create a project for a sample application and expose a CLI backed by cliff.
Structure
Project structure will be the following:
myproject
├── mycli
│ ├── __init__.py
│ ├── commands.py
│ └── shell.py
└── setup.py
- In
shell.py
we will define the application entry point and delegate command line arguments processing to cliff command manager. - In
commands.py
we will define commands that our CLI will propose to the user. - In
setup.py
we will declare the cliff dependency as well as each command and associated class path.
Dependencies
Obviously setup.py
should have the following line:
install_requires=['cliff']
Command map
Let’s say you want command
to be handled by the application. We will define
it as the Command
class in commands.py
.
In setup.py
, add the application main entry point and a
<command>=<class_path>
mapping bound to a namespace of your choice, for
instance mycli.cli
:
entry_points={
'console_scripts': ['mycli=mycli.shell:main'],
'mycli.cli': [
'command=mycli.commands:Command',
]
Subcommand handling is really straightforward, the framework uses underscores
as a convention for command groups. For instance group_command
will be
exposed as mycli group command
.
Entry point
This project now needs a main entry point responsible for running the cliff application.
import sys
from cliff.app import App
from cliff.commandmanager import CommandManager
def main(arg=sys.argv[1:]):
app = App(
description="My Application description",
version="0.0.1",
command_manager=CommandManager('mycli.cli'),
deferred_help=True,
)
return app.run(argv)
if __name__ == '__main__':
sys.exit(main())
The namespace passed to the cliff command manager is the one previously
defined in setup.py
.
Actions
Implementing a command action is mandatory. It is done through the overriding
of the abstract method take_action(self, parsed_args)
living in cliff command
base class.
from cliff import command
class Command(command.Command):
"""A basic command."""
def take_action(self, parsed_args):
"""Command action."""
return 'Hello World!'
Argument parsing
At this point you have a fully usable CLI with one command available. However,
this is a minimalistic example and you will most likely need to access python’s
argument parser. To do so, override the get_parser(self, prog_name)
method.
We will add two options to print our command result either in lower or upper case.
from cliff import command
class Command(command.Command):
"""A basic command."""
def get_parser(self, prog_name):
"""Command argument parsing."""
parser = super(Command, self).get_parser(prog_name)
group = parser.add_mutually_exclusive_group()
group.add_argument(
'--lowercase',
help='print result in lower case',
action='store_true',
)
group.add_argument(
'--uppercase',
help='print result in upper case',
action='store_true',
)
return parser
def take_action(self, parsed_args):
"""Command action."""
result = 'Hello World!'
# To lower case
if parsed_args.lowercase:
return result.lower()
# To upper case
if parsed_args.uppercase:
return result.upper()
return result
Formatting
If your command returns one or several structured elements, cliff is able to handle the result and convert it to multiple formats. To leverage this feature your command class should:
- Inherit from relevant cliff command class (
cliff.show.ShowOne
orcliff.lister.Lister
) - Return data as a
(fields, values)
tuple.
We will now add two new commands to show what it looks like.
entry_points={
'console_scripts': ['mycli=mycli.shell:main'],
'mycli.cli': [
'command=mycli.commands:Command',
'format_one=mycli.commands:FormatOne',
'format_many=mycli.commands:FormatMany',
]
from cliff.lister import Lister
from cliff.show import ShowOne
class FormatOne(ShowOne):
"""A command with one structured item as a result."""
def take_action(self, parsed_args):
return ('Field_1', 'Field_2'), ('Value_1', 'Value_2')
class FormatMany(Lister):
"""A command with several structured items as a result."""
def take_action(self, parsed_args):
values = (('Value_1', 'Value_2'), ('Value_1', 'Value_3'))
return ('Field_1', 'Field_2'), values
Result
You can play with it and discover native features by yourself, I will show here a few of them.
Before anything, install this package with pip:
me@home ~/myproject# pip install -e .
- Make sure the very first command work as expected:
me@home ~/myproject# mycli command --help
usage: mycli command [-h] [--lowercase | --uppercase]
A basic command.
optional arguments:
-h, --help show this help message and exit
--lowercase print result in lower case
--uppercase print result in upper case
me@home ~/myproject# mycli command
Hello World!
me@home ~/myproject# mycli command --lowercase
hello world!
me@home ~/myproject# mycli command --uppercase
HELLO WORLD!
- Experiment with command output formats:
me@home ~/myproject# mycli format --help
Command "format" matches:
format many
format one
me@home ~/myproject# mycli format one
+---------+---------+
| Field | Value |
+---------+---------+
| Field_1 | Value_1 |
| Field_2 | Value_2 |
+---------+---------+
me@home ~/myproject# mycli format one -f json
{
"Field_2": "Value_2",
"Field_1": "Value_1"
}
me@home ~/myproject# mycli format many
+---------+---------+
| Field_1 | Field_2 |
+---------+---------+
| Value_1 | Value_2 |
| Value_1 | Value_3 |
+---------+---------+
me@home ~/myproject# mycli format many -f yaml
- Field_1: Value_1
Field_2: Value_2
- Field_1: Value_1
Field_2: Value_3
- Let’s take a look at the interactive shell:
me@home ~/myproject# mycli
(mycli) help
Shell commands (type help <topic>):
===================================
cmdenvironment eof history load py run set shortcuts
edit help list pause quit save shell show
Application commands (type help <topic>):
=========================================
command complete format many format one help
- What about command suggestion ?
me@home ~/myproject# mycli kommand
mycli: 'kommand' is not a mycli command. See 'mycli --help'.
Did you mean one of these?
command
And that’s it!