Extending BASH completion with Python Click

Viewed 4765

I'm wanting to customize the BASH completion functionality of my Python Click CLI program to include not only the commands / subcommands of the script, but also objects that the script creates.

Let's say my program is called cli-tool, and it can create object foo using the command:

cli-tool object create foo

For simplicity, let's say the command simply concatenates the argument string (foo in this case) to a text file of the same name located in ~/.cli-tool/objects/foo. Doing cat ~/.cli-tool/objects/foo would then print foo in your terminal.

What I would like for the tool to do is when I then type:

cli-tool object get <TAB><TAB>

The terminal would then list foo and any other files that live inside ~/.cli-tool/objects.

For the record, I have read the Python Click 6.x stable documentation, which clearly states:

Currently, Bash completion is an internal feature that is not customizable. This might be relaxed in future versions.

What I was hoping is that there would be a way to extract the full BASH completion script from the following command:

eval "$(_CLI_TOOL_COMPLETE=source cli-tool)"

And then customize it myself. I've also seen the click-completion project, but I'm not sure what it does beyond extending the completion for Zsh and Fish shells.

Has anyone achieved the type of completion I mention above?

2 Answers

Using click-completion, this is quite straight forward.

Code:

Import and init() Click Completion:

import click
import click_completion

click_completion.init()

Then instantiate a click.Choice object:

option_type = click.Choice('obj1 obj2 obj3'.split())

In the case of your option directory, pass in a list of the appropriate items instead of the example obj1-obj3.

Then pass the option type to the click.argument() decorator like:

@click.argument('option', type=option_type)

And don't forget to activate your completion with your shell. The click variation for bash is here:

Test Code:

import click
import click_completion

click_completion.init()

option_type = click.Choice('obj1 obj2 obj3'.split())

@click.group()
def cli():
    """My Cool Tool"""

@cli.group(name='object')
def object_group():
    """Object subcommand"""

@object_group.command()
@click.argument('option', type=option_type)
def get(option):
    click.echo('option: {}'.format(option))


commands = (
    ('"" object get ""', 1),
    ('"" object get ""', 2),
    ('"" object get ""', 3),
    'object get obj1',
    '--help',
    'object --help',
    'object get --help',
)

os.environ['BASH_COMP'] = 'complete'

import sys, time
time.sleep(1)
print('Click Version: {}'.format(click.__version__))
print('Click Completion Version: {}'.format(click_completion.__version__))
print('Python Version: {}'.format(sys.version))
for cmd in commands:
    try:
        time.sleep(0.1)
        print('\n-----------')
        print('> ' + str(cmd))
        time.sleep(0.1)

        if len(cmd) == 2:
            os.environ['COMP_WORDS'] = cmd[0]
            os.environ['COMP_CWORD'] = str(cmd[1])
            cli(complete_var='BASH_COMP')
        else:
            try:
                del os.environ['COMP_WORDS']
                del os.environ['COMP_CWORD']
            except:
                pass
            cli(cmd.split())


    except BaseException as exc:
        if str(exc) != '0' and \
                not isinstance(exc, (click.ClickException, SystemExit)):
            raise

Results:

Click Version: 6.7
Click Completion Version: 0.4.1
Python Version: 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)]

-----------
> ('"" object get ""', 1)
object
-----------
> ('"" object get ""', 2)
get
-----------
> ('"" object get ""', 3)
obj1    obj2    obj3
-----------
> object get obj1
option: obj1

-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...

  My Cool Tool

Options:
  --help  Show this message and exit.

Commands:
  object  Object subcommand

-----------
> object --help
Usage: test.py object [OPTIONS] COMMAND [ARGS]...

  Object subcommand

Options:
  --help  Show this message and exit.

Commands:
  get

-----------
> object get --help
Usage: test.py object get [OPTIONS] OPTION

Options:
  --help  Show this message and exit.

For Click 8.1.3, you can use either of Overriding Value Completion or Custom Type Completion.

Here's an example from the docs where you can use a custom shell_complete function. It must return a list of CompletionItem objects, or as a shortcut it can return a list of strings.

def complete_env_vars(ctx, param, incomplete):
    return [k for k in os.environ if k.startswith(incomplete)]

@click.command()
@click.argument("name", shell_complete=complete_env_vars)
def cli(name):
    click.echo(f"Name: {name}")
    click.echo(f"Value: {os.environ[name]}")

Related