diff --git a/config/ruff.toml b/config/ruff.toml index 1db942e..0a1d2d4 100644 --- a/config/ruff.toml +++ b/config/ruff.toml @@ -53,6 +53,9 @@ ignore = [ "src/*/debug.py" = [ "T201", # Print statement ] +"src/*/completion.py" = [ + "T201", # Print statement +] "scripts/*.py" = [ "INP001", # File is part of an implicit namespace package "T201", # Print statement diff --git a/docs/usage.md b/docs/usage.md index fb96fb1..158663e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -877,12 +877,50 @@ duty task1 task2 ### Shell completions -You can enable auto-completion in Bash with these commands: - -```bash -completions_dir="${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion}/completions" -mkdir -p "${completions_dir}" -duty --completion > "${completions_dir}/duty" +Duty supports shell completions for Bash and Zsh, these can be automatically installed using: +```shell +duty --install-completion ``` +Completions can also be installed manually: + +=== "Bash" + ```bash + completions_dir="${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion}/completions" + mkdir -p "${completions_dir}" + duty --completion=bash > "${completions_dir}/duty" + ``` +=== "Zsh" + #### Using Zsh native completion + Since Zsh doesn't provide a default completion scripts directory, choosing it is up to user + [(read more)](https://github.com/zsh-users/zsh-completions/blob/master/zsh-completions-howto.org#telling-zsh-which-function-to-use-for-completing-a-command). + + You can use `~/.oh-my-zsh/custom/completions` if you use [Oh My Zsh](https://ohmyz.sh): + ```zsh + duty --completion=zsh > "$HOME/.oh-my-zsh/custom/completions/_duty" + ``` -Only Bash is supported for now. + If you don't use Oh My Zsh, you can install completions globally under `/usr/local/share/zsh/site-functions` + or use a custom directory, for example `~/.zfunc`. + To do this, make sure that the following get called in your `.zshrc` in this order: + ```zsh + fpath=($HOME/.zfunc $fpath) + autoload -Uz compinit && compinit + ``` + !!! Warning + Don't add `autoload -Uz compinit && compinit` when using Oh My Zsh. + + Then generate completion function and reload shell: + ```zsh + mkdir -p "$HOME/.zfunc" + duty --completion=zsh > "$HOME/.zfunc/_duty" + exec zsh + ``` + The completion script file must start with an underscore. + + #### Using Bash completion + It is recommended to use Zsh's native completion, as it is much richer. + If you decide to use Bash completion anyway, make sure that the following get called at the end of your `.zshrc`: + ```zsh + autoload -Uz bashcompinit && bashcompinit + ``` + Then reload your shell and follow instructions for Bash. diff --git a/mkdocs.yml b/mkdocs.yml index 308ae37..45bac2c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,7 @@ theme: - content.code.annotate - content.code.copy - content.tooltips + - content.tabs.link - navigation.footer - navigation.indexes - navigation.sections diff --git a/src/duty/cli.py b/src/duty/cli.py index 30ee554..c420164 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -15,15 +15,16 @@ import argparse import inspect +import os import sys import textwrap -from pathlib import Path -from typing import Any +from typing import Any, Literal from failprint.cli import ArgParser, add_flags from duty import debug from duty.collection import Collection, Duty +from duty.completion import Shell from duty.exceptions import DutyFailure from duty.validation import validate @@ -70,16 +71,29 @@ def get_parser() -> ArgParser: metavar="DUTY", help="Show this help message and exit. Pass duties names to print their help.", ) + parser.add_argument( + "--install-completion", + dest="install_completion", + nargs="?", + const=True, + metavar="SHELL", + help="Installs completion for the selected shell. If no value is provided, $SHELL is used.", + ) parser.add_argument( "--completion", dest="completion", - action="store_true", - help=argparse.SUPPRESS, + nargs="?", + const=True, + metavar="SHELL", + help="Prints completion script for the selected shell. If no value is provided, $SHELL is used.", ) parser.add_argument( "--complete", dest="complete", - action="store_true", + nargs="?", + # Default to bash for backwards compatibility with 1.5.0 (--complete used no parameters) + const="bash", + metavar="SHELL", help=argparse.SUPPRESS, ) parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug.get_version()}") @@ -256,6 +270,11 @@ def print_help(parser: ArgParser, opts: argparse.Namespace, collection: Collecti print(textwrap.indent(collection.format_help(), prefix=" ")) +def get_shell_name(arg: str | Literal[True]) -> str: + """Get shell name from passed arg, or try to guess based on `SHELL` environmental variable.""" + return os.path.basename(os.environ.get("SHELL", "/bin/bash")) if arg is True else arg.lower() + + def main(args: list[str] | None = None) -> int: """Run the main program. @@ -274,16 +293,26 @@ def main(args: list[str] | None = None) -> int: collection = Collection(opts.duties_file) collection.load() + if opts.install_completion: + shell = Shell.create(get_shell_name(opts.install_completion)) + shell.install_completion() + return 0 + if opts.completion: - print(Path(__file__).parent.joinpath("completions.bash").read_text()) + shell = Shell.create(get_shell_name(opts.completion)) + print(shell.completion_script_path.read_text()) return 0 if opts.complete: - words = collection.completion_candidates(remainder) - words += sorted( - opt for opt, action in parser._option_string_actions.items() if action.help != argparse.SUPPRESS + shell = Shell.create(get_shell_name(opts.complete)) + + candidates = collection.completion_candidates(remainder) + candidates += sorted( + (opt, action.help) + for opt, action in parser._option_string_actions.items() + if action.help != argparse.SUPPRESS ) - print(*words, sep="\n") + print(shell.parse_completion(candidates)) return 0 if opts.help is not None: diff --git a/src/duty/collection.py b/src/duty/collection.py index 5efa59b..9924158 100644 --- a/src/duty/collection.py +++ b/src/duty/collection.py @@ -6,10 +6,13 @@ import sys from copy import deepcopy from importlib import util as importlib_util -from typing import Any, Callable, ClassVar, Union +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Union from duty.context import Context +if TYPE_CHECKING: + from duty.completion import CompletionCandidateType + DutyListType = list[Union[str, Callable, "Duty"]] default_duties_file = "duties.py" @@ -143,11 +146,11 @@ def names(self) -> list[str]: """ return list(self.duties.keys()) + list(self.aliases.keys()) - def completion_candidates(self, args: tuple[str, ...]) -> list[str]: + def completion_candidates(self, args: tuple[str, ...]) -> list[CompletionCandidateType]: """Find shell completion candidates within this collection. Returns: - The list of shell completion candidates, sorted alphabetically. + The list of tuples containing shell completion candidates with help text, sorted alphabetically. """ # Find last duty name in args. name = None @@ -157,14 +160,16 @@ def completion_candidates(self, args: tuple[str, ...]) -> list[str]: name = arg break - completion_names = sorted(names) + completion_names: list[CompletionCandidateType] = sorted( + (name, self.get(name).description or None) for name in names + ) # If no duty found, return names. if name is None: return completion_names params = [ - f"{param.name}=" + (f"{param.name}=", None) for param in inspect.signature(self.get(name).function).parameters.values() if param.kind is not param.VAR_POSITIONAL ][1:] diff --git a/src/duty/completion.py b/src/duty/completion.py new file mode 100644 index 0000000..94213ee --- /dev/null +++ b/src/duty/completion.py @@ -0,0 +1,161 @@ +"""Shell completion utilities.""" + +from __future__ import annotations + +import abc +import os +import subprocess +import sys +from functools import cached_property +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, Final + +if TYPE_CHECKING: + from collections.abc import Sequence + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +CompletionCandidateType: TypeAlias = "tuple[str, str | None]" + + +class Shell(metaclass=abc.ABCMeta): + """ABC for shell completion utils, inherit from it to implement tab-completion for different shells.""" + + name: ClassVar[str] + implementations: Final[dict[str, type[Any]]] = {} + + @abc.abstractmethod + def parse_completion(self, candidates: Sequence[CompletionCandidateType]) -> str: + """Parses a list of completion candidates for shell's completion command. + + Parameters: + candidates: List of completion candidates with optional descriptions. + + Returns: + String to be passed to shell completion command. + """ + + @abc.abstractmethod + def install_completion(self) -> None: + """Installs shell completion.""" + + @cached_property + def completion_script_path(self) -> Path: + """Returns a path to the shell completion script file.""" + return Path(__file__).parent / f"completions.{self.name}" + + @cached_property + def install_dir(self) -> Path: + """Returns a path to the directory in which a shell completion script should be installed.""" + return Path.home() / ".duty" + + @classmethod + def create(cls, shell_type: str) -> Self: + """Creates an instance of Shell subclass, based on a shell name. + + Raises: + NotImplementedError: If shell type is not supported. + """ + try: + return cls.implementations[shell_type]() + except KeyError as exc: + msg = f"Completions for {shell_type!r} shell are not available, feature requests and PRs welcome!" + raise NotImplementedError(msg) from exc + + def __init_subclass__(cls) -> None: + cls.implementations[cls.name] = cls + + +class Bash(Shell): + """Completion utils for Bash.""" + + name = "bash" + + bash_completion_user_dir = os.environ.get("BASH_COMPLETION_USER_DIR") + xdg_data_home = os.environ.get("XDG_DATA_HOME") + + @cached_property + def install_dir(self) -> Path: # noqa: D102 + if self.bash_completion_user_dir: + directory = Path(self.bash_completion_user_dir) / "completions" + elif self.xdg_data_home: + directory = Path(self.xdg_data_home) / "bash-completion/completions" + else: + directory = Path.home() / ".local/share/bash-completion/completions" + if not directory.is_dir(): + msg = ( + f"Bash completions directory not found. Searched in: {str(directory)!r}, " + f"make sure you have bash-completion installed" + ) + raise OSError(msg) + return directory + + def parse_completion(self, candidates: Sequence[CompletionCandidateType]) -> str: # noqa: D102 + return "\n".join(completion for completion, _ in candidates) + + def install_completion(self) -> None: # noqa: D102 + symlink_path = self.install_dir / "duty" + try: + symlink_path.symlink_to(self.completion_script_path) + except FileExistsError: + print("Bash completions already installed.", file=sys.stderr) + else: + print( + f"Bash completions successfully symlinked to {str(symlink_path)!r}. " + f"Please reload Bash for changes to take effect.", + ) + + +class Zsh(Shell): + """Completion utils for Zsh.""" + + name = "zsh" + + site_functions_dirs = (Path("/usr/local/share/zsh/site-functions"), Path("/usr/share/zsh/site-functions")) + + @cached_property + def install_dir(self) -> Path: # noqa: D102 + try: + return next(d for d in self.site_functions_dirs if d.is_dir()) + except StopIteration as exc: + searched_in = ", ".join([repr(str(path)) for path in self.site_functions_dirs]) + msg = f"Zsh site-functions directory not found! Searched in: {searched_in}" + raise OSError(msg) from exc + + def parse_completion(self, candidates: Sequence[CompletionCandidateType]) -> str: # noqa: D102 + def parse_candidate(item: CompletionCandidateType) -> str: + completion, help_text = item + # We only have space for one line of description, + # so we remove descriptions of sub-command parameters from help_text + # by removing everything after the first newline. + return f"{completion}: {help_text or '-'}".split("\n", 1)[0] + + return "\n".join(parse_candidate(candidate) for candidate in candidates) + + def install_completion(self) -> None: # noqa: D102 + try: + symlink_path = self.install_dir / "_duty" + symlink_path.symlink_to(self.completion_script_path) + except PermissionError: + # retry as sudo + if os.geteuid() == 0: + raise + subprocess.run( # noqa: S603 + ["sudo", sys.executable, sys.argv[0], "--install-completion=zsh"], # noqa: S607 + check=True, + ) + except FileExistsError: + print("Zsh completions already installed.", file=sys.stderr) + else: + print( + f"Zsh completions successfully symlinked to {str(symlink_path)!r}. " + f"Please reload Zsh for changes to take effect.", + ) diff --git a/src/duty/completions.bash b/src/duty/completions.bash index 9dadeae..55fab6e 100644 --- a/src/duty/completions.bash +++ b/src/duty/completions.bash @@ -8,7 +8,7 @@ _complete_duty() { # COMP_WORDS contains the entire command string up til now (including # program name). # We hand it to Invoke so it can figure out the current context: # spit back core options, task names, the current task's options, or some combo. - candidates=$(duty --complete -- "${COMP_WORDS[@]}") + candidates=$(duty --complete=bash -- "${COMP_WORDS[@]}") # `compgen -W` takes list of valid options & a partial word & spits back possible matches. # Necessary for any partial word completions diff --git a/src/duty/completions.zsh b/src/duty/completions.zsh new file mode 100644 index 0000000..945c907 --- /dev/null +++ b/src/duty/completions.zsh @@ -0,0 +1,4 @@ +#compdef duty +local -a subcmds +IFS=$'\n' subcmds=( $(duty --complete=zsh -- "${words[@]}") ) +_describe 'duty' subcmds diff --git a/tests/test_collection.py b/tests/test_collection.py index b66c704..d0f226b 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -74,13 +74,15 @@ def test_completion_candidates() -> None: """Check whether proper completion candidates are returned from collections.""" collection = Collection() - collection.add(decorate(none, name="duty_1")) # type: ignore[call-overload] + duty_with_docs = decorate(none, name="duty_1") # type: ignore[call-overload] + duty_with_docs.description = "Some description" + collection.add(duty_with_docs) collection.add(decorate(none, name="duty_2", aliases=["alias_2"])) # type: ignore[call-overload] assert collection.completion_candidates(("duty",)) == [ - "alias_2", - "duty-1", - "duty-2", - "duty_1", - "duty_2", + ("alias_2", None), + ("duty-1", "Some description"), + ("duty-2", None), + ("duty_1", "Some description"), + ("duty_2", None), ]