Skip to main content

Turn Python functions into command line interfaces

Project description

Hashbang Build status

Hashbang is a Python 3 library for quickly creating command-line ready scripts. In the most basic form, a simple hashbang command can be just a simple annotation. For more complex types, it relies on Python3's keyword-only arguments to provide a seamless syntax for command line usage.

#!/usr/bin/env python3

from hashbang import command

@command
def echo(message):
  print(message)

if __name__ == '__main__':
  echo.execute()

Installation

Hashbang can be installed from pip

python3 -m pip install hashbang[completion]

This will also include argcomplete which powers the autocomplete for hashbang. The completion feature is optional; if you would like to exclude it, install using pip install hashbang.

Quick Start Examples

Let's start with some examples.

Simple, no argument script

#!/usr/bin/env python3

import os
from hashbang import command

@command
def pwd():
    return os.getcwd()

if __name__ == '__main__':
    pwd.execute()
$ pwd.py
$ pwd.py
/home/mauricelam/code/hashbang

The return value from the function is printed to stdout.

The additional value you get from using hashbang in this simple case is the help message, and the usage message when unexpected arguments are supplied.

$ pwd.py --help
$ pwd.py --help
usage: pwd.py [-h]

optional arguments:
  -h, --help  show this help message and exit

Positional argument (nargs='?')

@command
def ls(dir=None):
  return os.listdir(path=dir)
$ ls.py
$ ls.py
bin
etc
home
usr
var
$ ls.py bin
$ ls.py bin
cp
df
echo
mkdir
mv
pwd
rm

Multiple positional argument (nargs=None)

@command
def cp(src, dest):
  shutil.copy2(src, dest)
$ cp.py textfile.txt copy_of_textfile.txt
$ cp.py textfile.txt copy_of_textfile.txt
$ ls
textfile.txt    copy_of_textfile.txt

Variadic positional argument (nargs='*')

@command
def echo(*message):
  print(' '.join(message))
$ echo.py Hello world
$ echo.py Hello world
Hello world

Boolean flag (default False) (action='store_true')

@command
def pwd(*, resolve_symlink=False):
  cwd = os.cwd()
  if resolve_symlink:
    cwd = os.path.realpath(cwd)
  return cwd
$ pwd.py
$ pwd.py
/var
$ pwd.py --resolve_symlink
$ pwd.py --resolve_symlink
/private/var

Boolean flag (default True) (action='store_false')

@command
def echo(*message, trailing_newline=True):
  print(' '.join(message), end=('\n' if trailing_newline else ''))
$ echo.py Hello world && echo '.'
$ echo.py Hello world && echo '.'
Hello world
.
$ echo.py --notrailing_newline Hello world && echo '.'
$ echo.py --notrailing_newline Hello world && echo '.'
Hello world.

Keyword argument (action='store')

@command
def cut(*, fields=None, chars=None, delimeter='\t'):
  result = []
  for line in sys.stdin:
    seq = line.strip('\n').split(delimeter) if fields else line.strip('\n')
    pos = fields or chars
    result.append(''.join(seq[int(p)] for p in pos.split(',')))
  return '\n'.join(result)
$ echo -e 'a,b,c,d\ne,f,g,h' | cut.py --fields '1,2,3' --delimeter=','
$ echo -e 'a,b,c,d\ne,f,g,h' | cut.py --fields '1,2,3' --delimeter=','
bc
fg

Cheatsheet

Parameter type Python syntax Command line example argparse equivalent
Positional (no default value) def func(foo) command.py foo nargs=None
Positional (with default value) def func(foo=None) command.py foo nargs='?'
Var positional def func(*foo) command.py foo bar baz nargs='*'
Var positional (named \_REMAINDER\_) def func(*_REMAINDER_) nargs=argparse.REMAINDER
Keyword-only (default false) def func(*, foo=False) command.py --foo action='store_true'
Keyword-only (default true) def func(*, foo=True) command.py --nofoo action='store_false'
Keyword-only (other default types) def func(*, foo='bar') command.py --foo value action='store'
Var keyword def func(**kwargs) Not allowed in hashbang

Command delegation

The hashbang.subcommands function can be used to create a chain of commands, like git branch.

@command
def branch(newbranch=None):
  if newbranch is None:
    return '\n'.join(Repository('.').heads.keys())
  return Repository('.').create_head(newbranch)

@command
def log(*, max_count=None, graph=False):
  logs = Repository('.').log()
  if graph:
    return format_as_graph(logs)
  else:
    return '\n'.join(logs)

if __name__ == '__main__':
  subcommands(branch=branch, log=log).execute()
$ git.py branch
$ git.py branch
master
$ git.py branch hello
$ git.py branch hello
$ git.py branch
master
hello
$ git.py log
$ git.py log
commit 602cbd7c68b0980ab1dbe0d3b9e83b69c04d9698 (HEAD -> master, origin/master)
Merge: 333d617 34c0a0f
Author: Maurice Lam <me@mauricelam.com>
Date:   Mon May 13 23:32:56 2019 -0700

    Merge branch 'master' of ssh://github.com/mauricelam/hashbang

commit 333d6172a8afa9e81baea0d753d6cfdc7751d38d
Author: Maurice Lam <me@mauricelam.com>
Date:   Mon May 13 23:31:17 2019 -0700

    Move directory structure to match pypi import

Custom command delegator

If subocommands is not sufficient for your purposes, you can use the @command.delegator decorator. Its usage is the same as the @command decorator, but the implementing function must then either call .execute(_REMAINDER_) on another command, or raise NoMatchingDelegate exception.

@command
def normal_who(*, print_dead_process=False, print_runlevel=False):
  return ...

@command
def whoami():
  '''
  Prints who I am.
  '''
  return getpass.getuser()

@command.delegator
def who(am=None, i=None, *_REMAINDER_):
  if (am, i) == ('am', 'i'):
    return whoami.execute([])
  elif (am, i) == (None, None):
    return normal_who.execute(_REMAINDER_)
  else:
    raise NoMatchingDelegate
$ who.py
$ who.py
mauricelam console  May  8 00:02 
mauricelam ttys000  May  8 00:03 
mauricelam ttys001  May  8 00:04
$ who.py am i
$ who.py am i 
mauricelam ttys001  May  8 00:04
$ who.py --print_dead_process
$ who.py --print_dead_process
mauricelam ttys002  May  8 00:40 	term=0 exit=0
$ who.py are you
$ who.py are you
Error: No matching delegate

While using the regular @command decorator will still work in this situation, but tab-completion and help message will be wrong.

✓ Using @command.delegator
$ who.py --help
usage: who.py [--print_dead_process] [--print_runlevel]

optional arguments:
  --print_dead_process
  --print_runlevel
$ who.py am i --help
usage: who.py am i

Prints who I am.
✗ Using @command
$ who.py am i --help
usage: who.py [-h] [am] [i]

positional arguments:
  am
  i

optional arguments:
  -h, --help  show this help message and exit

Argument customization

An argument can be further customized using the Argument class in the @command decorator.

For example, an alias can be added to the argument.

@command(Argument('trailing_newline', aliases=('n',))
def echo(*message, trailing_newline=True):
  print(' '.join(message), end=('\n' if trailing_newline else ''))
$ echo.py Hello world && echo '.'
$ echo.py Hello world && echo '.'
Hello world
.
$ echo.py -n Hello world && echo '.'
$ echo.py -n Hello world && echo '.'
Hello world.
Alternatively, you can also choose to specify the Argument using argument annotation syntax defined in PEP 3107.
@command
def echo(
    *message,
    trailing_newline: Argument(aliases=('n',)) = True):
  print(' '.join(message), end=('\n' if trailing_newline else ''))

Argument API

Argument(*, choices=None, completer=None, aliases=(), help=None, type=None, remainder=False)
  • choices – A sequence of strings representing possible values for the argument. This is used in the help message and also in tab-completion.
  • completer – A function that returns a sequence of possible choices for this argument. This can be used for arguments where the choices are too expensive to generate ahead of time.
  • aliases – A sequence of strings that are aliases of the option. If the alias has only one character, it is specified with one dash -f. If the alias has multiple characters, it is specified with two dashes --foo.
  • help – The help message for this argument.
  • type – A function that takes a string, and its return value will be used as the parameter value instead of the raw input string.
  • remainder – Boolean indicating whether this argument should capture all the remaining arguments. This is True by default if the argument is named _REMAINDER_.

Help message

The help message for the command is take directly from the docstring of the function. Additionally, the help argument in Argument can be used to document each argument. A paragraph in the docstring prefixed with usage: (case insensitive) is used as the usage message.

@command
def git(
    command: Argument(help='Possible commands are "branch", "log", etc', choices=('branch', 'log')),
    *_REMAINDER_):
  '''
  git - the stupid content tracker

  Usage:  git [--version] [--help] [-C <path>] [-c <name>=<value>]
           [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
           [-p|--paginate|-P|--no-pager] [--no-replace-objects] [--bare]
           [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
           [--super-prefix=<path>]
           <command> [<args>]
  '''
  return ...
$ git.py --help
$ git.py --help
usage:  git [--version] [--help] [-C <path>] [-c <name>=<value>]
         [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
         [-p|--paginate|-P|--no-pager] [--no-replace-objects] [--bare]
         [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
         [--super-prefix=<path>]
         <command> [<args>]

git - the stupid content tracker

positional arguments:
  {branch,log}  Possible commands are "branch", "log", etc

optional arguments:
  -h, --help    show this help message and exit
$ git.py --nonexistent
$ git.py --nonexistent
unknown option: --nonexistent
usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]
           [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
           [-p | --paginate | -P | --no-pager] [--no-replace-objects] [--bare]
           [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
           <command> [<args>]

Tab completion

Setup

Hashbang also comes with tab completion functionality, powered by argcomplete. Since argcomplete is an optional dependency of hashbang, you can install the completion feature using

python3 -m pip install hashbang[completion]

After installing, to register a command for tab-completion, run

eval "$(register-python-argcomplete my-awesome-script)"

Alternatively, to activate global completion for all commands, follow the one-time setup directions in the Global completion section of argcomplete's documentation, and then add the string PYTHON_ARGCOMPLETE_OK to the top of the file as a comment (after the #! line).

Specifying the choices

The simplest way to use tab completion is via the choices argument in the Argument constructor.

@command
def apt_get(command: Argument(choices=('update', 'upgrade', 'install', 'remove')), *_REMAINDER_):
  return subprocess.check_output(['apt-get', command, *_REMAINDER])
$ apt_get.py <TAB><TAB>
$ apt_get.py <TAB><TAB>
update    upgrade    install    remove
$ apt_get.py up<TAB><TAB>
update    upgrade
$ apt_get.py upg<TAB>
$ apt_get.py upgrade

Using a completer

If the choices are not known ahead of time (before execution), or is too expensive to precompute, you can instead specify a completer for the argument.

@command
def cp(src: Argument(completer=lambda x: os.listdir()), dest):
  shutils.copy2(src, dest)
$ cp.py <TAB><TAB>
$ cp.py <TAB><TAB>
LICENSE           build             hashbang          requirements.txt  tests
$ cp.py LIC<TAB>
$ cp.py LICENSE 

Exit codes

Just like normal Python programs, the preferred way to set an exit code is using sys.exit(). By default, exit code 0 is returned for functions that run without raising an exception, or printed a help message with help. If a function raises an exception, the result code is 1. If a function quits with sys.exit(), the exit code of is preserved.

In addition, you can also call sys.exit() inside the exception_handler if you want to return different exit codes based on the exception that was thrown. See tests/extension/custom_exit_codes.py for an example.

Further reading

For further reading, check out the wiki pages.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

hashbang-0.0.8.tar.gz (5.9 kB view details)

Uploaded Source

Built Distribution

hashbang-0.0.8-py3-none-any.whl (6.1 kB view details)

Uploaded Python 3

File details

Details for the file hashbang-0.0.8.tar.gz.

File metadata

  • Download URL: hashbang-0.0.8.tar.gz
  • Upload date:
  • Size: 5.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/1.13.0 pkginfo/1.5.0.1 requests/2.21.0 setuptools/40.8.0 requests-toolbelt/0.9.1 tqdm/4.31.1 CPython/3.7.3

File hashes

Hashes for hashbang-0.0.8.tar.gz
Algorithm Hash digest
SHA256 ebf0c9ce9c9f063a2246723703b42ed8f6ca56bd788a1e874af8cd11760ded9b
MD5 e4b5a9a046d70f66d1020f67fcf7b305
BLAKE2b-256 698fc27de4f290973e36bb32b13f2713a1937e6ba568b87e1ebe24977bc000f3

See more details on using hashes here.

File details

Details for the file hashbang-0.0.8-py3-none-any.whl.

File metadata

  • Download URL: hashbang-0.0.8-py3-none-any.whl
  • Upload date:
  • Size: 6.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/1.13.0 pkginfo/1.5.0.1 requests/2.21.0 setuptools/40.8.0 requests-toolbelt/0.9.1 tqdm/4.31.1 CPython/3.7.3

File hashes

Hashes for hashbang-0.0.8-py3-none-any.whl
Algorithm Hash digest
SHA256 a275aa7cb08b0f0d8861ff5cadd9a51fca91681dd324a325d4f7881ab353058f
MD5 db87c98cf8b602b428929ffeccbb43f2
BLAKE2b-256 1605a6c86d27b31ff68972c06050da734a984e0f3368a4afa440b32692e1562e

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page