From 70c35982a4b72381026293caf3de5cbe414b971a Mon Sep 17 00:00:00 2001 From: Amal Thundiyil Date: Mon, 13 Jun 2022 19:39:09 +0530 Subject: [PATCH 1/3] ci(macos): update bash for shell completion See click's [shell completion docs](https://click.palletsprojects.com/en/8.1.x/shell-completion/#shell-completion). --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 683f96416..8787d368f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -549,6 +549,8 @@ jobs: brew uninstall --force php composer brew update-reset sudo installer -pkg .cache/pydist/${{ env.PY3_PKG }} -dumplog -target / + brew install bash + echo "/usr/local/bin" >> $GITHUB_PATH brew upgrade python@3.9 || echo "The link step of `brew upgrade python@3.9` fails. This is okay." brew install --force ccache pkg-config sqlite3 pandoc brew install --force --cask Packages From 65e92c4e92c0a5846c513d961fccf047e69ca40e Mon Sep 17 00:00:00 2001 From: Amal Thundiyil Date: Mon, 13 Jun 2022 20:56:39 +0530 Subject: [PATCH 2/3] ci(make): add "ISC License (ISCL)" license for shellingham `shellingham` is made use of in shell completion. The license they use is the ISC License [1]. [1] https://github.com/sarugaku/shellingham/blob/23cb5cbacaf86fd9e32368b1531e281607b32d13/LICENSE --- requirements/licenses.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/licenses.ini b/requirements/licenses.ini index 17e3361e9..64ec78090 100644 --- a/requirements/licenses.ini +++ b/requirements/licenses.ini @@ -9,6 +9,7 @@ authorized_licenses: mozilla public license 2.0 (mpl 2.0) PSF Python Software Foundation + ISC License (ISCL) unauthorized_licenses: From 80d593214d200665e81a432f2183536b41f9aeb2 Mon Sep 17 00:00:00 2001 From: Amal Thundiyil Date: Thu, 9 Jun 2022 21:19:29 +0530 Subject: [PATCH 3/3] Add `--install-tab-completion` option for installing shell completion The user gets four options to choose from bash, zsh, fish and auto (auto detects shell using `shellingham`). To activate shell completion run: kart config --install-completion auto Replace `auto` with desired shell eg: kart config --install-completion zsh Completion will take effect once you restart the terminal. --- CHANGELOG.md | 2 + kart/cli.py | 8 ++ kart/completion.py | 157 +++++++++++++++++++++++++++++++++++ requirements.txt | 2 + requirements/requirements.in | 1 + requirements/test.in | 1 + requirements/test.txt | 3 + tests/test_completion.py | 62 +++++++++++++- 8 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 kart/completion.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0151b0f88..4542c9ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ _When adding new entries to the changelog, please include issue/PR numbers where ## 0.11.4 (UNRELEASED) +- Add `--install-tab-completion` option for installing shell completion. [#643](https://github.com/koordinates/kart/issues/643) - Add tab completion for kart commands and user-specific data. [#643](https://github.com/koordinates/kart/issues/643) - Changed format of feature IDs in GeoJSON output to be more informative and consistent. [#135](https://github.com/koordinates/kart/issues/135) - Fixed primary key issues for shapefile import - now generates an `auto_pk` by default, but uses an existing field if specified (doesn't use the FID). [#646](https://github.com/koordinates/kart/pull/646) @@ -27,6 +28,7 @@ _When adding new entries to the changelog, please include issue/PR numbers where - Improve performance when creating a working copy in a spatially filtered repository (this was previously slower than in a non-filtered repository; now it is much faster) [#561](https://github.com/koordinates/kart/issues/561) - Added Sphinx [documentation](https://docs.kartproject.org/en/latest/), built from the `docs` directory. [#220](https://github.com/koordinates/kart/issues/220) + ## 0.11.0 ### Major changes diff --git a/kart/cli.py b/kart/cli.py index 44c300a56..630b9743f 100755 --- a/kart/cli.py +++ b/kart/cli.py @@ -17,6 +17,7 @@ from .cli_util import add_help_subcommand, call_and_exit_flag, tool_environment from .context import Context from .exec import execvp +from kart.completion import Shells, install_callback MODULE_COMMANDS = { "annotations.cli": {"build-annotations"}, @@ -280,6 +281,13 @@ def reflog(ctx, args): @cli.command(context_settings=dict(ignore_unknown_options=True)) @click.pass_context +@click.option( + "--install-tab-completion", + type=click.Choice([s.value for s in Shells] + ["auto"]), + callback=install_callback, + expose_value=False, + help="Install tab completion for the specific or current shell", +) @click.argument("args", nargs=-1, type=click.UNPROCESSED) def config(ctx, args): """Get and set repository or global options""" diff --git a/kart/completion.py b/kart/completion.py new file mode 100644 index 000000000..4152c1aaa --- /dev/null +++ b/kart/completion.py @@ -0,0 +1,157 @@ +import os +import re +import sys +from enum import Enum +from pathlib import Path +from typing import Optional, Tuple, Any, Optional + +import click +from click.shell_completion import _SOURCE_BASH, _SOURCE_ZSH, _SOURCE_FISH + +try: + import shellingham +except ImportError: + shellingham = None + + +class Shells(str, Enum): + bash = "bash" + zsh = "zsh" + fish = "fish" + + +_completion_scripts = { + "bash": _SOURCE_BASH, + "zsh": _SOURCE_ZSH, + "fish": _SOURCE_FISH, +} + +_invalid_ident_char_re = re.compile(r"[^a-zA-Z0-9_]") + + +def get_completion_script(*, prog_name: str, complete_var: str, shell: str) -> str: + cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_")) + script = _completion_scripts.get(shell) + if script is None: + click.echo(f"Shell {shell} not supported.", err=True) + sys.exit(1) + return ( + script + % dict( + complete_func="_{}_completion".format(cf_name), + complete_var=complete_var, + prog_name=prog_name, + ) + ).strip() + + +def install_helper( + prog_name, complete_var, shell, completion_path, rc_path, completion_init_lines +): + rc_path.parent.mkdir(parents=True, exist_ok=True) + rc_content = "" + if rc_path.is_file(): + rc_content = rc_path.read_text() + for line in completion_init_lines: + if line not in rc_content: + rc_content += f"\n{line}" + rc_content += "\n" + rc_path.write_text(rc_content) + # Install completion + completion_path.parent.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + completion_path.write_text(script_content) + return completion_path + + +def install_bash(*, prog_name: str, complete_var: str, shell: str) -> Path: + # Ref: https://github.com/scop/bash-completion#faq + # It seems bash-completion is the official completion system for bash: + # Ref: https://www.gnu.org/software/bash/manual/html_node/A-Programmable-Completion-Example.html + # But installing in the locations from the docs doesn't seem to have effect + completion_path = Path.home() / f".bash_completions/{prog_name}.sh" + bashrc_path = Path.home() / ".bashrc" + completion_init_lines = [f"source {completion_path}"] + return install_helper( + prog_name, + complete_var, + shell, + completion_path, + bashrc_path, + completion_init_lines, + ) + + +def install_zsh(*, prog_name: str, complete_var: str, shell: str) -> Path: + # Setup Zsh and load ~/.zfunc + completion_path = Path.home() / f".zfunc/_{prog_name}" + zshrc_path = Path.home() / ".zshrc" + completion_init_lines = [ + "autoload -Uz compinit", + "zstyle ':completion:*' menu select", + "fpath+=~/.zfunc; compinit", + ] + return install_helper( + prog_name, + complete_var, + shell, + completion_path, + zshrc_path, + completion_init_lines, + ) + + +def install_fish(*, prog_name: str, complete_var: str, shell: str) -> Path: + path_obj = Path.home() / f".config/fish/completions/{prog_name}.fish" + parent_dir: Path = path_obj.parent + parent_dir.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + path_obj.write_text(f"{script_content}\n") + return path_obj + + +def install( + shell: Optional[str] = None, + prog_name: Optional[str] = None, + complete_var: Optional[str] = None, +) -> Tuple[str, Path]: + prog_name = prog_name or click.get_current_context().find_root().info_name + assert prog_name + if complete_var is None: + complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) + if shell is None and shellingham is not None: + shell, _ = shellingham.detect_shell() + if shell == "bash": + installed_path = install_bash( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + elif shell == "zsh": + installed_path = install_zsh( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + elif shell == "fish": + installed_path = install_fish( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + else: + click.echo(f"Shell {shell} is not supported.") + raise click.exceptions.Exit(1) + + +def install_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any: + if not value or ctx.resilient_parsing: + return value + if value == "auto": + shell, path = install() + else: + shell, path = install(shell=value) + click.secho(f"{shell} completion installed in {path}", fg="green") + click.echo("Completion will take effect once you restart the terminal") + sys.exit(0) diff --git a/requirements.txt b/requirements.txt index c3c314425..bbe77133d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,6 +45,8 @@ pyrsistent==0.18.1 # via jsonschema rtree==0.9.7 # via -r requirements/requirements.in +shellingham==1.4.0 + # via -r requirements/requirements.in sqlalchemy==1.4.37 # via -r requirements/requirements.in typing-extensions==4.2.0 diff --git a/requirements/requirements.in b/requirements/requirements.in index 3744fdf97..5afff5c46 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -7,6 +7,7 @@ Pygments Rtree~=0.9.4 sqlalchemy pyodbc ; platform_system!="Linux" +shellingham # jsonschema>=4.2 pulls in importlib_resources, which breaks PyInstaller<4.8 # https://github.com/pyinstaller/pyinstaller/pull/6195 diff --git a/requirements/test.in b/requirements/test.in index 82078c77f..b5375cdbb 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -11,4 +11,5 @@ pytest-helpers-namespace pytest-benchmark[aspect] pytest-profiling pytest-shard +pytest-mock html5lib diff --git a/requirements/test.txt b/requirements/test.txt index 7dfd4e36c..07fd134e7 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -47,6 +47,8 @@ pytest-cov==3.0.0 # via -r requirements/test.in pytest-helpers-namespace==2021.12.29 # via -r requirements/test.in +pytest-mock==3.7.0 + # via -r requirements/test.in pytest-profiling==1.7.0 # via -r requirements/test.in pytest-shard==0.1.2 @@ -59,6 +61,7 @@ pytest==7.1.2 # pytest-benchmark # pytest-cov # pytest-helpers-namespace + # pytest-mock # pytest-profiling # pytest-shard # pytest-sugar diff --git a/tests/test_completion.py b/tests/test_completion.py index fb73ebd10..94d250384 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -1,11 +1,71 @@ -from kart.cli_util import OutputFormatType +import os +from pathlib import Path +from kart.cli_util import OutputFormatType from kart.completion_shared import conflict_completer, ref_completer, path_completer DIFF_OUTPUT_FORMATS = ["text", "geojson", "json", "json-lines", "quiet", "html"] SHOW_OUTPUT_FORMATS = DIFF_OUTPUT_FORMATS +def test_completion_install_no_shell(cli_runner): + r = cli_runner.invoke(["config", "--install-tab-completion"]) + assert "Error: Option '--install-tab-completion' requires an argument" in r.stderr + + +def test_completion_install_bash(cli_runner): + bash_completion_path: Path = Path.home() / ".bashrc" + text = "" + if bash_completion_path.is_file(): + text = bash_completion_path.read_text() + r = cli_runner.invoke(["config", "--install-tab-completion", "bash"]) + new_text = bash_completion_path.read_text() + bash_completion_path.write_text(text) + install_source = os.path.join(".bash_completions", "cli.sh") + assert install_source not in text + assert install_source in new_text + assert "completion installed in" in r.stdout + assert "Completion will take effect once you restart the terminal" in r.stdout + install_source_path = Path.home() / install_source + assert install_source_path.is_file() + install_content = install_source_path.read_text() + install_source_path.unlink() + assert "complete -o nosort -F _cli_completion cli" in install_content + + +def test_completion_install_zsh(cli_runner): + completion_path: Path = Path.home() / ".zshrc" + text = "" + if not completion_path.is_file(): + completion_path.write_text('echo "custom .zshrc"') + if completion_path.is_file(): + text = completion_path.read_text() + r = cli_runner.invoke(["config", "--install-tab-completion", "zsh"]) + new_text = completion_path.read_text() + completion_path.write_text(text) + zfunc_fragment = "fpath+=~/.zfunc" + assert zfunc_fragment in new_text + assert "completion installed in" in r.stdout + assert "Completion will take effect once you restart the terminal" in r.stdout + install_source_path = Path.home() / os.path.join(".zfunc", "_cli") + assert install_source_path.is_file() + install_content = install_source_path.read_text() + install_source_path.unlink() + assert "compdef _cli_completion cli" in install_content + + +def test_completion_install_fish(cli_runner): + completion_path: Path = Path.home() / os.path.join( + ".config", "fish", "completions", "cli.fish" + ) + r = cli_runner.invoke(["config", "--install-tab-completion", "fish"]) + new_text = completion_path.read_text() + completion_path.unlink() + assert "complete --no-files --command cli" in new_text + assert "completion installed in" in r.stdout + assert "Completion will take effect once you restart the terminal" in r.stdout + + def test_ref_completer(data_archive, cli_runner): with data_archive("points") as _: r = cli_runner.invoke(["checkout", "-b", "one"])