Python library for creating subcommand-style CLI applications
Python
Switch branches/tags
Nothing to show
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Failed to load latest commit information.
.gitignore
LICENSE
MANIFEST
README.rst
clik.py
setup.py

README.rst

Clik

Clik (CLI Kit) requires Python 2.5 [1].

Clik provides glue code for subcommand-style CLI applications. It does command dispatch and option parsing. It provides a terminal output helper connected to the standard -v/-q flags. It transparently gives your application a command shell. If you want, clik will set up a ConfigParser instance and read in ini-style config files for you. If your application wants logging, clik can set up a logging handler for you as well.

Clik is one module and less than a thousand lines of code, so it's easy to read, modify and include in your projects.

This README is an introduction, tutorial and the documentation wrapped in one. It shows the development of a simple app named downloader, which downloads content to a local directory. You can check out the example code, which basically matches the development of the code in this file, from git://github.com/jds/downloader. The output is included in the repository files; if you're not interested in my commentary you can simply step through the history.

Installation

Setuptools:

easy_install clik

Or download the latest version from http://pypi.python.org/pypi/clik, extract the tarball and run python setup.py install.

The git repository is at git://github.com/jds/clik.

The Basics

To start writing an application, you'll create an instance of clik.App and call its main method:

import clik

downloader = clik.App('downloader')

if __name__ == '__main__':
    downloader.main()

At this point you have a working application:

$ python downloader.py
downloader
Basic usage: downloader <subcommand> [options]

shell, sh
    A command shell for this application.

Run downloader <command> -h for command help

Typically you'll also want to provide a version and short summary:

downloader = clik.App('downloader',
                      version='1.0',
                      description='Manages downloads in a local directory.')


$ python downloader.py
downloader 1.0 -- Manages downloads in a local directory.
Basic usage: downloader <subcommand> [options]
# same output as before

$ python downloader.py --version
1.0

Add subcommands by defining a function and decorating it with the app instance. The command name will be the function name:

@downloader
def hello_world():
    print 'Hello, world!'


$ python downloader.py
downloader 1.0 -- Manages downloads in a local directory.
Basic usage: downloader <subcommand> [options]

hello_world
    No description.

shell, sh
    A command shell for this application.

Run downloader <command> -h for command help


$ python downloader.py hello_world -h
Usage: downloader hello_world [options]

No description.

Options:
  --version   show program's version number and exit
  -h, --help  show this help message and exit


$ python downloader.py hello_world
Hello, world!

Help is taken from the function's docstring, if it exists. The docstring should be formatted conventionally [2]:

  @downloader
  def hello_world():
      """
      Says hello to the world.

      For nontrivial commands, this text right here would be a
      more thorough description of what the command does and how
      to use it. For hello_world, you'd typically just use a
      one-liner with no extended help.
      """

$ python downloader.py
downloader 1.0 -- Manages downloads in a local directory.
Basic usage: downloader <subcommand> [options]

hello_world
    Says hello to the world.

shell, sh
    A command shell for this application.

Run downloader <command> -h for command help


$ python downloader.py hello_world -h
Usage: downloader hello_world [options]

Says hello to the world.

Options:
  --version   show program's version number and exit
  -h, --help  show this help message and exit

For nontrivial commands, this text right here would be a
more thorough description of what the command does and how
to use it. For hello_world, you'd typically just use a
one-liner with no extended help.

hello_world is aptly named but a bit painful to type over and over again. Adding shorter names is easy:

@downloader(alias='hw')
def hello_world():
    print 'Hello, world!'

# or

@downloader(alias=['hw', 'hllwrld'])
def hello_world():
    print 'Hello, world!'


$ python downloader.py
downloader 1.0 -- Manages downloads in a local directory.
Basic usage: downloader <subcommand> [options]

hello_world, hw, hllwrld
    Says hello to the world.

shell, sh
    A command shell for this application.

Run downloader <command> -h for command help


$ python downloader.py hw
Hello, world!

$ python downloader.py hllwrld
Hello, world!

Of course, clik makes sure your names don't run over each other:

@downloader
def hw():
    print 'You will not see me because the script will not run!'


$ python downloader.py
Traceback (most recent call last):
  File "downloader.py", line 22, in <module>
    @downloader
  File "/Users/jds/.virtualenvs/clik-tutorial/lib/python2.6/site-packages/clik.py", line 55, in __call__
    self.add(maybe_fn)
  File "/Users/jds/.virtualenvs/clik-tutorial/lib/python2.6/site-packages/clik.py", line 199, in add
    existing_fn.__module__, existing_fn.__name__))
ValueError: Command name hw from __main__.hw conflicts with name defined in __main__.hello_world

This is typical of much of the interaction with clik: start with the least amount of code necessary, extend application-wide functionality by providing arguments to the app constructor and configure subcommand-level functionality by providing arguments to the decorator.

Before moving on, I should be clear: while hello_world() has started its life as a typical function, its signature is inspected at runtime and dynamically passed the desired arguments. You may only ask for arguments with known names. Base arguments:

  • args: List of arguments, not including application or command name.
  • argv: List of arguments including the command name.
  • opts: optparse.Values for the invocation.
  • app: The click.App running the subcommand.
  • console: clik.Console object.
  • conf: ConfigParser.ConfigParser instance. Will be empty if conf is not enabled.
  • log: logging.Logger instance for the application. Has no handlers (thus does "nothing") if logging is not enabled.

You can extend (or override, if you want) the argument values by providing the args_callback a value in the app constructor:

def my_callback(opts, args):
    # my_callback can take any of the base objects as arguments.
    return {'conf': MyConfigObject(), 'someval': AnotherThing()}

downloader = clik.App('downloader',
                      args_callback=my_callback)

@downloader
def my_subcommand(conf, someval):
    # conf will be the MyConfigObject()
    # someval is the AnotherThing instance

downloader eventually makes use of all these facilities. Read on to see how.

Downloader

Let's get down to business with the code. I'll start by showing you the basic working implementation and then trick it out with only a bit more code.

downloader should be able to list files in a local downloads directory, remove files from the directory and download data from URLs into the directory.

import os
import urllib
import urlparse

import clik


DOWNLOADS_PATH = os.path.join(os.path.dirname(__file__), 'downloads')


def downloads_dir():
    path = os.path.expanduser(os.path.expandvars(DOWNLOADS_PATH))
    if not os.path.exists(path):
        os.mkdir(path)
    return path


downloader = clik.App('downloader',
                      version='1.0',
                      description='Manages downloads in a local directory.')


@downloader(alias='ls')
def list():
    """List the contents of the downloads directory."""
    downloads = downloads_dir()
    filenames = os.listdir(downloads)
    for filename in filenames:
        print filename
    return 0


@downloader(alias='rm')
def remove(args):
    """Remove a downloaded file."""
    if len(args) < 1:
        print >>sys.stderr, 'error: expecting at least one filename to remove'
        return 1
    downloads = downloads_dir()
    for arg in args:
        path = os.path.join(downloads, arg)
        if os.path.exists(path):
            os.unlink(path)
        else:
            print >>sys.stdout, 'no such file or directory: '+path
    return 0


@downloader(alias='dl')
def download(args):
    if len(args) < 1:
        print >>sys.stderr, 'error: you must provide a URL'
        return 1
    url = args[0]

    if len(args) > 1:
        name = args[1]
    else:
        name = urlparse.urlparse(url).path.split('/')[-1]
        if not name:
            name = 'index.html'

    downloads = downloads_dir()
    download_path = os.path.join(downloads, name)
    if os.path.exists(download_path):
        return 0

    print 'fetching %s...' % url
    try:
        urllib.urlretrieve(url, download_path)
    except IOError, e:
        print >>sys.stderr, e
        return 1
    return 0

In action:

$ python downloader.py dl -h
Usage: downloader download|dl [options]

No description.

Options:
  --version   show program's version number and exit
  -h, --help  show this help message and exit


$ python downloader.py ls -h
Usage: downloader list|ls [options]

List the contents of the downloads directory.

Options:
  --version   show program's version number and exit
  -h, --help  show this help message and exit


$ python downloader.py rm -h
Usage: downloader remove|rm [options]

Remove a downloaded file.

Options:
  --version   show program's version number and exit
  -h, --help  show this help message and exit


$ python downloader.py ls
$ python downloader.py dl http://python.org python-index.html
fetching http://python.org...
$ python downloader.py dl http://python.org python-index.html
$ python downloader.py ls
python-index.html
$ python downloader.py rm python-index.html
$ python downloader.py ls
$

The first niggling issue to take care of is the usage for dl and rm. Clik will always make the usage start with <app-name> <command-name> but you can override what comes after:

@downloader(alias='rm', usage='[file1 [file2 [...]]] [options]')
def remove(args):
    ...

@downloader(alias='dl', usage='URL [local-name] [options]')
def download(args):
    ...


$ python downloader.py dl -h
Usage: downloader download|dl URL [local-name] [options]

No description.

Options:
  --version   show program's version number and exit
  -h, --help  show this help message and exit


$ python downloader.py rm -h
Usage: downloader remove|rm [file1 [file2 [...]]] [options]

Remove a downloaded file.

Options:
  --version   show program's version number and exit
  -h, --help  show this help message and exit

Command Shell

Perhaps you noticed the shell/sh command I've neglected to talk about thus far. In my opinion, clik's best feature is the transparently-provided command shell for your application. Without changing a single line, downloader can do this:

$ python downloader.py sh
(downloader)> ?

Documented commands (type help <topic>):
========================================
clear  download  exit  list  quit  remove

Undocumented commands:
======================
dl  help  ls  rm

(downloader)> ? download
Usage: download|dl URL [local-name] [options]

No description.

Options:
  --version   show program's version number and exit
  -h, --help  show this help message and exit


(downloader)> ls
(downloader)> dl http://python.org python-index.html
fetching http://python.org...
(downloader)> dl http://python.org python-index.html
(downloader)> ls
python-index.html
(downloader)> rm python-index.html
(downloader)> ls
(downloader)> exit
$

Aliases are listed as undocumented commands so that "working command set" is clear.

Nitty Gritty

  • Passing shell_command=False to the app constructor disables the shell command entirely.

  • Passing shell_clear_command=False to the app constructor disables the automatically-provided shell clear command. If shell_command is False, this has no effect (the clear command will not be added).

  • You can change the prompt by passing a string to shell_prompt in the app constructor. %name will be substituted with the application name. E.g. shell_prompt='%name%' would make downloader's shell prompt downloader%.

  • You can change the shell's alias by passing shell_alias to the app constructor. This accepts the same values as other aliases (string or sequence of strings). Defaults to sh.

  • You can indicate that a subcommand should be unavailable in the command shell by passing shell=False to the decorator. E.g.:

    @downloader(shell=False)
    def no_shell_example():
        print 'I will be available only from the command-line'
    
  • You can indicate a subcommand should be unavailable from the command line by passing cli=False to the decorator. E.g.:

    @downloader(cli=False)
    def no_cli_example():
        print 'I will be available only in the command shell'
    

Options

Right now, the downloads path is hardcoded into downloader. Let's add an option to the app to let users specify which directory should contain the downloads:

from optparse import make_option as opt

def downloads_dir(opts):
    path = opts.downloads_directory or DOWNLOADS_PATH
    # ...

downloader = clik.App('downloader',
                      opts=opt('-d', '--downloads-dir',
                               dest='downloads_directory', default=None,
                               help=('Directory where downloads are stored '
                                     '[default: '+DOWNLOADS_PATH+']')))


# Add ``opts`` to each subcommand signature and call to downloads_dir() E.g.

@downloader(alias='ls')
def list(opts):
    downloads = downloads_dir(opts)
    # ...


$ python downloader.py ls -h
Usage: downloader list|ls [options]

List the contents of the downloads directory.

Options:
  -d DOWNLOADS_DIRECTORY, --downloads-dir=DOWNLOADS_DIRECTORY
                        Directory where downloads are stored [default:
                        downloads]
  --version             show program's version number and exit
  -h, --help            show this help message and exit


$ python downloader.py ls
$ python downloader.py dl http://python.org
fetching http://python.org...
$ python downloader.py ls
index.html
$ python downloader.py ls -d otherdir
$ python downloader.py dl http://python.org -d otherdir
fetching http://python.org...
$ python downloader.py ls -d otherdir
index.html
$ python downloader.py rm index.html -d otherdir
$ python downloader.py ls -d otherdir
$ python downloader.py ls
index.html

This is a step in the right direction, but still pretty annoying as there's no way to permanently specify the downloads directory. We'll deal with this later on, with the configuration system.

Note that opts can be a single optparse.Option or a sequence of options.

If you've tried to download content from a nonexistent URL, you might have noticed that downloader hangs forever (or, longer than I was willing to wait to find out). We'll add a -t option to let users specify the timeout.

Also, to get a fresh copy of a URL, the user must rm the local file before running dl. We'll add an -o option so users can indicate they'd like a fresh download:

@downloader(alias='dl', usage='URL [local-name] [options]',
            opts=(opt('-t', '--timeout', dest='timeout', type='int',
                      default=30, help='Connection timeout [default %default]'),
                  opt('-o', '--overwrite', dest='overwrite', action='store_true',
                      default=False, help='Overwrite (re-download) file')))
def download(args, opts):
    # ...
    if os.path.exists(download_path):
        if opts.overwrite:
            os.unlink(download_path)
        else:
            return 0

    import socket
    socket.setdefaulttimeout(opts.timeout)
    print 'fetching %s...' % url
    # ...



$ python downloader.py dl -h
Usage: downloader download|dl URL [local-name] [options]

Downloads content from the internet.

Options:
  -t TIMEOUT, --timeout=TIMEOUT
                        Connection timeout [default 30]
  -o, --overwrite       Overwrite (re-download) file
  -d DOWNLOADS_DIRECTORY, --downloads-dir=DOWNLOADS_DIRECTORY
                        Directory where downloads are stored [default:
                        downloads]
  --version             show program's version number and exit
  -h, --help            show this help message and exit


$ python downloader.py ls
$ python downloader.py dl http://python.org
fetching http://python.org...
$ python downloader.py dl http://python.org
$ python downloader.py dl http://python.org -o
fetching http://python.org...
$ python downloader.py ls
index.html

Supplying Custom Arguments

Each subcommand has to call downloads_dir, which is annoying (especially in the case of ls and rm which otherwise don't use the options argument). In this case you can use the args_callback argument to the app constructor. args_callback should be a function whose signature follows the same rules as subcommands and should return a dictionary with {argument name: value} pairs.

def downloads_dir(opts):
    # ...
    return {'downloads': path}

downloader = clik.App('downloader',
                      args_callback=downloads_dir)

def list(downloads):
    # -downloads = downloads_dir(opts)
    # ...

def remove(args, downloads):
    # -downloads = downloads_dir(opts)
    # ...

def download(args, opts, downloads):
    # -downloads = downloads_dir(opts)
    # ...

Configuration

Sooner or later every CLI application needs configuration. Clik provides for ini-style configuration with the common pattern of reading a list of files, each successive file's configuration overriding any previously-read values. By default, clik will look for configuration files in /etc/your-app-name, then ~/.your-app-name, then the filepath given in the $YOURAPPNAME_CONFIG envvar, then the value of --config, if provided.

For downloader, we want the user to be able to permanently configure the download directory. In their configuration file, they'll set it up like this:

[downloader]
path = /path/to/their/downloads

To enable configuration, add conf_enabled=True to the app constructor and specify the defaults:

downloader = clik.App('downloader',
                      conf_enabled=True,
                      conf_defaults={'downloader': {'path': DOWNLOADS_PATH}})

conf_defaults should be a dictionary of dictionaries representing the default sections and options. It can also be a string pointing to a module with a similarly-defined conf_defaults attribute. That is, we could create a file "downloader_conf.py", define conf_defaults in that file, and use clik.App(conf_defaults='downloader_conf').

When conf is enabled, subcommands can take the conf argument, which will be an instance of ConfigParser.SafeConfigParser by default. Because the directory-handling code for downloader is in downloads_dir() we'll add the config-handling code there:

def downloads_dir(opts, conf):
    path = opts.downloads_directory or conf.get('downloader', 'path') or DOWNLOADS_PATH
    # ...


$ python downloader.py ls -h
Usage: downloader list|ls [options]

List the contents of the downloads directory.

Options:
  -d DOWNLOADS_DIRECTORY, --downloads-dir=DOWNLOADS_DIRECTORY
                        Directory where downloads are stored [default:
                        downloads]
  --config=CONF_PATH    Path to config file (will read /etc/downloader,
                        ~/.downloader, $DOWNLOADER_CONFIG, then this value, if
                        set)
  --version             show program's version number and exit
  -h, --help            show this help message and exit


$ python downloader.py ls
$ python downloader.py dl http://python.org
fetching http://python.org...
$ python downloader.py ls
index.html
$ cat >>~/.downloader
[downloader]
path = ./downloads2
^C
$ python downloader.py ls
$ python downloader.py dl http://python.org
fetching http://python.org...
$ python downloader.py ls
index.html
$ ls
README              downloader.py   downloads       downloads2
$ cat >>cfg
[downloader]
path = ./downloads3
^C
$ export DOWNLOADER_CONFIG=./cfg
$ python downloader.py ls
$ python downloader.py dl http://python.org
fetching http://python.org...
$ python downloader.py ls
index.html
$ ls
README      downloader.py   downloads2
cfg         downloads       downloads3
$ cat >>cfg2
[downloader]
path = ./downloads4
^C
$ python downloader.py ls --config=./cfg2
$ python downloader.py dl http://python.org --config=./cfg2
fetching http://python.org...
$ python downloader.py ls --config=./cfg2
index.html
$ ls
README      cfg2            downloads       downloads3
cfg         downloader.py   downloads2      downloads4

Details

  • You can change the ConfigParser class by passing a value to configparser_class in the app constructor. By default this is ConfigParser.SafeConfigParser.

  • conf_locations determines the base list of places to look for configuration. This can be a string or sequence of strings. %name is replaced with the application's name. Defaults to ('/etc/%name', '~/.%name').

  • Disable configuration via --config by passing conf_opts=False to the app constructor.

  • You can change the envvar name by passing a string to conf_envvar_name. %NAME will be substituted with the app name in all caps. For example, to change the envvar name from DOWNLOADER_CONFIG to DOWNLOADER_CFG:

    downloader = clik.App('downloader',
                          conf_envvar_name='%NAME_CFG')
    
  • Disable config via envvar by passing conf_envvar_name=None.

Console Output

Another common need among CLI applications is output control (-v/-q options). To enable those options, add console_opts=True to the app constructor:

downloader = clik.App('downloader',
                      console_opts=True)

Subcommand functions can take the console object, which has these methods:

console.quiet('Always emitted to stdout')
console.q('Alias for console.quiet()')
console.normal('Emitted if the user does not pass -q')
console.n('Alias for console.normal()')
console.verbose('Emitted only if the user passes -v')
console.v('Alias for console.verbose()')
console.error('Always emitted to stderr')

By default, a single newline is emitted after the string. You can change that using the newlines argument:

console.n('Doing something...', newlines=0)
console.n('done')

There is also a small colorization markup language:

console.q('<red>Error:</> something bad happened.')

The complete list of colors is in the appendix.

Updating downloader to use the console system:

def downloads_dir(opts, conf, console):
    path = opts.downloads_directory or conf.get('downloader', 'path') or DOWNLOADS_PATH
    path = os.path.expanduser(os.path.expandvars(path))
    console.v('downloads directory is '+path)
    if not os.path.exists(path):
        console.v('downloads directory does not exist, creating')
        os.mkdir(path)
    return {'downloads': path}


downloader = clik.App('downloader',
                      version='1.0',
                      description='Manages downloads in a local directory.',
                      console_opts=True,
                      conf_enabled=True,
                      conf_defaults={'downloader': {'path': DOWNLOADS_PATH}},
                      opts=opt('-d', '--downloads-dir',
                               dest='downloads_directory', default=None,
                               help=('Directory where downloads are stored '
                                     '[default: '+DOWNLOADS_PATH+']')),
                      args_callback=downloads_dir)


@downloader(alias='ls')
def list(downloads, console):
    """List the contents of the downloads directory."""
    filenames = os.listdir(downloads)
    console.n('%i files in downloads' % len(filenames))
    for filename in filenames:
        console.q(filename)
    return 0


@downloader(alias='rm', usage='[file1 [file2 [...]]] [options]')
def remove(args, downloads, console):
    """Remove a downloaded file."""
    if len(args) < 1:
        console.error('error: expecting at least one filename to remove')
        return 1
    for arg in args:
        path = os.path.join(downloads, arg)
        if os.path.exists(path):
            console.v('removing '+path)
            os.unlink(path)
        else:
            console.error('<red>error:</> no such file or directory: '+path)
    return 0


@downloader(alias='dl', usage='URL [local-name] [options]',
            opts=(opt('-t', '--timeout', dest='timeout', type='int',
                      default=30, help='Connection timeout [default %default]'),
                  opt('-o', '--overwrite', dest='overwrite', action='store_true',
                      default=False, help='Overwrite (re-download) file')))
def download(args, opts, downloads, console):
    """Downloads content from the internet."""
    if len(args) < 1:
        console.error('<red>error:</> you must provide a URL')
        return 1
    url = args[0]

    if len(args) > 1:
        name = args[1]
    else:
        name = urlparse.urlparse(url).path.split('/')[-1]
        if not name:
            name = 'index.html'
    console.v('url is %s, local name is %s' % (url, name))

    download_path = os.path.join(downloads, name)
    if os.path.exists(download_path):
        if opts.overwrite:
            console.v('local file already exists, overwriting')
            os.unlink(download_path)
        else:
            console.v('local file already exists, not downloading')
            return 0

    import socket
    socket.setdefaulttimeout(opts.timeout)
    console.n('fetching %s...' % url, newlines=0)
    try:
        urllib.urlretrieve(url, download_path)
    except IOError, e:
        console.n('<red>error</>')
        console.error(e)
        return 1
    console.n('<bold>done</>')
    return 0

In the terminal:

$ python downloader.py dl -h
Usage: downloader download|dl URL [local-name] [options]

Downloads content from the internet.

Options:
  -t TIMEOUT, --timeout=TIMEOUT
                        Connection timeout [default 30]
  -o, --overwrite       Overwrite (re-download) file
  -d DOWNLOADS_DIRECTORY, --downloads-dir=DOWNLOADS_DIRECTORY
                        Directory where downloads are stored [default:
                        downloads]
  -v, --verbose         Emit verbose information
  -q, --quiet           Emit only errors
  --no-color            Do not colorize output
  --config=CONF_PATH    Path to config file (will read /etc/downloader,
                        ~/.downloader, $DOWNLOADER_CONFIG, then this value, if
                        set)
  --version             show program's version number and exit
  -h, --help            show this help message and exit


$ python downloader.py dl http://python.org -v
downloads directory is downloads
downloads directory does not exist, creating
url is http://python.org, local name is index.html
fetching http://python.org...done

$ python downloader.py dl http://python.org -v
downloads directory is downloads
url is http://python.org, local name is index.html
local file already exists, not downloading

$ python downloader.py dl http://python.org -vo
downloads directory is downloads
url is http://python.org, local name is index.html
local file already exists, overwriting
fetching http://python.org...done

$ python downloader.py dl http://python.org

$ python downloader.py dl http://python.org -oq

$ python downloader.py ls
1 files in downloads
index.html

$ python downloader.py ls -q
index.html

$ python downloader.py ls -v
downloads directory is downloads
1 files in downloads
index.html

$ python downloader.py rm foo
error: no such file or directory: downloads/foo

$ python downloader.py rm foo --no-color
error: no such file or directory: downloads/foo

$ python downloader.py rm index.html -v
downloads directory is downloads
removing downloads/index.html

Logging

Last but not least, clik provides an easy, flexible way to set up file-based logging. To get started, set log_enabled=True in the app constructor:

downloader = clik.App('downloader',
                      log_enabled=True)

Subcommands can take the log argument, which will be the logging.Logger instance for the application:

def downloads_dir(opts, conf, console, log):
    # ...
    if not os.path.exists(path):
        msg = 'downloads directory does not exist, creating'
        log.info(msg)
        console.v(msg)
        os.mkdir(path)
    return {'downloads': path}

def remove(args, downloads, console, log):
    # ...
    for arg in args:
        path = os.path.join(downloads, arg)
        if os.path.exists(path):
            console.v('removing '+path)
            os.unlink(path)
            log.info('removed '+path)
        else:
            console.error('<red>error:</> no such file or directory: '+path)
    return 0

def download(args, opts, downloads, console, log):
    # ...
    download_path = os.path.join(downloads, name)
    if os.path.exists(download_path):
        if opts.overwrite:
            console.v('local file already exists, overwriting')
            os.unlink(download_path)
            log.info('removed '+download_path)
        else:
            console.v('local file already exists, not downloading')
            return 0

    import socket
    socket.setdefaulttimeout(opts.timeout)
    console.n('fetching %s...' % url, newlines=0)
    try:
        urllib.urlretrieve(url, download_path)
    except IOError, e:
        console.n('<red>error</>')
        console.error(e)
        log.error('could not fetch %s: %s' % (url, e))
        return 1
    log.info('fetched '+url)
    console.n('<bold>done</>')
    return 0

In the shell:

$ python downloader.py dl -h
Usage: downloader download|dl URL [local-name] [options]

Downloads content from the internet.

Options:
  -t TIMEOUT, --timeout=TIMEOUT
                        Connection timeout [default 30]
  -o, --overwrite       Overwrite (re-download) file
  -d DOWNLOADS_DIRECTORY, --downloads-dir=DOWNLOADS_DIRECTORY
                        Directory where downloads are stored [default:
                        downloads]
  -v, --verbose         Emit verbose information
  -q, --quiet           Emit only errors
  --no-color            Do not colorize output
  --config=CONF_PATH    Path to config file (will read /etc/downloader,
                        ~/.downloader, $DOWNLOADER_CONFIG, then this value, if
                        set)
  --log-filename=LOG_FILENAME
                        Log to file [default: ~/downloader.log]
  --log-level=LOG_LEVEL
                        Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
                        [default INFO]
  --version             show program's version number and exit
  -h, --help            show this help message and exit


$ python downloader.py dl http://python.org
fetching http://python.org...done
$ python downloader.py rm index.html
$ python downloader.py dl http://does.not.exist
fetching http://does.not.exist...error
[Errno socket error] [Errno 8] nodename nor servname provided, or not known
$ cat ~/downloader.log
2010-01-02 05:46:59,274 INFO downloads directory does not exist, creating
2010-01-02 05:46:59,886 INFO fetched http://python.org
2010-01-02 05:47:02,409 INFO removed downloads/index.html
2010-01-02 05:47:05,226 ERROR could not fetch http://does.not.exist: [Errno socket error] [Errno 8] nodename nor servname provided, or not known
$

Detail

Logging is extremely configurable, both by you and your end users. Arguments to the app constructor that affect logging:

  • log_enabled: If True, a file-based logging handler is created for the application and attached to the logger with the name given in log_name. Defaults to False.

  • log_name: Logger name. "%name" is substituted with the application name. Defaults to "%name".

  • log_filename: Default filepath for the application's log (the user can override the log path). "%name" is subsituted with the application name. Defaults to "~/%name.log".

  • log_level: Default logging level for the application (user user can override the log level). Defaults to logging.INFO.

  • log_format: The string to use for the logging.Formatter for the handler. Defaults to "%(asctime)s %(levelname)s %(message)s".

  • log_handler_class: File-based handler class to use. Defaults to logging.handlers.RotatingFileHandler.

  • log_handler_kwargs: Dictionary to use as keyword arguments when constructing log_handler_class. filename will always be added to this dictionary. Defaults to:

    {'maxBytes': 10 * 1024 * 1024,
     'backupCount': 10,
     'delay': True}
    
  • log_conf: If True, the user can configure logging via config files (assuming conf_enabled=True). Defaults to True.

  • log_conf_section: The section of the configuration file containing log filepath and log level options. "%name" is substituted with the application name. Defaults to "%name".

  • log_conf_filename_option: Name of the option inside log_conf_section where the log filepath is specified. "%name" is substituted with the application name. Defaults to "log_filename".

  • log_conf_level_option: Name of the option inside log_conf_section where the log filepath is specified. "%name" is substituted with the application name. Defaults to "log_level".

  • log_opts: If True and logging is enabled, subcommands will accept --log-filename and --log-level options which can be used to configure logging on a per-call basis. Defaults to True (ie. "on" if logging is enabled).

Configuration via config files. For example, you can change the log filepath and level for downloader by setting log_filepath and log_level in one of the configuration files:

[downloader]
path = path/to/the/downloads/dir
log_filepath = ~/my-logs/downloader.log
log_level = DEBUG

Appendix: clik.App Constructor Arguments

  • name: Application name. Required.

  • description: One-line description of the application. Defaults to None.

  • version: Version number string. Defaults to None.

  • args_callback: Callback function for adding arguments for subcommands. Defaults to None.

  • conf_enabled: Boolean indicating whether the config system is enabled. Defaults to False.

  • conf_defaults: Dictionary of dictionaries specifying defaults for the config system. Can also be a string naming a module that has a conf_defaults attribute with the same format (dictionary of dictionaries of defaults). Defaults to {}.

  • conf_envvar_name: Environment variable name letting the user specify a path to a config file. If None, configuration via envvar is not allowed. Defaults to %NAME_CONFIG.

  • configparser_class: ConfigParser based class for the config object. Defaults to ConfigParser.SafeConfigParser.

  • log_enabled: If True, a file-based logging handler is created for the application and attached to the logger with the name given in log_name. Defaults to False.

  • log_name: Logger name. "%name" is substituted with the application name. Defaults to "%name".

  • log_filename: Default filepath for the application's log (the user can override the log path). "%name" is subsituted with the application name. Defaults to "~/%name.log".

  • log_level: Default logging level for the application (user user can override the log level). Defaults to logging.INFO.

  • log_format: The string to use for the logging.Formatter for the handler. Defaults to "%(asctime)s %(levelname)s %(message)s".

  • log_handler_class: File-based handler class to use. Defaults to logging.handlers.RotatingFileHandler.

  • log_handler_kwargs: Dictionary to use as keyword arguments when constructing log_handler_class. filename will always be added to this dictionary. Defaults to:

    {'maxBytes': 10 * 1024 * 1024,
     'backupCount': 10,
     'delay': True}
    
  • log_conf: If True, the user can configure logging via config files (assuming conf_enabled=True). Defaults to True.

  • log_conf_section: The section of the configuration file containing log filepath and log level options. "%name" is substituted with the application name. Defaults to "%name".

  • log_conf_filename_option: Name of the option inside log_conf_section where the log filepath is specified. "%name" is substituted with the application name. Defaults to "log_filename".

  • log_conf_level_option: Name of the option inside log_conf_section where the log filepath is specified. "%name" is substituted with the application name. Defaults to "log_level".

  • log_opts: If True and logging is enabled, subcommands will accept --log-filename and --log-level options which can be used to configure logging on a per-call basis. Defaults to True (ie. "on" if logging is enabled).

  • opts: Application-wide options. Can be an optparse.Option or sequence of options. Defaults to None.

  • console_opts: Whether -v/-q/--no-color are enabled. Defaults to False.

  • conf_opts: If conf_enabled=True and this is True, the user can set the configuration file via the --config option. Defaults to True.

  • log_opts: If log_enabled=True and this is True, the user can configure the log filename and log level via --log-filename and --log-level options.

  • shell_command: If True, provide the shell command for this application. Defaults to True.

  • shell_alias: String or list of strings for the shell command's alias. Defaults to sh.

  • shell_prompt: Prompt for the shell. "%name" is substituted with the application name. Defaults to "(%name)> ".

  • shell_clear_command: If shell_command=True and this is True, add the clear command to the command shell.

Appendix: Decorator Arguments

  • alias: Optional string or list of strings to use as aliases to this command. The "canonical" name is the name of the function. Defaults to None (no aliases).
  • usage: Usage string for help. The string "<app-name> <command-name> " is prepended to this string. Defaults to "[options]".
  • shell: Boolean indicating whether the command should be available in the command shell. Defaults to True.
  • cli: Boolean indicating whether the command should be available from the command line. Defaults to True.
  • opts: Extra options for this command.

You can also selectively disable any of the automatically added options. Note that this generally is not a good idea as that option becomes "global-except-for-that-one-command", which is annoying. Pass False to any one of these arguments to disable the associated options:

  • console_opts: -v/--verbose, -q/--quiet, --no-color
  • version_opts: --version
  • help_opts: -h/--help
  • conf_opts: --config
  • log_opts: --log-filename, --log-level
  • app_opts: Application-wide options.

In the extreme case where you want to turn off all these options, you can pass global_opts=False. With global_opts=False, you can selectively add the options back in by setting the associated arguments to True. For example, to disable all arguments except -h/--help, the decorator would be:

 @downloader(console_opts=False, version_opts=False, conf_opts=False,
             log_opts=False, app_opts=False)

# or

@downloader(global_opts=False, help_opts=True)

Appendix: Terminal Colors

These are the colors in the clik.Console library:

  • bold
  • faint
  • standout
  • underline
  • blink
  • black
  • darkgray
  • darkred
  • red
  • darkgreen
  • green
  • brown
  • yellow
  • darkblue
  • blue
  • purple
  • fuchsia
  • turqoise
  • teal
  • lightgray
  • white

clik.Console is based on Georg Brandl's Sphinx project's console.py.

[1]I don't have a machine handy to test on anything earlier. I'll let you know once I get around to it.
[2]

Specifically, clik can properly handle docstrings that consist of one line:

def hello_world():
    """Says hello to the world."""

def hello_world():
    """
    Says hello to the world.
    """

Docstrings with more information should have a one line description followed by a blank line followed by the extended info:

def hello_world():
    """
    Says hello to the world.

    If there were more to say about a hello world function,
    this is where it would go. The indentation of the first
    line after the short description is used as the
    baseline for the rest of the text. Otherwise,
    formatting is preserved. This is unlike optparse's
    handling of `epilog`, which annoyingly reformats the
    input its given. That makes it hard to write
    clearly-formatted examples, which is exactly
    what you want to do in the "more help" text!
    """