gitpro 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitpro/completion.py ADDED
@@ -0,0 +1,407 @@
1
+ """`gitpro completion bash|zsh|fish`: tab completion generated at run time from the argparse tree.
2
+
3
+ generate(parser, shell) walks every subparser (including commands and flags that other packages add
4
+ through register()/extend()), so the script always matches the installed gitpro. Option values come
5
+ from argparse `choices`, or from `gitpro completion --values identities|kinds|hooks|shells` at
6
+ completion time for the options in DYNAMIC (or any action carrying a `gitpro_values` attribute:
7
+ `p.add_argument("--x").gitpro_values = "identities"`). Module level imports only the standard library.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import importlib
13
+ import re
14
+ import sys
15
+ from dataclasses import dataclass, field
16
+ from typing import Dict, List, Optional, Tuple
17
+
18
+ PROG = "gitpro"
19
+ SHELLS = ("bash", "zsh", "fish")
20
+ VALUE_KINDS = ("identities", "kinds", "hooks", "shells")
21
+ HOOK_NAMES = ("pre-commit", "pre-push", "commit-msg")
22
+ # (command path, option or positional dest) -> --values kind; "*" matches any command.
23
+ DYNAMIC: Dict[Tuple[str, str], str] = {
24
+ ("*", "--identity"): "identities",
25
+ ("lost", "--only"): "kinds",
26
+ ("*", "hook"): "hooks",
27
+ ("completion", "shell"): "shells",
28
+ }
29
+ # (command path, option) -> (module, attribute, extra words): static values read from the module that owns
30
+ # the option when the script is generated (R-067: env --only keys, env --shell shells).
31
+ STATIC: Dict[Tuple[str, str], Tuple[str, str, Tuple[str, ...]]] = {
32
+ ("env", "--only"): ("gitpro.envcmd", "KEYS", ()),
33
+ ("env", "--shell"): ("gitpro.envcmd", "SHELLS", ("bash", "zsh")),
34
+ }
35
+ INSTALL = {
36
+ "bash": '# add to ~/.bashrc: eval "$(gitpro completion bash)"',
37
+ "zsh": '# add to ~/.zshrc (after compinit): eval "$(gitpro completion zsh)"',
38
+ "fish": "# add to ~/.config/fish/config.fish: gitpro completion fish | source",
39
+ }
40
+ _WORD_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@=,-]*$") # safe inside the generated scripts unquoted
41
+ _FLAG_RE = re.compile(r"^--?[A-Za-z0-9][A-Za-z0-9._-]*$")
42
+
43
+
44
+ # ---------------------------------------------------------------- the tree
45
+
46
+ @dataclass
47
+ class Opt:
48
+ flags: List[str]
49
+ takes_value: bool
50
+ values: List[str] = field(default_factory=list) # static choices
51
+ dynamic: Optional[str] = None # a VALUE_KINDS entry
52
+ help: str = ""
53
+
54
+
55
+ @dataclass
56
+ class Node:
57
+ path: List[str] # ["guard", "install"]; [] for gitpro itself
58
+ help: str = ""
59
+ opts: List[Opt] = field(default_factory=list)
60
+ subs: Dict[str, "Node"] = field(default_factory=dict)
61
+ pos_values: List[str] = field(default_factory=list)
62
+ pos_dynamic: Optional[str] = None
63
+ pos_files: bool = False # a positional without known values (a path)
64
+
65
+ @property
66
+ def key(self) -> str:
67
+ return " ".join([PROG, *self.path])
68
+
69
+
70
+ def _safe(words, pattern=_WORD_RE) -> List[str]:
71
+ """The words that can go into a script unquoted; others are dropped (never completed)."""
72
+ return [str(w) for w in words if pattern.match(str(w))]
73
+
74
+
75
+ def _dynamic(path: List[str], action: argparse.Action) -> Optional[str]:
76
+ kind = getattr(action, "gitpro_values", None)
77
+ if kind in VALUE_KINDS:
78
+ return kind
79
+ cmd = path[0] if path else ""
80
+ names = list(action.option_strings) or [action.dest]
81
+ for n in names:
82
+ for scope in (" ".join(path), cmd, "*"):
83
+ if (scope, n) in DYNAMIC:
84
+ return DYNAMIC[(scope, n)]
85
+ return None
86
+
87
+
88
+ def _static(path: List[str], action: argparse.Action) -> List[str]:
89
+ """Values for an option without argparse choices: an action's `gitpro_choices`, else STATIC."""
90
+ words = list(getattr(action, "gitpro_choices", None) or ())
91
+ for flag in action.option_strings:
92
+ spec = STATIC.get((" ".join(path), flag))
93
+ if spec and not words:
94
+ mod = sys.modules.get(spec[0])
95
+ if mod is None:
96
+ try:
97
+ mod = importlib.import_module(spec[0])
98
+ except ImportError:
99
+ continue
100
+ words = [*(getattr(mod, spec[1], None) or ()), *spec[2]]
101
+ return _safe(dict.fromkeys(words))
102
+
103
+
104
+ def tree(parser: argparse.ArgumentParser, path: Optional[List[str]] = None, help: str = "") -> Node:
105
+ """The completion tree of parser: options, positionals and subcommands, recursively."""
106
+ node = Node(list(path or []), help)
107
+ for a in parser._actions:
108
+ if isinstance(a, argparse._SubParsersAction):
109
+ helps = {c.dest: c.help or "" for c in a._choices_actions}
110
+ for name, sp in a.choices.items():
111
+ if _WORD_RE.match(name):
112
+ node.subs[name] = tree(sp, node.path + [name], helps.get(name, ""))
113
+ continue
114
+ if a.help == argparse.SUPPRESS:
115
+ continue
116
+ dyn = _dynamic(node.path, a)
117
+ values = _safe(a.choices) if a.choices is not None else _static(node.path, a)
118
+ if a.option_strings:
119
+ node.opts.append(Opt(_safe(a.option_strings, _FLAG_RE), a.nargs != 0, values, dyn, a.help or ""))
120
+ elif values or dyn:
121
+ node.pos_values += values
122
+ node.pos_dynamic = node.pos_dynamic or dyn
123
+ else:
124
+ node.pos_files = True
125
+ return node
126
+
127
+
128
+ def _walk(node: Node):
129
+ yield node
130
+ for s in node.subs.values():
131
+ yield from _walk(s)
132
+
133
+
134
+ def values(kind: str, config_path=None) -> List[str]:
135
+ """Completion values of one kind, only shell-safe words (bash's compgen -W expands $(...));
136
+ [] on any error (no config, bad config, package missing)."""
137
+ return _safe(_values(kind, config_path))
138
+
139
+
140
+ def _values(kind: str, config_path=None) -> List[str]:
141
+ try:
142
+ if kind == "identities":
143
+ from pathlib import Path
144
+
145
+ from . import config
146
+ path, how = config.find(config_path, Path.home())
147
+ return [i.id for i in config.load_all(path, how).identities]
148
+ if kind == "kinds":
149
+ try:
150
+ import importlib
151
+ lost = importlib.import_module("gitpro.lost") # 03; honours sys.modules (unlike `from . import`)
152
+ except ImportError:
153
+ return []
154
+ kinds = getattr(lost, "KINDS", None) or getattr(lost, "ALL_KINDS", None) or ()
155
+ return [str(k) for k in kinds]
156
+ if kind == "hooks":
157
+ return list(HOOK_NAMES)
158
+ if kind == "shells":
159
+ return list(SHELLS)
160
+ except Exception:
161
+ return []
162
+ return []
163
+
164
+
165
+ # ---------------------------------------------------------------- bash (zsh reuses the helpers)
166
+
167
+ def _case(entries: List[Tuple[str, str]], default: str = "", subject: str = "$1") -> List[str]:
168
+ """`case "SUBJECT" in "pattern") body ;; esac` lines; patterns are already shell-safe keys."""
169
+ out = [f' case "{subject}" in']
170
+ out += [f' "{pat}") {body} ;;' for pat, body in entries]
171
+ if default:
172
+ out.append(f" *) {default} ;;")
173
+ out.append(" esac")
174
+ return out
175
+
176
+
177
+ def _dyn_cmd(kind: str) -> str:
178
+ return f"{PROG} completion --values {kind} 2>/dev/null"
179
+
180
+
181
+ def _helpers(root: Node) -> List[str]:
182
+ """Shell functions shared by bash and zsh (POSIX-ish syntax both accept). Each prints words or
183
+ returns a status; the keys are 'gitpro sub sub'."""
184
+ nodes = list(_walk(root))
185
+ lines = ["# _gitpro_child KEY WORD: the key of subcommand WORD under KEY, or fail",
186
+ "_gitpro_child() {"]
187
+ lines += _case([(f"{n.key} {s}", f"echo '{n.key} {s}'") for n in nodes for s in n.subs], "return 1", "$1 $2")
188
+ lines += ["}", "", "# _gitpro_subs KEY: subcommand names", "_gitpro_subs() {"]
189
+ lines += _case([(n.key, f"echo '{' '.join(n.subs)}'") for n in nodes if n.subs])
190
+ lines += ["}", "", "# _gitpro_opts KEY: option names", "_gitpro_opts() {"]
191
+ lines += _case([(n.key, f"echo '{' '.join(f for o in n.opts for f in o.flags)}'") for n in nodes if n.opts])
192
+ lines += ["}", "", "# _gitpro_takes KEY OPT: does OPT take a value?", "_gitpro_takes() {"]
193
+ takes = [(f"{n.key} {f}", "return 0") for n in nodes for o in n.opts if o.takes_value for f in o.flags]
194
+ lines += _case(takes, "return 1", "$1 $2")
195
+ lines += ["}", "",
196
+ "# _gitpro_optvals KEY OPT: values for OPT; status 1 means complete file names",
197
+ "_gitpro_optvals() {"]
198
+ vals = []
199
+ for n in nodes:
200
+ for o in n.opts:
201
+ body = _values_body(o.values, o.dynamic)
202
+ if body:
203
+ vals += [(f"{n.key} {f}", body) for f in o.flags]
204
+ lines += _case(vals, "return 1", "$1 $2")
205
+ lines += ["}", "", "# _gitpro_posvals KEY: values for positionals; status 1 means complete file names",
206
+ "_gitpro_posvals() {"]
207
+ pos = [(n.key, _values_body(n.pos_values, n.pos_dynamic)) for n in nodes if n.pos_values or n.pos_dynamic]
208
+ files_ok = [n.key for n in nodes if n.pos_files]
209
+ lines += _case(pos + [(k, "return 1") for k in files_ok if k not in dict(pos)], "return 2")
210
+ lines += ["}", "",
211
+ "# _gitpro_key WORDS...: walk the words before the cursor to the deepest subcommand key",
212
+ "_gitpro_key() {",
213
+ f" local key='{PROG}' skip=0 w next",
214
+ " for w in \"$@\"; do",
215
+ " if [ \"$skip\" = 1 ]; then skip=0; continue; fi",
216
+ " case \"$w\" in",
217
+ " --*=*) continue ;;",
218
+ " -*) _gitpro_takes \"$key\" \"$w\" && skip=1; continue ;;",
219
+ " esac",
220
+ " next=$(_gitpro_child \"$key\" \"$w\") && key=$next",
221
+ " done",
222
+ " echo \"$key\"",
223
+ "}"]
224
+ return lines
225
+
226
+
227
+ def _values_body(static: List[str], dynamic: Optional[str]) -> str:
228
+ parts = []
229
+ if static:
230
+ parts.append(f"echo '{' '.join(static)}'")
231
+ if dynamic:
232
+ parts.append(_dyn_cmd(dynamic))
233
+ return "; ".join(parts)
234
+
235
+
236
+ _BASH_MAIN = r"""
237
+ _gitpro_files() {
238
+ compopt -o filenames 2>/dev/null
239
+ COMPREPLY=($(compgen -f -- "$cur"))
240
+ }
241
+
242
+ _gitpro() {
243
+ local cur="${COMP_WORDS[COMP_CWORD]}" prev="" key words pos rc
244
+ [ "$COMP_CWORD" -gt 1 ] && prev="${COMP_WORDS[COMP_CWORD-1]}"
245
+ key=$(_gitpro_key "${COMP_WORDS[@]:1:COMP_CWORD-1}")
246
+ COMPREPLY=()
247
+ if [[ $prev == -* ]] && _gitpro_takes "$key" "$prev"; then
248
+ if words=$(_gitpro_optvals "$key" "$prev"); then
249
+ COMPREPLY=($(compgen -W "$words" -- "$cur"))
250
+ else
251
+ _gitpro_files
252
+ fi
253
+ return 0
254
+ fi
255
+ if [[ $cur == -* ]]; then
256
+ COMPREPLY=($(compgen -W "$(_gitpro_opts "$key")" -- "$cur"))
257
+ return 0
258
+ fi
259
+ words=$(_gitpro_subs "$key")
260
+ pos=$(_gitpro_posvals "$key"); rc=$?
261
+ [ $rc = 0 ] && words="$words $pos"
262
+ if [ -n "${words// /}" ]; then
263
+ COMPREPLY=($(compgen -W "$words" -- "$cur"))
264
+ elif [ $rc = 1 ]; then
265
+ _gitpro_files
266
+ fi
267
+ return 0
268
+ }
269
+ complete -F _gitpro gitpro
270
+ """
271
+
272
+
273
+ def _bash(root: Node) -> str:
274
+ head = f"# bash completion for {PROG} (generated by `{PROG} completion bash`; regenerate after upgrading)"
275
+ return "\n".join([head, *_helpers(root)]) + "\n" + _BASH_MAIN
276
+
277
+
278
+ def _zsh(root: Node) -> str:
279
+ lines = [f"#compdef {PROG}",
280
+ f"# zsh completion for {PROG} (generated by `{PROG} completion zsh`; regenerate after upgrading)",
281
+ *_helpers(root), "",
282
+ "_gitpro() {",
283
+ " local cur=${words[CURRENT]} prev=\"\" key vals pos rc",
284
+ " (( CURRENT > 2 )) && prev=${words[CURRENT-1]}",
285
+ " key=$(_gitpro_key \"${(@)words[2,CURRENT-1]}\")",
286
+ " if [[ $prev == -* ]] && _gitpro_takes \"$key\" \"$prev\"; then",
287
+ " if vals=$(_gitpro_optvals \"$key\" \"$prev\"); then",
288
+ " compadd -- ${=vals}",
289
+ " else",
290
+ " _files",
291
+ " fi",
292
+ " return 0",
293
+ " fi",
294
+ " if [[ $cur == -* ]]; then",
295
+ " compadd -- ${=$(_gitpro_opts \"$key\")}",
296
+ " return 0",
297
+ " fi",
298
+ " vals=$(_gitpro_subs \"$key\")",
299
+ " pos=$(_gitpro_posvals \"$key\"); rc=$?",
300
+ " (( rc == 0 )) && vals=\"$vals $pos\"",
301
+ " if [[ -n ${vals// /} ]]; then",
302
+ " compadd -- ${=vals}",
303
+ " elif (( rc == 1 )); then",
304
+ " _files",
305
+ " fi",
306
+ " return 0",
307
+ "}",
308
+ "",
309
+ "if [[ ${zsh_eval_context[-1]} == loadautofunc ]]; then",
310
+ " _gitpro \"$@\"",
311
+ "elif (( $+functions[compdef] )); then",
312
+ f" compdef _gitpro {PROG}",
313
+ "fi", ""]
314
+ return "\n".join(lines)
315
+
316
+
317
+ # ---------------------------------------------------------------- fish
318
+
319
+ def _fq(text: str) -> str:
320
+ """A fish single-quoted string."""
321
+ return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
322
+
323
+
324
+ def _first_line(text: str) -> str:
325
+ return " ".join(text.split())[:80]
326
+
327
+
328
+ def _fish(root: Node) -> str:
329
+ lines = [f"# fish completion for {PROG} (generated by `{PROG} completion fish`; regenerate after upgrading)",
330
+ f"complete -c {PROG} -e", ""]
331
+ for n in _walk(root):
332
+ if n.path:
333
+ cond = "__fish_seen_subcommand_from " + n.path[-1]
334
+ if len(n.path) > 1:
335
+ cond = " ; and ".join(f"__fish_seen_subcommand_from {p}" for p in n.path)
336
+ else:
337
+ cond = ""
338
+ if n.subs:
339
+ names = " ".join(n.subs)
340
+ here = (cond + " ; and " if cond else "") + f"not __fish_seen_subcommand_from {names}"
341
+ for name, s in n.subs.items():
342
+ d = f" -d {_fq(_first_line(s.help))}" if s.help else ""
343
+ lines.append(f"complete -c {PROG} -f -n {_fq(here)} -a {name}{d}")
344
+ for o in n.opts:
345
+ spec = []
346
+ for f in o.flags:
347
+ if f.startswith("--"):
348
+ spec.append(f"-l {f[2:]}")
349
+ elif len(f) == 2:
350
+ spec.append(f"-s {f[1]}")
351
+ else:
352
+ spec.append(f"-o {f[1:]}")
353
+ args = ""
354
+ if o.takes_value:
355
+ vals = " ".join(o.values)
356
+ if o.dynamic:
357
+ vals = (vals + " " if vals else "") + f"({PROG} completion --values {o.dynamic} 2>/dev/null)"
358
+ args = f" -x -a {_fq(vals)}" if vals else " -r"
359
+ n_ = f" -n {_fq(cond)}" if cond else ""
360
+ d = f" -d {_fq(_first_line(o.help))}" if o.help else ""
361
+ lines.append(f"complete -c {PROG}{n_} {' '.join(spec)}{args}{d}")
362
+ if n.path and (n.pos_values or n.pos_dynamic):
363
+ vals = " ".join(n.pos_values)
364
+ if n.pos_dynamic:
365
+ vals = (vals + " " if vals else "") + f"({PROG} completion --values {n.pos_dynamic} 2>/dev/null)"
366
+ lines.append(f"complete -c {PROG} -f -n {_fq(cond)} -a {_fq(vals)}")
367
+ return "\n".join(lines) + "\n"
368
+
369
+
370
+ def generate(parser: argparse.ArgumentParser, shell: str) -> str:
371
+ """The completion script for shell ("bash", "zsh" or "fish") covering every command in parser."""
372
+ root = tree(parser)
373
+ if shell == "bash":
374
+ return _bash(root)
375
+ if shell == "zsh":
376
+ return _zsh(root)
377
+ if shell == "fish":
378
+ return _fish(root)
379
+ raise ValueError(f"unknown shell {shell!r} ({', '.join(SHELLS)})")
380
+
381
+
382
+ # ---------------------------------------------------------------- command
383
+
384
+ def cmd_completion(args) -> int:
385
+ from .args import fail
386
+ if args.values:
387
+ for v in values(args.values, args.config):
388
+ print(v)
389
+ return 0
390
+ if not args.shell:
391
+ return fail(f"name a shell: {PROG} completion {'|'.join(SHELLS)}", args)
392
+ if args.shell not in SHELLS:
393
+ return fail(f"unknown shell {args.shell!r} ({', '.join(SHELLS)})", args)
394
+ from . import cli
395
+ parser, _subs = cli.build_parser()
396
+ sys.stdout.write(generate(parser, args.shell))
397
+ print(INSTALL[args.shell], file=sys.stderr)
398
+ return 0
399
+
400
+
401
+ def register(sub, common, confirm) -> None:
402
+ from .args import capability
403
+ sp = sub.add_parser("completion", parents=[common], help="print a shell completion script (bash, zsh, fish)")
404
+ sp.add_argument("shell", nargs="?", metavar="SHELL", help="bash, zsh or fish")
405
+ sp.add_argument("--values", choices=VALUE_KINDS, help="print completion values, one per line (used by the scripts)")
406
+ sp.set_defaults(func=cmd_completion)
407
+ capability("completion")