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/_core.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import itertools
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
9
|
+
from typing import Any, ClassVar, TypeVar, cast, overload
|
|
10
|
+
|
|
11
|
+
from docopt2._completion import reply_to_completion_request
|
|
12
|
+
from docopt2._diagnostics import Caret, Diagnostic, Snippet, use_color
|
|
13
|
+
from docopt2._errors import DocoptExit, DocoptLanguageError
|
|
14
|
+
from docopt2._help import render_help
|
|
15
|
+
from docopt2._parser import (
|
|
16
|
+
MATCH_LIMIT,
|
|
17
|
+
Argument,
|
|
18
|
+
Command,
|
|
19
|
+
Option,
|
|
20
|
+
Pattern,
|
|
21
|
+
Tokens,
|
|
22
|
+
expand_options_shortcut,
|
|
23
|
+
formal_tokens,
|
|
24
|
+
formal_usage,
|
|
25
|
+
nearest_usage_line,
|
|
26
|
+
parse_argument_defaults,
|
|
27
|
+
parse_argv,
|
|
28
|
+
parse_defaults,
|
|
29
|
+
parse_pattern,
|
|
30
|
+
required_leaf_names,
|
|
31
|
+
single_usage_section,
|
|
32
|
+
)
|
|
33
|
+
from docopt2._spellcheck import _closest, suggest_option
|
|
34
|
+
from docopt2._typed import _CoercionError, bind_schema
|
|
35
|
+
|
|
36
|
+
SchemaT = TypeVar("SchemaT")
|
|
37
|
+
CliT = TypeVar("CliT", bound="Cli")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Source(enum.Enum):
|
|
41
|
+
"""Where a resolved value came from, in the precedence order docopt2 applies (highest first)."""
|
|
42
|
+
|
|
43
|
+
CLI = "cli"
|
|
44
|
+
ENV = "env"
|
|
45
|
+
CONFIG = "config"
|
|
46
|
+
DEFAULT = "default"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Arguments(dict[str, Any]):
|
|
50
|
+
"""Mapping of parsed element names (``"--flag"``, ``"<arg>"``, ``"command"``) to their values
|
|
51
|
+
(``str | bool | int | list[str] | None``); the back-compat return type, narrowed by the typed API.
|
|
52
|
+
|
|
53
|
+
``provided`` is the set of names actually supplied in ``argv`` (so a defaulted value is
|
|
54
|
+
distinguishable from an explicit one); ``extra`` holds leftover tokens kept by ``allow_extra=True``.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
58
|
+
super().__init__(*args, **kwargs)
|
|
59
|
+
self.provided: frozenset[str] = frozenset()
|
|
60
|
+
self.extra: list[str] = []
|
|
61
|
+
self._sources: dict[str, Source] = {}
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
body = ",\n ".join(f"{key!r}: {value!r}" for key, value in sorted(self.items()))
|
|
65
|
+
return f"{{{body}}}"
|
|
66
|
+
|
|
67
|
+
def was_given(self, name: str) -> bool:
|
|
68
|
+
"""Return whether ``name`` was supplied in ``argv`` (as opposed to left at its default)."""
|
|
69
|
+
return name in self.provided
|
|
70
|
+
|
|
71
|
+
def source(self, name: str) -> Source:
|
|
72
|
+
"""Where ``name``'s value was resolved from: the command line, ``[env:]``, ``[config:]``, or the default.
|
|
73
|
+
|
|
74
|
+
Answers "why is this value what it is?" for layered configuration; a name never resolved from a
|
|
75
|
+
fallback reports :attr:`Source.DEFAULT`.
|
|
76
|
+
"""
|
|
77
|
+
return self._sources.get(name, Source.DEFAULT)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extras(default_help: bool, version: object, options: list[Pattern], doc: str, help_style: str) -> None:
|
|
81
|
+
"""Handle the built-in ``-h``/``--help`` and ``--version`` options by printing and exiting."""
|
|
82
|
+
if default_help and any(option.name in ("-h", "--help") and option.value for option in options):
|
|
83
|
+
if help_style == "rich":
|
|
84
|
+
# scope the rendered help to the command path already typed (the positionals before --help)
|
|
85
|
+
tokens = tuple(str(leaf.value) for leaf in options if type(leaf) is Argument and leaf.value is not None)
|
|
86
|
+
print(render_help(doc, tokens, color=use_color(sys.stdout)))
|
|
87
|
+
else:
|
|
88
|
+
print(doc.strip("\n"))
|
|
89
|
+
sys.exit()
|
|
90
|
+
if version and any(option.name == "--version" and option.value for option in options):
|
|
91
|
+
print(version)
|
|
92
|
+
sys.exit()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _argv_snippet(argv: list[str] | tuple[str, ...] | str, token: str, label: str) -> Snippet:
|
|
96
|
+
"""An 'in the arguments:' snippet with a caret under ``token`` (dropped if not a literal substring)."""
|
|
97
|
+
argv_text = argv if isinstance(argv, str) else " ".join(str(item) for item in argv)
|
|
98
|
+
at = argv_text.find(token)
|
|
99
|
+
carets = [Caret(at, at + len(token), label)] if at != -1 else []
|
|
100
|
+
return Snippet(argv_text, "in the arguments:", carets)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _env_truthy(value: str) -> bool:
|
|
104
|
+
"""Interpret a fallback value for a boolean flag: set unless it reads as empty or false."""
|
|
105
|
+
return value.strip().lower() not in {"", "0", "false", "no", "off"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _span_of(leaves: Iterable[Pattern], name: str) -> tuple[int, int] | None:
|
|
109
|
+
"""The usage span of the leaf named ``name`` (the first that carries one), or None."""
|
|
110
|
+
return next((leaf.span for leaf in leaves if leaf.name == name and leaf.span is not None), None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _coercion_diagnostic(doc: str, argv: list[str] | tuple[str, ...] | str, err: _CoercionError) -> Diagnostic:
|
|
114
|
+
"""Render a schema coercion failure with the same two-span caret used for match errors: the value
|
|
115
|
+
in the argv, cross-referenced to the usage element that declared its type."""
|
|
116
|
+
usage = single_usage_section(doc)
|
|
117
|
+
snippets = []
|
|
118
|
+
in_argv = _argv_snippet(argv, str(err.raw), f"expected {err.expected}")
|
|
119
|
+
if in_argv.carets: # only caret the argv when the value is literally there (a CLI value, not env/default)
|
|
120
|
+
snippets.append(in_argv)
|
|
121
|
+
# Build the pattern the same way docopt() does (formal_tokens), so the leaf spans align with `usage`;
|
|
122
|
+
# parse_tree() uses formal_usage and would offset the caret.
|
|
123
|
+
pattern = parse_pattern(formal_tokens(usage), parse_defaults(doc))
|
|
124
|
+
where = _span_of(pattern.flat(), err.key)
|
|
125
|
+
if where is not None:
|
|
126
|
+
caret = Caret(*where, f"typed as {err.expected}")
|
|
127
|
+
snippets.append(Snippet(usage, "in the usage:", [caret]))
|
|
128
|
+
# "one of `a`, `b`" reads as "is not one of ..."; a plain type reads as "is not a valid int".
|
|
129
|
+
if err.expected.startswith("one of "):
|
|
130
|
+
advice = f"`{err.raw}` is not {err.expected}"
|
|
131
|
+
choices = [match.group(1) for match in re.finditer(r"`([^`]+)`", err.expected)]
|
|
132
|
+
suggestion = _closest(str(err.raw), choices) # a mistyped choice gets a spell-checked "did you mean"
|
|
133
|
+
if suggestion is not None:
|
|
134
|
+
advice += f" - did you mean `{suggestion}`?"
|
|
135
|
+
else:
|
|
136
|
+
advice = f"`{err.raw}` is not a valid {err.expected}"
|
|
137
|
+
return Diagnostic(summary=f"invalid value for `{err.key}`", snippets=snippets, help=advice)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _config_lookup(config: Mapping[str, Any], key: str) -> Any:
|
|
141
|
+
"""Walk a dotted `[config: a.b.c]` key into the config mapping, or None if any level is absent."""
|
|
142
|
+
node: Any = config
|
|
143
|
+
for part in key.split("."):
|
|
144
|
+
if not isinstance(node, Mapping) or part not in node:
|
|
145
|
+
return None
|
|
146
|
+
node = node[part]
|
|
147
|
+
return node
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _fallback_value(option: Option, config: Mapping[str, Any] | None) -> tuple[str, Source] | None:
|
|
151
|
+
"""The env-then-config fallback for an omitted option (env wins), with its source, or None to default.
|
|
152
|
+
|
|
153
|
+
An empty or unset source is treated as absent - the shell ``${VAR:-default}`` convention - so a blank
|
|
154
|
+
environment variable never silently overrides the config or default with an empty string.
|
|
155
|
+
"""
|
|
156
|
+
if option.env is not None:
|
|
157
|
+
env_value = os.environ.get(option.env)
|
|
158
|
+
if env_value: # non-empty; unset or "" falls through
|
|
159
|
+
return env_value, Source.ENV
|
|
160
|
+
if option.config_key is not None and config is not None:
|
|
161
|
+
found = _config_lookup(config, option.config_key)
|
|
162
|
+
if found is not None and str(found): # non-empty; a null or blank config value falls through
|
|
163
|
+
return str(found), Source.CONFIG # normalize to a string, so the schema coerces it like a CLI value
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _apply_fallbacks(result: Arguments, options: list[Option], config: Mapping[str, Any] | None) -> None:
|
|
168
|
+
"""Fill options absent from argv from their declared sources: CLI (provided) > env > config > default.
|
|
169
|
+
|
|
170
|
+
An option given on the command line (in ``result.provided``) is left untouched; otherwise an
|
|
171
|
+
``[env: VAR]`` (then a ``[config: key]`` against ``config``) supplies the value and records its
|
|
172
|
+
:class:`Source`, which coerces through the schema like any other string. ``was_given`` still reports
|
|
173
|
+
such an option as not given.
|
|
174
|
+
"""
|
|
175
|
+
for option in options:
|
|
176
|
+
name = option.name
|
|
177
|
+
if name is None or name in result.provided or name not in result:
|
|
178
|
+
continue
|
|
179
|
+
resolved = _fallback_value(option, config)
|
|
180
|
+
if resolved is not None:
|
|
181
|
+
raw, source = resolved
|
|
182
|
+
filled = raw if option.argcount else _env_truthy(raw)
|
|
183
|
+
# a repeating option holds a list everywhere else; keep the key's type consistent
|
|
184
|
+
result[name] = [filled] if isinstance(result[name], list) else filled
|
|
185
|
+
result._sources[name] = source
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@overload
|
|
189
|
+
def docopt(
|
|
190
|
+
doc: str | None,
|
|
191
|
+
argv: list[str] | tuple[str, ...] | str | None = ...,
|
|
192
|
+
help: bool = ...,
|
|
193
|
+
version: object = ...,
|
|
194
|
+
options_first: bool = ...,
|
|
195
|
+
*,
|
|
196
|
+
default_help: bool | None = ...,
|
|
197
|
+
suggest: bool = ...,
|
|
198
|
+
negative_numbers: bool = ...,
|
|
199
|
+
allow_abbrev: bool = ...,
|
|
200
|
+
allow_extra: bool = ...,
|
|
201
|
+
exit_code: int = ...,
|
|
202
|
+
complete: bool = ...,
|
|
203
|
+
schema: type[SchemaT],
|
|
204
|
+
config: Mapping[str, Any] | None = ...,
|
|
205
|
+
help_style: str = ...,
|
|
206
|
+
) -> SchemaT: ...
|
|
207
|
+
@overload
|
|
208
|
+
def docopt(
|
|
209
|
+
doc: str | None,
|
|
210
|
+
argv: list[str] | tuple[str, ...] | str | None = ...,
|
|
211
|
+
help: bool = ...,
|
|
212
|
+
version: object = ...,
|
|
213
|
+
options_first: bool = ...,
|
|
214
|
+
*,
|
|
215
|
+
default_help: bool | None = ...,
|
|
216
|
+
suggest: bool = ...,
|
|
217
|
+
negative_numbers: bool = ...,
|
|
218
|
+
allow_abbrev: bool = ...,
|
|
219
|
+
allow_extra: bool = ...,
|
|
220
|
+
exit_code: int = ...,
|
|
221
|
+
complete: bool = ...,
|
|
222
|
+
schema: None = ...,
|
|
223
|
+
config: Mapping[str, Any] | None = ...,
|
|
224
|
+
help_style: str = ...,
|
|
225
|
+
) -> Arguments: ...
|
|
226
|
+
def docopt(
|
|
227
|
+
doc: str | None,
|
|
228
|
+
argv: list[str] | tuple[str, ...] | str | None = None,
|
|
229
|
+
help: bool = True, # noqa: A002 - original docopt public parameter name; kept for drop-in compatibility
|
|
230
|
+
version: object = None,
|
|
231
|
+
options_first: bool = False,
|
|
232
|
+
*,
|
|
233
|
+
default_help: bool | None = None,
|
|
234
|
+
suggest: bool = False,
|
|
235
|
+
negative_numbers: bool = False,
|
|
236
|
+
allow_abbrev: bool = True,
|
|
237
|
+
allow_extra: bool = False,
|
|
238
|
+
exit_code: int = 1,
|
|
239
|
+
complete: bool = True,
|
|
240
|
+
schema: type[SchemaT] | None = None,
|
|
241
|
+
config: Mapping[str, Any] | None = None,
|
|
242
|
+
help_style: str = "raw",
|
|
243
|
+
) -> Arguments | SchemaT:
|
|
244
|
+
"""Parse ``argv`` against the command-line interface described in ``doc``.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
doc: Description of the command-line interface (the usage message).
|
|
248
|
+
argv: Argument vector to parse. ``sys.argv[1:]`` is used if omitted; a string
|
|
249
|
+
is split on whitespace.
|
|
250
|
+
help: Set to False to disable automatic help on ``-h``/``--help``. This is the
|
|
251
|
+
original docopt name, kept for drop-in compatibility.
|
|
252
|
+
version: If truthy, printed when ``--version`` appears in ``argv``.
|
|
253
|
+
options_first: Set to True to require options to precede positional arguments.
|
|
254
|
+
default_help: Alias for ``help``. When not None it takes precedence.
|
|
255
|
+
suggest: On a failed parse, if a mistyped long option resembles a known one,
|
|
256
|
+
include a "did you mean ..." hint in the ``DocoptExit`` message.
|
|
257
|
+
negative_numbers: Treat tokens like ``-3`` or ``-6.28`` as positional arguments
|
|
258
|
+
instead of short-option clusters.
|
|
259
|
+
allow_abbrev: When False, a long option in ``argv`` must be written in full; an
|
|
260
|
+
unambiguous prefix like ``--ver`` no longer de-abbreviates to ``--version``.
|
|
261
|
+
allow_extra: When True, leftover ``argv`` tokens that the usage cannot place no longer
|
|
262
|
+
raise; the best partial match is returned and the surplus is exposed on the result's
|
|
263
|
+
``extra`` list (the ``parse_known_args`` idiom). Missing required elements still fail.
|
|
264
|
+
exit_code: Process status carried by a ``DocoptExit`` from a failed parse. The default,
|
|
265
|
+
1, keeps the usage message auto-printing on an uncaught error; any other value exits
|
|
266
|
+
with that status (per ``SystemExit``, the message then travels on ``str(exc)``).
|
|
267
|
+
complete: Answer shell completion requests (on by default). docopt inspects the environment
|
|
268
|
+
for a request from a ``generate_completion`` script; when one is present it prints the
|
|
269
|
+
candidates and exits, otherwise it parses normally. Set to False to opt out, so this
|
|
270
|
+
call never responds to the completion protocol (the check is one environment lookup).
|
|
271
|
+
schema: If given (a dataclass, TypedDict, or pydantic model), the parsed result
|
|
272
|
+
is bound to it and returned as that type instead of an ``Arguments`` mapping.
|
|
273
|
+
config: A mapping (from a config file you loaded) to resolve ``[config: key]`` fallbacks
|
|
274
|
+
against. Precedence is command-line argument > ``[env: VAR]`` > ``[config: key]`` >
|
|
275
|
+
``[default: ...]``. Left as ``None``, any ``[config: ...]`` annotation is inert.
|
|
276
|
+
help_style: ``"raw"`` (the default) prints the usage message verbatim on ``--help``, as docopt
|
|
277
|
+
does. ``"rich"`` renders an aligned, colored help screen that documents each option's value
|
|
278
|
+
source - its ``[env: ...]``/``[config: ...]``/``[default: ...]`` chain - and is scoped to the
|
|
279
|
+
command path already typed (``prog sub --help`` shows only ``sub``).
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
An ``Arguments`` mapping of element names to parsed values, or an instance of
|
|
283
|
+
``schema`` when one is supplied. On the mapping, ``provided`` is the set of names given
|
|
284
|
+
in ``argv`` and ``extra`` holds surplus tokens kept by ``allow_extra``.
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
DocoptLanguageError: The usage message is malformed, or the schema disagrees
|
|
288
|
+
with it (see ``bind_schema``).
|
|
289
|
+
DocoptExit: The user-supplied ``argv`` does not match, or a value cannot be
|
|
290
|
+
coerced to a schema field's declared type.
|
|
291
|
+
|
|
292
|
+
Example:
|
|
293
|
+
``docopt("Usage: prog <host> <port>", "127.0.0.1 8080")`` returns an ``Arguments``
|
|
294
|
+
mapping ``{"<host>": "127.0.0.1", "<port>": "8080"}``. Passing ``schema=Args`` (a
|
|
295
|
+
dataclass with ``host: str`` and ``port: int``) instead returns an ``Args`` whose
|
|
296
|
+
``port`` is an ``int``.
|
|
297
|
+
"""
|
|
298
|
+
if doc is None:
|
|
299
|
+
raise DocoptLanguageError(Diagnostic(summary="doc (the usage message) must not be None").render())
|
|
300
|
+
if complete:
|
|
301
|
+
# On by default (opt out with complete=False): answer a shell completion request from the
|
|
302
|
+
# environment and exit; only a generate_completion script sets it, so a normal run gets None.
|
|
303
|
+
completion_reply = reply_to_completion_request(doc)
|
|
304
|
+
if completion_reply is not None:
|
|
305
|
+
print(completion_reply)
|
|
306
|
+
sys.exit()
|
|
307
|
+
show_help = help if default_help is None else default_help
|
|
308
|
+
if help_style not in ("raw", "rich"):
|
|
309
|
+
raise ValueError(f"help_style must be 'raw' or 'rich', not {help_style!r}")
|
|
310
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
311
|
+
|
|
312
|
+
usage = single_usage_section(doc)
|
|
313
|
+
|
|
314
|
+
def _exit(diagnostic: Diagnostic, **fields: Any) -> DocoptExit:
|
|
315
|
+
"""A DocoptExit carrying this call's usage text and exit code (no shared class state)."""
|
|
316
|
+
return DocoptExit(diagnostic=diagnostic, usage=usage, exit_code=exit_code, **fields)
|
|
317
|
+
|
|
318
|
+
argument_defaults = parse_argument_defaults(doc)
|
|
319
|
+
options = parse_defaults(doc)
|
|
320
|
+
try:
|
|
321
|
+
pattern = parse_pattern(formal_tokens(usage), options)
|
|
322
|
+
except RecursionError:
|
|
323
|
+
raise DocoptLanguageError(Diagnostic(summary="the usage pattern nests too deeply to parse").render()) from None
|
|
324
|
+
argv_tokens = Tokens(argv, usage=usage, exit_code=exit_code)
|
|
325
|
+
argv_patterns = parse_argv(argv_tokens, list(options), options_first, negative_numbers, allow_abbrev)
|
|
326
|
+
expand_options_shortcut(pattern, options)
|
|
327
|
+
# POSIX `--` ends option parsing; drop it unless the usage declares a `--`, so it is not a positional.
|
|
328
|
+
if not any(leaf.name == "--" for leaf in pattern.flat(Command)):
|
|
329
|
+
for index, leaf in enumerate(argv_patterns):
|
|
330
|
+
if type(leaf) is Argument and leaf.value == "--":
|
|
331
|
+
del argv_patterns[index]
|
|
332
|
+
break
|
|
333
|
+
_extras(show_help, version, argv_patterns, doc, help_style)
|
|
334
|
+
# Greedy-first: the first outcome is the greedy result, so every argv vanilla accepts is unchanged;
|
|
335
|
+
# if it leaves leaves over we keep looking (bounded) for a fully-consuming match.
|
|
336
|
+
left: list[Pattern]
|
|
337
|
+
collected: list[Pattern]
|
|
338
|
+
complete_match: list[Pattern] | None
|
|
339
|
+
try:
|
|
340
|
+
fixed = pattern.fix()
|
|
341
|
+
outcome_iter = fixed.matches(argv_patterns, [])
|
|
342
|
+
greedy = next(outcome_iter, None)
|
|
343
|
+
if greedy is None:
|
|
344
|
+
# nothing matched at all; report the whole argv as left over
|
|
345
|
+
left, collected, complete_match = argv_patterns, [], None
|
|
346
|
+
else:
|
|
347
|
+
left, collected = greedy
|
|
348
|
+
bounded = itertools.islice(outcome_iter, MATCH_LIMIT)
|
|
349
|
+
if left == []:
|
|
350
|
+
complete_match = collected
|
|
351
|
+
else:
|
|
352
|
+
complete_match = next((accumulated for remaining, accumulated in bounded if remaining == []), None)
|
|
353
|
+
except RecursionError:
|
|
354
|
+
raise _exit(Diagnostic(summary="the arguments are too deeply nested to match")) from None
|
|
355
|
+
extra_tokens: list[str] = []
|
|
356
|
+
if complete_match is None and allow_extra and greedy is not None:
|
|
357
|
+
# A prefix matched but not fully: keep it and return the surplus as `extra` instead of failing.
|
|
358
|
+
# A missing required element leaves `greedy is None`, so it still fails - surplus tolerated, not gaps.
|
|
359
|
+
complete_match = collected
|
|
360
|
+
extra_tokens = [str(leaf.name) if isinstance(leaf, Option) else str(leaf.value) for leaf in left]
|
|
361
|
+
if complete_match is not None:
|
|
362
|
+
result = Arguments((cast("str", leaf.name), leaf.value) for leaf in [*fixed.flat(), *complete_match])
|
|
363
|
+
result.provided = frozenset(cast("str", leaf.name) for leaf in complete_match)
|
|
364
|
+
result.extra = extra_tokens
|
|
365
|
+
_apply_fallbacks(result, options, config)
|
|
366
|
+
for name, default in argument_defaults.items():
|
|
367
|
+
if name in result and result[name] is None:
|
|
368
|
+
result[name] = default
|
|
369
|
+
for name in result:
|
|
370
|
+
# _apply_fallbacks already recorded ENV/CONFIG; here CLI wins for anything given on argv,
|
|
371
|
+
# and everything else settles on its literal default.
|
|
372
|
+
if name in result.provided:
|
|
373
|
+
result._sources[name] = Source.CLI
|
|
374
|
+
else:
|
|
375
|
+
result._sources.setdefault(name, Source.DEFAULT)
|
|
376
|
+
if schema is None:
|
|
377
|
+
return result
|
|
378
|
+
try:
|
|
379
|
+
return bind_schema(result, schema)
|
|
380
|
+
except _CoercionError as exc:
|
|
381
|
+
raise _exit(_coercion_diagnostic(doc, argv, exc), collected=complete_match, left=left) from exc
|
|
382
|
+
if suggest:
|
|
383
|
+
raw_tokens = argv.split() if isinstance(argv, str) else argv
|
|
384
|
+
hint = suggest_option(raw_tokens, options, allow_abbrev)
|
|
385
|
+
if hint is not None:
|
|
386
|
+
unknown, suggestion = hint
|
|
387
|
+
snippets = [_argv_snippet(argv, unknown, "not a known option")]
|
|
388
|
+
declared_at = _span_of(fixed.flat(Option), suggestion)
|
|
389
|
+
if declared_at is not None: # the suggested option is written in the usage: cross-reference it
|
|
390
|
+
where = Snippet(usage, "in the usage:", [Caret(*declared_at, f"`{suggestion}` is defined here")])
|
|
391
|
+
snippets.append(where)
|
|
392
|
+
diagnostic = Diagnostic(
|
|
393
|
+
summary=f"unknown option `{unknown}`", snippets=snippets, help=f"did you mean `{suggestion}`?"
|
|
394
|
+
)
|
|
395
|
+
raise _exit(diagnostic, collected=collected, left=left)
|
|
396
|
+
if left and greedy is not None:
|
|
397
|
+
# A prefix matched, so left[0] is the first token with no place in the usage. Caret it in the
|
|
398
|
+
# argv; if it is an option the usage declares (mutual exclusion, or a non-repeatable option
|
|
399
|
+
# given twice), add a second caret at that declaration - the argv-to-usage cross-reference.
|
|
400
|
+
offending = left[0]
|
|
401
|
+
shown = str(offending.name) if isinstance(offending, Option) else str(offending.value)
|
|
402
|
+
snippets = [_argv_snippet(argv, shown, "not allowed here")]
|
|
403
|
+
usage_span = _span_of(fixed.flat(Option), shown)
|
|
404
|
+
advice: str | None
|
|
405
|
+
if usage_span is not None:
|
|
406
|
+
snippets.append(Snippet(usage, "in the usage:", [Caret(*usage_span, "declared here")]))
|
|
407
|
+
advice = "give it at most once, not with a mutually exclusive option"
|
|
408
|
+
else:
|
|
409
|
+
advice = None
|
|
410
|
+
summary = f"unexpected argument `{shown}`"
|
|
411
|
+
raise _exit(Diagnostic(summary=summary, snippets=snippets, help=advice), collected=collected, left=left)
|
|
412
|
+
# Score against a freshly-parsed (unfixed) pattern: fix() dedups identical leaves across lines onto
|
|
413
|
+
# one shared span, which would caret the wrong line when a name repeats (e.g. `<y>` in several lines).
|
|
414
|
+
near_miss = nearest_usage_line(parse_pattern(formal_tokens(usage), parse_defaults(doc)), argv_patterns)
|
|
415
|
+
if near_miss is not None:
|
|
416
|
+
# A multi-line usage: caret the one element the closest line still needs (Snippet shows that line).
|
|
417
|
+
name, span, total = near_miss
|
|
418
|
+
snippet = Snippet(usage, "in the usage:", [Caret(*span, "required here")])
|
|
419
|
+
diagnostic = Diagnostic(
|
|
420
|
+
summary=f"missing required `{name}`", snippets=[snippet], help=f"closest of {total} usage patterns"
|
|
421
|
+
)
|
|
422
|
+
raise _exit(diagnostic, collected=collected, left=left)
|
|
423
|
+
required = required_leaf_names(fixed)
|
|
424
|
+
if required:
|
|
425
|
+
missing = Diagnostic(
|
|
426
|
+
summary="missing or mismatched arguments", help=f"the usage requires: {' '.join(required)}"
|
|
427
|
+
)
|
|
428
|
+
raise _exit(missing, collected=collected, left=left)
|
|
429
|
+
raise _exit(Diagnostic(summary="the arguments do not match the usage"), collected=collected, left=left)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def parse_tree(doc: str) -> Pattern:
|
|
433
|
+
"""Build the usage-pattern :class:`Pattern` tree for ``doc`` without matching argv - ``repr()`` it,
|
|
434
|
+
walk it, or serialize with :meth:`Pattern.to_dict` to diff how a change affects parsing. The
|
|
435
|
+
``[options]`` shortcut stays an :class:`OptionsShortcut` node rather than expanded."""
|
|
436
|
+
options = parse_defaults(doc)
|
|
437
|
+
return parse_pattern(formal_usage(single_usage_section(doc)), options)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class Cli:
|
|
441
|
+
"""Optional typed base class for a class-first API.
|
|
442
|
+
|
|
443
|
+
Subclass it, set ``__cli_doc__`` to the usage message, and declare fields as
|
|
444
|
+
annotations; ``YourClass.parse(argv)`` returns an instance typed as the subclass. This is
|
|
445
|
+
the only decorator-shaped sugar that keeps real static types under mypy, pyright and
|
|
446
|
+
ty (a method-injecting decorator degrades the result to ``Any``).
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
__cli_doc__: ClassVar[str | None] = None
|
|
450
|
+
|
|
451
|
+
def __init__(self, **fields: Any) -> None:
|
|
452
|
+
# Generic value-object init so a plain (non-dataclass) subclass constructs from
|
|
453
|
+
# the bound fields; a @dataclass subclass overrides this with its generated init.
|
|
454
|
+
for name, value in fields.items():
|
|
455
|
+
setattr(self, name, value)
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def parse(
|
|
459
|
+
cls: type[CliT],
|
|
460
|
+
argv: list[str] | tuple[str, ...] | str | None = None,
|
|
461
|
+
*,
|
|
462
|
+
help: bool = True, # noqa: A002 - mirrors docopt()'s public parameter name
|
|
463
|
+
version: object = None,
|
|
464
|
+
options_first: bool = False,
|
|
465
|
+
suggest: bool = False,
|
|
466
|
+
negative_numbers: bool = False,
|
|
467
|
+
allow_abbrev: bool = True,
|
|
468
|
+
allow_extra: bool = False,
|
|
469
|
+
exit_code: int = 1,
|
|
470
|
+
complete: bool = True,
|
|
471
|
+
config: Mapping[str, Any] | None = None,
|
|
472
|
+
help_style: str = "raw",
|
|
473
|
+
) -> CliT:
|
|
474
|
+
"""Parse ``argv`` against ``__cli_doc__`` and return a typed instance of the subclass."""
|
|
475
|
+
return docopt(
|
|
476
|
+
cls.__cli_doc__,
|
|
477
|
+
argv,
|
|
478
|
+
help,
|
|
479
|
+
version,
|
|
480
|
+
options_first,
|
|
481
|
+
suggest=suggest,
|
|
482
|
+
negative_numbers=negative_numbers,
|
|
483
|
+
allow_abbrev=allow_abbrev,
|
|
484
|
+
allow_extra=allow_extra,
|
|
485
|
+
exit_code=exit_code,
|
|
486
|
+
complete=complete,
|
|
487
|
+
schema=cls,
|
|
488
|
+
config=config,
|
|
489
|
+
help_style=help_style,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
_DispatchHandler = Callable[[Any], Any]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class Dispatch:
|
|
497
|
+
"""Route a parsed command to a handler - the subcommand dispatch docopt itself omits.
|
|
498
|
+
|
|
499
|
+
Register one handler per command path with :meth:`on`, then :meth:`run` parses ``argv`` and
|
|
500
|
+
calls the handler for the most specific command path that matched, passing it the parsed
|
|
501
|
+
``Arguments`` (or, when the registration supplies ``schema=``, an instance of that schema bound
|
|
502
|
+
from the result, so each subcommand gets its own typed view). An ``on()`` with no command path
|
|
503
|
+
registers a fallback, used when no more specific path matches.
|
|
504
|
+
|
|
505
|
+
Example:
|
|
506
|
+
``app = Dispatch(doc); @app.on("user", "create") def create(args): ...; app.run()``.
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
def __init__(self, doc: str) -> None:
|
|
510
|
+
self.doc = doc
|
|
511
|
+
self._handlers: list[tuple[tuple[str, ...], _DispatchHandler, type[Any] | None]] = []
|
|
512
|
+
|
|
513
|
+
def on(self, *command_path: str, schema: type[Any] | None = None) -> Callable[[_DispatchHandler], _DispatchHandler]:
|
|
514
|
+
"""Register the decorated handler for ``command_path`` (empty path = fallback handler)."""
|
|
515
|
+
|
|
516
|
+
def register(handler: _DispatchHandler) -> _DispatchHandler:
|
|
517
|
+
self._handlers.append((command_path, handler, schema))
|
|
518
|
+
return handler
|
|
519
|
+
|
|
520
|
+
return register
|
|
521
|
+
|
|
522
|
+
def run(self, argv: list[str] | tuple[str, ...] | str | None = None, **options: Any) -> Any:
|
|
523
|
+
"""Parse ``argv`` against the doc and invoke the handler for the matched command path.
|
|
524
|
+
|
|
525
|
+
Extra keyword arguments are forwarded to :func:`docopt` (``suggest``, ``exit_code``, ...);
|
|
526
|
+
``schema`` is not among them, since dispatch matches on the mapping and binds per handler.
|
|
527
|
+
"""
|
|
528
|
+
arguments = cast("Arguments", docopt(self.doc, argv, **options))
|
|
529
|
+
resolved = self._resolve(arguments)
|
|
530
|
+
if resolved is None:
|
|
531
|
+
raise DocoptExit(diagnostic=Diagnostic(summary="no handler is registered for the given command"))
|
|
532
|
+
handler, schema = resolved
|
|
533
|
+
if schema is None:
|
|
534
|
+
return handler(arguments)
|
|
535
|
+
try:
|
|
536
|
+
bound = bind_schema(arguments, schema)
|
|
537
|
+
except _CoercionError as exc:
|
|
538
|
+
resolved_argv = sys.argv[1:] if argv is None else argv
|
|
539
|
+
raise DocoptExit(diagnostic=_coercion_diagnostic(self.doc, resolved_argv, exc)) from exc
|
|
540
|
+
return handler(bound)
|
|
541
|
+
|
|
542
|
+
def _resolve(self, arguments: Arguments) -> tuple[_DispatchHandler, type[Any] | None] | None:
|
|
543
|
+
best_length = -1
|
|
544
|
+
best: tuple[_DispatchHandler, type[Any] | None] | None = None
|
|
545
|
+
for command_path, handler, schema in self._handlers:
|
|
546
|
+
if len(command_path) > best_length and all(arguments.get(name) for name in command_path):
|
|
547
|
+
best_length = len(command_path)
|
|
548
|
+
best = (handler, schema)
|
|
549
|
+
return best
|
docopt2/_diagnostics.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
# Zero-dependency ANSI. Rendering defaults to plain text (color off), since the message travels on
|
|
11
|
+
# an exception and is often inspected as a string; color belongs at the print site, not in str(exc).
|
|
12
|
+
_RESET, _BOLD, _DIM = "\033[0m", "\033[1m", "\033[2m"
|
|
13
|
+
_RED, _YELLOW, _CYAN, _GREEN = "\033[31m", "\033[33m", "\033[36m", "\033[32m"
|
|
14
|
+
_TAB = 8 # tab stop width; source lines are expanded to spaces so carets align under real columns
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def use_color(stream: TextIO) -> bool:
|
|
18
|
+
"""Whether to emit ANSI to ``stream``: only to a real terminal, and never when ``NO_COLOR`` is set."""
|
|
19
|
+
if os.environ.get("NO_COLOR"):
|
|
20
|
+
return False
|
|
21
|
+
return stream.isatty()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Caret:
|
|
26
|
+
"""A ``[start, end)`` range in a snippet's source, drawn as ``^`` with a short label beneath."""
|
|
27
|
+
|
|
28
|
+
start: int
|
|
29
|
+
end: int
|
|
30
|
+
label: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Snippet:
|
|
35
|
+
"""One captioned source (a usage string or an argv line) and the carets drawn under it."""
|
|
36
|
+
|
|
37
|
+
source: str
|
|
38
|
+
intro: str
|
|
39
|
+
carets: list[Caret]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Diagnostic:
|
|
44
|
+
"""An error lowered to a uniform shape: a summary, captioned snippets, and note/help lines.
|
|
45
|
+
|
|
46
|
+
Every error path renders through here, so all messages share one visual grammar; a snippet per
|
|
47
|
+
source lets a single error point a caret at both the input and the usage that rejected it.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
summary: str
|
|
51
|
+
snippets: list[Snippet] = field(default_factory=list)
|
|
52
|
+
note: str | None = None
|
|
53
|
+
help: str | None = None
|
|
54
|
+
level: str = "error" # "error" (red) or "warning" (yellow, used by the static linter)
|
|
55
|
+
|
|
56
|
+
def render(self, *, color: bool = False) -> str:
|
|
57
|
+
def paint(code: str, text: str) -> str:
|
|
58
|
+
return f"{code}{text}{_RESET}" if color else text
|
|
59
|
+
|
|
60
|
+
gutter = " |"
|
|
61
|
+
heading = _YELLOW if self.level == "warning" else _RED
|
|
62
|
+
lines = [paint(_BOLD + heading, self.level) + paint(_BOLD, f": {self.summary}")]
|
|
63
|
+
for snippet in self.snippets:
|
|
64
|
+
lines.append(gutter)
|
|
65
|
+
lines.append(f"{gutter} {paint(_DIM, snippet.intro)}")
|
|
66
|
+
anchor = min((caret.start for caret in snippet.carets), default=0)
|
|
67
|
+
line_start = snippet.source.rfind("\n", 0, anchor) + 1
|
|
68
|
+
newline = snippet.source.find("\n", line_start)
|
|
69
|
+
line = snippet.source[line_start : len(snippet.source) if newline == -1 else newline]
|
|
70
|
+
lines.append(f"{gutter} {line.expandtabs(_TAB)}")
|
|
71
|
+
for caret in sorted(snippet.carets, key=lambda item: item.start):
|
|
72
|
+
# pad by the DISPLAY width of the prefix (tabs expanded), not its character count
|
|
73
|
+
pad = " " * len(line[: caret.start - line_start].expandtabs(_TAB))
|
|
74
|
+
underline = paint(_RED, "^" * max(1, caret.end - caret.start))
|
|
75
|
+
lines.append(f"{gutter} {pad}{underline} {paint(_YELLOW, caret.label)}".rstrip())
|
|
76
|
+
if self.snippets: # close the gutter only when a source block was drawn
|
|
77
|
+
lines.append(gutter)
|
|
78
|
+
if self.note is not None:
|
|
79
|
+
lines.append(f" = {paint(_CYAN, 'note')}: {self.note}")
|
|
80
|
+
if self.help is not None:
|
|
81
|
+
lines.append(f" = {paint(_GREEN, 'help')}: {self.help}")
|
|
82
|
+
return "\n".join(lines)
|
docopt2/_errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from docopt2._diagnostics import use_color
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from docopt2._diagnostics import Diagnostic
|
|
10
|
+
from docopt2._parser import Pattern
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocoptLanguageError(Exception):
|
|
14
|
+
"""Error in the construction of the usage message by the developer."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DocoptExit(SystemExit):
|
|
18
|
+
"""Exit because the user invoked the program with incorrect arguments."""
|
|
19
|
+
|
|
20
|
+
# Class-level defaults; docopt() passes the real usage/exit_code per instance (no shared-state race).
|
|
21
|
+
usage: str = ""
|
|
22
|
+
exit_code: int = 1
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str = "",
|
|
27
|
+
*,
|
|
28
|
+
diagnostic: Diagnostic | None = None,
|
|
29
|
+
collected: list[Pattern] | None = None,
|
|
30
|
+
left: list[Pattern] | None = None,
|
|
31
|
+
usage: str | None = None,
|
|
32
|
+
exit_code: int | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Build the exit; ``collected``/``left`` expose the partial parse (a ported improvement; see NOTICE),
|
|
35
|
+
``usage``/``exit_code`` default to the class attributes when omitted. When ``diagnostic`` is given it
|
|
36
|
+
supplies the message; ``str(exc)`` stays plain while the copy the interpreter auto-prints carries color."""
|
|
37
|
+
self.collected: list[Pattern] = collected if collected is not None else []
|
|
38
|
+
self.left: list[Pattern] = left if left is not None else []
|
|
39
|
+
if usage is not None:
|
|
40
|
+
self.usage = usage
|
|
41
|
+
if exit_code is not None:
|
|
42
|
+
self.exit_code = exit_code
|
|
43
|
+
plain = diagnostic.render() if diagnostic is not None else message
|
|
44
|
+
self._message: str = (plain + "\n" + self.usage).strip()
|
|
45
|
+
# exit_code 1 passes a message as SystemExit's code (uncaught -> the interpreter prints str(code) and
|
|
46
|
+
# exits 1). That printed copy is colored when stderr is a terminal; str(exc) keeps the plain text.
|
|
47
|
+
display = diagnostic.render(color=use_color(sys.stderr)) if diagnostic is not None else message
|
|
48
|
+
printable = (display + "\n" + self.usage).strip()
|
|
49
|
+
super().__init__(printable if self.exit_code == 1 else self.exit_code)
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
return self._message
|