Skip to content

Commit

Permalink
Merge pull request #648 from amal-thundiyil/tab-build
Browse files Browse the repository at this point in the history
Add `--install-tab-completion` option for installing shell completion
  • Loading branch information
craigds authored Jul 4, 2022
2 parents 1a57970 + 80d5932 commit 97ef458
Show file tree
Hide file tree
Showing 10 changed files with 238 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 [email protected] || echo "The link step of `brew upgrade [email protected]` fails. This is okay."
brew install --force ccache pkg-config sqlite3 pandoc
brew install --force --cask Packages
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions kart/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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"""
Expand Down
157 changes: 157 additions & 0 deletions kart/completion.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements/licenses.ini
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ authorized_licenses:
mozilla public license 2.0 (mpl 2.0)
PSF
Python Software Foundation
ISC License (ISCL)

unauthorized_licenses:

Expand Down
1 change: 1 addition & 0 deletions requirements/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements/test.in
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ pytest-helpers-namespace
pytest-benchmark[aspect]
pytest-profiling
pytest-shard
pytest-mock
html5lib
3 changes: 3 additions & 0 deletions requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,6 +61,7 @@ pytest==7.1.2
# pytest-benchmark
# pytest-cov
# pytest-helpers-namespace
# pytest-mock
# pytest-profiling
# pytest-shard
# pytest-sugar
Expand Down
62 changes: 61 additions & 1 deletion tests/test_completion.py
Original file line number Diff line number Diff line change
@@ -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"])
Expand Down

0 comments on commit 97ef458

Please sign in to comment.