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/_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
@@ -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