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/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/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..57ef248 --- /dev/null +++ b/src/duty/completion.py @@ -0,0 +1,155 @@ +"""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_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: + """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_path(self) -> Path: # noqa: D102 + if self.bash_completion_user_dir: + bash_completion_directory = Path(self.bash_completion_user_dir) + elif self.xdg_data_home: + bash_completion_directory = Path(self.xdg_data_home) / "bash-completion" + else: + bash_completion_directory = Path.home() / ".local/share/bash-completion" + if not bash_completion_directory.is_dir(): + msg = ( + f"Bash completion directory not found. Searched in: {bash_completion_directory}, " + f"make sure you have bash-completion installed" + ) + raise OSError(msg) + 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 + self.install_path.unlink(missing_ok=True) + self.install_path.symlink_to(self.completion_script_path) + print( + f"Bash completions successfully symlinked to {self.install_path}. " + 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_path(self) -> Path: # noqa: D102 + try: + return next(d for d in self.site_functions_dirs if d.is_dir()) / "_duty" + except StopIteration as exc: + 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 + + 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: + 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: + raise + subprocess.run( # noqa: S603 + ["sudo", sys.executable, sys.argv[0], "--install-completion=zsh"], # noqa: S607 + check=True, + ) + else: + print( + f"Zsh completions successfully symlinked to {self.install_path}. " + 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), ] 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..213ad8d --- /dev/null +++ b/tests/test_completion/test_bash.py @@ -0,0 +1,139 @@ +"""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_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 + # Cleanup + Bash().install_path.unlink(missing_ok=True) + + +@needs_shell(Bash) +@needs_platform("linux", "darwin") +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") +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() + # Cleanup + Bash().install_path.unlink(missing_ok=True) + + +@needs_shell(Bash) +@needs_platform("linux", "darwin") +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() + # Cleanup + Bash().install_path.unlink(missing_ok=True) + + +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") +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 + # Cleanup + Bash().install_path.unlink(missing_ok=True) diff --git a/tests/test_completion/test_zsh.py b/tests/test_completion/test_zsh.py new file mode 100644 index 0000000..f411167 --- /dev/null +++ b/tests/test_completion/test_zsh.py @@ -0,0 +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 new file mode 100644 index 0000000..50cc10a --- /dev/null +++ b/tests/test_completion/utils.py @@ -0,0 +1,29 @@ +"""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", + )