The other side of executing programs with ease is writing CLI programs with ease. Python scripts normally use optparse
or the more recent argparse
, and their derivatives; but all of these are somewhat limited in their expressive power, and are quite unintuitive (and even unpythonic). Plumbum's CLI toolkit offers a programmatic approach to building command-line applications; instead of creating a parser object and populating it with a series of "options", the CLI toolkit translates these primitives into Pythonic constructs and relies on introspection.
From a bird's eye view, CLI applications are classes that extend plumbum.cli.Application
. They define a main()
method and optionally expose methods and attributes as command-line switches <plumbum.cli.switch>
. Switches may take arguments, and any remaining positional arguments are given to the main
method, according to its signature. A simple CLI application might look like this:
from plumbum import cli
class MyApp(cli.Application):
verbose = cli.Flag(["v", "verbose"], help = "If given, I will be very talkative")
def main(self, filename):
print "I will now read", filename
if self.verbose:
print "Yadda " * 200
if __name__ == "__main__":
MyApp.run()
And you can run it:
$ python example.py foo
I will now read foo
$ python example.py --help
example.py v1.0
Usage: example.py [SWITCHES] filename
Meta-switches:
-h, --help Prints this help message and quits
--version Prints the program's version and quits
Switches:
-v, --verbose If given, I will be very talkative
So far you've only seen the very basic usage. We'll now start to explore the library.
The Application <plumbum.cli.Application>
class is the "container" of your application. It consists of the main()
method, which you should implement, and any number of CLI-exposed switch functions or attributes. The entry-point for your application is the classmethod run
, which instantiates your class, parses the arguments, invokes all switch functions, and then calls main()
with the given positional arguments. In order to run your application from the command-line, all you have to do is :
if __name__ == "__main__":
MyApp.run()
Aside from run()
and main()
, the Application
class exposes two built-in switch functions: help()
and version()
which take care of displaying the help and program's version, respectively. By default, --help
and -h
invoke help()
, and --version
and -v
invoke version()
; if any of these functions is called, the application will display the message and quit (without processing any other switch).
You can customize the information displayed by help()
and version
by defining class-level attributes, such as PROGNAME
, VERSION
and DESCRIPTION
. For instance, :
class MyApp(cli.Application):
PROGNAME = "Foobar"
VERSION = "7.3"
Colors are supported through the class level attributes COLOR_PROGNAME
, COLOR_DISCRIPTION
, COLOR_VERSION
, COLOR_HEADING
, COLOR_USAGE
, COLOR_SUBCOMMANDS
, COLOR_GROUPS[]
, and COLOR_GROUPS_BODY[]
, which should contain anything that is valid to pass to plumbum.colors
(Styles, ansi color sequences, color strings). The dictionaries support custom colors for named groups. The default is colors.do_nothing
, but if you just want more colorful defaults, subclass cli.ColorfulApplication
.
1.6
The decorator switch <plumbum.cli.switch>
can be seen as the "heart and soul" of the CLI toolkit; it exposes methods of your CLI application as CLI-switches, allowing them to be invoked from the command line. Let's examine the following toy application:
class MyApp(cli.Application):
_allow_root = False # provide a default
@cli.switch("--log-to-file", str)
def log_to_file(self, filename):
"""Sets the file into which logs will be emitted"""
logger.addHandler(FileHandle(filename))
@cli.switch(["-r", "--root"])
def allow_as_root(self):
"""If given, allow running as root"""
self._allow_root = True
def main(self):
if os.geteuid() == 0 and not self._allow_root:
raise ValueError("cannot run as root")
When the program is run, the switch functions are invoked with their appropriate arguments; for instance, $ ./myapp.py --log-to-file=/tmp/log
would translate to a call to app.log_to_file("/tmp/log")
. After all switches were processed, control passes to main
.
Note
Methods' docstrings and argument names will be used to render the help message, keeping your code as DRY as possible.
There's also autoswitch <plumbum.cli.autoswitch>
, which infers the name of the switch from the function's name, e.g. :
@cli.autoswitch(str)
def log_to_file(self, filename):
pass
Will bind the switch function to --log-to-file
.
As demonstrated in the example above, switch functions may take no arguments (not counting self
) or a single argument argument. If a switch function accepts an argument, it must specify the argument's type. If you require no special validation, simply pass str
; otherwise, you may pass any type (or any callable, in fact) that will take a string and convert it to a meaningful object. If conversion is not possible, the type (or callable) is expected to raise either TypeError
or ValueError
.
For instance :
class MyApp(cli.Application):
_port = 8080
@cli.switch(["-p"], int)
def server_port(self, port):
self._port = port
def main(self):
print self._port
$ ./example.py -p 17
17
$ ./example.py -p foo
Argument of -p expected to be <type 'int'>, not 'foo':
ValueError("invalid literal for int() with base 10: 'foo'",)
The toolkit includes two additional "types" (or rather, validators): Range
and Set
. Range
takes a minimal value and a maximal value and expects an integer in that range (inclusive). Set
takes a set of allowed values, and expects the argument to match one of these values. Here's an example :
class MyApp(cli.Application):
_port = 8080
_mode = "TCP"
@cli.switch("-p", cli.Range(1024,65535))
def server_port(self, port):
self._port = port
@cli.switch("-m", cli.Set("TCP", "UDP", case_sensitive = False))
def server_mode(self, mode):
self._mode = mode
def main(self):
print self._port, self._mode
$ ./example.py -p 17
Argument of -p expected to be [1024..65535], not '17':
ValueError('Not in range [1024..65535]',)
$ ./example.py -m foo
Argument of -m expected to be Set('udp', 'tcp'), not 'foo':
ValueError("Expected one of ['UDP', 'TCP']",)
Note
The toolkit also provides some other useful validators: ExistingFile (ensures the given argument is an existing file), ExistingDirectory (ensures the given argument is an existing directory), and NonexistentPath (ensures the given argument is not an existing path). All of these convert the argument to a local path <guide-paths>
.
Many times, you would like to allow a certain switch to be given multiple times. For instance, in gcc
, you may give several include directories using -I
. By default, switches may only be given once, unless you allow multiple occurrences by passing list = True
to the switch
decorator :
class MyApp(cli.Application):
_dirs = []
@cli.switch("-I", str, list = True)
def include_dirs(self, dirs):
self._dirs = dirs
def main(self):
print self._dirs
$ ./example.py -I/foo/bar -I/usr/include
['/foo/bar', '/usr/include']
Note
The switch function will be called only once, and its argument will be a list of items
If a certain switch is required, you can specify this by passing mandatory = True
to the switch
decorator. The user will not be able to run the program without specifying a value for this switch.
Many time, the occurrence of a certain switch depends on the occurrence of another, e..g, it may not be possible to give -x
without also giving -y
. This constraint can be achieved by specifying the requires
keyword argument to the switch
decorator; it is a list of switch names that this switch depends on. If the required switches are missing, the user will not be able to run the program. :
class MyApp(cli.Application):
@cli.switch("--log-to-file", str)
def log_to_file(self, filename):
logger.addHandler(logging.FileHandler(filename))
@cli.switch("--verbose", requires = ["--log-to-file"])
def verbose(self):
logger.setLevel(logging.DEBUG)
$ ./example --verbose
Given --verbose, the following are missing ['log-to-file']
Warning
The toolkit invokes the switch functions in the same order in which the switches were given on the command line. It doesn't go as far as computing a topological order on the fly, but this will change in the future.
Just as some switches may depend on others, some switches mutually-exclude others. For instance, it does not make sense to allow --verbose
and --terse
. For this purpose, you can set the excludes
list in the switch
decorator. :
class MyApp(cli.Application):
@cli.switch("--log-to-file", str)
def log_to_file(self, filename):
logger.addHandler(logging.FileHandler(filename))
@cli.switch("--verbose", requires = ["--log-to-file"], excludes = ["--terse"])
def verbose(self):
logger.setLevel(logging.DEBUG)
@cli.switch("--terse", requires = ["--log-to-file"], excludes = ["--verbose"])
def terse(self):
logger.setLevel(logging.WARNING)
$ ./example --log-to-file=log.txt --verbose --terse
Given --verbose, the following are invalid ['--terse']
If you wish to group certain switches together in the help message, you can specify group = "Group Name"
, where Group Name
is any string. When the help message is rendered, all the switches that belong to the same group will be grouped together. Note that grouping has no other effects on the way switches are processed, but it can help improve the readability of the help message.
Many times it's desired to simply store a switch's argument in an attribute, or set a flag if a certain switch is given. For this purpose, the toolkit provides SwitchAttr <plumbum.cli.SwitchAttr>
, which is data descriptor that stores the argument in an instance attribute. There are two additional "flavors" of SwitchAttr
: Flag
(which toggles its default value if the switch is given) and CountingAttr
(which counts the number of occurrences of the switch) :
class MyApp(cli.Application):
log_file = cli.SwitchAttr("--log-file", str, default = None)
enable_logging = cli.Flag("--no-log", default = True)
verbosity_level = cli.CountingAttr("-v")
def main(self):
print self.log_file, self.enable_logging, self.verbosity_level
$ ./example.py -v --log-file=log.txt -v --no-log -vvv
log.txt False 5
You can also set a SwitchAttr
to take an environment variable as an input using the envname parameter. For example:
class MyApp(cli.Application):
log_file = cli.SwitchAttr("--log-file", str, envname="MY_LOG_FILE")
def main(self):
print(self.log_file)
$ MY_LOG_FILE=this.log ./example.py
this.log
Giving the switch on the command line will override the environment variable value.
1.6.0
The main()
method takes control once all the command-line switches have been processed. It may take any number of positional argument; for instance, in cp -r /foo /bar
, /foo
and /bar
are the positional arguments. The number of positional arguments that the program would accept depends on the signature of the method: if the method takes 5 arguments, 2 of which have default values, then at least 3 positional arguments must be supplied by the user and at most 5. If the method also takes varargs (*args
), the number of arguments that may be given is unbound :
class MyApp(cli.Application):
def main(self, src, dst, mode = "normal"):
print src, dst, mode
$ ./example.py /foo /bar
/foo /bar normal
$ ./example.py /foo /bar spam
/foo /bar spam
$ ./example.py /foo
Expected at least 2 positional arguments, got ['/foo']
$ ./example.py /foo /bar spam bacon
Expected at most 3 positional arguments, got ['/foo', '/bar', 'spam', 'bacon']
Note
The method's signature is also used to generate the help message, e.g. :
Usage: [SWITCHES] src dst [mode='normal']
With varargs:
class MyApp(cli.Application):
def main(self, src, dst, *eggs):
print src, dst, eggs
$ ./example.py a b c d
a b ('c', 'd')
$ ./example.py --help
Usage: [SWITCHES] src dst eggs...
Meta-switches:
-h, --help Prints this help message and quits
-v, --version Prints the program's version and quits
1.1
A common practice of CLI applications, as they span out and get larger, is to split their logic into multiple, pluggable sub-applications (or sub-commands). A classic example is version control systems, such as git, where git
is the root command, under which sub-commands such as commit
or push
are nested. Git even supports alias
-ing, which creates allows users to create custom sub-commands. Plumbum makes writing such applications really easy.
Before we get to the code, it is important to stress out two things:
- Under Plumbum, each sub-command is a full-fledged
cli.Application
on its own; if you wish, you can execute it separately, detached from its so-called root application. When an application is run independently, itsparent
attribute isNone
; when it is run as a sub-command, itsparent
attribute points to its parent application. Likewise, when an parent application is executed with a sub-command, itsnested_command
is set to the nested application; otherwise it'sNone
. - Each sub-command is responsible of all arguments that follow it (up to the next sub-command). This allows applications to process their own switches and positional arguments before the nested application is invoked. Take, for instance,
git --foo=bar spam push origin --tags
: the root application,git
, is in charge of the switch--foo
and the positional argumentspam
, and the nested applicationpush
is in charge of the arguments that follow it. In theory, you can nest several sub-applications one into the other; in practice, only a single level is normally used.
Here is an example of a mock version control system, called geet
. We're going to have a root application Geet
, which has two sub-commands - GeetCommit
and GeetPush
: these are attached to the root application using the subcommand
decorator :
class Geet(cli.Application):
"""The l33t version control"""
VERSION = "1.7.2"
def main(self, *args):
if args:
print "Unknown command %r" % (args[0],)
return 1 # error exit code
if not self.nested_command: # will be ``None`` if no sub-command follows
print "No command given"
return 1 # error exit code
@Geet.subcommand("commit") # attach 'geet commit'
class GeetCommit(cli.Application):
"""creates a new commit in the current branch"""
auto_add = cli.Flag("-a", help = "automatically add changed files")
message = cli.SwitchAttr("-m", str, mandatory = True, help = "sets the commit message")
def main(self):
print "doing the commit..."
@Geet.subcommand("push") # attach 'geet push'
class GeetPush(cli.Application):
"""pushes the current local branch to the remote one"""
def main(self, remote, branch = None):
print "doing the push..."
if __name__ == "__main__":
Geet.run()
Note
* Since GeetCommit
is a cli.Application
on its own right, you may invoke GeetCommit.run()
directly (should that make sense in the context of your application) * You can also attach sub-commands "imperatively", using subcommand
as a method instead of a decorator: Geet.subcommand("push", GeetPush)
Here's an example of running this application:
$ python geet.py --help
geet v1.7.2
The l33t version control
Usage: geet.py [SWITCHES] [SUBCOMMAND [SWITCHES]] args...
Meta-switches:
-h, --help Prints this help message and quits
-v, --version Prints the program's version and quits
Subcommands:
commit creates a new commit in the current branch; see
'geet commit --help' for more info
push pushes the current local branch to the remote
one; see 'geet push --help' for more info
$ python geet.py commit --help
geet commit v1.7.2
creates a new commit in the current branch
Usage: geet commit [SWITCHES]
Meta-switches:
-h, --help Prints this help message and quits
-v, --version Prints the program's version and quits
Switches:
-a automatically add changed files
-m VALUE:str sets the commit message; required
$ python geet.py commit -m "foo"
committing...
Several terminal utilities are available in plumbum.cli.terminal
to assist in making terminal applications.
get_terminal_size(default=(80,25))
allows cross platform access to the terminal size as a tuple (width, height)
. Several methods to ask the user for input, such as readline
, ask
, choose
, and prompt
are available.
Progress(iterator)
allows you to quickly create a progress bar from an iterator. Simply wrap a slow iterator with this and iterate over it, and it will produce a nice text progress bar based on the user's screen width, with estimated time remaining displayed. If you need to create a progress bar for a fast iterator but with a loop containing code, use Progress.wrap
or Progress.range
. For example:
for i in Progress.range(10):
time.sleep(1)
If you have something that produces output, but still needs a progress bar, pass has_output=True
to force the bar not to try to erase the old one each time.
For the full list of helpers, see the api docs <api-cli>
.
- filecopy.py example
- geet.py - a runnable example of using sub-commands
- RPyC has changed it bash-based build script to Plumbum CLI. Notice how short and readable it is.
- A blog post describing the philosophy of the CLI module