Package: comandante
Comandante is a toolkit for building command-line interfaces in Python.Table of ContentsInstallationGetting StartedExampleJust a Normal Classes and Methods!OptionsCommand OptionsClass OptionsSubcommandsArgumentsType LibraryPython 2Printing HelpError HandlingTesting Your CLIInstallationTo get the latest release simply install it with apip:pip3install--upgradecomandanteGetting StartedSome command-line interfaces (likepip,git,go, etc.)
have a rich hierarchy of subcommands. Each subcommand may have
its own set of arguments and options. Consider for examplegit commit,git remote add <repo>,git remote rename <old> <new>etc.Comandanteis a Python library that makes building
rich command-line interfaces (CLI) in Python extremely simple
and straightforward.ExampleHere is a simple example:#!/usr/bin/env python3importsys,comandanteascli# Define a new CLI handler as a `cli.Handler` subclassclassCliTool(cli.Handler):# define CLI commands as methods decorated with `@cli.command()`@cli.command()defrepeat(self,message,times:int):foriinrange(times):print(message)@cli.command()defsum(self,a:int,b:int):result=a+bprint(result)returnresult# Then simply pass command-line arguments to the CliTool#invokeCliTool().invoke(sys.argv[1:])That's it!To executerepeatcommand simply run./tool repeatwith its required arguments:$./toolrepeat"Hello world"2Helloworld
HelloworldThe same goes for any defined command-methods:$./toolsum212142So in other words to create a command-line interface you simply:define a normal Python class (inherited from thecomandante.Handler)equip your class methods with a minimal amount of metadata (via decorators)callinvoke(sys.argv[1:])method on your class instancecomandantewill inspect metadata and decide how to parse
command-line arguments and which method to call.Just a Normal Classes and MethodsNo surprises! Handlers and commands are just a normal classes and methods.OptionsCommand options are declared with@comandante.option(...)decorator.Each command-method receives options as**kwargsparameters (if present).Each command-method has a convenience methodCommand#options(kwargs)to
merge default values with specified command-line options.Command OptionsExample below demonstrates how to define command option:#!/usr/bin/env python3importsys,comandanteascliclassDatabaseCli(cli.Handler):@cli.option(name='force',short='f',type=bool,default=False)@cli.command()defdrop(self,database_name,**specified_options):# merge specified options with default valuesoptions=self.drop.options(specified_options)# merged options provides attribute-like element accessifoptions.forceorself.confirm():print("Database '{name}' was deleted".format(name=database_name))else:print("Aborted")@staticmethoddefconfirm():question='Are you sure? [y/N]: 'value=input(question).lower()whilevaluenotin['','n','y']:print("Please answer 'y' or 'n'")value=input(question)returnvalue=='y'database_cli=DatabaseCli()database_cli.invoke(sys.argv[1:])Then in shell:$./databasedropproduction
Areyousure?[y/N]:y
Database'production'wasdeletedThe following two examples are identical:$./databasedrop-fproduction
Database'production'wasdeleted$./databasedrop--forceproduction
Database'production'wasdeletedClass OptionsOptions could be declared on handler itself withHandler#declare_optionmethod.Options declared on handler will be declared on each of its command and subcommand recursively.#!/usr/bin/env python3importsys,comandanteascliclassCliTool(cli.Handler):def__init__(self):super().__init__()# define optionsself.declare_option('verbose','v',bool,False)@cli.command()deffirst(self,**specified_options):if'verbose'inspecified_options:print("Hello from the first!")@cli.command()defsecond(self,**specified_options):if'verbose'inspecified_options:print("Hello from the second!")tool=CliTool()tool.invoke(sys.argv[1:])Then in shell:$./toolfirst--verbose
Hellofromthefirst!$./toolsecond-v
Hellofromthesecond!SubcommandsAs 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
useHandler#declare_commandmethod.Here is a simple example:#!/usr/bin/env python3importsys,comandanteascliclassRemote(cli.Handler):@cli.command()defadd(self,name,uri):print("Adding repository",name,uri)@cli.command()defrename(self,old,new):print("Renaming repository",old,new)classGit(cli.Handler):def__init__(self):super().__init__()# define subcommandsself.declare_command(name='remote',handler=Remote())@cli.option('message','m',str,'')@cli.command()defcommit(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$./gitcommit-m"Initial commit"Committingchangeswithmessage'Initial commit'$./gitremoteaddorigingit@github.com:stepan-anokhin/comandante.git
Addingrepositoryorigingit@github.com:stepan-anokhin/comandante.git$./gitremoterenameorigindestination
RenamingrepositoryorigindestinationArgumentsIn python3 command argument types are declared using annotations:importcomandanteascliclassCliTool(cli.Handler):@cli.command()defdo_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 thecommand-methodas argument. The same is true for option types.The only exception isboolarguments and options. Bool option doesn't
receive any value, if specified it is considered to beTrueotherwise
default value is used.If no argument type is specified, thenstris 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 python3importsys,comandanteascliclassCliTool(cli.Handler):@cli.command()defsum(self,*values:int):print(sum(values))@cli.command()defrepeat(self,message,times:int=2):foriinrange(times):print(message)CliTool().invoke(sys.argv[1:])$./toolsum1234515$./toolrepeat"Hello world!"Helloworld!
Helloworld!Type LibraryComandante 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 thecomandante.typesto get some additional insights.Python 2Python 2 doesn't support parameter annotations.
To specify argument types use@comandante.signature()importcomandanteascliclassCliTool(cli.Handler):@cli.signature(a=int,b=float)@cli.command()defdo_something(self,a,b,c):print(a+b,c)Printing HelpComandante provides predefinedhelpcommand for you which will print
formatted help information to the stdout. Command and handler descriptions
are taken from the corresponding docstrings.Example:importsysimportcomandanteascliclassGit(cli.Handler):"""The stupid content tracker.Git is a fast, scalable, distributed revision control system withan unusually *rich command* set that provides both high-level operationsand full access to internals.See *gittutorial*(7) to get started, then see *giteveryday*(7) for a usefulminimum set of commands. The *Git User’s Manual*[1] has a more in-depthintroduction."""@cli.option(name='message',short='m',type=str,default='',descr="""Use the given <msg> as the commit message. If multiple *-m* options aregiven, their values are concatenated as separate paragraphs.""")@cli.command()defcommit(self):"""Record changes to the repositoryCreate a new commit containing the current contentsof the index and the given log message describingthe changes. The new commit is a direct child of HEAD,usually the tip of the current branch, and the branchis updated to point to it (unless no branch is associatedwith the working tree, in which case HEAD is "detached"as described in *git-checkout*(1))."""print("Committing...")@cli.command()defclone(self,repository,directory=None):"""Clone a repository into a new directoryClones a repository into a newly created directory, createsremote-tracking branches for each branch in the clonedrepository (visible using git branch *-r*), and creates andchecks out an initial branch that is forked from the clonedrepository’s currently active branch."""print("Cloning...")Git().invoke(sys.argv[1:])./gitoutput:./git help cloneoutput:./git help commitoutput:Error HandlingSuccessful calls toHandler#invokeandCommand#invokemethods return
the same value as the correspondingcommand-method.By design Comandante doesn't hide any exceptions raised in the course ofinvokecall. The only special case is subclasses ofcomandnate.errors.CliSyntaxExceptionwhich 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:importsys,logging,comandanteascli# ... initialize handler and logger ...try:handler.invoke(sys.argv[1:])exceptcli.errors.CliSyntaxException:sys.exit(1)except:logger.exception('Unexpected exception')sys.exit(1)Testing Your CLIHandlers and commands could be tested just likeany otherclasses and methods.Example cli:importcomandanteascliclassDatabaseCli(cli.Handler):def__init__(self,database):super().__init__()self._database=database@cli.option('force','f',bool,False)@cli.command()defdrop(self,database_name,**specified_options):options=self.drop.options(specified_options)ifoptions.forceorself.confirm():self._database.drop(database_name)defconfirm(self):question='Are you sure? [y/N]: 'value=input(question).lower()whilevaluenotin['','n','y']:print("Please answer 'y' or 'n'")value=input(question)returnvalue=='y'Example tests:fromunittestimportTestCasefromunittest.mockimportMagicMockasMockclassDatabaseCliTests(TestCase):deftest_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')deftest_database_drop(self):fake_database=Mock()database_cli=DatabaseCli(database=fake_database)database_cli.confirm=Mock(return_value=True)# confirmdatabase_cli.drop('production')fake_database.drop.assert_called_with('production')deftest_rejected_database_drop(self):fake_database=Mock()database_cli=DatabaseCli(database=fake_database)database_cli.confirm=Mock(return_value=False)# rejectdatabase_cli.drop('production')fake_database.drop.assert_not_called()