Shell auto-completion using python
My latest blog post acted as inspiration for my most
ambitious open-source library yet: PipePy. Part of this library is
pymake
, a command line program that aims to replace GNU make, but with the
makefiles written in Python. As I was polishing the whole thing, I started
wondering if there was a way to add shell autocompletion for it, with the shell
offering different options based on the current makefile’s targets.
For both bash
and zsh
there are multiple ways to go about this. I will only
list those that I ended up using myself and that allow using python for
generating the completions.
bash
One way shell completion works in bash
In bash, at some point you can run complete -C command_A command_B
. This
tells bash the following:
When the user wants to get auto-complete suggestions for command_B, run
command_A command_B <last_word> <second_to_last_word>
and use each line of its output as completion suggestions
So, for example, lets assume the user types:
→ complete -C command_A command_B
→ command_B hello abc<Tab>
bash will run:
→ command_A command_B abc hello
If command_A
returns:
abcdef
then bash will complete the missing ‘def’ to the shell
If command_A
returns:
abcdef
abc123
then bash will offer both completions as suggestions.
Do it with Python
So, we need a way for the user to set up auto-completion without having to know
the intricacies of how it works. For setting things up, we can create a command
that outputs something like complete -C command_A command_B
that they can
wrap with eval $(...)
. Then we need to provide a command to play the role of
command_A
from our example.
Fortunately, we don’t have to pollute the user’s path with lots of executables;
if they have pymake
installed, then they can simply run eval $(pymake
--setup-bash-completion)
, which will run complete -C 'pymake --complete-bash'
pymake
. Then, as promised, pymake --complete-bash
will generate completion
options for pymake
:
# setup.cfg
[options.entry_points]
console_scripts =
pymake = pipepy.pymake:pymake
# pipepy/pymake.py
def pymake():
if _pymake_complete(*sys.argv[1:]):
return
# Run actual pymake code
...
def _pymake_complete(*args):
if args and args[0] == "--setup-bash-completion":
print("complete -C 'pymake --complete-bash' pymake")
elif args and args[0] == "--complete-bash":
# TODO
print("option 1")
print("option 2")
print("option 3")
# More `elif`s ...
else:
return False
return True
Here is the output of pymake --setup-bash-completion
→ pymake --setup-bash-completion
complete -C 'pymake --complete-bash' pymake
Now we need to fill the body of the elif
part with actual suggestions for
pymake
. The suggestions we want are the names of the make targets, which
means that we want all top-level functions defined in the Makefile.py
module
in the current directory:
# pipepy/pymake.py
Makefile = None
def _pymake_complete(*args):
if args and args[0] == "--setup-bash-completion":
print("complete -C 'pymake --complete-bash' pymake")
elif args and args[0] == "--complete-bash":
# imports the local Makefile.py file and assigns it to the global
# `Makefile` variable
_load_makefile()
word = args[-2] # This is where bash will put the word being completed
result = []
for attr in dir(Makefile):
if not attr.startswith(word):
continue
func = getattr(Makefile, attr):
if (not callable(func) or
getattr(func, '__module__', '') != "Makefile"):
# Only functions and only if they were defined in Makefile.py,
# not imported from somewhere else
continue
result.append(attr)
print("\n".join(result))
# More `elif`s ...
else:
return False
return True
Here is how the result looks:
[kbairak@kbairakdelllaptop pipepy]$ pymake <TAB><TAB>
build clean debugtest publish watchtest
checks covtest html test
zsh
One way shell completion works in zsh
Similarly to bash, you can type the following in zsh: compdef func command
.
The catch here is that func
has to be a zsh function and instead of using its
output for the suggestions, func
has to call some zsh-specific builtins that
instruct zsh on how to perform completion. This is better explained with an
example:
→ _pymake() {
local -a subcmds
subcmds = (
'water:water the plants'
'pet:pet the dog'
)
_describe 'command' subcmds
}
→ compdef _pymake pymake
The nice thing that zsh offers is that, apart from offering completions, it can
offer descriptions of each option. So if, after the previous snippet, you type
pymake <TAB>
, you will see something like this:
→ pymake <TAB>
water -- water the plants
pet -- pet the dog
Do it with Python
The problem with how zsh does things is that the _pymake
function from the
previous example must be written in zsh code and not in Python. We can, and
will, get around this with eval
again. Our goal is to be able to again offer
the user the option of running eval $(pymake --setup-zsh-completion)
and have
everything taken care of.
Here is our python code for making this possible:
def _pymake_complete(*args):
if args and args[0] == "--setup-bash-completion":
...
elif args and args[0] == "--complete-bash":
...
elif args and args[0] == "--setup-zsh-completion":
print("_pymake() { eval $(pymake --complete-zsh) }; "
"compdef _pymake pymake")
elif args and args[0] == "--complete-zsh":
result = """
local -a subcmds;
subcmds=(
'water:water the plants'
'pet:pet the dog'
);
_describe 'command' subcmds
"""
print(" ".join((line.strip() for line in result.splitlines())))
else:
return False
return True
Here is the output of pymake --setup-zsh-completion
→ pymake --setup-zsh-completion
_pymake() { eval $(pymake --complete-zsh) }; compdef _pymake pymake
Now its time to fill in the code that generates the actual suggestions. Since zsh gives us the option of providing the descriptions of the suggestions, we are going to use the functions’ docstrings, if available:
def _pymake_complete(*args):
if args and args[0] == "--setup-bash-completion":
...
elif args and args[0] == "--complete-bash":
...
elif args and args[0] == "--setup-zsh-completion":
...
elif args and args[0] == "--complete-zsh":
_load_makefile()
result = """
local -a subcmds;
subcmds=(
"""
for attr in dir(Makefile):
func = getattr(Makefile, attr)
if (not callable(func) or
getattr(func, '__module__', '') != "Makefile"):
continue
if func.__doc__:
doc = func.__doc__
# Perform escaping
doc = doc.\
replace("'", "\\'").\
replace(':', '\\:').\
replace('\\', '\\\\')
doc = " ".join([line.strip()
for line in doc.splitlines()
if line.strip()])
result += f" '{attr}:{doc}'"
else:
result += f" '{attr}'"
result += """
);
_describe 'command' subcmds
"""
print(" ".join((line.strip() for line in result.splitlines())))
else:
return False
return True
And here is the output of pymake --complete-zsh
(adding some newlines to make
this more readable):
→ pymake --complete-zsh
local -a subcmds;
subcmds=(
'build:Build package'
'checks:Run static checks on the code (flake8, isort)'
'clean:Clean up build directories'
'covtest:Run tests and produce coverge report'
'debugtest:Run tests without capturing their output. This makes using an interactive debugger possible'
'html:Run tests and open coverage report in browser'
'publish:Publish pacage to PyPI'
'test:Run tests'
'watchtest:Automatically run tests when a source file changes'
);
_describe 'command' subcmds
(A reminder here that this output will be different depending on which folder
we are running it from and the contents of the local Makefile.py
file)
So, after all this, we can get the following lovely auto-completion from pymake (if run at the same folder as PipePy’s source code):
→ pymake <TAB>
build -- Build package
checks -- Run static checks on the code (flake8, isort)
clean -- Clean up build directories
covtest -- Run tests and produce coverge report
debugtest -- Run tests without capturing their output. This makes using an interactive debugger possible
html -- Run tests and open coverage report in browser
publish -- Publish pacage to PyPI
test -- Run tests
watchtest -- Automatically run tests when a source file changes
Conclusion
Python works a lot better than bash or zsh scripts when you have to tackle
complex logic (this is the main reason behind the development of the PipePy
library in general), so it’s nice that we have an option to write the
auto-completion in Python. Plus, in this example, it was a requirement, since
in order to be able to provide suggestions, we have to import the
Makefile.py
module first.
The nice thing is that, despite all the underlying complexity, the only thing
the user has to be instructed to do, is to run
eval $(pymake --setup-bash-completion)
or
eval $(pymake --setup-zsh-completion)
or put it at the end of their .bashrc
or .zshrc
and they’re good to go!