A toolkit for building command-line interfaces
Project description
Comandante is a toolkit for building command-line interfaces in Python.
Table of Contents
- Installation
- Getting Started
- Options
- Subcommands
- Arguments
- Printing Help
- Error Handling
- Testing Your CLI
Installation
To get the latest release simply install it with a pip
:
pip3 install --upgrade comandante
Getting Started
Some command-line interfaces (like pip
, git
, go
, etc.)
have a rich hierarchy of subcommands. Each subcommand may have
its own set of arguments and options. Consider for example
git commit
, git remote add <repo>
, git remote rename <old> <new>
etc. Comandante is a Python library that makes building
rich command-line interfaces (CLI) in Python extremely simple
and straightforward.
Example
Here is a simple example:
#!/usr/bin/env python3
import sys, comandante as cli
# Define a new CLI handler as a `cli.Handler` subclass
class CliTool(cli.Handler):
# define CLI commands as methods decorated with `@cli.command()`
@cli.command()
def repeat(self, message, times: int):
for i in range(times):
print(message)
@cli.command()
def sum(self, a: int, b: int):
result = a + b
print(result)
return result
# Then simply pass command-line arguments to the CliTool#invoke
CliTool().invoke(sys.argv[1:])
That's it!
To execute repeat
command simply run ./tool repeat
with its required arguments:
$ ./tool repeat "Hello world" 2
Hello world
Hello world
The same goes for any defined command-methods:
$ ./tool sum 21 21
42
So in other words to create a command-line interface you simply:
- define a normal Python class (inherited from the
comandante.Handler
) - equip your class methods with a minimal amount of metadata (via decorators)
- call
invoke(sys.argv[1:])
method on your class instance - comandante will inspect metadata and decide how to parse command-line arguments and which method to call.
Just a Normal Classes and Methods
No surprises! Handlers and commands are just a normal classes and methods.
Options
Command options are declared with @comandante.option(...)
decorator.
Each command-method receives options as **kwargs
parameters (if present).
Each command-method has a convenience method Command#options(kwargs)
to
merge default values with specified command-line options.
Command Options
Example below demonstrates how to define command option:
#!/usr/bin/env python3
import sys, comandante as cli
class DatabaseCli(cli.Handler):
@cli.option(name='force', short='f', type=bool, default=False)
@cli.command()
def drop(self, database_name, **specified_options):
# merge specified options with default values
options = self.drop.options(specified_options)
# merged options provides attribute-like element access
if options.force or self.confirm():
print("Database '{name}' was deleted".format(name=database_name))
else:
print("Aborted")
@staticmethod
def confirm():
question = 'Are you sure? [y/N]: '
value = input(question).lower()
while value not in ['', 'n', 'y']:
print("Please answer 'y' or 'n'")
value = input(question)
return value == 'y'
database_cli = DatabaseCli()
database_cli.invoke(sys.argv[1:])
Then in shell:
$ ./database drop production
Are you sure? [y/N]: y
Database 'production' was deleted
The following two examples are identical:
$ ./database drop -f production
Database 'production' was deleted
$ ./database drop --force production
Database 'production' was deleted
Class Options
Options could be declared on handler itself with Handler#declare_option
method.
Options declared on handler will be declared on each of its command and subcommand recursively.
#!/usr/bin/env python3
import sys, comandante as cli
class CliTool(cli.Handler):
def __init__(self):
super().__init__()
# define options
self.declare_option('verbose', 'v', bool, False)
@cli.command()
def first(self, **specified_options):
if 'verbose' in specified_options:
print("Hello from the first!")
@cli.command()
def second(self, **specified_options):
if 'verbose' in specified_options:
print("Hello from the second!")
tool = CliTool()
tool.invoke(sys.argv[1:])
Then in shell:
$ ./tool first --verbose
Hello from the first!
$ ./tool second -v
Hello from the second!
Subcommands
As your CLI becomes more complex and harder to maintain, you might want to
have commands that have its own set of commands handled by separate class.
In Comandante to compose handlers into hierarchy you may simply
use Handler#declare_command
method.
Here is a simple example:
#!/usr/bin/env python3
import sys, comandante as cli
class Remote(cli.Handler):
@cli.command()
def add(self, name, uri):
print("Adding repository", name, uri)
@cli.command()
def rename(self, old, new):
print("Renaming repository", old, new)
class Git(cli.Handler):
def __init__(self):
super().__init__()
# define subcommands
self.declare_command(name='remote', handler=Remote())
@cli.option('message', 'm', str, '')
@cli.command()
def commit(self, **specified_options):
options = self.commit.options(specified_options)
print("Committing changes with message '{}'".format(options.message))
git = Git()
git.invoke(sys.argv[1:])
Then in shell
$ ./git commit -m "Initial commit"
Committing changes with message 'Initial commit'
$ ./git remote add origin git@github.com:stepan-anokhin/comandante.git
Adding repository origin git@github.com:stepan-anokhin/comandante.git
$ ./git remote rename origin destination
Renaming repository origin destination
Arguments
In python3 command argument types are declared using annotations:
import comandante as cli
class CliTool(cli.Handler):
@cli.command()
def do_something(self, a: int, b: float, c: str):
print(a + b, c)
Type could be any callable. This callable will be simply called with a command-line string as the only argument. A result will be passed to the command-method as argument. The same is true for option types.
The only exception is bool
arguments and options. Bool option doesn't
receive any value, if specified it is considered to be True
otherwise
default value is used.
If no argument type is specified, then str
is assumed by default.
Type's __name__
attribute is used in automatic help output to
represent argument/option type.
Comandante honors default argument values and varargs:
#!/usr/bin/env python3
import sys, comandante as cli
class CliTool(cli.Handler):
@cli.command()
def sum(self, *values: int):
print(sum(values))
@cli.command()
def repeat(self, message, times: int = 2):
for i in range(times):
print(message)
CliTool().invoke(sys.argv[1:])
$ ./tool sum 1 2 3 4 5
15
$ ./tool repeat "Hello world!"
Hello world!
Hello world!
Type Library
Comandante also provides several higher-order types:
comandante.types.choice
- to make sure argument value is one of the specified optionscomandante.types.listof
- to parse comma-separated lists (e.g.listof(int)
will parse"1,2,3,4"
into[1, 2, 3, 4]
)
You may take a look into the comandante.types to get some additional insights.
Python 2
Python 2 doesn't support parameter annotations.
To specify argument types use @comandante.signature()
import comandante as cli
class CliTool(cli.Handler):
@cli.signature(a=int, b=float)
@cli.command()
def do_something(self, a, b, c):
print(a + b, c)
Printing Help
Comandante provides predefined help
command for you which will print
formatted help information to the stdout. Command and handler descriptions
are taken from the corresponding docstrings.
Example:
import sys
import comandante as cli
class Git(cli.Handler):
"""The stupid content tracker.
Git is a fast, scalable, distributed revision control system with
an unusually *rich command* set that provides both high-level operations
and full access to internals.
See *gittutorial*(7) to get started, then see *giteveryday*(7) for a useful
minimum set of commands. The *Git User’s Manual*[1] has a more in-depth
introduction.
"""
@cli.option(name='message', short='m', type=str, default='', descr="""
Use the given <msg> as the commit message. If multiple *-m* options are
given, their values are concatenated as separate paragraphs.
""")
@cli.command()
def commit(self):
"""Record changes to the repository
Create a new commit containing the current contents
of the index and the given log message describing
the changes. The new commit is a direct child of HEAD,
usually the tip of the current branch, and the branch
is updated to point to it (unless no branch is associated
with the working tree, in which case HEAD is "detached"
as described in *git-checkout*(1)).
"""
print("Committing...")
@cli.command()
def clone(self, repository, directory=None):
"""Clone a repository into a new directory
Clones a repository into a newly created directory, creates
remote-tracking branches for each branch in the cloned
repository (visible using git branch *-r*), and creates and
checks out an initial branch that is forked from the cloned
repository’s currently active branch.
"""
print("Cloning...")
Git().invoke(sys.argv[1:])
./git
output:
./git help clone
output:
./git help commit
output:
Error Handling
Successful calls to Handler#invoke
and Command#invoke
methods return
the same value as the corresponding command-method.
By design Comandante doesn't hide any exceptions raised in the course of
invoke
call. The only special case is subclasses of
comandnate.errors.CliSyntaxException
which results in help printing
before being re-raised.
So it is up to the caller to decide how to handle exceptions.
A reasonable error handling may look like this:
import sys, logging, comandante as cli
# ... initialize handler and logger ...
try:
handler.invoke(sys.argv[1:])
except cli.errors.CliSyntaxException:
sys.exit(1)
except:
logger.exception('Unexpected exception')
sys.exit(1)
Testing Your CLI
Handlers and commands could be tested just like any other classes and methods.
Example cli:
import comandante as cli
class DatabaseCli(cli.Handler):
def __init__(self, database):
super().__init__()
self._database = database
@cli.option('force', 'f', bool, False)
@cli.command()
def drop(self, database_name, **specified_options):
options = self.drop.options(specified_options)
if options.force or self.confirm():
self._database.drop(database_name)
def confirm(self):
question = 'Are you sure? [y/N]: '
value = input(question).lower()
while value not in ['', 'n', 'y']:
print("Please answer 'y' or 'n'")
value = input(question)
return value == 'y'
Example tests:
from unittest import TestCase
from unittest.mock import MagicMock as Mock
class DatabaseCliTests(TestCase):
def test_forced_database_drop(self):
fake_database = Mock()
database_cli = DatabaseCli(database=fake_database)
database_cli.drop('production', force=True)
fake_database.drop.assert_called_with('production')
def test_database_drop(self):
fake_database = Mock()
database_cli = DatabaseCli(database=fake_database)
database_cli.confirm = Mock(return_value=True) # confirm
database_cli.drop('production')
fake_database.drop.assert_called_with('production')
def test_rejected_database_drop(self):
fake_database = Mock()
database_cli = DatabaseCli(database=fake_database)
database_cli.confirm = Mock(return_value=False) # reject
database_cli.drop('production')
fake_database.drop.assert_not_called()
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file comandante-0.0.3.tar.gz
.
File metadata
- Download URL: comandante-0.0.3.tar.gz
- Upload date:
- Size: 19.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/1.15.0 pkginfo/1.5.0.1 requests/2.22.0 setuptools/41.2.0 requests-toolbelt/0.9.1 tqdm/4.36.1 CPython/3.5.2
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | cf279c36c70053360acc6b12cfec5b18d03beb6c1fb7f6ab219f037103495298 |
|
MD5 | cbdfe366c28393ca746596e42baad075 |
|
BLAKE2b-256 | 270f3dd4a6968af46413dfb2b044d55fa7b20ce89ec1b4d3f790ab9eeee517b8 |
File details
Details for the file comandante-0.0.3-py3-none-any.whl
.
File metadata
- Download URL: comandante-0.0.3-py3-none-any.whl
- Upload date:
- Size: 25.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/1.15.0 pkginfo/1.5.0.1 requests/2.22.0 setuptools/41.2.0 requests-toolbelt/0.9.1 tqdm/4.36.1 CPython/3.5.2
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 662392fa2a872e8f72831a41e9600d738d0ed49475e509578a07489999b683a5 |
|
MD5 | 3442ab62adc3765faa004e6cf57bc04d |
|
BLAKE2b-256 | daad1b93710a37204e9e7ee52292477d04c17fa7d9b795a5c6b02243bf47c70d |