paramspecli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
paramspecli/cli.py ADDED
@@ -0,0 +1,956 @@
1
+ import argparse
2
+ import dataclasses
3
+ import sys
4
+ from typing import (
5
+ Any,
6
+ Callable,
7
+ Concatenate,
8
+ Final,
9
+ Iterable,
10
+ Iterator,
11
+ Literal,
12
+ Mapping,
13
+ Protocol,
14
+ Self,
15
+ Sequence,
16
+ TypeGuard,
17
+ overload,
18
+ )
19
+
20
+ from . import util
21
+ from .apstub import (
22
+ ArgumentGroupLike,
23
+ ArgumentParserLike,
24
+ SupportsAddArgument,
25
+ SupportsAddArgumentGroup,
26
+ SupportsAddOneofGroup,
27
+ SupportsAddParser,
28
+ SupportsSetDefaults,
29
+ TypeConverter,
30
+ )
31
+
32
+ # TODO:
33
+ # if section has a headline, it's not auto-purged from the help.
34
+ # Ok for user sections, bad for the default options/arguments sections
35
+
36
+ type ArgparseActionCls = type[argparse.Action]
37
+
38
+ type HandlerSpec = tuple[Callable[..., None] | None, list[Any], dict[str, Any]]
39
+
40
+
41
+ class Markup(Protocol):
42
+ def plain(self) -> str: ...
43
+
44
+ def __str__(self) -> str: ...
45
+
46
+
47
+ class DefaultFunc[**P](Protocol):
48
+ def __call__(self, parser: ArgumentParserLike, /, *args: P.args, **kwargs: P.kwargs) -> None: ...
49
+
50
+
51
+ def _make_spec(prefix: str, func: Callable[..., None] | None, nargs: int, options: Iterable[str]) -> HandlerSpec:
52
+ return (
53
+ func,
54
+ [f"{prefix}[{i}]" for i in range(nargs)],
55
+ {param: f"{prefix}.{param}" for param in options},
56
+ )
57
+
58
+
59
+ def _repr_class(obj: object, fields: Mapping[str, Any], *, skip_none: bool = True, skip_empty: bool = True) -> str:
60
+ items: list[str] = []
61
+ for k, v in fields.items():
62
+ if skip_none and v is None:
63
+ continue
64
+ if skip_empty and isinstance(v, (list, dict, set)) and not len(v):
65
+ continue
66
+ items.append(f"{k}={v!r}")
67
+
68
+ return f"{obj.__class__.__name__}({', '.join(items)})"
69
+
70
+
71
+ def name_and_aliases(arg: str | tuple[str, ...]) -> tuple[str, tuple[str, ...]]:
72
+ if isinstance(arg, str):
73
+ return arg, ()
74
+ return arg[0], arg[1:]
75
+
76
+
77
+ def is_markup(obj: object) -> TypeGuard[Markup]:
78
+ return hasattr(obj, "plain")
79
+
80
+
81
+ @overload
82
+ def _as_plain(arg: str | Markup) -> str: ...
83
+
84
+
85
+ @overload
86
+ def _as_plain(arg: None) -> None: ...
87
+
88
+
89
+ def _as_plain(arg: str | Markup | None) -> str | None:
90
+ if arg is None:
91
+ return arg
92
+ if is_markup(arg):
93
+ return arg.plain()
94
+ return str(arg)
95
+
96
+
97
+ def print_group_help(parser: ArgumentParserLike, /, *args: Any, **kwargs: Any) -> None:
98
+ parser.print_help()
99
+ parser.exit()
100
+
101
+
102
+ def _with_argparser_arg[**P](
103
+ f: Callable[Concatenate[ArgumentParserLike, P], None], argparser: ArgumentParserLike
104
+ ) -> Callable[P, None]:
105
+ def _wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
106
+ f(argparser, *args, **kwargs)
107
+
108
+ # ty as of 0.0.18 suggests some nonsense here
109
+ return _wrapper # ty: ignore[invalid-return-type]
110
+
111
+
112
+ # took the idea from mypy. nice and simple.
113
+ # if there are newlines in text, emit as is (with indent)
114
+ # otherwise use default wrapping HelpFormatter.
115
+ class HelpFormatter(argparse.HelpFormatter):
116
+ def __init__(self, prog: str, *args: Any, **kwargs: Any) -> None:
117
+ super().__init__(prog=prog, max_help_position=28)
118
+
119
+ def _fill_text(self, text: str, width: int, indent: str) -> str:
120
+ if "\n" in text:
121
+ return "".join(indent + line for line in text.splitlines(keepends=True))
122
+ return super()._fill_text(text, width, indent)
123
+
124
+ def _split_lines(self, text: str, width: int) -> list[str]:
125
+ if "\n" in text:
126
+ return text.splitlines()
127
+ return super()._split_lines(text, width)
128
+
129
+
130
+ @dataclasses.dataclass
131
+ class Config:
132
+ """Settings to fine-tune the generated argparser"""
133
+
134
+ show_default: bool = True
135
+ """Show default value in help. Set to `False` to globally opt-out. Options may set own `show default` to selectively opt-in"""
136
+ propagate_epilog: bool = False
137
+ """Propagate root epilog for all groups and commands"""
138
+ commands_title: str = "commands"
139
+ """Section for the list of commands in group"""
140
+ arguments_title: str = "arguments"
141
+ """Title for the list of arguments"""
142
+ options_title: str = "options"
143
+ """Title for the list of options"""
144
+ arguments_headline: str | Markup | None = None
145
+ """Headline for the list of arguments"""
146
+ options_headline: str | Markup | None = None
147
+ """Headline for the list of options"""
148
+ catch_typeconv_exceptions: bool = False
149
+ """Catch all type converter exceptions and print as CLI errors"""
150
+ allow_abbrev: bool = False
151
+ """Guess incomplete arguments"""
152
+ parser_class: type[argparse.ArgumentParser] = argparse.ArgumentParser
153
+ """Argparge parser class to use"""
154
+ formatter_class: type[argparse.HelpFormatter] = HelpFormatter
155
+ """Argparse help formatter class to use"""
156
+ root_parser_extra_kwargs: Mapping[str, Any] = dataclasses.field(default_factory=dict)
157
+ """Dict of extra kwargs for the root (CLI) argparse.ArgumentParser"""
158
+ sub_parser_extra_kwargs: Mapping[str, Any] = dataclasses.field(default_factory=dict)
159
+ """Dict of extra kwargs for sub (groups) argparse.ArgumentParsers"""
160
+
161
+
162
+ @dataclasses.dataclass(repr=False, slots=True)
163
+ class Arg:
164
+ """Represents the argument"""
165
+
166
+ metavar: str
167
+ _: dataclasses.KW_ONLY
168
+ type: TypeConverter[Any] | None = None
169
+ help: str | Markup | bool | None = None
170
+ choices: Iterable[Any] | None = None
171
+ nargs: int | Literal["*", "+", "?"] | None = None
172
+ default: Any = None
173
+ extra: Mapping[str, Any] | None = None
174
+
175
+ def __repr__(self) -> str:
176
+ return _repr_class(self, {k: getattr(self, k) for k in self.__dataclass_fields__}, skip_empty=False)
177
+
178
+ # (weak) hash is based on a metavar
179
+ def __hash__(self) -> int:
180
+ return hash(self.metavar)
181
+
182
+ def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str) -> None:
183
+ _type = self.type
184
+ if _type and config.catch_typeconv_exceptions:
185
+ _type = util.catch_all(_type)
186
+
187
+ help = self.help
188
+
189
+ if help is False:
190
+ help = argparse.SUPPRESS
191
+ elif help is True:
192
+ help = ""
193
+
194
+ # most kwargs shouldn't be set at all if None
195
+ kwargs: dict[str, Any] = {
196
+ "choices": self.choices,
197
+ "default": self.default,
198
+ "nargs": self.nargs,
199
+ "metavar": self.metavar,
200
+ # NOTE: if type is not specified, argparse's MetavarTypeHelpFormatter may fail!
201
+ "type": _type,
202
+ }
203
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
204
+ if self.extra:
205
+ kwargs |= self.extra
206
+
207
+ owner.add_argument(dest=dest, help=_as_plain(help), **kwargs)
208
+
209
+
210
+ # Too many fields to manage: dataclass to the rescue
211
+ @dataclasses.dataclass(repr=False, slots=True)
212
+ class Opt:
213
+ """Abstract option"""
214
+
215
+ names: tuple[str, ...]
216
+ _: dataclasses.KW_ONLY
217
+ type: TypeConverter[Any] | None = None
218
+ help: str | Markup | bool | None = None
219
+ hard_show_default: bool | str | None = None
220
+ soft_show_default: bool | str = False
221
+ required: bool = False
222
+ choices: Iterable[Any] | None = None
223
+ metavar: str | tuple[str, ...] | None = None
224
+ action: str | ArgparseActionCls | None = None
225
+ nargs: int | Literal["*", "+", "?"] | None = None
226
+ default: Any = None
227
+ const: Any = None
228
+ deprecated: bool = False
229
+ extra: Mapping[str, Any] | None = None
230
+
231
+ def __post_init__(self) -> None:
232
+ if not self.names:
233
+ raise ValueError("at least one name is required")
234
+
235
+ for name in self.names:
236
+ if not name.startswith("-"):
237
+ raise ValueError(f"Option name {name!r} should start with '-'")
238
+
239
+ def __repr__(self) -> str:
240
+ return _repr_class(self, {k: getattr(self, k) for k in self.__dataclass_fields__}, skip_empty=False)
241
+
242
+ # (weak) hash is based on names
243
+ def __hash__(self) -> int:
244
+ return hash(self.names)
245
+
246
+ def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str | Literal[False]) -> None:
247
+ help = self.help
248
+
249
+ if help is False:
250
+ help = argparse.SUPPRESS
251
+ elif help is True:
252
+ help = ""
253
+ elif help is None:
254
+ pass
255
+ else:
256
+ help = _as_plain(help)
257
+
258
+ # str
259
+ if self.hard_show_default is not None:
260
+ show_default = self.hard_show_default
261
+ else:
262
+ show_default = self.soft_show_default if config.show_default is True else False
263
+
264
+ if show_default is True:
265
+ help += " (default: %(default)s)"
266
+ elif show_default is False:
267
+ pass
268
+ else:
269
+ help += f" (default: {show_default})"
270
+
271
+ _type = self.type
272
+ if _type and config.catch_typeconv_exceptions:
273
+ _type = util.catch_all(_type)
274
+
275
+ # most kwargs shouldn't be set at all if None
276
+ kwargs: dict[str, Any] = {
277
+ "action": self.action,
278
+ "choices": self.choices,
279
+ "default": self.default,
280
+ "nargs": self.nargs,
281
+ "const": self.const,
282
+ "metavar": self.metavar,
283
+ "type": _type,
284
+ "required": self.required or None,
285
+ "deprecated": self.deprecated if sys.version_info >= (3, 13) else None,
286
+ }
287
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
288
+
289
+ if self.extra:
290
+ kwargs |= self.extra
291
+
292
+ owner.add_argument(*self.names, dest=dest or argparse.SUPPRESS, help=help, **kwargs)
293
+
294
+ @property
295
+ def is_hidden(self) -> bool:
296
+ return self.help is False
297
+
298
+ def __getitem__(self, section: "Section") -> Self:
299
+ section.include(self)
300
+ return self
301
+
302
+
303
+ class MixedOpts:
304
+ """Abstract mix of options"""
305
+
306
+ __slots__ = ("options",)
307
+
308
+ def __init__(self, *options: Opt):
309
+ self.options: Final = options
310
+
311
+ def __eq__(self, other: object) -> bool:
312
+ if not isinstance(other, MixedOpts):
313
+ return NotImplemented
314
+ return self.options == other.options
315
+
316
+ def __repr__(self) -> str:
317
+ return _repr_class(self, {"options": self.options})
318
+
319
+ def __getitem__(self, section: "Section") -> Self:
320
+ for option in self.options:
321
+ section.include(option)
322
+ return self
323
+
324
+
325
+ class Cnst:
326
+ __slots__ = ("value",)
327
+
328
+ def __init__(self, value: Any):
329
+ self.value: Final = value
330
+
331
+ def __repr__(self) -> str:
332
+ return _repr_class(self, {"value": self.value}, skip_none=False)
333
+
334
+ def __eq__(self, other: object) -> bool:
335
+ if not isinstance(other, Cnst):
336
+ return NotImplemented
337
+ return bool(self.value == other.value)
338
+
339
+ def _build(self, owner: SupportsSetDefaults, config: Config, *, dest: str) -> None:
340
+ owner.set_defaults(**{dest: self.value})
341
+
342
+
343
+ class Action(Opt):
344
+ """Special kind of option with side effects"""
345
+
346
+ __slots__ = ()
347
+
348
+ def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str | Literal[False]) -> None:
349
+ if dest is not False:
350
+ raise TypeError(f"Action is used in place of Option {dest!r}")
351
+ return super()._build(owner, config, dest=dest)
352
+
353
+
354
+ class Ctx:
355
+ __slots__ = ()
356
+
357
+ def _build(self, owner: SupportsSetDefaults, config: Config, *, dest: str, context: Any) -> None:
358
+ if context is None:
359
+ raise ValueError("context can't be None")
360
+ owner.set_defaults(**{dest: context})
361
+
362
+
363
+ class Section:
364
+ """Shows its options in a separate block of a `--help` output"""
365
+
366
+ def __init__(self, title: str, *, headline: str | Markup | None = None):
367
+ self.title: Final = title
368
+ self.headline: Final = headline
369
+ self.options: Final[set[Opt]] = set()
370
+
371
+ def __repr__(self) -> str:
372
+ return _repr_class(self, self.__dict__)
373
+
374
+ def include[T: Opt | MixedOpts](self, option: T) -> T:
375
+ if isinstance(option, (Opt)):
376
+ self.options.add(option)
377
+ else:
378
+ assert isinstance(option, MixedOpts)
379
+ self.options.update(option.options)
380
+ return option
381
+
382
+ def _build(self, owner: SupportsAddArgumentGroup, config: Config) -> ArgumentGroupLike:
383
+ return owner.add_argument_group(title=self.title, description=_as_plain(self.headline))
384
+
385
+ __call__ = include
386
+
387
+
388
+ class Oneof:
389
+ """Mutually exclusive section. Ensures only one of options present on a command line"""
390
+
391
+ def __init__(self, *, required: bool = False):
392
+ self.options: Final[set[Opt]] = set()
393
+ self.required: Final = required
394
+
395
+ def __repr__(self) -> str:
396
+ return _repr_class(self, self.__dict__)
397
+
398
+ def _build(self, owner: SupportsAddOneofGroup, config: Config) -> SupportsAddArgument:
399
+ return owner.add_mutually_exclusive_group(required=self.required)
400
+
401
+ def include[T: Opt | MixedOpts](self, option: T) -> T:
402
+ if isinstance(option, Opt):
403
+ self.options.add(option)
404
+ else:
405
+ assert isinstance(option, MixedOpts)
406
+ self.options.update(option.options)
407
+ return option
408
+
409
+ __call__ = include
410
+
411
+
412
+ class ParserLike:
413
+ def __init__(
414
+ self,
415
+ *,
416
+ help: str | Markup | None = None,
417
+ info: str | Markup | None = None,
418
+ usage: str | Markup | None = None,
419
+ epilog: str | Markup | None = None,
420
+ prog: str | None = None,
421
+ ):
422
+ self.help = help
423
+ self.info = info
424
+ self.usage = usage
425
+ self.epilog = epilog
426
+ self.prog = prog
427
+
428
+ self.arguments: list[Arg | Cnst | Ctx] | None = None
429
+ self.actions: list[Action] = []
430
+ self.options: dict[str, Opt | MixedOpts | Cnst | Ctx] | None = {}
431
+ self.sections: list[Section] = []
432
+ self.oneofs: list[Oneof] = []
433
+
434
+ # manually set if required
435
+ self._func: Callable[..., None] | None = None
436
+
437
+ def __repr__(self) -> str:
438
+ return _repr_class(self, self.__dict__)
439
+
440
+ def _set_params(self, *arguments: Any, **options: Any) -> None:
441
+ """Set arguments and options. Positional parameters are arguments. Keyword parameters are options"""
442
+
443
+ for i, argument in enumerate(arguments):
444
+ if not isinstance(argument, (Arg, Cnst, Ctx)):
445
+ raise TypeError(f"parameter {i} should be an Argument | Const | Context")
446
+
447
+ for name, option in options.items():
448
+ if not isinstance(option, (Opt, MixedOpts, Cnst, Ctx)):
449
+ raise TypeError(f"parameter {name!r} should be Option | MixedOptions | Const | Context")
450
+
451
+ self.arguments = [*arguments]
452
+ self.options = {**options}
453
+
454
+ def _build_sections(
455
+ self, owner: ArgumentParserLike, config: Config, *, default_group: ArgumentGroupLike
456
+ ) -> dict[Opt, SupportsAddArgument]:
457
+ sectmap: dict[Opt, ArgumentGroupLike] = {}
458
+ oneofmap: dict[Opt, SupportsAddArgument] = {}
459
+
460
+ for section in self.sections:
461
+ if section_dupes := section.options.intersection(sectmap):
462
+ raise KeyError(f"options {section_dupes} are in several sections at once")
463
+
464
+ sectmap |= dict.fromkeys(section.options, section._build(owner, config))
465
+
466
+ for oneof in self.oneofs:
467
+ if not oneof.options:
468
+ continue
469
+
470
+ if oneof_dupes := oneof.options.intersection(oneofmap):
471
+ raise KeyError(f"options {oneof_dupes} are in several oneofs at once")
472
+
473
+ groups = list({sectmap.get(option, default_group) for option in oneof.options})
474
+ if len(groups) != 1:
475
+ raise KeyError("all options in oneof should belong to the same section")
476
+
477
+ oneofmap |= dict.fromkeys(oneof.options, oneof._build(groups[0], config))
478
+
479
+ tgtmap = sectmap | oneofmap
480
+ return tgtmap
481
+
482
+ def _build_params(
483
+ self, owner: ArgumentParserLike, config: Config, *, parents: Sequence["ParserLike"], context: Any
484
+ ) -> None:
485
+ if self._func:
486
+ if self.arguments is None or self.options is None:
487
+ raise ValueError("bind() was not called")
488
+
489
+ key = str(len(parents))
490
+
491
+ spec = _make_spec(key, self._func, len(self.arguments or []), self.options or {})
492
+ owner.set_defaults(**{key: spec})
493
+
494
+ if self.arguments:
495
+ arguments_group = owner.add_argument_group(
496
+ title=config.arguments_title, description=_as_plain(config.arguments_headline)
497
+ )
498
+
499
+ for i, argument in enumerate(self.arguments):
500
+ name = f"{key}[{i}]"
501
+ if isinstance(argument, Cnst):
502
+ argument._build(owner, config, dest=name)
503
+ elif isinstance(argument, Ctx):
504
+ argument._build(owner, config, dest=name, context=context)
505
+ else:
506
+ argument._build(arguments_group, config, dest=name)
507
+
508
+ default_options_group = owner.add_argument_group(
509
+ title=config.options_title, description=_as_plain(config.options_headline)
510
+ )
511
+
512
+ tgtmap = self._build_sections(owner, config, default_group=default_options_group)
513
+
514
+ for action in self.actions:
515
+ action._build(tgtmap.pop(action, default_options_group), config, dest=False)
516
+
517
+ if self.options:
518
+ for param, item in self.options.items():
519
+ name = f"{key}.{param}"
520
+ if isinstance(item, Cnst):
521
+ item._build(owner, config, dest=name)
522
+ elif isinstance(item, Ctx):
523
+ item._build(owner, config, dest=name, context=context)
524
+ else:
525
+ seq = (item,) if isinstance(item, Opt) else item.options
526
+ for opt in seq:
527
+ opt._build(tgtmap.pop(opt, default_options_group), config, dest=name)
528
+
529
+ if tgtmap:
530
+ raise KeyError(f"unconsumed items in sections: {tgtmap.keys()}")
531
+
532
+ def _build_subparser(
533
+ self,
534
+ owner: SupportsAddParser,
535
+ config: Config,
536
+ *,
537
+ parents: Sequence["ParserLike"],
538
+ name: str | tuple[str, ...],
539
+ ) -> ArgumentParserLike:
540
+ basename, aliases = name_and_aliases(name)
541
+
542
+ epilog = self.epilog
543
+
544
+ if epilog is None and config.propagate_epilog and parents:
545
+ epilog = parents[0].epilog
546
+
547
+ return owner.add_parser(
548
+ basename,
549
+ aliases=aliases,
550
+ help=_as_plain(self.help),
551
+ description=_as_plain(self.info if self.info is not None else self.help),
552
+ epilog=_as_plain(epilog),
553
+ usage=_as_plain(self.usage),
554
+ prog=self.prog,
555
+ formatter_class=config.formatter_class,
556
+ add_help=False,
557
+ allow_abbrev=config.allow_abbrev,
558
+ **config.sub_parser_extra_kwargs,
559
+ )
560
+
561
+ def _build_root_parser(self, config: Config) -> argparse.ArgumentParser:
562
+ return config.parser_class(
563
+ description=_as_plain(self.info),
564
+ epilog=_as_plain(self.epilog),
565
+ usage=_as_plain(self.usage),
566
+ prog=self.prog,
567
+ formatter_class=config.formatter_class,
568
+ add_help=False,
569
+ allow_abbrev=config.allow_abbrev,
570
+ **config.root_parser_extra_kwargs,
571
+ )
572
+
573
+ def append_action(self, action: Action) -> None:
574
+ """Append Action"""
575
+ self.actions.append(action)
576
+
577
+ def add_oneof(self, *, required: bool = False) -> "Oneof":
578
+ """Creates and adds new mutually exclusive section"""
579
+ oneof = Oneof(required=required)
580
+ self.oneofs.append(oneof)
581
+ return oneof
582
+
583
+ def add_section(self, title: str, *, headline: str | Markup | None = None) -> "Section":
584
+ """Creates and adds new help section"""
585
+ section = Section(title, headline=headline)
586
+ self.sections.append(section)
587
+ return section
588
+
589
+ def get_real_arguments(self) -> list[Arg]:
590
+ if not self.arguments:
591
+ return []
592
+ return [arg for arg in self.arguments if isinstance(arg, Arg)]
593
+
594
+ def get_real_options(self) -> list[Opt]:
595
+ if not self.options:
596
+ return []
597
+ out: list[Opt] = []
598
+ for item in self.options.values():
599
+ if isinstance(item, Opt):
600
+ out.append(item)
601
+ elif isinstance(item, MixedOpts):
602
+ out.extend(item.options)
603
+ return out
604
+
605
+ def __enter__(self) -> Self:
606
+ return self
607
+
608
+ def __exit__(self, *args: Any, **kwargs: Any) -> None:
609
+ pass
610
+
611
+
612
+ class Handler:
613
+ """Represents parsed arguments and options.
614
+ Calling it will invoke the command handler"""
615
+
616
+ def __init__(self, func: Callable[..., None] | None, arguments: list[Any], options: dict[str, Any]):
617
+ self.func = func
618
+ self.arguments = arguments
619
+ self.options = options
620
+
621
+ def __eq__(self, other: object) -> bool:
622
+ if not isinstance(other, Handler):
623
+ return NotImplemented
624
+ return self.func == other.func and self.arguments == other.arguments and self.options == other.options
625
+
626
+ def __repr__(self) -> str:
627
+ return _repr_class(self, self.__dict__)
628
+
629
+ def __call__(self) -> None:
630
+ if not self.func:
631
+ return
632
+ self.func(*self.arguments, **self.options)
633
+
634
+ @classmethod
635
+ def from_ns(cls, ns: argparse.Namespace, spec: HandlerSpec) -> Self:
636
+ vars = ns.__dict__
637
+
638
+ func, argnames, kwargnames = spec
639
+
640
+ args = [vars[name] for name in argnames]
641
+ kwargs = {k: vars[v] for k, v in kwargnames.items()}
642
+
643
+ return cls(func, args, kwargs)
644
+
645
+ @classmethod
646
+ def from_spec(cls, func: Callable[..., None] | None, /, *arguments: Any, **options: Any) -> Self:
647
+ return cls(func, list(arguments), options)
648
+
649
+
650
+ class Route:
651
+ """Represents sequence of handers, arguments and options.
652
+ Calling it will invoke the handlers"""
653
+
654
+ def __init__(self, handlers: Sequence[Handler]):
655
+ self.handlers = handlers
656
+
657
+ def __eq__(self, other: object) -> bool:
658
+ if not isinstance(other, Route):
659
+ return NotImplemented
660
+ return self.handlers == other.handlers
661
+
662
+ def __repr__(self) -> str:
663
+ return _repr_class(self, self.__dict__)
664
+
665
+ def __call__(self) -> None:
666
+ for handler in self.handlers:
667
+ handler()
668
+
669
+ def __iter__(self) -> Iterator[Handler]:
670
+ yield from self.handlers
671
+
672
+ def __len__(self) -> int:
673
+ return len(self.handlers)
674
+
675
+ def __getitem__(self, idx: int) -> Handler:
676
+ return self.handlers[idx]
677
+
678
+ @property
679
+ def nonempty(self) -> list[Handler]:
680
+ return [h for h in self.handlers if h.func is not None]
681
+
682
+ @classmethod
683
+ def from_ns(cls, ns: argparse.Namespace) -> Self:
684
+ specs: list[tuple[int, HandlerSpec]] = []
685
+ for k, v in ns.__dict__.items():
686
+ try:
687
+ idx = int(k)
688
+ except ValueError:
689
+ pass
690
+ else:
691
+ specs.append((idx, v))
692
+ specs.sort()
693
+
694
+ handlers = [Handler.from_ns(ns, spec[1]) for spec in specs]
695
+ return cls(handlers)
696
+
697
+
698
+ ## More concrete
699
+
700
+
701
+ class Command[**P](ParserLike):
702
+ """Represents the handler with parameters bound to arguments and options"""
703
+
704
+ def __init__(
705
+ self,
706
+ func: Callable[P, None],
707
+ *,
708
+ help: str | Markup | None = None,
709
+ info: str | Markup | None = None,
710
+ usage: str | Markup | None = None,
711
+ epilog: str | Markup | None = None,
712
+ prog: str | None = None,
713
+ add_help: bool = True,
714
+ ):
715
+ super().__init__(help=help, info=info, usage=usage, epilog=epilog, prog=prog)
716
+ self._func = func
717
+
718
+ if add_help:
719
+ self.append_action(_help_action)
720
+
721
+ def _build(
722
+ self,
723
+ owner: ArgumentParserLike,
724
+ config: Config,
725
+ *,
726
+ parents: Sequence["Group"],
727
+ context: Any,
728
+ **kwargs: Any,
729
+ ) -> None:
730
+ self._build_params(owner, config, parents=parents, context=context)
731
+
732
+ def bind(self, *arguments: P.args, **options: P.kwargs) -> Self:
733
+ """Bind function parameters to arguments/options"""
734
+ self._set_params(*arguments, **options)
735
+ return self
736
+
737
+ def parse(self, input: Sequence[str] | None = None, *, config: Config | None = None, context: Any = None) -> Route:
738
+ """Parse command line. Allows to use the Command as standalone groupless CLI"""
739
+
740
+ config = config or Config()
741
+ parser = self._build_root_parser(config)
742
+ self._build(parser, config, parents=[], context=context)
743
+
744
+ ns = parser.parse_args(input)
745
+ return Route.from_ns(ns)
746
+
747
+
748
+ class Group(ParserLike):
749
+ """Represents group of commands. May contain nested groups"""
750
+
751
+ def __init__(
752
+ self,
753
+ *,
754
+ help: str | Markup | None = None,
755
+ info: str | Markup | None = None,
756
+ usage: str | Markup | None = None,
757
+ epilog: str | Markup | None = None,
758
+ prog: str | None = None,
759
+ title: str | None = None,
760
+ headline: str | Markup | None = None,
761
+ metavar: str | None = None,
762
+ default_func: DefaultFunc[...] | None = print_group_help,
763
+ add_help: bool = True,
764
+ ):
765
+ super().__init__(help=help, info=info, usage=usage, epilog=epilog, prog=prog)
766
+
767
+ self.headline: Final = headline
768
+ self.title: Final = title
769
+ self.metavar: Final = metavar
770
+ self.default_func: Final = default_func
771
+ self.nodes: dict[str | tuple[str, ...], Group | Command[...]] = {}
772
+
773
+ if add_help:
774
+ self.append_action(_help_action)
775
+
776
+ def _build(self, owner: ArgumentParserLike, config: Config, *, parents: Sequence["Group"], context: Any) -> None:
777
+ self._build_params(owner, config, parents=parents, context=context)
778
+ self._build_default_handler(owner, config, parents=parents)
779
+ self._build_nodes(owner, config, parents=parents, context=context)
780
+
781
+ def _build_default_handler(
782
+ self, owner: ArgumentParserLike, config: Config, *, parents: Sequence["ParserLike"]
783
+ ) -> None:
784
+ if not self.default_func:
785
+ return
786
+
787
+ default_func = _with_argparser_arg(self.default_func, owner)
788
+
789
+ key = str(len(parents))
790
+ deeper_key = str(len(parents) + 1)
791
+ spec = _make_spec(key, default_func, len(self.arguments or []), self.options or {})
792
+ owner.set_defaults(**{deeper_key: spec})
793
+
794
+ def _build_nodes(
795
+ self, owner: ArgumentParserLike, config: Config, *, parents: Sequence["Group"], context: Any
796
+ ) -> None:
797
+ if not self.nodes:
798
+ return
799
+
800
+ sub = owner.add_subparsers(
801
+ title=self.title if self.title is not None else config.commands_title,
802
+ description=_as_plain(self.headline),
803
+ parser_class=config.parser_class,
804
+ metavar=self.metavar,
805
+ required=False if self.default_func else True,
806
+ )
807
+ for name, node in self.nodes.items():
808
+ try:
809
+ parser = node._build_subparser(sub, config, name=name, parents=[*parents, self])
810
+ node._build(parser, config, parents=[*parents, self], context=context)
811
+ except Exception as e:
812
+ e.add_note(f"{' ' * len(parents)}- {name!r}")
813
+ raise
814
+
815
+ def parse(self, input: Sequence[str] | None = None, *, config: Config | None = None, context: Any = None) -> Route:
816
+ """Parse command line"""
817
+
818
+ config = config or Config()
819
+
820
+ try:
821
+ parser = self._build_root_parser(config)
822
+ self._build(parser, config, parents=[], context=context)
823
+ except Exception as e:
824
+ e.add_note("- Cli")
825
+ raise
826
+
827
+ ns = parser.parse_args(input)
828
+ return Route.from_ns(ns)
829
+
830
+ def add_command[**P](
831
+ self,
832
+ name: str | tuple[str, ...],
833
+ func: Callable[P, None],
834
+ *,
835
+ help: str | Markup | None = None,
836
+ info: str | Markup | None = None,
837
+ usage: str | Markup | None = None,
838
+ epilog: str | Markup | None = None,
839
+ add_help: bool = True,
840
+ ) -> Command[P]:
841
+ """Creates and adds new command"""
842
+ cmd = Command(func, help=help, info=info, usage=usage, epilog=epilog, add_help=add_help)
843
+ self.nodes[name] = cmd
844
+ return cmd
845
+
846
+ def add_group(
847
+ self,
848
+ name: str | tuple[str, ...],
849
+ *,
850
+ help: str | Markup | None = None,
851
+ info: str | Markup | None = None,
852
+ metavar: str | None = None,
853
+ usage: str | Markup | None = None,
854
+ epilog: str | Markup | None = None,
855
+ title: str | None = None,
856
+ headline: str | Markup | None = None,
857
+ default_func: DefaultFunc[...] | None = print_group_help,
858
+ add_help: bool = True,
859
+ ) -> "Group":
860
+ """Creates and adds new group"""
861
+ group = Group(
862
+ help=help,
863
+ info=info,
864
+ headline=headline,
865
+ title=title,
866
+ metavar=metavar,
867
+ usage=usage,
868
+ epilog=epilog,
869
+ default_func=default_func,
870
+ add_help=add_help,
871
+ )
872
+ self.nodes[name] = group
873
+ return group
874
+
875
+ def add_callable_group[**P](
876
+ self,
877
+ name: str | tuple[str, ...],
878
+ func: Callable[P, None],
879
+ *,
880
+ help: str | Markup | None = None,
881
+ info: str | Markup | None = None,
882
+ metavar: str | None = None,
883
+ usage: str | Markup | None = None,
884
+ epilog: str | Markup | None = None,
885
+ title: str | None = None,
886
+ headline: str | Markup | None = None,
887
+ default_func: DefaultFunc[P] | None = print_group_help,
888
+ add_help: bool = True,
889
+ ) -> "CallableGroup[P]":
890
+ """Creates and adds new group with handler and parameters"""
891
+ group = CallableGroup(
892
+ func=func,
893
+ help=help,
894
+ info=info,
895
+ headline=headline,
896
+ title=title,
897
+ metavar=metavar,
898
+ usage=usage,
899
+ epilog=epilog,
900
+ default_func=default_func,
901
+ add_help=add_help,
902
+ )
903
+ self.nodes[name] = group
904
+ return group
905
+
906
+ def __setitem__(self, name: str | tuple[str, ...], val: "Group | Command[...]") -> None:
907
+ self.nodes[name] = val
908
+
909
+
910
+ class CallableGroup[**P](Group):
911
+ """Group with with parameters"""
912
+
913
+ def __init__(
914
+ self,
915
+ func: Callable[P, None],
916
+ *,
917
+ help: str | Markup | None = None,
918
+ info: str | Markup | None = None,
919
+ usage: str | Markup | None = None,
920
+ epilog: str | Markup | None = None,
921
+ prog: str | None = None,
922
+ title: str | None = None,
923
+ headline: str | Markup | None = None,
924
+ metavar: str | None = None,
925
+ default_func: DefaultFunc[P] | None = print_group_help,
926
+ add_help: bool = True,
927
+ ):
928
+ super().__init__(
929
+ help=help,
930
+ info=info,
931
+ usage=usage,
932
+ epilog=epilog,
933
+ prog=prog,
934
+ title=title,
935
+ headline=headline,
936
+ metavar=metavar,
937
+ default_func=default_func,
938
+ add_help=add_help,
939
+ )
940
+ self._func = func
941
+
942
+ def bind(self, *arguments: P.args, **options: P.kwargs) -> Self:
943
+ """Bind function parameters to arguments/options"""
944
+ self._set_params(*arguments, **options)
945
+ return self
946
+
947
+
948
+ def help_action(*, help: str | Markup | bool = "Show help and exit") -> Action:
949
+ return Action(("--help", "-h"), action="help", help=help)
950
+
951
+
952
+ def version_action(version: str, *, help: str | Markup | bool = "Show program's version number and exit") -> Action:
953
+ return Action(("--version",), action="version", help=help, extra={"version": version})
954
+
955
+
956
+ _help_action: Final = help_action()