Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 45 additions & 7 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 22 additions & 2 deletions duties.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import os
import sys
from contextlib import contextmanager
Expand Down Expand Up @@ -185,20 +186,39 @@ 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:
match: A pytest expression to filter selected tests.
"""
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}")
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ theme:
- content.code.annotate
- content.code.copy
- content.tooltips
- content.tabs.link
- navigation.footer
- navigation.indexes
- navigation.sections
Expand Down
49 changes: 39 additions & 10 deletions src/duty/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()}")
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down
15 changes: 10 additions & 5 deletions src/duty/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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:]
Expand Down
Loading