From ef0e7373a7baeb5bd9cd56c97e1679f6e094e161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Tue, 4 Feb 2025 21:20:57 +0100 Subject: [PATCH 01/19] WIP: zsh completion --- docs/usage.md | 10 +++++++++- src/duty/cli.py | 7 ++++++- src/duty/completions.zsh | 6 ++++++ 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/duty/completions.zsh diff --git a/docs/usage.md b/docs/usage.md index fb96fb1..283694d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -885,4 +885,12 @@ mkdir -p "${completions_dir}" duty --completion > "${completions_dir}/duty" ``` -Only Bash is supported for now. +Or in Zsh with: + +```zsh +completions_dir="$HOME/.duty" +mkdir -p "${completions_dir}" +echo "source ${completions_dir}/completion" >> $HOME/.zshrc +duty --completion > "${completions_dir}/completion" +exec zsh +``` diff --git a/src/duty/cli.py b/src/duty/cli.py index 30ee554..381fcdb 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -16,6 +16,7 @@ import argparse import inspect import sys +import os import textwrap from pathlib import Path from typing import Any @@ -275,7 +276,11 @@ def main(args: list[str] | None = None) -> int: collection.load() if opts.completion: - print(Path(__file__).parent.joinpath("completions.bash").read_text()) + shell = os.path.basename(os.environ.get('SHELL')) + if shell == 'zsh': + print(Path(__file__).parent.joinpath("completions.zsh").read_text()) + else: + print(Path(__file__).parent.joinpath("completions.bash").read_text()) return 0 if opts.complete: diff --git a/src/duty/completions.zsh b/src/duty/completions.zsh new file mode 100644 index 0000000..3bef616 --- /dev/null +++ b/src/duty/completions.zsh @@ -0,0 +1,6 @@ +# Based on pyinvoke implementation of zsh completion +_complete_duty() { + reply=( $(duty --complete -- ${words}) ) +} + +compctl -K _complete_duty + -f duty From 57499a5b0c1a3f9e6799a6fac6dd6702dcd46c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Tue, 4 Feb 2025 21:43:37 +0100 Subject: [PATCH 02/19] Option to set shell with --completion argument --- docs/usage.md | 4 ++-- src/duty/cli.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 283694d..2378a4f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -882,7 +882,7 @@ 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 --completion=bash > "${completions_dir}/duty" ``` Or in Zsh with: @@ -891,6 +891,6 @@ Or in Zsh with: completions_dir="$HOME/.duty" mkdir -p "${completions_dir}" echo "source ${completions_dir}/completion" >> $HOME/.zshrc -duty --completion > "${completions_dir}/completion" +duty --completion=zsh > "${completions_dir}/completion" exec zsh ``` diff --git a/src/duty/cli.py b/src/duty/cli.py index 381fcdb..adbdb56 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -74,8 +74,10 @@ def get_parser() -> ArgParser: parser.add_argument( "--completion", dest="completion", - action="store_true", - help=argparse.SUPPRESS, + nargs="?", + const=True, + metavar="SHELL", + help="Prints completion script for selected shell. If no value is provided, $SHELL is used.", ) parser.add_argument( "--complete", @@ -276,7 +278,11 @@ def main(args: list[str] | None = None) -> int: collection.load() if opts.completion: - shell = os.path.basename(os.environ.get('SHELL')) + if opts.completion is True: + shell = os.path.basename(os.environ.get('SHELL')) + else: + shell = opts.completion.lower() + if shell == 'zsh': print(Path(__file__).parent.joinpath("completions.zsh").read_text()) else: From 65d67378f8645fbd974ea313c613a338d7417e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20=C5=9Al=C4=85zak?= Date: Tue, 4 Feb 2025 22:01:23 +0100 Subject: [PATCH 03/19] Shell completions - don't use a fallback shell as this can be misleading. --- src/duty/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/duty/cli.py b/src/duty/cli.py index adbdb56..d5c1390 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -285,8 +285,11 @@ def main(args: list[str] | None = None) -> int: if shell == 'zsh': print(Path(__file__).parent.joinpath("completions.zsh").read_text()) - else: + elif shell == 'bash': print(Path(__file__).parent.joinpath("completions.bash").read_text()) + else: + raise NotImplementedError(f"Completion is only supported on Bash and Zsh, got '{shell}'.") + return 0 if opts.complete: From 682e301b8f630804fae5c20d917a4d8345130335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:39:27 +0100 Subject: [PATCH 04/19] Update src/duty/cli.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't require changes when adding support for completion in other shells. Co-authored-by: Bartosz Sławecki --- src/duty/cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/duty/cli.py b/src/duty/cli.py index d5c1390..e8da87b 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -283,12 +283,12 @@ def main(args: list[str] | None = None) -> int: else: shell = opts.completion.lower() - if shell == 'zsh': - print(Path(__file__).parent.joinpath("completions.zsh").read_text()) - elif shell == 'bash': - print(Path(__file__).parent.joinpath("completions.bash").read_text()) - else: - raise NotImplementedError(f"Completion is only supported on Bash and Zsh, got '{shell}'.") + COMPLETION_DIR = Path(__file__).parent + try: + print((completion_dir / f"completions.{shell}").read_text()) + except FileNotFoundError as exc: + msg = f"Completion script not found for {shell!r}, looked in {COMPLETION_DIR}" + raise NotImplementedError(msg) from exc return 0 From 152040259ba68317798a6a03b270c712e51e885f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Wed, 5 Feb 2025 18:26:23 +0100 Subject: [PATCH 05/19] Remove an unnecessary COMPLETION_DIR, change completion error message. --- src/duty/cli.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/duty/cli.py b/src/duty/cli.py index e8da87b..077c525 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -283,11 +283,10 @@ def main(args: list[str] | None = None) -> int: else: shell = opts.completion.lower() - COMPLETION_DIR = Path(__file__).parent try: - print((completion_dir / f"completions.{shell}").read_text()) + print((Path(__file__).parent / f"completions.{shell}").read_text()) except FileNotFoundError as exc: - msg = f"Completion script not found for {shell!r}, looked in {COMPLETION_DIR}" + msg = f"Completions for {shell!r} shell are not available, feature requests and PRs welcome!" raise NotImplementedError(msg) from exc return 0 From 338de7c38cb5c086080d28431e2116e7a4bfe8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Wed, 5 Feb 2025 23:32:05 +0100 Subject: [PATCH 06/19] WIP: Use compdef for richer completions and improve docs --- docs/usage.md | 55 +++++++++++++++++++++++++++++----------- mkdocs.yml | 1 + src/duty/cli.py | 6 ++--- src/duty/completions.zsh | 10 +++----- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 2378a4f..3619081 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -877,20 +877,45 @@ 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=bash > "${completions_dir}/duty" -``` +=== "Bash" + 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=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" + ``` -Or in Zsh with: + 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 `~/.duty`. + To do this, make sure that the following get called in your `.zshrc` in this order: + ```zsh + fpath=($HOME/.duty $fpath) + autoload -Uz compinit && compinit + ``` + !!! Warning + Don't add `autoload -Uz compinit && compinit` when using Oh My Zsh. + + Then generate completion function and restart shell: + ```zsh + mkdir -p "$HOME/.duty" + duty --completion=zsh > "$HOME/.duty/_duty" + exec zsh + ``` + The completion script file must start with an underscore. -```zsh -completions_dir="$HOME/.duty" -mkdir -p "${completions_dir}" -echo "source ${completions_dir}/completion" >> $HOME/.zshrc -duty --completion=zsh > "${completions_dir}/completion" -exec zsh -``` + #### 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 restart 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 077c525..170e62e 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -15,8 +15,8 @@ import argparse import inspect -import sys import os +import sys import textwrap from pathlib import Path from typing import Any @@ -279,7 +279,7 @@ def main(args: list[str] | None = None) -> int: if opts.completion: if opts.completion is True: - shell = os.path.basename(os.environ.get('SHELL')) + shell = os.path.basename(os.environ.get("SHELL", "/bin/bash")) else: shell = opts.completion.lower() @@ -296,7 +296,7 @@ def main(args: list[str] | None = None) -> int: words += sorted( opt for opt, action in parser._option_string_actions.items() if action.help != argparse.SUPPRESS ) - print(*words, sep="\n") + print(*words, sep=" ") return 0 if opts.help is not None: diff --git a/src/duty/completions.zsh b/src/duty/completions.zsh index 3bef616..951713a 100644 --- a/src/duty/completions.zsh +++ b/src/duty/completions.zsh @@ -1,6 +1,4 @@ -# Based on pyinvoke implementation of zsh completion -_complete_duty() { - reply=( $(duty --complete -- ${words}) ) -} - -compctl -K _complete_duty + -f duty +#compdef duty +local -a subcmds +subcmds=( $(duty --complete -- "${words[@]}") ) +_describe 'duty' subcmds From 592f938b8cae108e122c6b8b9e2a601d38a0bf9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Mon, 10 Feb 2025 09:40:53 +0100 Subject: [PATCH 07/19] WIP: Zsh completions with descriptions --- src/duty/_completion.py | 41 +++++++++++++++++++++++++++++++++++++++ src/duty/cli.py | 15 +++++++++----- src/duty/collection.py | 14 +++++++++---- src/duty/completions.bash | 2 +- src/duty/completions.zsh | 2 +- tests/test_collection.py | 14 +++++++------ 6 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 src/duty/_completion.py diff --git a/src/duty/_completion.py b/src/duty/_completion.py new file mode 100644 index 0000000..291d7a8 --- /dev/null +++ b/src/duty/_completion.py @@ -0,0 +1,41 @@ +from typing import Optional + +CompletionCandidateType = tuple[str, Optional[str]] + + +class CompletionParser: + @classmethod + def parse(cls, candidates: list[CompletionCandidateType], shell: str) -> str: + """Parses a list of completion candidates for selected shell completion command. + + Parameters: + candidates: List of completion candidates with optional descriptions. + shell: Shell for which to parse the candidates. + + Raises: + NotImplementedError: When parser is not implemented for selected shell. + + Returns: + String to be passed to shell completion command. + """ + try: + return getattr(cls, f"_{shell}")(candidates) + except AttributeError as exc: + msg = f"CompletionParser method for {shell!r} shell is not implemented!" + raise NotImplementedError(msg) from exc + + @staticmethod + def _zsh(candidates: list[CompletionCandidateType]) -> str: + def parse_candidate(item: CompletionCandidateType) -> str: + completion, help_text = item + # We only have space for one line of description, + # so I remove descriptions of sub-command parameters from help_text + # by removing everything after the first newline. + # I don't think it is the best approach and should be discussed. + return f"{completion}: {help_text or '-'}".split("\n", 1)[0] + + return "\n".join(parse_candidate(candidate) for candidate in candidates) + + @staticmethod + def _bash(candidates: list[CompletionCandidateType]) -> str: + return "\n".join(completion for completion, _ in candidates) diff --git a/src/duty/cli.py b/src/duty/cli.py index 170e62e..83cfa38 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -24,6 +24,7 @@ from failprint.cli import ArgParser, add_flags from duty import debug +from duty._completion import CompletionParser from duty.collection import Collection, Duty from duty.exceptions import DutyFailure from duty.validation import validate @@ -82,7 +83,8 @@ def get_parser() -> ArgParser: parser.add_argument( "--complete", dest="complete", - action="store_true", + nargs="?", + metavar="SHELL", help=argparse.SUPPRESS, ) parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug.get_version()}") @@ -292,11 +294,14 @@ def main(args: list[str] | None = None) -> int: 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 + 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=" ") + # Default to bash for backwards compatibility with 1.5.0 (--complete used no parameters) + print(CompletionParser.parse(candidates, opts.complete or "bash")) return 0 if opts.help is not None: diff --git a/src/duty/collection.py b/src/duty/collection.py index 5efa59b..5a081e5 100644 --- a/src/duty/collection.py +++ b/src/duty/collection.py @@ -4,12 +4,16 @@ import inspect import sys +import typing from copy import deepcopy from importlib import util as importlib_util from typing import Any, Callable, ClassVar, Union from duty.context import Context +if typing.TYPE_CHECKING: + from duty._completion import CompletionCandidateType + DutyListType = list[Union[str, Callable, "Duty"]] default_duties_file = "duties.py" @@ -143,11 +147,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 +161,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/completions.bash b/src/duty/completions.bash index d8e1430..f216862 100644 --- a/src/duty/completions.bash +++ b/src/duty/completions.bash @@ -8,7 +8,7 @@ _complete_duty() { # 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 (vs diff --git a/src/duty/completions.zsh b/src/duty/completions.zsh index 951713a..945c907 100644 --- a/src/duty/completions.zsh +++ b/src/duty/completions.zsh @@ -1,4 +1,4 @@ #compdef duty local -a subcmds -subcmds=( $(duty --complete -- "${words[@]}") ) +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), ] From 6b08ffe6304d23973a6d104d7344d728eb438852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Mon, 10 Feb 2025 20:33:39 +0100 Subject: [PATCH 08/19] WIP: --install-completion flag --- config/ruff.toml | 3 + docs/usage.md | 19 +++--- src/duty/_completion.py | 41 ------------- src/duty/cli.py | 40 ++++++++----- src/duty/collection.py | 2 +- src/duty/completion.py | 125 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 64 deletions(-) delete mode 100644 src/duty/_completion.py create mode 100644 src/duty/completion.py 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 3619081..158663e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -877,8 +877,13 @@ duty task1 task2 ### Shell completions +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" - 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}" @@ -895,19 +900,19 @@ duty task1 task2 ``` 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 `~/.duty`. + 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/.duty $fpath) + 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 restart shell: + Then generate completion function and reload shell: ```zsh - mkdir -p "$HOME/.duty" - duty --completion=zsh > "$HOME/.duty/_duty" + mkdir -p "$HOME/.zfunc" + duty --completion=zsh > "$HOME/.zfunc/_duty" exec zsh ``` The completion script file must start with an underscore. @@ -918,4 +923,4 @@ duty task1 task2 ```zsh autoload -Uz bashcompinit && bashcompinit ``` - Then restart your shell and follow instructions for Bash. + Then reload your shell and follow instructions for Bash. diff --git a/src/duty/_completion.py b/src/duty/_completion.py deleted file mode 100644 index 291d7a8..0000000 --- a/src/duty/_completion.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Optional - -CompletionCandidateType = tuple[str, Optional[str]] - - -class CompletionParser: - @classmethod - def parse(cls, candidates: list[CompletionCandidateType], shell: str) -> str: - """Parses a list of completion candidates for selected shell completion command. - - Parameters: - candidates: List of completion candidates with optional descriptions. - shell: Shell for which to parse the candidates. - - Raises: - NotImplementedError: When parser is not implemented for selected shell. - - Returns: - String to be passed to shell completion command. - """ - try: - return getattr(cls, f"_{shell}")(candidates) - except AttributeError as exc: - msg = f"CompletionParser method for {shell!r} shell is not implemented!" - raise NotImplementedError(msg) from exc - - @staticmethod - def _zsh(candidates: list[CompletionCandidateType]) -> str: - def parse_candidate(item: CompletionCandidateType) -> str: - completion, help_text = item - # We only have space for one line of description, - # so I remove descriptions of sub-command parameters from help_text - # by removing everything after the first newline. - # I don't think it is the best approach and should be discussed. - return f"{completion}: {help_text or '-'}".split("\n", 1)[0] - - return "\n".join(parse_candidate(candidate) for candidate in candidates) - - @staticmethod - def _bash(candidates: list[CompletionCandidateType]) -> str: - return "\n".join(completion for completion, _ in candidates) diff --git a/src/duty/cli.py b/src/duty/cli.py index 83cfa38..dd7cd40 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -18,14 +18,13 @@ 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._completion import CompletionParser from duty.collection import Collection, Duty +from duty.completion import CompletionInstaller, CompletionParser from duty.exceptions import DutyFailure from duty.validation import validate @@ -72,13 +71,21 @@ 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", nargs="?", const=True, metavar="SHELL", - help="Prints completion script for selected shell. If no value is provided, $SHELL is used.", + help="Prints completion script for the selected shell. If no value is provided, $SHELL is used.", ) parser.add_argument( "--complete", @@ -261,6 +268,13 @@ def print_help(parser: ArgParser, opts: argparse.Namespace, collection: Collecti print(textwrap.indent(collection.format_help(), prefix=" ")) +def get_shell(arg: str | Literal[True]) -> str: + """Get shell from passed arg, or try to guess based on `SHELL` environmental variable.""" + if arg is True: + return os.path.basename(os.environ.get("SHELL", "/bin/bash")) + return arg.lower() + + def main(args: list[str] | None = None) -> int: """Run the main program. @@ -279,18 +293,14 @@ def main(args: list[str] | None = None) -> int: collection = Collection(opts.duties_file) collection.load() - if opts.completion: - if opts.completion is True: - shell = os.path.basename(os.environ.get("SHELL", "/bin/bash")) - else: - shell = opts.completion.lower() - - try: - print((Path(__file__).parent / f"completions.{shell}").read_text()) - except FileNotFoundError as exc: - msg = f"Completions for {shell!r} shell are not available, feature requests and PRs welcome!" - raise NotImplementedError(msg) from exc + if opts.install_completion: + shell = get_shell(opts.install_completion) + CompletionInstaller.install(shell) + return 0 + if opts.completion: + shell = get_shell(opts.completion) + print(CompletionInstaller.get_completion_script_path(shell).read_text()) return 0 if opts.complete: diff --git a/src/duty/collection.py b/src/duty/collection.py index 5a081e5..c238993 100644 --- a/src/duty/collection.py +++ b/src/duty/collection.py @@ -12,7 +12,7 @@ from duty.context import Context if typing.TYPE_CHECKING: - from duty._completion import CompletionCandidateType + from duty.completion import CompletionCandidateType DutyListType = list[Union[str, Callable, "Duty"]] default_duties_file = "duties.py" diff --git a/src/duty/completion.py b/src/duty/completion.py new file mode 100644 index 0000000..f19d997 --- /dev/null +++ b/src/duty/completion.py @@ -0,0 +1,125 @@ +"""Shell completion utilities.""" + +import os +import subprocess +import sys +from pathlib import Path +from typing import Optional + +CompletionCandidateType = tuple[str, Optional[str]] + + +class CompletionParser: + """Shell completion parser.""" + + @classmethod + def parse(cls, candidates: list[CompletionCandidateType], shell: str) -> str: + """Parses a list of completion candidates for the selected shell's completion command. + + Parameters: + candidates: List of completion candidates with optional descriptions. + shell: Shell for which to parse the candidates. + + Raises: + NotImplementedError: When parser is not implemented for selected shell. + + Returns: + String to be passed to shell completion command. + """ + try: + return getattr(cls, f"_{shell}")(candidates) + except AttributeError as exc: + msg = f"Completion parser method for {shell!r} shell is not implemented!" + raise NotImplementedError(msg) from exc + + @staticmethod + def _zsh(candidates: list[CompletionCandidateType]) -> str: + 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) + + @staticmethod + def _bash(candidates: list[CompletionCandidateType]) -> str: + return "\n".join(completion for completion, _ in candidates) + + +class CompletionInstaller: + """Shell completion installer.""" + + @classmethod + def install(cls, shell: str) -> None: + """Installs shell completions for selected shell. + + Raises: + NotImplementedError: When installer is not implemented for selected shell. + """ + try: + return getattr(cls, f"_{shell}")() + except AttributeError as exc: + msg = f"Completion installer method for {shell!r} shell is not implemented!" + raise NotImplementedError(msg) from exc + + @staticmethod + def get_completion_script_path(shell: str) -> Path: + """Gets the path of a shell completion script for the selected shell.""" + completions_file_path = Path(__file__).parent / f"completions.{shell}" + if not completions_file_path.exists(): + msg = f"Completions for {shell!r} shell are not available, feature requests and PRs welcome!" + raise NotImplementedError(msg) + return completions_file_path + + @classmethod + def _zsh(cls) -> None: + site_functions_dirs = (Path("/usr/local/share/zsh/site-functions"), Path("/usr/share/zsh/site-functions")) + try: + completions_dir = next(d for d in site_functions_dirs if d.is_dir()) + except StopIteration as exc: + raise OSError("Zsh site-functions directory not found!") from exc + + try: + symlink_path = completions_dir / "_duty" + symlink_path.symlink_to(cls.get_completion_script_path("zsh")) + 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.") + else: + print( + f"Zsh completions successfully symlinked to {symlink_path}. " + f"Please reload Zsh for changes to take effect.", + ) + + @classmethod + def _bash(cls) -> None: + bash_completion_user_dir = os.environ.get("BASH_COMPLETION_USER_DIR") + xdg_data_home = os.environ.get("XDG_DATA_HOME") + + if bash_completion_user_dir: + completion_dir = Path(bash_completion_user_dir) / "completions" + elif xdg_data_home: + completion_dir = Path(xdg_data_home) / "bash-completion/completions" + else: + completion_dir = Path.home() / ".local/share/bash-completion/completions" + + completion_dir.mkdir(parents=True, exist_ok=True) + symlink_path = completion_dir / "duty" + try: + symlink_path.symlink_to(cls.get_completion_script_path("bash")) + except FileExistsError: + print("Bash completions already installed.") + else: + print( + f"Bash completions successfully symlinked to {symlink_path!r}. " + f"Please reload Bash for changes to take effect.", + ) From e3931a0b479829e95ffe714438a3cbe43a61c0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:59:14 +0100 Subject: [PATCH 09/19] Update src/duty/completion.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartosz Sławecki --- src/duty/completion.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/duty/completion.py b/src/duty/completion.py index f19d997..135507d 100644 --- a/src/duty/completion.py +++ b/src/duty/completion.py @@ -4,9 +4,13 @@ import subprocess import sys from pathlib import Path -from typing import Optional -CompletionCandidateType = tuple[str, Optional[str]] +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +CompletionCandidateType: TypeAlias = "tuple[str, str | None]" class CompletionParser: From 1db233cd55cd7546d2b4a57aab4bee16ed433212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Thu, 13 Feb 2025 16:32:09 +0100 Subject: [PATCH 10/19] Improve shell completions implementation --- src/duty/cli.py | 25 +++--- src/duty/collection.py | 5 +- src/duty/completion.py | 177 +++++++++++++++++++++++------------------ 3 files changed, 116 insertions(+), 91 deletions(-) diff --git a/src/duty/cli.py b/src/duty/cli.py index dd7cd40..c420164 100644 --- a/src/duty/cli.py +++ b/src/duty/cli.py @@ -24,7 +24,7 @@ from duty import debug from duty.collection import Collection, Duty -from duty.completion import CompletionInstaller, CompletionParser +from duty.completion import Shell from duty.exceptions import DutyFailure from duty.validation import validate @@ -91,6 +91,8 @@ def get_parser() -> ArgParser: "--complete", dest="complete", nargs="?", + # Default to bash for backwards compatibility with 1.5.0 (--complete used no parameters) + const="bash", metavar="SHELL", help=argparse.SUPPRESS, ) @@ -268,11 +270,9 @@ def print_help(parser: ArgParser, opts: argparse.Namespace, collection: Collecti print(textwrap.indent(collection.format_help(), prefix=" ")) -def get_shell(arg: str | Literal[True]) -> str: - """Get shell from passed arg, or try to guess based on `SHELL` environmental variable.""" - if arg is True: - return os.path.basename(os.environ.get("SHELL", "/bin/bash")) - return arg.lower() +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: @@ -294,24 +294,25 @@ def main(args: list[str] | None = None) -> int: collection.load() if opts.install_completion: - shell = get_shell(opts.install_completion) - CompletionInstaller.install(shell) + shell = Shell.create(get_shell_name(opts.install_completion)) + shell.install_completion() return 0 if opts.completion: - shell = get_shell(opts.completion) - print(CompletionInstaller.get_completion_script_path(shell).read_text()) + shell = Shell.create(get_shell_name(opts.completion)) + print(shell.completion_script_path.read_text()) return 0 if opts.complete: + 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 ) - # Default to bash for backwards compatibility with 1.5.0 (--complete used no parameters) - print(CompletionParser.parse(candidates, opts.complete or "bash")) + 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 c238993..9924158 100644 --- a/src/duty/collection.py +++ b/src/duty/collection.py @@ -4,14 +4,13 @@ import inspect import sys -import typing 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 typing.TYPE_CHECKING: +if TYPE_CHECKING: from duty.completion import CompletionCandidateType DutyListType = list[Union[str, Callable, "Duty"]] diff --git a/src/duty/completion.py b/src/duty/completion.py index 135507d..2925dbf 100644 --- a/src/duty/completion.py +++ b/src/duty/completion.py @@ -1,93 +1,142 @@ """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 CompletionParser: - """Shell completion parser.""" +class Shell(metaclass=abc.ABCMeta): + """ABC for shell completion utils, inherit from it to implement tab-completion for different shells.""" - @classmethod - def parse(cls, candidates: list[CompletionCandidateType], shell: str) -> str: - """Parses a list of completion candidates for the selected shell's completion command. + 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. - shell: Shell for which to parse the candidates. - - Raises: - NotImplementedError: When parser is not implemented for selected shell. Returns: String to be passed to shell completion command. """ - try: - return getattr(cls, f"_{shell}")(candidates) - except AttributeError as exc: - msg = f"Completion parser method for {shell!r} shell is not implemented!" - raise NotImplementedError(msg) from exc - @staticmethod - def _zsh(candidates: list[CompletionCandidateType]) -> str: - 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) - - @staticmethod - def _bash(candidates: list[CompletionCandidateType]) -> str: - return "\n".join(completion for completion, _ in candidates) + @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}" -class CompletionInstaller: - """Shell completion installer.""" + @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 install(cls, shell: str) -> None: - """Installs shell completions for selected shell. + def create(cls, shell_type: str) -> Self: + """Creates an instance of Shell subclass, based on a shell name. Raises: - NotImplementedError: When installer is not implemented for selected shell. + NotImplementedError: If shell type is not supported. """ try: - return getattr(cls, f"_{shell}")() - except AttributeError as exc: - msg = f"Completion installer method for {shell!r} shell is not implemented!" + 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 - @staticmethod - def get_completion_script_path(shell: str) -> Path: - """Gets the path of a shell completion script for the selected shell.""" - completions_file_path = Path(__file__).parent / f"completions.{shell}" - if not completions_file_path.exists(): - msg = f"Completions for {shell!r} shell are not available, feature requests and PRs welcome!" - raise NotImplementedError(msg) - return completions_file_path + def __init_subclass__(cls) -> None: + cls.implementations[cls.name] = cls - @classmethod - def _zsh(cls) -> None: - site_functions_dirs = (Path("/usr/local/share/zsh/site-functions"), Path("/usr/share/zsh/site-functions")) + +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: + return Path(self.bash_completion_user_dir) / "completions" + if self.xdg_data_home: + return Path(self.xdg_data_home) / "bash-completion/completions" + return Path.home() / ".local/share/bash-completion/completions" + + 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 + self.install_dir.mkdir(parents=True, exist_ok=True) + symlink_path = self.install_dir / "duty" + print(self.completion_script_path) + print(symlink_path) try: - completions_dir = next(d for d in site_functions_dirs if d.is_dir()) + 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 {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: raise OSError("Zsh site-functions directory not found!") 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 = completions_dir / "_duty" - symlink_path.symlink_to(cls.get_completion_script_path("zsh")) + symlink_path = self.install_dir / "_duty" + symlink_path.symlink_to(self.completion_script_path) except PermissionError: # retry as sudo if os.geteuid() == 0: @@ -97,33 +146,9 @@ def _zsh(cls) -> None: check=True, ) except FileExistsError: - print("Zsh completions already installed.") + print("Zsh completions already installed.", file=sys.stderr) else: print( f"Zsh completions successfully symlinked to {symlink_path}. " f"Please reload Zsh for changes to take effect.", ) - - @classmethod - def _bash(cls) -> None: - bash_completion_user_dir = os.environ.get("BASH_COMPLETION_USER_DIR") - xdg_data_home = os.environ.get("XDG_DATA_HOME") - - if bash_completion_user_dir: - completion_dir = Path(bash_completion_user_dir) / "completions" - elif xdg_data_home: - completion_dir = Path(xdg_data_home) / "bash-completion/completions" - else: - completion_dir = Path.home() / ".local/share/bash-completion/completions" - - completion_dir.mkdir(parents=True, exist_ok=True) - symlink_path = completion_dir / "duty" - try: - symlink_path.symlink_to(cls.get_completion_script_path("bash")) - except FileExistsError: - print("Bash completions already installed.") - else: - print( - f"Bash completions successfully symlinked to {symlink_path!r}. " - f"Please reload Bash for changes to take effect.", - ) From a0e7dbd106b2464a58af492dbf53146d5f46ae56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Fri, 14 Feb 2025 13:23:14 +0100 Subject: [PATCH 11/19] Remove leftover prints --- src/duty/completion.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/duty/completion.py b/src/duty/completion.py index 2925dbf..8d47616 100644 --- a/src/duty/completion.py +++ b/src/duty/completion.py @@ -96,8 +96,6 @@ def parse_completion(self, candidates: Sequence[CompletionCandidateType]) -> str def install_completion(self) -> None: # noqa: D102 self.install_dir.mkdir(parents=True, exist_ok=True) symlink_path = self.install_dir / "duty" - print(self.completion_script_path) - print(symlink_path) try: symlink_path.symlink_to(self.completion_script_path) except FileExistsError: From 59b20e1952d3edb03866fb93525e7674082917b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Fri, 14 Feb 2025 13:46:57 +0100 Subject: [PATCH 12/19] Fail if no bash-completion directory; Improve error messages --- src/duty/completion.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/duty/completion.py b/src/duty/completion.py index 8d47616..c9de4ee 100644 --- a/src/duty/completion.py +++ b/src/duty/completion.py @@ -85,16 +85,21 @@ class Bash(Shell): @cached_property def install_dir(self) -> Path: # noqa: D102 if self.bash_completion_user_dir: - return Path(self.bash_completion_user_dir) / "completions" - if self.xdg_data_home: - return Path(self.xdg_data_home) / "bash-completion/completions" - return Path.home() / ".local/share/bash-completion/completions" + 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 - self.install_dir.mkdir(parents=True, exist_ok=True) symlink_path = self.install_dir / "duty" try: symlink_path.symlink_to(self.completion_script_path) @@ -102,7 +107,7 @@ def install_completion(self) -> None: # noqa: D102 print("Bash completions already installed.", file=sys.stderr) else: print( - f"Bash completions successfully symlinked to {symlink_path!r}. " + f"Bash completions successfully symlinked to {str(symlink_path)!r}. " f"Please reload Bash for changes to take effect.", ) @@ -119,7 +124,9 @@ 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: - raise OSError("Zsh site-functions directory not found!") from 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: @@ -147,6 +154,6 @@ def install_completion(self) -> None: # noqa: D102 print("Zsh completions already installed.", file=sys.stderr) else: print( - f"Zsh completions successfully symlinked to {symlink_path}. " + f"Zsh completions successfully symlinked to {str(symlink_path)!r}. " f"Please reload Zsh for changes to take effect.", ) From f04950baf6eb4428af63fefa2a96a9a421aa7bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Fri, 14 Feb 2025 21:39:22 +0100 Subject: [PATCH 13/19] Fix quotes --- src/duty/completion.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/duty/completion.py b/src/duty/completion.py index c9de4ee..94213ee 100644 --- a/src/duty/completion.py +++ b/src/duty/completion.py @@ -85,14 +85,16 @@ class Bash(Shell): @cached_property def install_dir(self) -> Path: # noqa: D102 if self.bash_completion_user_dir: - directory = Path(self.bash_completion_user_dir) / "completions" + 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') + 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 @@ -124,7 +126,7 @@ 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]) + 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 From 4179b0750bb7c0172da8cedd32aeaa65818ce8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Sat, 15 Feb 2025 16:06:44 +0100 Subject: [PATCH 14/19] Rewrite symlinks instead of informing the user that completions are already installed --- src/duty/completion.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/duty/completion.py b/src/duty/completion.py index 94213ee..56e0d59 100644 --- a/src/duty/completion.py +++ b/src/duty/completion.py @@ -103,15 +103,13 @@ def parse_completion(self, candidates: Sequence[CompletionCandidateType]) -> str 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.", - ) + if symlink_path.is_symlink(): + symlink_path.unlink() + symlink_path.symlink_to(self.completion_script_path) + print( + f"Bash completions successfully symlinked to {str(symlink_path)!r}. " + f"Please reload Bash for changes to take effect.", + ) class Zsh(Shell): @@ -143,6 +141,8 @@ def parse_candidate(item: CompletionCandidateType) -> str: def install_completion(self) -> None: # noqa: D102 try: symlink_path = self.install_dir / "_duty" + if symlink_path.is_symlink(): + symlink_path.unlink() symlink_path.symlink_to(self.completion_script_path) except PermissionError: # retry as sudo @@ -152,8 +152,6 @@ def install_completion(self) -> None: # noqa: D102 ["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}. " From 2cb10e195d288a281c7df3de32ccd98c7e62716d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Sat, 15 Feb 2025 16:21:35 +0100 Subject: [PATCH 15/19] Don't single-quote symlink_path --- src/duty/completion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/duty/completion.py b/src/duty/completion.py index 56e0d59..9e9e630 100644 --- a/src/duty/completion.py +++ b/src/duty/completion.py @@ -107,7 +107,7 @@ def install_completion(self) -> None: # noqa: D102 symlink_path.unlink() symlink_path.symlink_to(self.completion_script_path) print( - f"Bash completions successfully symlinked to {str(symlink_path)!r}. " + f"Bash completions successfully symlinked to {symlink_path}. " f"Please reload Bash for changes to take effect.", ) @@ -154,6 +154,6 @@ def install_completion(self) -> None: # noqa: D102 ) else: print( - f"Zsh completions successfully symlinked to {str(symlink_path)!r}. " + f"Zsh completions successfully symlinked to {symlink_path}. " f"Please reload Zsh for changes to take effect.", ) From 71a8626073323c74e0f4a22fc2ce697a048ce99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Sat, 15 Feb 2025 17:35:24 +0100 Subject: [PATCH 16/19] Minor improvements --- src/duty/completion.py | 42 +++++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/duty/completion.py b/src/duty/completion.py index 9e9e630..57ef248 100644 --- a/src/duty/completion.py +++ b/src/duty/completion.py @@ -53,9 +53,9 @@ def completion_script_path(self) -> Path: 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" + def install_path(self) -> Path: + """Returns a path that should be symlinked to the shell completion script.""" + return Path.home() / ".duty/completion" @classmethod def create(cls, shell_type: str) -> Self: @@ -83,31 +83,29 @@ class Bash(Shell): xdg_data_home = os.environ.get("XDG_DATA_HOME") @cached_property - def install_dir(self) -> Path: # noqa: D102 + def install_path(self) -> Path: # noqa: D102 if self.bash_completion_user_dir: - directory = Path(self.bash_completion_user_dir) / "completions" + bash_completion_directory = Path(self.bash_completion_user_dir) elif self.xdg_data_home: - directory = Path(self.xdg_data_home) / "bash-completion/completions" + bash_completion_directory = Path(self.xdg_data_home) / "bash-completion" else: - directory = Path.home() / ".local/share/bash-completion/completions" - if not directory.is_dir(): + bash_completion_directory = Path.home() / ".local/share/bash-completion" + if not bash_completion_directory.is_dir(): msg = ( - f"Bash completions directory not found. Searched in: {str(directory)!r}, " + f"Bash completion directory not found. Searched in: {bash_completion_directory}, " f"make sure you have bash-completion installed" ) raise OSError(msg) - return directory + return bash_completion_directory / "completions/duty" 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" - if symlink_path.is_symlink(): - symlink_path.unlink() - symlink_path.symlink_to(self.completion_script_path) + self.install_path.unlink(missing_ok=True) + self.install_path.symlink_to(self.completion_script_path) print( - f"Bash completions successfully symlinked to {symlink_path}. " + f"Bash completions successfully symlinked to {self.install_path}. " f"Please reload Bash for changes to take effect.", ) @@ -120,11 +118,11 @@ class Zsh(Shell): 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 + def install_path(self) -> Path: # noqa: D102 try: - return next(d for d in self.site_functions_dirs if d.is_dir()) + return next(d for d in self.site_functions_dirs if d.is_dir()) / "_duty" except StopIteration as exc: - searched_in = ", ".join([repr(str(path)) for path in self.site_functions_dirs]) + searched_in = ", ".join(map(str, self.site_functions_dirs)) msg = f"Zsh site-functions directory not found! Searched in: {searched_in}" raise OSError(msg) from exc @@ -140,10 +138,8 @@ def parse_candidate(item: CompletionCandidateType) -> str: def install_completion(self) -> None: # noqa: D102 try: - symlink_path = self.install_dir / "_duty" - if symlink_path.is_symlink(): - symlink_path.unlink() - symlink_path.symlink_to(self.completion_script_path) + self.install_path.unlink(missing_ok=True) + self.install_path.symlink_to(self.completion_script_path) except PermissionError: # retry as sudo if os.geteuid() == 0: @@ -154,6 +150,6 @@ def install_completion(self) -> None: # noqa: D102 ) else: print( - f"Zsh completions successfully symlinked to {symlink_path}. " + f"Zsh completions successfully symlinked to {self.install_path}. " f"Please reload Zsh for changes to take effect.", ) From 6daed95b498cf430a4bce428bf7f15ce3483a7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Sun, 16 Feb 2025 15:57:05 +0100 Subject: [PATCH 17/19] WIP: Tests - bash --- .github/workflows/ci.yml | 86 ++++++++++++++++++ Makefile | 1 + config/pytest.ini | 2 + duties.py | 24 ++++- tests/test_completion/__init__.py | 1 + tests/test_completion/test_bash.py | 137 +++++++++++++++++++++++++++++ tests/test_completion/test_zsh.py | 1 + tests/test_completion/utils.py | 35 ++++++++ 8 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 tests/test_completion/__init__.py create mode 100644 tests/test_completion/test_bash.py create mode 100644 tests/test_completion/test_zsh.py create mode 100644 tests/test_completion/utils.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de566ed..d6d7803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,92 @@ jobs: - name: Check for breaking changes in the API run: make check-api + collect-isolated-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + + - name: Install dependencies + run: make setup + + - name: Collect tests + run: make collect-isolated-tests + + isolated-tests: + + needs: collect-isolated-tests + + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + python-version: + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + resolution: + - highest + - lowest-direct + pytest-nodeid: ${{ fromJSON(needs.collect-isolated-tests.outputs.isolated_tests) }} + exclude: + - os: macos-latest + resolution: lowest-direct + - os: windows-latest + resolution: lowest-direct + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.python-version == '3.14' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: pyproject.toml + cache-suffix: py${{ matrix.python-version }} + + - name: Install dependencies + env: + UV_RESOLUTION: ${{ matrix.resolution }} + run: make setup + + - name: Run the test suite + env: + _DUTY_ISOLATED_TEST_CONTAINER: true + run: duty test ${{ matrix.pytest-nodeid }} parallel=False + tests: strategy: diff --git a/Makefile b/Makefile index 5e88121..c94e352 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ actions = \ check-quality \ check-types \ clean \ + collect-isolated-tests \ coverage \ docs \ docs-deploy \ diff --git a/config/pytest.ini b/config/pytest.ini index 052a2f1..483b1c1 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -6,6 +6,8 @@ addopts = --cov-config config/coverage.ini testpaths = tests +markers = + isolate: marks test to be run in isolated container # action:message_regex:warning_class:module_regex:line filterwarnings = diff --git a/duties.py b/duties.py index c0928cf..be0079c 100644 --- a/duties.py +++ b/duties.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import sys from contextlib import contextmanager @@ -185,7 +186,7 @@ def coverage(ctx: Context) -> None: @duty -def test(ctx: Context, *cli_args: str, match: str = "") -> None: +def test(ctx: Context, *cli_args: str, match: str = "", parallel: bool = True) -> None: """Run the test suite. Parameters: @@ -193,12 +194,31 @@ def test(ctx: Context, *cli_args: str, match: str = "") -> None: """ py_version = f"{sys.version_info.major}{sys.version_info.minor}" os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" + xdist_args = ["-n", "auto"] if parallel else [] ctx.run( tools.pytest( "tests", config_file="config/pytest.ini", select=match, color="yes", - ).add_args("-n", "auto", *cli_args), + ).add_args(*xdist_args, *cli_args), title=pyprefix("Running tests"), ) + + +@duty +def collect_isolated_tests(ctx: Context) -> None: + """Collect tests marked with `isolate` tag.""" + output = ctx.run( + tools.pytest( + "tests", + config_file="config/pytest.ini", + quiet=True, + collect_only=True, + select_markers="isolate", + ), + ) + + isolated_tests_json = json.dumps([line for line in output.split("\n") if "::" in line]) + with open(os.environ["GITHUB_OUTPUT"], "a") as gh_output: + gh_output.write(f"isolated_tests={isolated_tests_json}") diff --git a/tests/test_completion/__init__.py b/tests/test_completion/__init__.py new file mode 100644 index 0000000..e4feb29 --- /dev/null +++ b/tests/test_completion/__init__.py @@ -0,0 +1 @@ +"""Shell completion tests.""" diff --git a/tests/test_completion/test_bash.py b/tests/test_completion/test_bash.py new file mode 100644 index 0000000..c5a1aaa --- /dev/null +++ b/tests/test_completion/test_bash.py @@ -0,0 +1,137 @@ +"""Bash completion tests.""" + +import os +import subprocess +from pathlib import Path + +import pytest + +from duty import cli +from duty.completion import Bash +from tests.test_completion.utils import needs_isolated_container, needs_platform, needs_shell + +completion_test_cases = ( + ("tests/fixtures/basic.py", "", "hello\n--"), + ("tests/fixtures/multiple.py", "", "first-duty\nfirst_duty\nsecond-duty\nsecond_duty\n--"), + ("tests/fixtures/code.py", "exit", "exit-with\nexit_with\n--"), + ("tests/fixtures/code.py", "exit-with", "exit-with\nexit_with\ncode=\n--"), +) +parametrize_completions = pytest.mark.parametrize( + ("duties_file", "partial", "expected"), + completion_test_cases, + ids=range(len(completion_test_cases)), +) + + +def assert_completion_loaded() -> None: + """Asserts that bash has completions for duty.""" + result = subprocess.run(["/bin/bash", "-c", "complete -p duty"], capture_output=True, check=True, encoding="utf-8") # noqa: S603 + assert "complete -o default -F _complete_duty duty" in result.stdout + + +@needs_shell(Bash) +@needs_platform("linux", "darwin") +@needs_isolated_container +@pytest.mark.isolate +def test_install(capsys: pytest.CaptureFixture) -> None: + """Test bash completion installation.""" + assert cli.main(["--install-completion", "bash"]) == 0 + captured = capsys.readouterr() + assert "Bash completions successfully symlinked" in captured.out + assert_completion_loaded() + + +@needs_shell(Bash) +@needs_platform("linux", "darwin") +@needs_isolated_container +@pytest.mark.isolate +def test_install_no_param(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: + """Test bash completion installation with no shell parameter provided.""" + monkeypatch.setenv("SHELL", "/bin/bash") + assert cli.main(["--install-completion"]) == 0 + captured = capsys.readouterr() + assert "Bash completions successfully symlinked" in captured.out + assert_completion_loaded() + + +@needs_shell(Bash) +@needs_platform("linux", "darwin") +@needs_isolated_container +@pytest.mark.isolate +def test_install_path_exists(capsys: pytest.CaptureFixture) -> None: + """Test bash completion installation with symlink/file already present.""" + Bash().install_path.touch() + assert cli.main(["--install-completion", "bash"]) == 0 + captured = capsys.readouterr() + assert "Bash completions successfully symlinked" in captured.out + assert_completion_loaded() + + +def test_completion(capsys: pytest.CaptureFixture) -> None: + """Test printing out bash completion script.""" + assert cli.main(["--completion", "bash"]) == 0 + captured = capsys.readouterr() + assert "complete -F _complete_duty -o default duty" in captured.out + + +def test_completion_no_param(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: + """Test printing out bash completion script with no shell parameter provided.""" + monkeypatch.setenv("SHELL", "/bin/bash") + assert cli.main(["--completion"]) == 0 + captured = capsys.readouterr() + assert "complete -F _complete_duty" in captured.out + + +@parametrize_completions +def test_complete(duties_file: str, partial: str, expected: str, capsys: pytest.CaptureFixture) -> None: + """Test bash completion.""" + assert cli.main(["-d", duties_file, "--complete", "bash", "--", "duty", partial]) == 0 + captured = capsys.readouterr() + assert expected in captured.out + + +@parametrize_completions +def test_complete_no_param( + duties_file: str, + partial: str, + expected: str, + capsys: pytest.CaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test bash completion with no shell parameter provided. + + `--complete` with no shell parameter should always default to bash for backward compatibility with 1.5.0 version. + """ + monkeypatch.setenv("SHELL", "anything") + assert cli.main(["-d", duties_file, "--complete", "--", "duty", partial]) == 0 + captured = capsys.readouterr() + assert expected in captured.out + + +@parametrize_completions +@needs_shell(Bash) +@needs_platform("linux", "darwin") +@needs_isolated_container +@pytest.mark.isolate +def test_completion_function(duties_file: str, partial: str, expected: str) -> None: + """Test bash `_complete_duty` function.""" + # TODO: Temporary hack, as for now completions don't respect the `-d` flag - to be fixed in another PR. + duties_path = Path() / "duties.py" + duties_path.unlink(missing_ok=True) + duties_path.symlink_to(duties_file) + + commands = ( + f"source {Bash().completion_script_path}", + "_complete_duty", + 'echo "${COMPREPLY[@]}"', + ) + comp_words = f"duty {partial}" + result = subprocess.run( # noqa: S603 + ["/bin/bash", "-c", " && ".join(commands)], + capture_output=True, + check=True, + encoding="utf-8", + env={**os.environ, "COMP_WORDS": comp_words}, + ) + # In this test case, output is a bash array, so we expect spaces instead of newlines. + assert expected.replace("\n", " ") in result.stdout diff --git a/tests/test_completion/test_zsh.py b/tests/test_completion/test_zsh.py new file mode 100644 index 0000000..e51da5c --- /dev/null +++ b/tests/test_completion/test_zsh.py @@ -0,0 +1 @@ +"""Zsh completion tests.""" diff --git a/tests/test_completion/utils.py b/tests/test_completion/utils.py new file mode 100644 index 0000000..ed8a00f --- /dev/null +++ b/tests/test_completion/utils.py @@ -0,0 +1,35 @@ +"""Shell completion testing utilities.""" + +from __future__ import annotations + +import os +import sys +from typing import TYPE_CHECKING, Literal + +import pytest + +if TYPE_CHECKING: + from duty.completion import Shell + + +def needs_platform(*platforms: Literal["linux", "darwin", "win32"]) -> pytest.MarkDecorator: + """Skip test if the current platform doesn't match one of `platforms`.""" + return pytest.mark.skipif( + not sys.platform not in platforms, + reason=f"Test requires one of these platforms: {', '.join(platforms)}", + ) + + +def needs_shell(shell: type[Shell]) -> pytest.MarkDecorator: + """Skip test if the current shell doesn't match `shell`.""" + shell_environ = os.environ.get("SHELL") + return pytest.mark.skipif( + not (shell_environ and os.path.basename(shell_environ) == shell.name), + reason=f"Test requires {shell.name} shell and SHELL environment variable set", + ) + + +needs_isolated_container = pytest.mark.skipif( + not os.getenv("_DUTY_ISOLATED_TEST_CONTAINER"), + reason="Test requires to be run in an isolated container", +) From 57f101d6d1edd1334d0d305db0ca87bfc63afd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Sun, 25 May 2025 22:17:16 +0200 Subject: [PATCH 18/19] WIP: Tests --- .github/workflows/ci.yml | 86 ------------------------ Makefile | 1 - tests/test_completion/test_bash.py | 20 +++--- tests/test_completion/test_zsh.py | 104 +++++++++++++++++++++++++++++ tests/test_completion/utils.py | 6 -- 5 files changed, 115 insertions(+), 102 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6d7803..de566ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,92 +55,6 @@ jobs: - name: Check for breaking changes in the API run: make check-api - collect-isolated-tests: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Setup uv - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - cache-dependency-glob: pyproject.toml - - - name: Install dependencies - run: make setup - - - name: Collect tests - run: make collect-isolated-tests - - isolated-tests: - - needs: collect-isolated-tests - - strategy: - matrix: - os: - - ubuntu-latest - - macos-latest - - windows-latest - python-version: - - "3.9" - - "3.10" - - "3.11" - - "3.12" - - "3.13" - - "3.14" - resolution: - - highest - - lowest-direct - pytest-nodeid: ${{ fromJSON(needs.collect-isolated-tests.outputs.isolated_tests) }} - exclude: - - os: macos-latest - resolution: lowest-direct - - os: windows-latest - resolution: lowest-direct - runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.python-version == '3.14' }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - - - name: Setup uv - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - cache-dependency-glob: pyproject.toml - cache-suffix: py${{ matrix.python-version }} - - - name: Install dependencies - env: - UV_RESOLUTION: ${{ matrix.resolution }} - run: make setup - - - name: Run the test suite - env: - _DUTY_ISOLATED_TEST_CONTAINER: true - run: duty test ${{ matrix.pytest-nodeid }} parallel=False - tests: strategy: diff --git a/Makefile b/Makefile index c94e352..5e88121 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,6 @@ actions = \ check-quality \ check-types \ clean \ - collect-isolated-tests \ coverage \ docs \ docs-deploy \ diff --git a/tests/test_completion/test_bash.py b/tests/test_completion/test_bash.py index c5a1aaa..213ad8d 100644 --- a/tests/test_completion/test_bash.py +++ b/tests/test_completion/test_bash.py @@ -8,7 +8,7 @@ from duty import cli from duty.completion import Bash -from tests.test_completion.utils import needs_isolated_container, needs_platform, needs_shell +from tests.test_completion.utils import needs_platform, needs_shell completion_test_cases = ( ("tests/fixtures/basic.py", "", "hello\n--"), @@ -27,24 +27,24 @@ def assert_completion_loaded() -> None: """Asserts that bash has completions for duty.""" result = subprocess.run(["/bin/bash", "-c", "complete -p duty"], capture_output=True, check=True, encoding="utf-8") # noqa: S603 assert "complete -o default -F _complete_duty duty" in result.stdout + # Cleanup + Bash().install_path.unlink(missing_ok=True) @needs_shell(Bash) @needs_platform("linux", "darwin") -@needs_isolated_container -@pytest.mark.isolate def test_install(capsys: pytest.CaptureFixture) -> None: """Test bash completion installation.""" assert cli.main(["--install-completion", "bash"]) == 0 captured = capsys.readouterr() assert "Bash completions successfully symlinked" in captured.out assert_completion_loaded() + # Cleanup + Bash().install_path.unlink(missing_ok=True) @needs_shell(Bash) @needs_platform("linux", "darwin") -@needs_isolated_container -@pytest.mark.isolate def test_install_no_param(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: """Test bash completion installation with no shell parameter provided.""" monkeypatch.setenv("SHELL", "/bin/bash") @@ -52,12 +52,12 @@ def test_install_no_param(capsys: pytest.CaptureFixture, monkeypatch: pytest.Mon captured = capsys.readouterr() assert "Bash completions successfully symlinked" in captured.out assert_completion_loaded() + # Cleanup + Bash().install_path.unlink(missing_ok=True) @needs_shell(Bash) @needs_platform("linux", "darwin") -@needs_isolated_container -@pytest.mark.isolate def test_install_path_exists(capsys: pytest.CaptureFixture) -> None: """Test bash completion installation with symlink/file already present.""" Bash().install_path.touch() @@ -65,6 +65,8 @@ def test_install_path_exists(capsys: pytest.CaptureFixture) -> None: captured = capsys.readouterr() assert "Bash completions successfully symlinked" in captured.out assert_completion_loaded() + # Cleanup + Bash().install_path.unlink(missing_ok=True) def test_completion(capsys: pytest.CaptureFixture) -> None: @@ -111,8 +113,6 @@ def test_complete_no_param( @parametrize_completions @needs_shell(Bash) @needs_platform("linux", "darwin") -@needs_isolated_container -@pytest.mark.isolate def test_completion_function(duties_file: str, partial: str, expected: str) -> None: """Test bash `_complete_duty` function.""" # TODO: Temporary hack, as for now completions don't respect the `-d` flag - to be fixed in another PR. @@ -135,3 +135,5 @@ def test_completion_function(duties_file: str, partial: str, expected: str) -> N ) # In this test case, output is a bash array, so we expect spaces instead of newlines. assert expected.replace("\n", " ") in result.stdout + # Cleanup + Bash().install_path.unlink(missing_ok=True) diff --git a/tests/test_completion/test_zsh.py b/tests/test_completion/test_zsh.py index e51da5c..f411167 100644 --- a/tests/test_completion/test_zsh.py +++ b/tests/test_completion/test_zsh.py @@ -1 +1,105 @@ """Zsh completion tests.""" + +from pathlib import Path + +import pytest + +from duty import cli +from duty.completion import Zsh +from tests.test_completion.utils import needs_platform, needs_shell + +completion_test_cases = ( + ("tests/fixtures/basic.py", "", "TODO"), + ("tests/fixtures/multiple.py", "", "TODO"), + ("tests/fixtures/code.py", "exit", "TODO"), + ("tests/fixtures/code.py", "exit-with", "TODO"), +) +parametrize_completions = pytest.mark.parametrize( + ("duties_file", "partial", "expected"), + completion_test_cases, + ids=range(len(completion_test_cases)), +) + + +def assert_completion_loaded() -> None: + """Asserts that zsh has completions for duty.""" + # TODO + + # Cleanup + Zsh().install_path.unlink(missing_ok=True) + + +@needs_shell(Zsh) +@needs_platform("linux", "darwin") +def test_install(capsys: pytest.CaptureFixture) -> None: + """Test zsh completion installation.""" + assert cli.main(["--install-completion", "zsh"]) == 0 + captured = capsys.readouterr() + assert "Zsh completions successfully symlinked" in captured.out + assert_completion_loaded() + # Cleanup + Zsh().install_path.unlink(missing_ok=True) + + +@needs_shell(Zsh) +@needs_platform("linux", "darwin") +def test_install_no_param(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: + """Test zsh completion installation with no shell parameter provided.""" + monkeypatch.setenv("SHELL", "/bin/zsh") + assert cli.main(["--install-completion"]) == 0 + captured = capsys.readouterr() + assert "Zsh completions successfully symlinked" in captured.out + assert_completion_loaded() + # Cleanup + Zsh().install_path.unlink(missing_ok=True) + + +@needs_shell(Zsh) +@needs_platform("linux", "darwin") +def test_install_path_exists(capsys: pytest.CaptureFixture) -> None: + """Test zsh completion installation with symlink/file already present.""" + Zsh().install_path.touch() + assert cli.main(["--install-completion", "zsh"]) == 0 + captured = capsys.readouterr() + assert "Zsh completions successfully symlinked" in captured.out + assert_completion_loaded() + # Cleanup + Zsh().install_path.unlink(missing_ok=True) + + +def test_completion(capsys: pytest.CaptureFixture) -> None: + """Test printing out zsh completion script.""" + assert cli.main(["--completion", "zsh"]) == 0 + captured = capsys.readouterr() + assert "_describe 'duty' subcmds" in captured.out + + +def test_completion_no_param(capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: + """Test printing out zsh completion script with no shell parameter provided.""" + monkeypatch.setenv("SHELL", "/bin/zsh") + assert cli.main(["--completion"]) == 0 + captured = capsys.readouterr() + assert "_describe 'duty' subcmds" in captured.out + + +@parametrize_completions +def test_complete(duties_file: str, partial: str, expected: str, capsys: pytest.CaptureFixture) -> None: + """Test zsh completion.""" + assert cli.main(["-d", duties_file, "--complete", "zsh", "--", "duty", partial]) == 0 + captured = capsys.readouterr() + assert expected in captured.out + + +@parametrize_completions +@needs_shell(Zsh) +@needs_platform("linux", "darwin") +def test_completion_function(duties_file: str, partial: str, expected: str) -> None: + """Test zsh `_complete_duty` function.""" + # TODO: Temporary hack, as for now completions don't respect the `-d` flag - to be fixed in another PR. + duties_path = Path() / "duties.py" + duties_path.unlink(missing_ok=True) + duties_path.symlink_to(duties_file) + # TODO + + # Cleanup + Zsh().install_path.unlink(missing_ok=True) diff --git a/tests/test_completion/utils.py b/tests/test_completion/utils.py index ed8a00f..50cc10a 100644 --- a/tests/test_completion/utils.py +++ b/tests/test_completion/utils.py @@ -27,9 +27,3 @@ def needs_shell(shell: type[Shell]) -> pytest.MarkDecorator: not (shell_environ and os.path.basename(shell_environ) == shell.name), reason=f"Test requires {shell.name} shell and SHELL environment variable set", ) - - -needs_isolated_container = pytest.mark.skipif( - not os.getenv("_DUTY_ISOLATED_TEST_CONTAINER"), - reason="Test requires to be run in an isolated container", -) From c8db8ea2bc4a6a4f39ad2183d613e1dbcfc3995e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= Date: Sun, 25 May 2025 22:18:52 +0200 Subject: [PATCH 19/19] WIP: remove pytest isolate marker --- config/pytest.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/pytest.ini b/config/pytest.ini index 483b1c1..052a2f1 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -6,8 +6,6 @@ addopts = --cov-config config/coverage.ini testpaths = tests -markers = - isolate: marks test to be run in isolated container # action:message_regex:warning_class:module_regex:line filterwarnings =