docopt2 1.0.0__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.
- docopt2/__init__.py +80 -0
- docopt2/__main__.py +144 -0
- docopt2/_compat.py +53 -0
- docopt2/_completion.py +299 -0
- docopt2/_core.py +549 -0
- docopt2/_diagnostics.py +82 -0
- docopt2/_errors.py +52 -0
- docopt2/_fmt.py +23 -0
- docopt2/_format.py +177 -0
- docopt2/_generate.py +225 -0
- docopt2/_help.py +110 -0
- docopt2/_lint.py +172 -0
- docopt2/_parser.py +931 -0
- docopt2/_spellcheck.py +69 -0
- docopt2/_stub.py +125 -0
- docopt2/_typed.py +289 -0
- docopt2/hypothesis.py +56 -0
- docopt2/py.typed +0 -0
- docopt2-1.0.0.dist-info/METADATA +450 -0
- docopt2-1.0.0.dist-info/RECORD +24 -0
- docopt2-1.0.0.dist-info/WHEEL +4 -0
- docopt2-1.0.0.dist-info/entry_points.txt +2 -0
- docopt2-1.0.0.dist-info/licenses/LICENSE +24 -0
- docopt2-1.0.0.dist-info/licenses/NOTICE +54 -0
docopt2/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _package_version
|
|
5
|
+
|
|
6
|
+
from docopt2._compat import check_compat
|
|
7
|
+
from docopt2._completion import complete, generate_completion
|
|
8
|
+
from docopt2._core import Arguments, Cli, Dispatch, Source, docopt, parse_tree
|
|
9
|
+
from docopt2._errors import DocoptExit, DocoptLanguageError
|
|
10
|
+
from docopt2._fmt import format_usage
|
|
11
|
+
from docopt2._format import format_argv
|
|
12
|
+
from docopt2._generate import generate_config_template, generate_examples
|
|
13
|
+
from docopt2._lint import check
|
|
14
|
+
from docopt2._parser import (
|
|
15
|
+
Argument,
|
|
16
|
+
BranchPattern,
|
|
17
|
+
Command,
|
|
18
|
+
Either,
|
|
19
|
+
LeafPattern,
|
|
20
|
+
OneOrMore,
|
|
21
|
+
Option,
|
|
22
|
+
Optional,
|
|
23
|
+
OptionsShortcut,
|
|
24
|
+
Pattern,
|
|
25
|
+
Required,
|
|
26
|
+
Tokens,
|
|
27
|
+
formal_usage,
|
|
28
|
+
parse_argv,
|
|
29
|
+
parse_defaults,
|
|
30
|
+
parse_pattern,
|
|
31
|
+
parse_section,
|
|
32
|
+
transform,
|
|
33
|
+
)
|
|
34
|
+
from docopt2._stub import generate_stub
|
|
35
|
+
|
|
36
|
+
# This module is the public facade: it only re-exports names; the runtime (docopt, Cli, Dispatch,
|
|
37
|
+
# Arguments, parse_tree) lives in _core, and the parser primitives (Tokens, formal_usage, parse_*,
|
|
38
|
+
# transform) are re-exported for drop-in compatibility with the original docopt module.
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Argument",
|
|
41
|
+
"Arguments",
|
|
42
|
+
"BranchPattern",
|
|
43
|
+
"Cli",
|
|
44
|
+
"Command",
|
|
45
|
+
"Dispatch",
|
|
46
|
+
"DocoptExit",
|
|
47
|
+
"DocoptLanguageError",
|
|
48
|
+
"Either",
|
|
49
|
+
"LeafPattern",
|
|
50
|
+
"OneOrMore",
|
|
51
|
+
"Option",
|
|
52
|
+
"Optional",
|
|
53
|
+
"OptionsShortcut",
|
|
54
|
+
"Pattern",
|
|
55
|
+
"Required",
|
|
56
|
+
"Source",
|
|
57
|
+
"Tokens",
|
|
58
|
+
"check",
|
|
59
|
+
"check_compat",
|
|
60
|
+
"complete",
|
|
61
|
+
"docopt",
|
|
62
|
+
"formal_usage",
|
|
63
|
+
"format_argv",
|
|
64
|
+
"format_usage",
|
|
65
|
+
"generate_completion",
|
|
66
|
+
"generate_config_template",
|
|
67
|
+
"generate_examples",
|
|
68
|
+
"generate_stub",
|
|
69
|
+
"parse_argv",
|
|
70
|
+
"parse_defaults",
|
|
71
|
+
"parse_pattern",
|
|
72
|
+
"parse_section",
|
|
73
|
+
"parse_tree",
|
|
74
|
+
"transform",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
__version__ = _package_version("docopt2")
|
|
79
|
+
except PackageNotFoundError: # pragma: no cover - only when imported from a source tree with no installed dist
|
|
80
|
+
__version__ = "0.0.0"
|
docopt2/__main__.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from docopt2 import (
|
|
9
|
+
Dispatch,
|
|
10
|
+
__version__,
|
|
11
|
+
check,
|
|
12
|
+
check_compat,
|
|
13
|
+
format_usage,
|
|
14
|
+
generate_config_template,
|
|
15
|
+
generate_examples,
|
|
16
|
+
generate_stub,
|
|
17
|
+
)
|
|
18
|
+
from docopt2._diagnostics import use_color
|
|
19
|
+
from docopt2._errors import DocoptLanguageError
|
|
20
|
+
|
|
21
|
+
_STYLES = ("dataclass", "typeddict", "cli")
|
|
22
|
+
|
|
23
|
+
# The tool's own interface is a docopt usage message parsed by docopt2 itself (via Dispatch): the
|
|
24
|
+
# command line is described here, not built imperatively - the library dogfooding its own model.
|
|
25
|
+
_DOC = """\
|
|
26
|
+
docopt2 - typed tooling for docopt usage messages.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
docopt2 stub <source> [--name=<name>] [--style=<style>]
|
|
30
|
+
docopt2 check <source>
|
|
31
|
+
docopt2 examples <source> [--count=<n>] [--invalid] [--seed=<n>]
|
|
32
|
+
docopt2 config-template <source>
|
|
33
|
+
docopt2 compat <old-source> <new-source>
|
|
34
|
+
docopt2 fmt <source>
|
|
35
|
+
docopt2 (-h | --help)
|
|
36
|
+
docopt2 --version
|
|
37
|
+
|
|
38
|
+
<source> is a Python file (its module docstring is read, without importing it), a text
|
|
39
|
+
file of raw usage, or - for standard input.
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--name=<name> Class name for the generated schema [default: Args].
|
|
43
|
+
--style=<style> Schema style: dataclass, typeddict, or cli [default: dataclass].
|
|
44
|
+
--count=<n> How many example invocations to generate [default: 10].
|
|
45
|
+
--invalid Generate argument vectors the usage rejects, not ones it accepts.
|
|
46
|
+
--seed=<n> Seed the generator for reproducible output.
|
|
47
|
+
-h --help Show this help and exit.
|
|
48
|
+
--version Show the docopt2 version and exit.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _CliError(Exception):
|
|
53
|
+
"""A user-facing CLI error (bad --style, no readable usage); reported as ``error: ...``, exit 1."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_usage(source: str) -> str:
|
|
57
|
+
"""Load the usage message from ``source``: stdin (``-``), a ``.py`` module docstring, or a text file."""
|
|
58
|
+
if source == "-":
|
|
59
|
+
return sys.stdin.read()
|
|
60
|
+
path = Path(source)
|
|
61
|
+
try:
|
|
62
|
+
text = path.read_text(encoding="utf-8")
|
|
63
|
+
except UnicodeDecodeError as error:
|
|
64
|
+
raise _CliError(f"{source}: not a UTF-8 text file") from error
|
|
65
|
+
if path.suffix != ".py":
|
|
66
|
+
return text
|
|
67
|
+
docstring = ast.get_docstring(ast.parse(text))
|
|
68
|
+
if docstring is None:
|
|
69
|
+
raise _CliError(f"{source}: no module docstring to read a usage message from")
|
|
70
|
+
return docstring
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_stub(arguments: Any) -> int:
|
|
74
|
+
style = arguments["--style"]
|
|
75
|
+
if style not in _STYLES:
|
|
76
|
+
raise _CliError(f"--style must be dataclass, typeddict, or cli, not {style!r}")
|
|
77
|
+
print(generate_stub(_read_usage(arguments["<source>"]), name=arguments["--name"], style=style), end="")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run_check(arguments: Any) -> int:
|
|
82
|
+
warnings = check(_read_usage(arguments["<source>"]))
|
|
83
|
+
for warning in warnings:
|
|
84
|
+
print(warning.render(color=use_color(sys.stderr)), file=sys.stderr)
|
|
85
|
+
return 1 if warnings else 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _as_int(value: str, flag: str) -> int:
|
|
89
|
+
try:
|
|
90
|
+
return int(value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise _CliError(f"{flag} must be an integer, not {value!r}") from None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _run_examples(arguments: Any) -> int:
|
|
96
|
+
seed = None if arguments["--seed"] is None else _as_int(arguments["--seed"], "--seed")
|
|
97
|
+
examples = generate_examples(
|
|
98
|
+
_read_usage(arguments["<source>"]),
|
|
99
|
+
count=_as_int(arguments["--count"], "--count"),
|
|
100
|
+
valid=not arguments["--invalid"],
|
|
101
|
+
seed=seed,
|
|
102
|
+
)
|
|
103
|
+
for argv in examples:
|
|
104
|
+
print(" ".join(argv))
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run_config_template(arguments: Any) -> int:
|
|
109
|
+
print(generate_config_template(_read_usage(arguments["<source>"])), end="")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _run_compat(arguments: Any) -> int:
|
|
114
|
+
breaks = check_compat(_read_usage(arguments["<old-source>"]), _read_usage(arguments["<new-source>"]))
|
|
115
|
+
for entry in breaks:
|
|
116
|
+
print(entry, file=sys.stderr)
|
|
117
|
+
return 1 if breaks else 0 # like `check`: silent and 0 when no breakage is found, else the breaks and 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_fmt(arguments: Any) -> int:
|
|
121
|
+
print(format_usage(_read_usage(arguments["<source>"])), end="")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv: list[str] | None = None) -> int:
|
|
126
|
+
"""Entry point for the ``docopt2`` console command and ``python -m docopt2``."""
|
|
127
|
+
app = Dispatch(_DOC)
|
|
128
|
+
app.on("stub")(_run_stub)
|
|
129
|
+
app.on("check")(_run_check)
|
|
130
|
+
app.on("examples")(_run_examples)
|
|
131
|
+
app.on("config-template")(_run_config_template)
|
|
132
|
+
app.on("compat")(_run_compat)
|
|
133
|
+
app.on("fmt")(_run_fmt)
|
|
134
|
+
try:
|
|
135
|
+
result: int = app.run(argv, version=__version__, complete=False)
|
|
136
|
+
except (_CliError, DocoptLanguageError, OSError, SyntaxError, ValueError) as exc:
|
|
137
|
+
# ValueError also covers generate_stub rejecting a bad --name and a NUL byte in a source file.
|
|
138
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__": # pragma: no cover - module entry point
|
|
144
|
+
sys.exit(main())
|
docopt2/_compat.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from docopt2._core import docopt
|
|
6
|
+
from docopt2._errors import DocoptExit, DocoptLanguageError
|
|
7
|
+
from docopt2._generate import _usage_pattern, generate_examples
|
|
8
|
+
from docopt2._parser import Command, Option, Pattern
|
|
9
|
+
|
|
10
|
+
_MAX_EXAMPLES = 5 # distinct counterexample shapes to report; the report stays scannable, not a wall of argvs
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _usable(doc: str, kind: type[Pattern]) -> set[str]:
|
|
14
|
+
"""The names of the leaves of ``kind`` actually usable in ``doc`` (its ``[options]`` shortcut expanded)."""
|
|
15
|
+
return {leaf.name for leaf in _usage_pattern(doc).flat(kind) if leaf.name is not None}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_compat(old_doc: str, new_doc: str, *, samples: int = 300) -> list[str]:
|
|
19
|
+
"""Report backward-incompatible changes from ``old_doc`` to ``new_doc``, most reliable part first.
|
|
20
|
+
|
|
21
|
+
Every entry is a *definite* break - an invocation the old usage accepts that the new one rejects: a
|
|
22
|
+
removed option or command (named, structural), or a concrete argument vector the new grammar no longer
|
|
23
|
+
accepts (found by sampling the old grammar's accepted set and replaying it against the new).
|
|
24
|
+
|
|
25
|
+
An empty list means **no break was found**, not a proof of compatibility: the accepted set is infinite
|
|
26
|
+
and only ``samples`` invocations are checked, so read it like a passing test ("no breakage detected"),
|
|
27
|
+
never as a guarantee. It never claims "compatible" - it only surfaces breaks it can prove.
|
|
28
|
+
"""
|
|
29
|
+
removed_options = _usable(old_doc, Option) - _usable(new_doc, Option)
|
|
30
|
+
removed_commands = _usable(old_doc, Command) - _usable(new_doc, Command)
|
|
31
|
+
breaks = [f"option `{name}` removed" for name in sorted(removed_options)]
|
|
32
|
+
breaks += [f"command `{name}` removed" for name in sorted(removed_commands)]
|
|
33
|
+
removed = removed_options | removed_commands
|
|
34
|
+
seen_shapes: set[tuple[str, ...]] = set()
|
|
35
|
+
examples: list[str] = []
|
|
36
|
+
for argv in generate_examples(old_doc, count=samples, seed=0):
|
|
37
|
+
shape = tuple(re.sub(r"v\d+", "<val>", token) for token in argv) # collapse placeholder values
|
|
38
|
+
if shape in seen_shapes or _explained(argv, removed): # one example per shape; skip structural repeats
|
|
39
|
+
seen_shapes.add(shape)
|
|
40
|
+
continue
|
|
41
|
+
seen_shapes.add(shape)
|
|
42
|
+
try:
|
|
43
|
+
docopt(new_doc, argv, help=False, complete=False)
|
|
44
|
+
except (DocoptExit, DocoptLanguageError):
|
|
45
|
+
examples.append(f"`{' '.join(argv)}` no longer accepted")
|
|
46
|
+
if len(examples) >= _MAX_EXAMPLES:
|
|
47
|
+
break
|
|
48
|
+
return breaks + examples
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _explained(argv: list[str], removed: set[str]) -> bool:
|
|
52
|
+
"""Whether the argv uses a removed option or command - so a named structural break already covers it."""
|
|
53
|
+
return any(token.split("=", 1)[0] in removed or token in removed for token in argv)
|
docopt2/_completion.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
from docopt2._errors import DocoptExit, DocoptLanguageError
|
|
9
|
+
from docopt2._parser import (
|
|
10
|
+
MATCH_LIMIT,
|
|
11
|
+
Argument,
|
|
12
|
+
Command,
|
|
13
|
+
Either,
|
|
14
|
+
LeafPattern,
|
|
15
|
+
OneOrMore,
|
|
16
|
+
Option,
|
|
17
|
+
Optional,
|
|
18
|
+
OptionsShortcut,
|
|
19
|
+
Pattern,
|
|
20
|
+
Required,
|
|
21
|
+
Tokens,
|
|
22
|
+
_option_chunks,
|
|
23
|
+
expand_options_shortcut,
|
|
24
|
+
formal_usage,
|
|
25
|
+
parse_argv,
|
|
26
|
+
parse_defaults,
|
|
27
|
+
parse_pattern,
|
|
28
|
+
single_usage_section,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
33
|
+
|
|
34
|
+
# Environment protocol shared with the generated shell scripts: when completion fires, the script
|
|
35
|
+
# sets these variables and re-invokes the program, whose docopt() call answers with the candidates.
|
|
36
|
+
_TRIGGER_ENV = "_DOCOPT2_COMPLETE"
|
|
37
|
+
_WORDS_ENV = "_DOCOPT2_WORDS"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --- the resolver: which tokens may legally come next -------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _frontier(node: Pattern, left: list[Pattern], *, block: bool) -> Iterator[tuple[list[Pattern], set[Pattern], bool]]:
|
|
44
|
+
"""Yield ``(remaining, frontier, blocks)`` per partial-consumption path; a token sequence is a
|
|
45
|
+
valid prefix iff some path leaves ``remaining == []``.
|
|
46
|
+
|
|
47
|
+
``frontier`` is the leaves that could match the NEXT token. An option floats (offerable, and the
|
|
48
|
+
path proceeds past it, non-blocking); an unfilled positional/command sets ``blocks`` to ``block``,
|
|
49
|
+
so the command pass (``block=True``) stops at the next positional while the option pass
|
|
50
|
+
(``block=False``) floats past it to reach later options. A present positional that fails is dead.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(node, LeafPattern):
|
|
53
|
+
position, matched = node.single_match(left)
|
|
54
|
+
if matched is not None and position is not None:
|
|
55
|
+
yield left[:position] + left[position + 1 :], set(), False # already typed; consume it
|
|
56
|
+
elif isinstance(node, Option):
|
|
57
|
+
yield left, {node}, False # floats: offerable next, sequence proceeds (deferred)
|
|
58
|
+
elif not any(type(leaf) is Argument for leaf in left):
|
|
59
|
+
yield left, {node}, block # unfilled positional/command (only options, if any, remain)
|
|
60
|
+
return
|
|
61
|
+
if isinstance(node, Either):
|
|
62
|
+
for child in node.children:
|
|
63
|
+
yield from _frontier(child, left, block=block)
|
|
64
|
+
return
|
|
65
|
+
if isinstance(node, OneOrMore):
|
|
66
|
+
for remaining, frontier, blocks in _frontier(node.children[0], left, block=block):
|
|
67
|
+
yield remaining, frontier, blocks
|
|
68
|
+
if not frontier and len(remaining) < len(left): # consumed one and progressed: may repeat
|
|
69
|
+
yield from _frontier(node, remaining, block=block)
|
|
70
|
+
return
|
|
71
|
+
if isinstance(node, (Optional, OptionsShortcut)):
|
|
72
|
+
yield from _frontier_seq(node.children, left, optional=True, block=block)
|
|
73
|
+
return
|
|
74
|
+
yield from _frontier_seq(cast("Required", node).children, left, optional=False, block=block)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _frontier_seq(
|
|
78
|
+
children: list[Pattern], left: list[Pattern], *, optional: bool, block: bool
|
|
79
|
+
) -> Iterator[tuple[list[Pattern], set[Pattern], bool]]:
|
|
80
|
+
if not children:
|
|
81
|
+
yield left, set(), False
|
|
82
|
+
return
|
|
83
|
+
head, rest = children[0], children[1:]
|
|
84
|
+
for remaining, frontier, blocks in _frontier(head, left, block=block):
|
|
85
|
+
if blocks: # an unfilled required positional; stop, do not advance past it
|
|
86
|
+
yield remaining, frontier, True
|
|
87
|
+
else: # head consumed input or floated past; advance, carrying its frontier along
|
|
88
|
+
for tail_remaining, tail_frontier, tail_blocks in _frontier_seq(
|
|
89
|
+
rest, remaining, optional=optional, block=block
|
|
90
|
+
):
|
|
91
|
+
yield tail_remaining, frontier | tail_frontier, tail_blocks
|
|
92
|
+
if optional: # an optional child may be skipped entirely
|
|
93
|
+
yield from _frontier_seq(rest, left, optional=optional, block=block)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve(doc: str, typed: list[str]) -> list[str]:
|
|
97
|
+
"""Names (commands and options) that may legally follow the already-typed ``typed`` tokens."""
|
|
98
|
+
options = parse_defaults(doc)
|
|
99
|
+
pattern = parse_pattern(formal_usage(single_usage_section(doc)), options)
|
|
100
|
+
expand_options_shortcut(pattern, options)
|
|
101
|
+
base = parse_argv(Tokens(list(typed)), list(options))
|
|
102
|
+
after_separator = "--" in typed and not any(leaf.name == "--" for leaf in pattern.flat(Command))
|
|
103
|
+
# Command pass: the typed positionals only (options and `--` stripped), positionals blocking so
|
|
104
|
+
# only the next command in order is offered. Bounded, so an exponential-skip pattern cannot hang.
|
|
105
|
+
positionals: list[Pattern] = [leaf for leaf in base if type(leaf) is Argument and leaf.value != "--"]
|
|
106
|
+
command_paths = itertools.islice(_frontier(pattern, positionals, block=True), MATCH_LIMIT)
|
|
107
|
+
command_frontiers = [frontier for remaining, frontier, _b in command_paths if remaining == []]
|
|
108
|
+
if not command_frontiers:
|
|
109
|
+
return [] # the typed positionals cannot be consumed - not a valid prefix, nothing to complete
|
|
110
|
+
names: set[str] = {
|
|
111
|
+
cast("str", leaf.name) for frontier in command_frontiers for leaf in frontier if type(leaf) is Command
|
|
112
|
+
}
|
|
113
|
+
if after_separator:
|
|
114
|
+
return sorted(names) # options are not parsed after a POSIX `--` separator
|
|
115
|
+
# Option pass: over the full prefix, positionals do NOT block, so the walk floats past unfilled
|
|
116
|
+
# positionals to every reachable unconsumed option (repeats return via `...`); dead branches prune.
|
|
117
|
+
for remaining, frontier, _b in itertools.islice(_frontier(pattern, base, block=False), MATCH_LIMIT):
|
|
118
|
+
if remaining == []:
|
|
119
|
+
names.update(str(leaf.long or leaf.short) for leaf in frontier if isinstance(leaf, Option))
|
|
120
|
+
return sorted(names)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def complete(doc: str, words: Sequence[str]) -> list[str]:
|
|
124
|
+
"""Return the completion candidates for the last (cursor) word of ``words``.
|
|
125
|
+
|
|
126
|
+
Earlier tokens are consumed against the usage pattern; the command literals and option names
|
|
127
|
+
(never positional values) that could legally come next are returned, filtered to the partial
|
|
128
|
+
word. A malformed doc or a prefix ending mid-option-argument yields no candidates, never raises.
|
|
129
|
+
"""
|
|
130
|
+
tokens = list(words)
|
|
131
|
+
incomplete = tokens[-1] if tokens else ""
|
|
132
|
+
typed = tokens[:-1]
|
|
133
|
+
try:
|
|
134
|
+
candidates = _resolve(doc, typed)
|
|
135
|
+
except (DocoptLanguageError, DocoptExit, RecursionError):
|
|
136
|
+
# a malformed doc, a prefix ending mid-option-argument, or a pathologically deep pattern:
|
|
137
|
+
# complete nothing rather than raising into the shell
|
|
138
|
+
return []
|
|
139
|
+
return [name for name in candidates if name.startswith(incomplete)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _describe(doc: str) -> dict[str, str]:
|
|
143
|
+
"""Map each option name (long and short) to its help text from the ``Options:`` block(s).
|
|
144
|
+
|
|
145
|
+
This is the same right-hand column ``Option.parse`` reads for ``[default: ...]``, kept here for
|
|
146
|
+
completion tooltips instead of discarded. Options with no description, and commands (which have no
|
|
147
|
+
description column), simply get no entry, so their completion candidate shows without a tooltip.
|
|
148
|
+
"""
|
|
149
|
+
descriptions: dict[str, str] = {}
|
|
150
|
+
for chunk in _option_chunks(doc):
|
|
151
|
+
option = Option.parse(chunk)
|
|
152
|
+
_, _, text = chunk.strip().partition(" ")
|
|
153
|
+
# Collapse wrapped continuation lines and stray tabs to single spaces: a newline or tab in
|
|
154
|
+
# the text would split the `name\tdescription` reply line the shells rely on, injecting a
|
|
155
|
+
# bogus candidate. join(split()) also trims the ends before the trailing period is dropped.
|
|
156
|
+
text = " ".join(text.split()).rstrip(".").strip()
|
|
157
|
+
for name in (option.long, option.short):
|
|
158
|
+
if name is not None:
|
|
159
|
+
descriptions[name] = text
|
|
160
|
+
return descriptions
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def reply_to_completion_request(doc: str) -> str | None:
|
|
164
|
+
"""If a completion request is in the environment, return its reply, else None.
|
|
165
|
+
|
|
166
|
+
Each reply line is ``name\\tdescription`` (the description may be empty). The request var holds the
|
|
167
|
+
completed tokens before the cursor; only a :func:`generate_completion` script sets it, so a normal
|
|
168
|
+
run returns None. Shells that show descriptions (zsh, fish, PowerShell) render the second column;
|
|
169
|
+
bash keeps the name only.
|
|
170
|
+
"""
|
|
171
|
+
if os.environ.get(_TRIGGER_ENV) is None:
|
|
172
|
+
return None
|
|
173
|
+
raw = os.environ.get(_WORDS_ENV, "")
|
|
174
|
+
words = raw.split("\n") if raw else []
|
|
175
|
+
try:
|
|
176
|
+
descriptions = _describe(doc)
|
|
177
|
+
except DocoptLanguageError:
|
|
178
|
+
descriptions = {} # a malformed Options section must not raise into the shell on Tab
|
|
179
|
+
return "\n".join(f"{name}\t{descriptions.get(name, '')}" for name in complete(doc, [*words, ""]))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- shell scripts: thin callbacks that ask the program at completion time ----------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _function_name(prog: str) -> str:
|
|
186
|
+
"""A shell-function-safe identifier derived from the program name."""
|
|
187
|
+
return "_" + re.sub(r"\W", "_", prog) + "_completion"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _render_bash(prog: str, function: str) -> str:
|
|
191
|
+
# bash completion shows names only, so strip the tab-joined description column with `cut -f1`.
|
|
192
|
+
return (
|
|
193
|
+
f"{function}() {{\n"
|
|
194
|
+
f" local IFS=$'\\n'\n"
|
|
195
|
+
f' local words="${{COMP_WORDS[*]:1:COMP_CWORD-1}}"\n' # completed tokens, not the current word
|
|
196
|
+
f' local reply; reply="$({_TRIGGER_ENV}=1 {_WORDS_ENV}="$words" "${{COMP_WORDS[0]}}" | cut -f1)"\n'
|
|
197
|
+
f' COMPREPLY=( $(compgen -W "$reply" -- "${{COMP_WORDS[COMP_CWORD]}}") )\n'
|
|
198
|
+
f"}}\n"
|
|
199
|
+
f"complete -F {function} {prog}\n"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _render_zsh(prog: str, function: str) -> str:
|
|
204
|
+
# Split each `name\tdescription` reply line and hand it to `_describe`, which renders the two-column
|
|
205
|
+
# name/description list and filters by the word being completed. Template, not an f-string, to keep
|
|
206
|
+
# the brace- and quote-heavy zsh readable.
|
|
207
|
+
template = (
|
|
208
|
+
"#compdef __PROG__\n"
|
|
209
|
+
"__FUNC__() {\n"
|
|
210
|
+
' local joined="${(pj:\\n:)words[2,CURRENT-1]}"\n' # completed tokens, not the current word
|
|
211
|
+
" local -a lines described\n"
|
|
212
|
+
' lines=("${(@f)$(__TRIGGER__=1 __WORDS__=$joined ${words[1]})}")\n'
|
|
213
|
+
" local line\n"
|
|
214
|
+
" for line in $lines; do\n"
|
|
215
|
+
" described+=(\"${line%%$'\\t'*}:${line#*$'\\t'}\")\n"
|
|
216
|
+
" done\n"
|
|
217
|
+
" _describe -t candidates candidate described\n"
|
|
218
|
+
"}\n"
|
|
219
|
+
"compdef __FUNC__ __PROG__\n"
|
|
220
|
+
)
|
|
221
|
+
return (
|
|
222
|
+
template.replace("__PROG__", prog)
|
|
223
|
+
.replace("__FUNC__", function)
|
|
224
|
+
.replace("__TRIGGER__", _TRIGGER_ENV)
|
|
225
|
+
.replace("__WORDS__", _WORDS_ENV)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _render_fish(prog: str, function: str) -> str:
|
|
230
|
+
return (
|
|
231
|
+
f"function {function}\n"
|
|
232
|
+
# `commandline -opc` is the completed tokens before the cursor (the partial word is excluded);
|
|
233
|
+
# fish filters the candidates we return by that partial word itself.
|
|
234
|
+
f" set -l tokens (commandline -opc)\n"
|
|
235
|
+
f" env {_TRIGGER_ENV}=1 {_WORDS_ENV}=(string join \\n -- $tokens[2..-1]) $tokens[1]\n"
|
|
236
|
+
f"end\n"
|
|
237
|
+
f"complete -c {prog} -f -a '({function})'\n"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _render_powershell(prog: str, _function: str) -> str:
|
|
242
|
+
# PowerShell script blocks are brace-heavy, so build from a template with plain placeholders
|
|
243
|
+
# rather than an f-string that would need every `{`/`}` doubled.
|
|
244
|
+
template = (
|
|
245
|
+
"Register-ArgumentCompleter -Native -CommandName __PROG__ -ScriptBlock {\n"
|
|
246
|
+
" param($wordToComplete, $commandAst, $cursorPosition)\n"
|
|
247
|
+
" $words = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() })\n"
|
|
248
|
+
" if ($wordToComplete -ne '' -and $words.Count -gt 0) { $words = @($words | Select-Object -SkipLast 1) }\n"
|
|
249
|
+
" $env:__TRIGGER__ = '1'\n"
|
|
250
|
+
' $env:__WORDS__ = ($words -join "`n")\n'
|
|
251
|
+
" try {\n"
|
|
252
|
+
" & $commandAst.CommandElements[0].ToString() | ForEach-Object {\n"
|
|
253
|
+
' $parts = $_ -split "`t", 2\n' # reply line is name`tdescription
|
|
254
|
+
" $name = $parts[0]\n"
|
|
255
|
+
' if ($name -like "$wordToComplete*") {\n'
|
|
256
|
+
" $tip = if ($parts.Count -ge 2 -and $parts[1]) { $parts[1] } else { $name }\n"
|
|
257
|
+
" [System.Management.Automation.CompletionResult]::new($name, $name, 'ParameterValue', $tip)\n"
|
|
258
|
+
" }\n"
|
|
259
|
+
" }\n"
|
|
260
|
+
" } finally {\n"
|
|
261
|
+
" Remove-Item Env:__TRIGGER__ -ErrorAction SilentlyContinue\n"
|
|
262
|
+
" Remove-Item Env:__WORDS__ -ErrorAction SilentlyContinue\n"
|
|
263
|
+
" }\n"
|
|
264
|
+
"}\n"
|
|
265
|
+
)
|
|
266
|
+
return template.replace("__PROG__", prog).replace("__TRIGGER__", _TRIGGER_ENV).replace("__WORDS__", _WORDS_ENV)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
_RENDERERS: dict[str, Callable[[str, str], str]] = {
|
|
270
|
+
"bash": _render_bash,
|
|
271
|
+
"zsh": _render_zsh,
|
|
272
|
+
"fish": _render_fish,
|
|
273
|
+
"powershell": _render_powershell,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def generate_completion(doc: str, prog: str, shell: str = "bash") -> str:
|
|
278
|
+
"""Return a context-aware shell completion script for the CLI described by ``doc``.
|
|
279
|
+
|
|
280
|
+
``shell`` is one of ``"bash"``, ``"zsh"``, ``"fish"`` or ``"powershell"`` - the same four Click
|
|
281
|
+
and Typer emit. The script is a thin callback: at each Tab it re-invokes the program with a
|
|
282
|
+
completion request in the environment, and the program's :func:`docopt` call resolves the
|
|
283
|
+
tokens legal at the cursor from the usage grammar (so suggestions narrow to the matched
|
|
284
|
+
subcommand's options and arguments, not a flat global list).
|
|
285
|
+
|
|
286
|
+
A docopt program answers the script's requests by default; a program that does not want this
|
|
287
|
+
passes ``docopt(..., complete=False)`` to opt out.
|
|
288
|
+
"""
|
|
289
|
+
if shell not in _RENDERERS:
|
|
290
|
+
supported = ", ".join(sorted(_RENDERERS))
|
|
291
|
+
raise ValueError(f"unsupported shell: {shell!r}; expected one of {supported}")
|
|
292
|
+
# The script interpolates `prog` into shell/PowerShell source that is sourced or eval'd, so a
|
|
293
|
+
# name with metacharacters (`;`, `$()`, spaces, quotes) would inject or corrupt it. Require a
|
|
294
|
+
# plain command name.
|
|
295
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]+", prog):
|
|
296
|
+
raise ValueError(f"prog {prog!r} must be a plain command name (letters, digits, . _ -)")
|
|
297
|
+
# Validate the docstring up front so a malformed usage fails loudly here, not silently at Tab.
|
|
298
|
+
single_usage_section(doc)
|
|
299
|
+
return _RENDERERS[shell](prog, _function_name(prog))
|