diff --git a/CHANGELOG.md b/CHANGELOG.md index 626822323..b8be541a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ _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) - 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) -- Add `--with-dataset-types` option to `kart meta get` which is informative now that there is more than one type of dataset. [#649](https://github.com/koordinates/kart/pull/649) +- Add `--with-dataset-types` option to `kart meta get` which is informative now that there is more than one type of dataset. [#649](https://github.com/koordinates/kart/pull/649) ## 0.11.3 @@ -26,6 +27,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 378a718b2..bbe77133d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,20 +8,21 @@ attrs==21.4.0 # via jsonschema cached-property==1.5.2 # via pygit2 -certifi==2021.10.8 +certifi==2022.5.18.1 # via -r requirements/requirements.in cffi==1.15.0 # via # cryptography # pygit2 -click==7.1.2 +click==8.1.3 # via -r requirements/requirements.in cryptography==37.0.2 # via -r requirements/requirements.in greenlet==1.1.2 # via sqlalchemy -importlib-metadata==4.11.3 +importlib-metadata==4.11.4 # via + # click # jsonschema # sqlalchemy jsonschema==4.1.2 @@ -44,7 +45,9 @@ pyrsistent==0.18.1 # via jsonschema rtree==0.9.7 # via -r requirements/requirements.in -sqlalchemy==1.4.36 +shellingham==1.4.0 + # via -r requirements/requirements.in +sqlalchemy==1.4.37 # via -r requirements/requirements.in typing-extensions==4.2.0 # via importlib-metadata diff --git a/requirements/dev.txt b/requirements/dev.txt index a7305aa80..4b0cfdfc4 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -24,7 +24,7 @@ decorator==5.1.1 # ipython execnet==1.9.0 # via pytest-xdist -importlib-metadata==4.11.3 +importlib-metadata==4.11.4 # via # -c requirements/../requirements.txt # -c requirements/docs.txt @@ -76,27 +76,27 @@ pygments==2.12.0 # -c requirements/../requirements.txt # -c requirements/docs.txt # ipython -pyparsing==3.0.8 +pyparsing==3.0.9 # via # -c requirements/docs.txt # -c requirements/test.txt # packaging +pytest-forked==1.4.0 + # via pytest-xdist +pytest-xdist==2.5.0 + # via -r requirements/dev.in pytest==7.1.2 # via # -c requirements/test.txt # pytest-forked # pytest-xdist -pytest-forked==1.4.0 - # via pytest-xdist -pytest-xdist==2.5.0 - # via -r requirements/dev.in toml==0.10.2 # via ipdb tomli==2.0.1 # via # -c requirements/test.txt # pytest -traitlets==5.1.1 +traitlets==5.2.2.post1 # via # ipython # matplotlib-inline diff --git a/requirements/docs.txt b/requirements/docs.txt index 5a221f193..056fa5784 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -8,7 +8,7 @@ alabaster==0.7.12 # via sphinx babel==2.10.1 # via sphinx -certifi==2021.10.8 +certifi==2022.5.18.1 # via # -c requirements/../requirements.txt # requests @@ -26,7 +26,7 @@ idna==3.3 # via requests imagesize==1.3.0 # via sphinx -importlib-metadata==4.11.3 +importlib-metadata==4.11.4 # via # -c requirements/../requirements.txt # -c requirements/test.txt @@ -45,13 +45,13 @@ pygments==2.12.0 # via # -c requirements/../requirements.txt # sphinx -pyparsing==3.0.8 +pyparsing==3.0.9 # via # -c requirements/test.txt # packaging pytz==2022.1 # via babel -requests==2.27.1 +requests==2.28.0 # via sphinx six==1.16.0 # via @@ -63,7 +63,7 @@ sphinx-autobuild==2021.3.14 # via -r requirements/docs.in sphinx-rtd-theme==1.0.0 # via -r requirements/docs.in -sphinx==4.5.0 +sphinx==5.0.1 # via # sphinx-autobuild # sphinx-rtd-theme diff --git a/requirements/requirements.in b/requirements/requirements.in index 723c2d609..5afff5c46 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -1,5 +1,5 @@ certifi -Click~=7.0 +Click~=8.1 cryptography msgpack~=0.6.1 pymysql @@ -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 defcad0d0..07fd134e7 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -14,7 +14,7 @@ attrs==21.4.0 # pytest colorama==0.4.4 # via -r requirements/test.in -coverage[toml]==6.3.2 +coverage[toml]==6.4.1 # via pytest-cov fields==5.0.0 # via aspectlib @@ -22,7 +22,7 @@ gprof2dot==2021.2.21 # via pytest-profiling html5lib==1.1 # via -r requirements/test.in -importlib-metadata==4.11.3 +importlib-metadata==4.11.4 # via # -c requirements/../requirements.txt # pluggy @@ -39,7 +39,7 @@ py-cpuinfo==8.0.0 # via pytest-benchmark py==1.11.0 # via pytest -pyparsing==3.0.8 +pyparsing==3.0.9 # via packaging pytest-benchmark[aspect]==3.4.1 # via -r requirements/test.in @@ -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 new file mode 100644 index 000000000..2d990a379 --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,60 @@ +import os +from pathlib import Path + + +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