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()
You can try Hashbang in your browser here: https://mauricelam.github.io/hashbang/
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
.
Let's start with some examples.
#!/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
@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
@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
@command
def echo(*message):
print(' '.join(message))
$ echo.py Hello world
$ echo.py Hello world
Hello world
@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
@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.
@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
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 |
See the API reference wiki page for the full APIs.
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 <[email protected]>
Date: Mon May 13 23:32:56 2019 -0700
Merge branch 'master' of ssh://github.com/mauricelam/hashbang
commit 333d6172a8afa9e81baea0d753d6cfdc7751d38d
Author: Maurice Lam <[email protected]>
Date: Mon May 13 23:31:17 2019 -0700
Move directory structure to match pypi import
If subcommands
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
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 ''))
See https://github.com/mauricelam/hashbang/wiki/API-reference#argument for the full
Argument
API.
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>]
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).
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
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
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.
For further reading, check out the wiki pages.