Skip to content

Commit 5e6939f

Browse files
authored
cli: --completions bash|zsh generated from the parser (#1)
Walks the argparse definition at print time, so a new flag or subcommand lands in completions without a second list to maintain. Bash output verified with bash -n; zsh emits a standard #compdef file. Covered by parser-driven tests, no new dependencies.
1 parent a75527a commit 5e6939f

4 files changed

Lines changed: 186 additions & 0 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@ by zero-based position (use this for steps with no `name:`).
6060
| `--act-arg ARG` | extra argument passed through to `act` (repeatable) |
6161
| `-v`, `--verbose` | print the injection/act commands being run |
6262

63+
### Shell completions
64+
65+
`actbreak --completions bash` (or `zsh`) prints a completion script built
66+
from the argparse parser, so new flags show up without touching it:
67+
68+
```bash
69+
# bash
70+
source <(actbreak --completions bash)
71+
72+
# zsh
73+
source <(actbreak --completions zsh)
74+
```
75+
6376
### Examples
6477

6578
```

actbreak/cli.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@
3232
"""
3333

3434

35+
class PrintCompletionsAction(argparse.Action):
36+
def __call__(self, parser, namespace, values, option_string=None):
37+
from .completions import generate_bash, generate_zsh
38+
39+
if values == "bash":
40+
print(generate_bash(parser), end="")
41+
elif values == "zsh":
42+
print(generate_zsh(parser), end="")
43+
parser.exit(0)
44+
45+
3546
def build_parser() -> argparse.ArgumentParser:
3647
parser = argparse.ArgumentParser(
3748
prog=PROG,
@@ -40,6 +51,12 @@ def build_parser() -> argparse.ArgumentParser:
4051
formatter_class=argparse.RawDescriptionHelpFormatter,
4152
)
4253
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
54+
parser.add_argument(
55+
"--completions",
56+
choices=["bash", "zsh"],
57+
action=PrintCompletionsAction,
58+
help="print shell completion script",
59+
)
4360

4461
sub = parser.add_subparsers(dest="command", required=True)
4562

actbreak/completions.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
6+
def _extract(parser: argparse.ArgumentParser) -> tuple[list[str], list[str], dict[str, list[str]]]:
7+
top_flags: list[str] = []
8+
commands: list[str] = []
9+
cmd_flags: dict[str, list[str]] = {}
10+
11+
for action in parser._actions:
12+
if isinstance(action, argparse._SubParsersAction):
13+
for cmd, subparser in action.choices.items():
14+
commands.append(cmd)
15+
flags: list[str] = []
16+
for subaction in subparser._actions:
17+
if subaction.option_strings and subaction.option_strings[0] != "-h":
18+
flags.extend(subaction.option_strings)
19+
cmd_flags[cmd] = flags
20+
elif action.option_strings and action.option_strings[0] != "-h":
21+
top_flags.extend(action.option_strings)
22+
23+
return top_flags, commands, cmd_flags
24+
25+
26+
def generate_bash(parser: argparse.ArgumentParser) -> str:
27+
top_flags, commands, cmd_flags = _extract(parser)
28+
29+
cases = []
30+
for cmd, flags in cmd_flags.items():
31+
if flags:
32+
flags_str = " ".join(flags)
33+
cases.append(
34+
f''' {cmd})
35+
COMPREPLY=( $(compgen -W "{flags_str}" -- "$cur") )
36+
return 0
37+
;;'''
38+
)
39+
else:
40+
cases.append(
41+
f''' {cmd})
42+
return 0
43+
;;'''
44+
)
45+
46+
cases_str = "\n".join(cases)
47+
commands_str = " ".join(commands)
48+
top_flags_str = " ".join(top_flags)
49+
50+
return f'''\
51+
_actbreak() {{
52+
local cur prev words cword
53+
COMPREPLY=()
54+
cur="${{COMP_WORDS[COMP_CWORD]}}"
55+
56+
local commands="{commands_str}"
57+
local top_flags="{top_flags_str}"
58+
59+
if [[ ${{COMP_CWORD}} -eq 1 ]]; then
60+
if [[ "$cur" == -* ]]; then
61+
COMPREPLY=( $(compgen -W "$top_flags" -- "$cur") )
62+
else
63+
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
64+
fi
65+
return 0
66+
fi
67+
68+
local command="${{COMP_WORDS[1]}}"
69+
if [[ "$cur" == -* ]]; then
70+
case "$command" in
71+
{cases_str}
72+
esac
73+
fi
74+
return 0
75+
}}
76+
complete -F _actbreak actbreak
77+
'''
78+
79+
80+
def generate_zsh(parser: argparse.ArgumentParser) -> str:
81+
top_flags, commands, cmd_flags = _extract(parser)
82+
83+
cases = []
84+
for cmd, flags in cmd_flags.items():
85+
if flags:
86+
flag_args = " ".join(f'"{f}"' for f in flags)
87+
cases.append(
88+
f''' {cmd})
89+
_arguments {flag_args}
90+
;;'''
91+
)
92+
else:
93+
cases.append(
94+
f''' {cmd})
95+
;;'''
96+
)
97+
98+
cases_str = "\n".join(cases)
99+
top_flag_args = " ".join(f'"{f}"' for f in top_flags)
100+
commands_args = " ".join(f'"{c}"' for c in commands)
101+
102+
return f'''\
103+
#compdef actbreak
104+
105+
_actbreak() {{
106+
local context state state_descr line
107+
typeset -A opt_args
108+
109+
_arguments -C \\
110+
{top_flag_args} \\
111+
'1: :->cmds' \\
112+
'*::arg:->args'
113+
114+
case $state in
115+
cmds)
116+
_values "actbreak command" {commands_args}
117+
;;
118+
args)
119+
case $line[1] in
120+
{cases_str}
121+
esac
122+
;;
123+
esac
124+
}}
125+
126+
_actbreak "$@"
127+
'''

tests/test_cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from __future__ import annotations
55

6+
import contextlib
7+
import io
68
import unittest
79
from unittest import mock
810

@@ -70,6 +72,33 @@ def test_version_flag(self):
7072
parser.parse_args(["--version"])
7173
self.assertEqual(ctx.exception.code, 0)
7274

75+
def _completions(self, shell):
76+
parser = build_parser()
77+
buf = io.StringIO()
78+
with contextlib.redirect_stdout(buf):
79+
with self.assertRaises(SystemExit) as ctx:
80+
parser.parse_args(["--completions", shell])
81+
self.assertEqual(ctx.exception.code, 0)
82+
return buf.getvalue()
83+
84+
def test_completions_bash_covers_parser(self):
85+
out = self._completions("bash")
86+
self.assertIn("_actbreak() {", out)
87+
for token in ("run", "resume", "clean", "--version", "--completions",
88+
"--break-before", "--break-after", "--break-on-failure",
89+
"--job", "--runtime", "--no-attach", "--act-arg",
90+
"-v", "--verbose"):
91+
self.assertIn(token, out)
92+
93+
def test_completions_zsh_covers_parser(self):
94+
out = self._completions("zsh")
95+
self.assertIn("#compdef actbreak", out)
96+
for token in ('"run"', '"resume"', '"clean"', '"--version"',
97+
'"--completions"', '"--break-before"', '"--break-after"',
98+
'"--break-on-failure"', '"--job"', '"--runtime"',
99+
'"--no-attach"', '"--act-arg"', '"-v"', '"--verbose"'):
100+
self.assertIn(token, out)
101+
73102
def test_missing_command_is_an_error(self):
74103
parser = build_parser()
75104
with self.assertRaises(SystemExit):

0 commit comments

Comments
 (0)