Skip to content

Commit

Permalink
Merge pull request #693 from NatLibFi/issue684-cli-command-completions
Browse files Browse the repository at this point in the history
Support for CLI command completions
  • Loading branch information
juhoinkinen authored Apr 25, 2023
2 parents d0c6d02 + 35bc222 commit 3451bd3
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 10 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ COPY tests /Annif/tests
RUN poetry install -E "$optional_dependencies"

WORKDIR /annif-projects
RUN annif completion --bash >> /etc/bash.bashrc # Enable tab completion

# Switch user to non-root:
RUN groupadd -g 998 annif_user && \
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ Start up the application:
See [Getting Started](https://github.com/NatLibFi/Annif/wiki/Getting-started)
in the wiki for more details.

## Shell compeletions
Annif supports tab-key completion in bash, zsh and fish shells for commands and options
and project id, vocabulary id and path parameters.

To enable the completion support in your current terminal session use `annif completion`
command with the option according to your shell to produce the completion script and
source it. For example, run

source <(annif completion --bash)

To enable the completion support in all new sessions first add the completion script in
your home directory:

annif completion --bash > ~/.annif-complete.bash

Then make the script to be automatically sourced for new terminal sessions by adding the
following to your `~/.bashrc` file (or in some [alternative startup
file](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html)):

source ~/.annif-complete.bash

For details and usage for other shells see
[Click documentation](https://click.palletsprojects.com/en/8.1.x/shell-completion/).
# Docker install

You can use Annif as a pre-built Docker container. Please see the
Expand Down
39 changes: 29 additions & 10 deletions annif/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@


import collections
import importlib
import json
import os.path
import re
Expand Down Expand Up @@ -56,7 +57,7 @@ def run_list_projects():


@cli.command("show-project")
@click.argument("project_id")
@cli_util.project_id
@cli_util.common_options
def run_show_project(project_id):
"""
Expand All @@ -75,7 +76,7 @@ def run_show_project(project_id):


@cli.command("clear")
@click.argument("project_id")
@cli_util.project_id
@cli_util.common_options
def run_clear_project(project_id):
"""
Expand Down Expand Up @@ -110,7 +111,7 @@ def run_list_vocabs():


@cli.command("load-vocab")
@click.argument("vocab_id")
@click.argument("vocab_id", shell_complete=cli_util.complete_param)
@click.argument("subjectfile", type=click.Path(exists=True, dir_okay=False))
@click.option("--language", "-L", help="Language of subject file")
@click.option(
Expand Down Expand Up @@ -148,7 +149,7 @@ def run_load_vocab(vocab_id, language, force, subjectfile):


@cli.command("train")
@click.argument("project_id")
@cli_util.project_id
@click.argument("paths", type=click.Path(exists=True), nargs=-1)
@click.option(
"--cached/--no-cached",
Expand Down Expand Up @@ -192,7 +193,7 @@ def run_train(project_id, paths, cached, docs_limit, jobs, backend_param):


@cli.command("learn")
@click.argument("project_id")
@cli_util.project_id
@click.argument("paths", type=click.Path(exists=True), nargs=-1)
@cli_util.docs_limit_option
@cli_util.backend_param_option
Expand All @@ -214,7 +215,7 @@ def run_learn(project_id, paths, docs_limit, backend_param):


@cli.command("suggest")
@click.argument("project_id")
@cli_util.project_id
@click.argument(
"paths", type=click.Path(dir_okay=False, exists=True, allow_dash=True), nargs=-1
)
Expand Down Expand Up @@ -258,7 +259,7 @@ def run_suggest(


@cli.command("index")
@click.argument("project_id")
@cli_util.project_id
@click.argument("directory", type=click.Path(exists=True, file_okay=False))
@click.option(
"--suffix", "-s", default=".annif", help="File name suffix for result files"
Expand Down Expand Up @@ -305,7 +306,7 @@ def run_index(


@cli.command("eval")
@click.argument("project_id")
@cli_util.project_id
@click.argument("paths", type=click.Path(exists=True), nargs=-1)
@click.option("--limit", "-l", default=10, help="Maximum number of subjects")
@click.option("--threshold", "-t", default=0.0, help="Minimum score threshold")
Expand Down Expand Up @@ -416,7 +417,7 @@ def run_eval(


@cli.command("optimize")
@click.argument("project_id")
@cli_util.project_id
@click.argument("paths", type=click.Path(exists=True), nargs=-1)
@click.option(
"--jobs", "-j", default=1, help="Number of parallel jobs (0 means all CPUs)"
Expand Down Expand Up @@ -514,7 +515,7 @@ def run_optimize(project_id, paths, jobs, docs_limit, backend_param):


@cli.command("hyperopt")
@click.argument("project_id")
@cli_util.project_id
@click.argument("paths", type=click.Path(exists=True), nargs=-1)
@click.option("--trials", "-T", default=10, help="Number of trials")
@click.option(
Expand Down Expand Up @@ -551,5 +552,23 @@ def run_hyperopt(project_id, paths, docs_limit, trials, jobs, metric, results_fi
click.echo("---")


@cli.command("completion")
@click.option("--bash", "shell", flag_value="bash")
@click.option("--zsh", "shell", flag_value="zsh")
@click.option("--fish", "shell", flag_value="fish")
def completion(shell):
"""Generate the script for tab-key autocompletion for the given shell. To enable the
completion support in your current bash terminal session run\n
source <(annif completion --bash)
"""

if shell is None:
raise click.UsageError("Shell not given, try --bash, --zsh or --fish")

script = os.popen(f"_ANNIF_COMPLETE={shell}_source annif").read()
click.echo(f"# Generated by Annif {importlib.metadata.version('annif')}")
click.echo(script)


if __name__ == "__main__":
cli()
23 changes: 23 additions & 0 deletions annif/cli_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ def common_options(f):
return click_log.simple_verbosity_option(logger)(f)


def project_id(f):
"""Decorator to add a project ID parameter to a CLI command"""
return click.argument("project_id", shell_complete=complete_param)(f)


def backend_param_option(f):
"""Decorator to add an option for CLI commands to override BE parameters"""
return click.option(
Expand Down Expand Up @@ -170,3 +175,21 @@ def generate_filter_params(filter_batch_max_limit):
limits = range(1, filter_batch_max_limit + 1)
thresholds = [i * 0.05 for i in range(20)]
return list(itertools.product(limits, thresholds))


def _get_completion_choices(param):
if param.name == "project_id":
return annif.registry.get_projects()
elif param.name == "vocab_id":
return annif.registry.get_vocabs()
else:
return []


def complete_param(ctx, param, incomplete):
with ctx.obj.load_app().app_context():
return [
choice
for choice in _get_completion_choices(param)
if choice.startswith(incomplete)
]
5 changes: 5 additions & 0 deletions docs/source/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ setting a (non-default) path to a `project configuration file
<https://github.com/NatLibFi/Annif/wiki/Project-configuration>`_ and
``--verbosity`` for selecting logging level.

Annif supports tab-key completion in bash, zsh and fish shells for commands and options
and project id, vocabulary id and path parameters. See `README.md
<https://github.com/NatLibFi/Annif#shell-completions>`_ for instructions on how to
enable the support.

.. contents::
:local:
:backlinks: none
Expand Down
75 changes: 75 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import random
import re
import shutil
from unittest import mock

from click.shell_completion import ShellComplete
from click.testing import CliRunner

import annif.cli
Expand Down Expand Up @@ -1005,3 +1007,76 @@ def test_version_option():
assert result.exit_code == 0
version = importlib.metadata.version("annif")
assert result.output.strip() == version.strip()


def test_completion_script_generation():
result = runner.invoke(annif.cli.cli, ["completion", "--bash"])
assert not result.exception
assert result.exit_code == 0
assert "# Generated by Annif " in result.output


def test_completion_script_generation_shell_not_given():
failed_result = runner.invoke(annif.cli.cli, ["completion"])
assert failed_result.exception
assert failed_result.exit_code != 0
assert "Shell not given" in failed_result.output


def get_completions(cli, args, incomplete):
completer = ShellComplete(cli, {}, cli.name, "_ANNIF_COMPLETE")
completions = completer.get_completions(args, incomplete)
return [c.value for c in completions]


def test_completion_list_commands():
completions = get_completions(annif.cli.cli, [""], "list")
assert completions == ["list-projects", "list-vocabs"]


def test_completion_version_option():
completions = get_completions(annif.cli.cli, [""], "--ver")
assert completions == ["--version"]


@mock.patch.dict(os.environ, {"ANNIF_CONFIG": "annif.default_config.TestingConfig"})
def test_completion_show_project_project_ids_all():
completions = get_completions(annif.cli.cli, ["show-project"], "")
assert completions == [
"dummy-fi",
"dummy-en",
"dummy-private",
"dummy-vocablang",
"dummy-transform",
"limit-transform",
"ensemble",
"noanalyzer",
"novocab",
"nobackend",
"noname",
"noparams-tfidf-fi",
"noparams-fasttext-fi",
"pav",
"tfidf-fi",
"tfidf-en",
"fasttext-en",
"fasttext-fi",
]


@mock.patch.dict(os.environ, {"ANNIF_CONFIG": "annif.default_config.TestingConfig"})
def test_completion_show_project_project_ids_dummy():
completions = get_completions(annif.cli.cli, ["show-project"], "dummy")
assert completions == [
"dummy-fi",
"dummy-en",
"dummy-private",
"dummy-vocablang",
"dummy-transform",
]


@mock.patch.dict(os.environ, {"ANNIF_CONFIG": "annif.default_config.TestingConfig"})
def test_completion_load_vocab_vocab_ids_all():
completions = get_completions(annif.cli.cli, ["load-vocab"], "")
assert completions == ["dummy", "dummy-noname", "yso"]

0 comments on commit 3451bd3

Please sign in to comment.