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 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))