cal-docs-client 1.0.0b1__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.
@@ -0,0 +1,1424 @@
1
+ # ────────────────────────────────────────────────────────────────────────────────────────
2
+ # argbuilder.py
3
+ # ─────────────
4
+ #
5
+ # A wrapper around Python's argparse using deferred execution. Arguments and commands
6
+ # are collected first, then applied when parse() is called. This enables:
7
+ #
8
+ # - Reusable arguments: add the same ArgsArgument instance to multiple commands
9
+ # - Flexible ordering: define arguments in any order, not just top-down
10
+ # - Command groups: organise subcommands under headings in --help output
11
+ # - Collections: bundle related arguments for easy reuse across commands
12
+ #
13
+ # Main classes:
14
+ # - ArgsParser: entry point, call parse() to build argparse and get results
15
+ # - ArgsCommand: defines a subcommand with its own arguments
16
+ # - ArgsArgument: stores add_argument() params for later application
17
+ # - ArgsCommandGroup: groups commands under a heading (cosmetic only)
18
+ # - ArgsGroup: groups arguments under a heading in command help
19
+ # - ArgsMutexGroup: mutually exclusive argument group
20
+ # - ArgsCollection: reusable bundle of arguments
21
+ #
22
+ # (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
23
+ #
24
+ # Authors
25
+ # ───────
26
+ # bena (via Claude)
27
+ #
28
+ # Created: Nov 2025
29
+ # Version: 2026-01-30
30
+ # ────────────────────────────────────────────────────────────────────────────────────────
31
+
32
+ # ────────────────────────────────────────────────────────────────────────────────────────
33
+ # Exports
34
+ # ────────────────────────────────────────────────────────────────────────────────────────
35
+
36
+ __all__ = [
37
+ "ArgsArgument",
38
+ "ArgsCollection",
39
+ "ArgsCommand",
40
+ "ArgsCommandGroup",
41
+ "ArgsGroup",
42
+ "ArgsMutexGroup",
43
+ "ArgsParser",
44
+ "Namespace",
45
+ ]
46
+
47
+ # ────────────────────────────────────────────────────────────────────────────────────────
48
+ # Settings
49
+ # ────────────────────────────────────────────────────────────────────────────────────────
50
+
51
+ # pyright: reportPrivateUsage=false
52
+
53
+ # ────────────────────────────────────────────────────────────────────────────────────────
54
+ # Imports
55
+ # ────────────────────────────────────────────────────────────────────────────────────────
56
+
57
+ import argparse
58
+ import sys
59
+ from argparse import Action
60
+ from argparse import Namespace
61
+ from typing import TYPE_CHECKING
62
+ from typing import Any
63
+ from typing import NoReturn
64
+ from typing import TypeVar
65
+ from typing import cast
66
+ from typing import overload
67
+
68
+ if TYPE_CHECKING:
69
+ from collections.abc import Callable
70
+ from collections.abc import Iterable
71
+ from collections.abc import Sequence
72
+
73
+ # ────────────────────────────────────────────────────────────────────────────────────────
74
+ # Types
75
+ # ────────────────────────────────────────────────────────────────────────────────────────
76
+
77
+
78
+ _T = TypeVar("_T")
79
+
80
+ # All argparse container types that can receive arguments
81
+ type ParserLike = (
82
+ argparse.ArgumentParser | argparse._ArgumentGroup | argparse._MutuallyExclusiveGroup
83
+ )
84
+
85
+ # Union of all item types that can be added to a parser/command
86
+ type ArgsItem = ArgsArgument | ArgsGroup | ArgsCollection | ArgsMutexGroup
87
+
88
+ type ArgsCompleteItem = ArgsItem | ArgsCommand | ArgsCommandGroup
89
+
90
+ # ────────────────────────────────────────────────────────────────────────────────────────
91
+ # Private Base Class (must be defined before public classes that inherit from it)
92
+ # ────────────────────────────────────────────────────────────────────────────────────────
93
+
94
+
95
+ # ────────────────────────────────────────────────────────────────────────────────────────
96
+ class _BaseClass:
97
+ """Base class providing common argument/command management functionality.
98
+
99
+ Uses deferred execution: items are collected first, then applied to argparse
100
+ when parse() is called. This allows flexible ordering and reuse of arguments.
101
+ """
102
+
103
+ # ────────────────────────────────────────────────────────────────────────────────────
104
+ def __init__(self, *, items: list[ArgsItem] | None = None):
105
+ # Uses __all_items__ (dunder) to avoid name mangling, allowing subclass access
106
+ self.__all_items__: list[ArgsCompleteItem] = []
107
+ if items:
108
+ self.add(*items)
109
+
110
+ # ────────────────────────────────────────────────────────────────────────────────────
111
+ @overload
112
+ def add_argument(
113
+ self,
114
+ *name_or_flags: str,
115
+ action: str | type[Action] = ...,
116
+ nargs: int | str = ...,
117
+ const: Any = ...,
118
+ default: Any = ...,
119
+ type: Callable[[str], _T] | Callable[[str], _T] = ...,
120
+ choices: Iterable[_T] = ...,
121
+ required: bool = ...,
122
+ help: str | None = ...,
123
+ metavar: str | tuple[str, ...] | None = ...,
124
+ dest: str | None = ...,
125
+ version: str = ...,
126
+ **kwargs: Any,
127
+ ) -> ArgsArgument:
128
+ """
129
+ Create and add a new argument.
130
+
131
+ Takes the same parameters as `argparse.ArgumentParser.add_argument()`.
132
+
133
+ Args:
134
+ *name_or_flags: Argument name or option flags (e.g., "--verbose", "-v").
135
+ action: How to handle the argument (store, store_true, count, etc.).
136
+ nargs: Number of arguments to consume.
137
+ const: Constant value for certain actions.
138
+ default: Default value if argument not provided.
139
+ type: Callable to convert the argument string.
140
+ choices: Allowed values for the argument.
141
+ required: Whether the argument is required.
142
+ help: Help text for the argument.
143
+ metavar: Display name in usage/help messages.
144
+ dest: Attribute name in the resulting Namespace.
145
+ version: Version string for version action.
146
+ **kwargs: Additional keyword arguments passed to argparse.
147
+
148
+ Returns:
149
+ The created ArgsArgument instance, which can be reused in other commands.
150
+
151
+ Example:
152
+ ```python
153
+ parser = ArgsParser("My app")
154
+ cmd = parser.add_command("run")
155
+ cmd.add_argument(
156
+ "--verbose", "-v", action="store_true", help="Verbose output"
157
+ )
158
+ ```
159
+ """
160
+ ...
161
+
162
+ @overload
163
+ def add_argument(self) -> ArgsArgument: ...
164
+
165
+ def add_argument(self, *args: Any, **kwargs: Any) -> ArgsArgument:
166
+ new_argument = ArgsArgument(*args, **kwargs)
167
+ self.__all_items__.append(new_argument)
168
+ return new_argument
169
+
170
+ # ────────────────────────────────────────────────────────────────────────────────────
171
+ def add_group(self, name: str, *, items: list[ArgsItem] | None = None) -> ArgsGroup:
172
+ """
173
+ Create and add an argument group for organised help display.
174
+
175
+ Groups arguments under a heading in the --help output.
176
+
177
+ Args:
178
+ name: The group heading shown in help output.
179
+ items: Optional list of arguments to add to this group.
180
+
181
+ Returns:
182
+ The created ArgsGroup instance.
183
+
184
+ Example:
185
+ ```python
186
+ cmd = parser.add_command("run")
187
+ output_group = cmd.add_group("Output Options")
188
+ output_group.add_argument("--format", choices=["json", "text"])
189
+ output_group.add_argument("--output", "-o", help="Output file")
190
+ ```
191
+ """
192
+ new_group = ArgsGroup(name=name, items=items)
193
+ self.__all_items__.append(new_group)
194
+ return new_group
195
+
196
+ # ────────────────────────────────────────────────────────────────────────────────────
197
+ def add_mutex_group(
198
+ self, *, required: bool = False, items: list[ArgsItem] | None = None
199
+ ) -> ArgsMutexGroup:
200
+ """
201
+ Create and add a mutually exclusive argument group.
202
+
203
+ Only one argument from this group can be used at a time.
204
+
205
+ Args:
206
+ required: If True, one of the arguments must be provided.
207
+ items: Optional list of arguments to add to this group.
208
+
209
+ Returns:
210
+ The created ArgsMutexGroup instance.
211
+
212
+ Example:
213
+ ```python
214
+ cmd = parser.add_command("output")
215
+ mutex = cmd.add_mutex_group(required=True)
216
+ mutex.add_argument("--json", action="store_true")
217
+ mutex.add_argument("--xml", action="store_true")
218
+ ```
219
+ """
220
+ mutex_group = ArgsMutexGroup(required=required)
221
+ if items:
222
+ mutex_group.add(*items)
223
+ self.__all_items__.append(mutex_group)
224
+ return mutex_group
225
+
226
+ # ────────────────────────────────────────────────────────────────────────────────────
227
+ def add(self, *items: ArgsItem) -> None:
228
+ """
229
+ Add existing items (arguments, groups, collections) to this container.
230
+
231
+ Use this to reuse ArgsArgument or ArgsCollection instances across
232
+ multiple commands.
233
+
234
+ Args:
235
+ *items: One or more items to add.
236
+
237
+ Example:
238
+ ```python
239
+ verbose = ArgsArgument("--verbose", "-v", action="store_true")
240
+ cmd1.add(verbose)
241
+ cmd2.add(verbose) # Same argument in both commands
242
+ ```
243
+ """
244
+ for item in items:
245
+ self.__all_items__.append(item)
246
+
247
+ # ────────────────────────────────────────────────────────────────────────────────────
248
+ def _add_item(self, item: ArgsCompleteItem) -> None:
249
+ """Internal method to add an item to the parser."""
250
+ self.__all_items__.append(item)
251
+
252
+ # ────────────────────────────────────────────────────────────────────────────────────
253
+ def __add_to_parser__(self, parser: ParserLike) -> None:
254
+ """Apply all collected items to the actual argparse parser.
255
+
256
+ This is the deferred execution point where our wrapper objects
257
+ are converted into real argparse objects.
258
+ """
259
+ sub_parsers = None
260
+ for item in self.__all_items__:
261
+ if isinstance(item, ArgsCommand):
262
+ # Lazily create subparsers container on first command
263
+ if not sub_parsers:
264
+ assert isinstance(parser, argparse.ArgumentParser)
265
+ sub_parsers = parser.add_subparsers(
266
+ title="commands",
267
+ dest="command",
268
+ required=False,
269
+ metavar="command",
270
+ )
271
+ item.__add_to_sub_parser__(sub_parsers)
272
+ else:
273
+ item.__add_to_parser__(parser)
274
+
275
+
276
+ # ────────────────────────────────────────────────────────────────────────────────────────
277
+ # Public Classes
278
+ # ────────────────────────────────────────────────────────────────────────────────────────
279
+
280
+
281
+ # ────────────────────────────────────────────────────────────────────────────────────────
282
+ class ArgsParser(_BaseClass):
283
+ """
284
+ Main entry point for building CLI applications.
285
+
286
+ Create a parser, add commands and arguments, then call `parse()` to
287
+ process command-line arguments.
288
+
289
+ Args:
290
+ prog: Program name for usage/help output (optional).
291
+ description: Description shown at the top of --help output (optional).
292
+ version: Version string; if set, adds --version option (optional).
293
+ default_command: Default command when user doesn't specify one (optional).
294
+
295
+ Attributes:
296
+ prog (str | None): Can be set after initialisation, before parse().
297
+ description (str | None): Can be set after initialisation, before parse().
298
+ version (str | None): Can be set after initialisation, before parse().
299
+ default_command (str | None): Can be set after initialisation, before parse().
300
+
301
+ Example:
302
+ ```python
303
+ parser = ArgsParser("My CLI application")
304
+
305
+ # Can also set attributes directly
306
+ parser.prog = "mycli"
307
+ parser.version = "1.0.0"
308
+ parser.default_command = "help"
309
+
310
+ # Add global arguments
311
+ parser.add_argument("--verbose", "-v", action="store_true")
312
+
313
+ # Add commands
314
+ init = parser.add_command("init", help="Initialise project")
315
+ init.add_argument("--force", action="store_true")
316
+
317
+ # Parse and use results
318
+ args = parser.parse()
319
+ if args.command == "init":
320
+ initialise(force=args.force)
321
+ ```
322
+ """
323
+
324
+ prog: str | None
325
+ """Program name shown in usage/help output."""
326
+
327
+ description: str | None
328
+ """Description shown at top of help output."""
329
+
330
+ version: str | None
331
+ """Version string; if set, adds --version option."""
332
+
333
+ default_command: str | None
334
+ """Default command when none specified by user."""
335
+
336
+ # ────────────────────────────────────────────────────────────────────────────────────
337
+ def __init__(
338
+ self,
339
+ prog: str | None = None,
340
+ description: str | None = None,
341
+ version: str | None = None,
342
+ default_command: str | None = None,
343
+ ):
344
+ """
345
+ Initialise the helper with program metadata and defaults.
346
+
347
+ Parameters:
348
+ prog: Program name for usage output.
349
+ description: Top-level description for help.
350
+ version: Version string; if provided, a --version option is added.
351
+ default_command: Default command to use when none is specified.
352
+ If set and user doesn't provide a command, this one is used.
353
+ If not set and user doesn't provide a command, help is shown.
354
+ """
355
+
356
+ super().__init__()
357
+ self.description = description
358
+ self.prog = prog
359
+ self.version = version
360
+ self.default_command = default_command
361
+ self._common_options_first = False
362
+ self.__command_groups: list[ArgsCommandGroup] = []
363
+ self.__common_collection__: ArgsCollection | None = None
364
+ self.__parser: argparse.ArgumentParser | None = None
365
+
366
+ # ────────────────────────────────────────────────────────────────────────────────────
367
+ def add_command(
368
+ self,
369
+ name: str,
370
+ *,
371
+ items: list[ArgsItem] | None = None,
372
+ help: str | None = None,
373
+ description: str | None = None,
374
+ exclude_common: bool = False,
375
+ ) -> ArgsCommand:
376
+ """
377
+ Create and add a new subcommand.
378
+
379
+ Args:
380
+ name: The command name used on the command line.
381
+ items: Optional list of arguments/groups to add to this command.
382
+ help: Short help text shown in parent's command list.
383
+ description: Longer description shown in command's own --help.
384
+ If only one of help/description is provided, it's used for both.
385
+ exclude_common: If True, excludes common options from this command.
386
+
387
+ Returns:
388
+ The created ArgsCommand instance.
389
+
390
+ Example:
391
+ ```python
392
+ parser = ArgsParser("My app")
393
+ cmd = parser.add_command("init", help="Initialise project")
394
+ cmd.add_argument("--force", action="store_true")
395
+
396
+ # Command without common options
397
+ special = parser.add_command("special", exclude_common=True)
398
+ ```
399
+ """
400
+ new_command = ArgsCommand(
401
+ name=name, items=items, help=help, description=description
402
+ )
403
+ if self.__common_collection__ and not exclude_common:
404
+ if self._common_options_first:
405
+ # Insert at the beginning of items list
406
+ new_command._prepend_item(self.__common_collection__)
407
+ else:
408
+ # Store for later application at the end
409
+ new_command._common_collection = self.__common_collection__
410
+ self.__all_items__.append(new_command)
411
+ return new_command
412
+
413
+ # ────────────────────────────────────────────────────────────────────────────────────
414
+ def create_common_collection(
415
+ self, *, items: list[ArgsItem] | None = None, options_first: bool = False
416
+ ) -> ArgsCollection:
417
+ """
418
+ Create a reusable collection automatically added to every command.
419
+
420
+ Use this when you have arguments that should be present on all commands
421
+ (for example `--verbose`). The returned collection behaves like any other
422
+ `ArgsCollection` and can be added to additional commands manually if
423
+ needed.
424
+
425
+ Note:
426
+ To exclude common options from specific commands, use the
427
+ `exclude_common=True` parameter when calling `add_command()`.
428
+
429
+ Args:
430
+ items: Optional list of pre-defined arguments/groups to seed the
431
+ collection with.
432
+ options_first: If True, common options appear at the beginning of
433
+ each command's help output. If False (default), they appear at
434
+ the end.
435
+
436
+ Returns:
437
+ The collection instance that will be appended to each command added
438
+ to this parser (unless excluded via `exclude_common=True`).
439
+
440
+ Example:
441
+ ```python
442
+ parser = ArgsParser("My app")
443
+ common = parser.create_common_collection(options_first=True)
444
+ common.add_argument("--verbose", "-v", action="count")
445
+
446
+ deploy = parser.add_command("deploy")
447
+ test = parser.add_command("test")
448
+ # Both commands now accept --verbose automatically at the beginning
449
+
450
+ # Exclude common options from a specific command
451
+ special = parser.add_command("special", exclude_common=True)
452
+ # special command will NOT have --verbose
453
+
454
+ args = parser.parse()
455
+ ```
456
+ """
457
+
458
+ self._common_options_first = options_first
459
+ self.__common_collection__ = ArgsCollection(items=items)
460
+ return self.__common_collection__
461
+
462
+ # ────────────────────────────────────────────────────────────────────────────────────
463
+ def add_command_group(
464
+ self, name: str, *, description: str | None = None
465
+ ) -> ArgsCommandGroup:
466
+ """
467
+ Create a command group for organising commands in help output.
468
+
469
+ Command groups are purely cosmetic - they organise how commands
470
+ appear in --help but don't affect parsing behaviour.
471
+
472
+ Args:
473
+ name: The group heading shown in help output.
474
+ description: Optional description shown below the heading.
475
+
476
+ Returns:
477
+ The created ArgsCommandGroup instance.
478
+
479
+ Example:
480
+ ```python
481
+ parser = ArgsParser("My app")
482
+
483
+ basic = parser.add_command_group("Basic Commands")
484
+ basic.add_command("init", help="Initialise project")
485
+ basic.add_command("status", help="Show status")
486
+
487
+ advanced = parser.add_command_group("Advanced Commands")
488
+ advanced.add_command("migrate", help="Run migrations")
489
+ ```
490
+ """
491
+ group = ArgsCommandGroup(name=name, description=description, parent=self)
492
+ self.__command_groups.append(group)
493
+ return group
494
+
495
+ # ────────────────────────────────────────────────────────────────────────────────────
496
+ def _get_all_command_names(self) -> set[str]:
497
+ """Get all registered command names."""
498
+ names: set[str] = set()
499
+ # Commands added directly to parser
500
+ for item in self.__all_items__:
501
+ if isinstance(item, ArgsCommand):
502
+ names.add(item._command_name)
503
+ # Commands added via command groups
504
+ for group in self.__command_groups:
505
+ for cmd in group.commands:
506
+ names.add(cmd._command_name)
507
+ return names
508
+
509
+ # ────────────────────────────────────────────────────────────────────────────────────
510
+ def _reorder_args(self, argv: list[str]) -> list[str]:
511
+ """Move command to front of argument list if found.
512
+
513
+ Allows commands to appear anywhere in the argument list, not just
514
+ at the beginning. For example: `mytool --verbose run --force`
515
+ becomes `mytool run --verbose --force`.
516
+ """
517
+ command_names = self._get_all_command_names()
518
+ if not command_names:
519
+ return argv
520
+
521
+ # Find the first argument that matches a command name
522
+ command_index = None
523
+ for i, arg in enumerate(argv):
524
+ if arg in command_names:
525
+ command_index = i
526
+ break
527
+
528
+ # If command found and not already at front, move it
529
+ if command_index is not None and command_index > 0:
530
+ command = argv[command_index]
531
+ return [command] + argv[:command_index] + argv[command_index + 1 :]
532
+
533
+ return argv
534
+
535
+ # ────────────────────────────────────────────────────────────────────────────────────
536
+ def _print_help_all(self, parser: argparse.ArgumentParser) -> None:
537
+ """Print markdown-formatted help for main parser and all commands."""
538
+ prog = self.prog or sys.argv[0]
539
+
540
+ # Print main help
541
+ print(f"## `{prog}`\n")
542
+ print("```")
543
+ print(parser.format_help())
544
+ print("```\n")
545
+
546
+ # Find and print help for each command
547
+ subparsers_group = parser._subparsers # pyright: ignore[reportAttributeAccessIssue]
548
+ if subparsers_group is None:
549
+ return
550
+
551
+ for action in subparsers_group._actions:
552
+ if isinstance(action, argparse._SubParsersAction):
553
+ parser_map = cast(
554
+ "dict[str, argparse.ArgumentParser]",
555
+ action._name_parser_map, # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
556
+ )
557
+ for name, subparser in parser_map.items():
558
+ print(f"## `{prog} {name}`\n")
559
+ print("```")
560
+ print(subparser.format_help())
561
+ print("```\n")
562
+
563
+ # ────────────────────────────────────────────────────────────────────────────────────
564
+ def parse(self, argv: list[str] | None = None) -> Namespace:
565
+ """Build the argument parser and parse command-line arguments.
566
+
567
+ This triggers deferred execution: all collected arguments and commands
568
+ are applied to create the actual argparse structure, then arguments
569
+ are parsed.
570
+
571
+ Commands can appear anywhere in the argument list - they will be
572
+ automatically moved to the front before parsing.
573
+
574
+ Args:
575
+ argv: Optional list of argument strings to parse. If not provided,
576
+ defaults to sys.argv[1:] (command-line arguments).
577
+
578
+ Returns:
579
+ Namespace object with parsed argument values. Access the selected
580
+ command name via the `command` attribute.
581
+
582
+ Example:
583
+ ```python
584
+ # Parse command-line arguments (default behaviour)
585
+ args = parser.parse()
586
+ print(args.command) # "init", "run", etc.
587
+ print(args.verbose) # True/False
588
+
589
+ # Parse custom arguments
590
+ args = parser.parse(argv=["init", "--config", "app.yml"])
591
+ print(args.command) # "init"
592
+ ```
593
+ """
594
+ # Use provided argv or default to sys.argv[1:]
595
+ if argv is None:
596
+ argv = sys.argv[1:]
597
+
598
+ # Build command group metadata for the custom help formatter
599
+ command_groups: dict[str, list[tuple[str, str | None]]] = {}
600
+ group_descriptions: dict[str, str | None] = {}
601
+
602
+ for group in self.__command_groups:
603
+ group_descriptions[group.name] = group.description
604
+ command_groups[group.name] = [
605
+ (cmd._command_name, cmd._help) for cmd in group.commands
606
+ ]
607
+
608
+ # Factory function to inject group metadata into formatter
609
+ # (argparse only passes prog to formatter_class, so we use a closure)
610
+ def formatter_class(prog: str, **kwargs: Any) -> _GroupedCommandsHelpFormatter:
611
+ return _GroupedCommandsHelpFormatter(
612
+ prog,
613
+ command_groups=command_groups,
614
+ group_descriptions=group_descriptions,
615
+ **kwargs,
616
+ )
617
+
618
+ parser = argparse.ArgumentParser(
619
+ prog=self.prog,
620
+ description=self.description,
621
+ formatter_class=formatter_class,
622
+ )
623
+
624
+ if self.version:
625
+ parser.add_argument(
626
+ "--version", action=_RawVersionAction, version=self.version
627
+ )
628
+
629
+ self.__add_to_parser__(parser)
630
+
631
+ # Handle --help-all before normal parsing
632
+ if "--help-all" in argv:
633
+ self._print_help_all(parser)
634
+ sys.exit(0)
635
+
636
+ # Reorder arguments to allow command anywhere in the argument list
637
+ reordered_argv = self._reorder_args(argv)
638
+
639
+ # Handle missing command when commands exist
640
+ command_names = self._get_all_command_names()
641
+ if command_names:
642
+ # Check if a command was provided
643
+ has_command = any(arg in command_names for arg in reordered_argv)
644
+
645
+ if not has_command:
646
+ # Check if user is asking for help or version (let argparse handle these)
647
+ is_help = "--help" in reordered_argv or "-h" in reordered_argv
648
+ is_version = "--version" in reordered_argv
649
+
650
+ if is_help or is_version:
651
+ # User wants help or version - let argparse show them
652
+ pass
653
+ elif self.default_command:
654
+ # Inject default command at the front
655
+ reordered_argv = [self.default_command] + reordered_argv
656
+ else:
657
+ # No command and no default: show help and exit
658
+ parser.print_help()
659
+ sys.exit(0)
660
+
661
+ args = parser.parse_args(reordered_argv)
662
+ self.__parser = parser
663
+ return args
664
+
665
+ # ────────────────────────────────────────────────────────────────────────────────────
666
+ def error(self, msg: str) -> NoReturn:
667
+ """Print an error message and exit, using the same format as argparse.
668
+
669
+ This method can only be called after parse() has been invoked, as the
670
+ underlying ArgumentParser is created during parsing.
671
+
672
+ Args:
673
+ msg: The error message to display.
674
+
675
+ Raises:
676
+ RuntimeError: If called before parse() has been run.
677
+
678
+ Example:
679
+ ```python
680
+ args = parser.parse()
681
+ if not args.config:
682
+ parser.error("--config is required")
683
+ ```
684
+ """
685
+ if self.__parser is None:
686
+ raise RuntimeError(
687
+ "parser.error() cannot be called before parse() has been executed. "
688
+ "Call parse() first to initialise the argument parser."
689
+ )
690
+ self.__parser.error(msg)
691
+
692
+ # ────────────────────────────────────────────────────────────────────────────────────
693
+ @overload
694
+ @classmethod
695
+ def new_argument(
696
+ cls,
697
+ *name_or_flags: str,
698
+ action: str | type[Action] = ...,
699
+ nargs: int | str = ...,
700
+ const: Any = ...,
701
+ default: Any = ...,
702
+ type: Callable[[str], _T] | Callable[[str], _T] = ...,
703
+ choices: Iterable[_T] = ...,
704
+ required: bool = ...,
705
+ help: str | None = ...,
706
+ metavar: str | tuple[str, ...] | None = ...,
707
+ dest: str | None = ...,
708
+ version: str = ...,
709
+ **kwargs: Any,
710
+ ) -> ArgsArgument: ...
711
+
712
+ @overload
713
+ @classmethod
714
+ def new_argument(cls) -> ArgsArgument: ...
715
+
716
+ @classmethod
717
+ def new_argument(cls, *args: Any, **kwargs: Any) -> ArgsArgument:
718
+ """
719
+ Convenience constructor for `ArgsArgument`.
720
+
721
+ Mirrors `ArgsArgument(*args, **kwargs)` so callers can create reusable
722
+ arguments without importing the class directly.
723
+ """
724
+ return ArgsArgument(*args, **kwargs)
725
+
726
+ # ────────────────────────────────────────────────────────────────────────────────────
727
+ @classmethod
728
+ def new_collection(cls, items: list[ArgsItem] | None = None) -> ArgsCollection:
729
+ """
730
+ Convenience constructor for `ArgsCollection`.
731
+
732
+ Args:
733
+ items: Optional list of arguments or groups to seed the collection.
734
+
735
+ Returns:
736
+ A new `ArgsCollection` instance.
737
+ """
738
+ return ArgsCollection(items=items)
739
+
740
+ # ────────────────────────────────────────────────────────────────────────────────────
741
+ @classmethod
742
+ def new_group(cls, name: str, *, items: list[ArgsItem] | None = None) -> ArgsGroup:
743
+ """
744
+ Convenience constructor for `ArgsGroup`.
745
+
746
+ Args:
747
+ name: Heading shown in command help.
748
+ items: Optional list of arguments or nested groups to add.
749
+
750
+ Returns:
751
+ A new `ArgsGroup` instance.
752
+ """
753
+ return ArgsGroup(name=name, items=items)
754
+
755
+ # ────────────────────────────────────────────────────────────────────────────────────
756
+ @classmethod
757
+ def new_mutex_group(
758
+ cls, *, required: bool = False, items: list[ArgsItem] | None = None
759
+ ) -> ArgsMutexGroup:
760
+ """
761
+ Convenience constructor for `ArgsMutexGroup`.
762
+
763
+ Args:
764
+ required: If True, one of the arguments must be provided.
765
+ items: Optional list of mutually exclusive arguments to add.
766
+
767
+ Returns:
768
+ A new `ArgsMutexGroup` instance.
769
+ """
770
+ return ArgsMutexGroup(required=required, items=items)
771
+
772
+ # ────────────────────────────────────────────────────────────────────────────────────
773
+ @classmethod
774
+ def new_command(
775
+ cls,
776
+ name: str,
777
+ *,
778
+ items: list[ArgsItem] | None = None,
779
+ help: str | None = None,
780
+ description: str | None = None,
781
+ ) -> ArgsCommand:
782
+ """
783
+ Convenience constructor for `ArgsCommand`.
784
+
785
+ Note:
786
+ This creates a standalone command not attached to any parser.
787
+ The `exclude_common` parameter is not available here since
788
+ common collections are managed by `ArgsParser`.
789
+
790
+ Args:
791
+ name: Command name shown on the CLI.
792
+ items: Optional list of arguments/groups to add to the command.
793
+ help: Short summary displayed in parent command lists.
794
+ description: Detailed description for the command's own help.
795
+
796
+ Returns:
797
+ A new `ArgsCommand` instance.
798
+ """
799
+ return ArgsCommand(name=name, items=items, help=help, description=description)
800
+
801
+
802
+ # ────────────────────────────────────────────────────────────────────────────────────────
803
+ class ArgsArgument:
804
+ """
805
+ A reusable command-line argument definition.
806
+
807
+ Wraps the parameters for `argparse.ArgumentParser.add_argument()` so they
808
+ can be stored and applied later. The same ArgsArgument instance can be
809
+ added to multiple commands for reuse.
810
+
811
+ Args:
812
+ *name_or_flags: Argument name or option flags (e.g., "filename", "--verbose",
813
+ "-v").
814
+ action: How to handle the argument (store, store_true, count, etc.).
815
+ nargs: Number of arguments to consume.
816
+ const: Constant value for certain actions.
817
+ default: Default value if argument not provided.
818
+ type: Callable to convert the argument string.
819
+ choices: Allowed values for the argument.
820
+ required: Whether the argument is required.
821
+ help: Help text for the argument.
822
+ metavar: Display name in usage/help messages.
823
+ dest: Attribute name in the resulting Namespace.
824
+ version: Version string for version action.
825
+ **kwargs: Additional keyword arguments passed to argparse.
826
+
827
+ Example:
828
+ ```python
829
+ # Create reusable argument
830
+ verbose = ArgsArgument(
831
+ "--verbose", "-v", action="store_true", help="Verbose mode"
832
+ )
833
+
834
+ # Use in multiple commands
835
+ cmd1.add(verbose)
836
+ cmd2.add(verbose)
837
+ ```
838
+ """
839
+
840
+ @overload
841
+ def __init__(
842
+ self,
843
+ *name_or_flags: str,
844
+ action: str | type[Action] = ...,
845
+ nargs: int | str = ...,
846
+ const: Any = ...,
847
+ default: Any = ...,
848
+ type: Callable[[str], _T] | Callable[[str], _T] = ...,
849
+ choices: Iterable[_T] = ...,
850
+ required: bool = ...,
851
+ help: str | None = ...,
852
+ metavar: str | tuple[str, ...] | None = ...,
853
+ dest: str | None = ...,
854
+ version: str = ...,
855
+ **kwargs: Any,
856
+ ) -> None: ...
857
+
858
+ @overload
859
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
860
+
861
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
862
+ self.__args = args
863
+ self.__kwargs = kwargs
864
+
865
+ # ────────────────────────────────────────────────────────────────────────────────────
866
+ @property
867
+ def args(self) -> tuple[Any, ...]:
868
+ """The positional arguments (name/flags) for this argument."""
869
+ return self.__args
870
+
871
+ # ────────────────────────────────────────────────────────────────────────────────────
872
+ @property
873
+ def kwargs(self) -> dict[str, Any]:
874
+ """The keyword arguments (action, help, etc.) for this argument."""
875
+ return self.__kwargs
876
+
877
+ # ────────────────────────────────────────────────────────────────────────────────────
878
+ def clone(self, **kwargs: Any) -> ArgsArgument:
879
+ """
880
+ Clone this argument with modified keyword arguments.
881
+
882
+ Returns a new ArgsArgument instance with the same positional arguments
883
+ (name/flags) and keyword arguments as this one, but with specified
884
+ keyword arguments overridden.
885
+
886
+ Args:
887
+ **kwargs: Keyword arguments to override or add. These will replace
888
+ any matching kwargs from the original argument.
889
+
890
+ Returns:
891
+ A new ArgsArgument instance with the modifications applied.
892
+
893
+ Example:
894
+ ```python
895
+ # Create base argument
896
+ dir_arg = ArgsArgument(
897
+ "--dir", "-d",
898
+ help="Directory to process"
899
+ )
900
+
901
+ # Create required variant
902
+ dir_required = dir_arg.clone(required=True)
903
+
904
+ # Create variant with different help text
905
+ dir_custom = dir_arg.clone(
906
+ help="Custom help text",
907
+ metavar="PATH"
908
+ )
909
+ ```
910
+ """
911
+ # Create a new dict with existing kwargs, then update with modifications
912
+ new_kwargs = self.__kwargs.copy()
913
+ new_kwargs.update(kwargs)
914
+ return ArgsArgument(*self.__args, **new_kwargs)
915
+
916
+ # ────────────────────────────────────────────────────────────────────────────────────
917
+ def __add_to_parser__(self, parser: ParserLike) -> None:
918
+ parser.add_argument(*self.__args, **self.__kwargs)
919
+
920
+
921
+ # ────────────────────────────────────────────────────────────────────────────────────────
922
+ class ArgsCommand(_BaseClass):
923
+ """
924
+ A subcommand in the CLI.
925
+
926
+ Commands can have their own arguments, argument groups, and even nested
927
+ subcommands.
928
+
929
+ Args:
930
+ name: The command name used on the command line.
931
+ items: Optional list of arguments/groups to add to this command.
932
+ help: Short help text shown in parent's command list.
933
+ description: Longer description shown in command's own --help.
934
+ If only one of help/description is provided, it's used for both.
935
+
936
+ Example:
937
+ ```python
938
+ parser = ArgsParser("My app")
939
+ cmd = parser.add_command("deploy", help="Deploy the application")
940
+ cmd.add_argument("--env", choices=["dev", "prod"], required=True)
941
+ cmd.add_argument("--dry-run", action="store_true")
942
+ ```
943
+ """
944
+
945
+ # ────────────────────────────────────────────────────────────────────────────────────
946
+ def __init__(
947
+ self,
948
+ name: str,
949
+ *,
950
+ items: list[ArgsItem] | None = None,
951
+ help: str | None = None,
952
+ description: str | None = None,
953
+ ):
954
+ super().__init__()
955
+ self._command_name = name
956
+ self._help = help or description
957
+ self._description = description or help
958
+ self._common_collection: ArgsCollection | None = None
959
+ if items:
960
+ self.add(*items)
961
+
962
+ # ────────────────────────────────────────────────────────────────────────────────────
963
+ def _prepend_item(self, item: ArgsItem) -> None:
964
+ """Add an item at the beginning of the items list."""
965
+ self.__all_items__.insert(0, item)
966
+
967
+ # ────────────────────────────────────────────────────────────────────────────────────
968
+ def __add_to_sub_parser__(
969
+ self, sub_parsers: argparse._SubParsersAction[argparse.ArgumentParser]
970
+ ) -> None:
971
+ command_parser = sub_parsers.add_parser(
972
+ name=self._command_name,
973
+ description=self._description,
974
+ help=self._help,
975
+ add_help=False,
976
+ formatter_class=_FixedHelpFormatter,
977
+ )
978
+ command_parser.add_argument(
979
+ "--help", "-h", action="help", help=argparse.SUPPRESS
980
+ )
981
+ for item in self.__all_items__:
982
+ item.__add_to_parser__(command_parser)
983
+ # Apply common options at the end if they were deferred
984
+ if self._common_collection:
985
+ self._common_collection.__add_to_parser__(command_parser)
986
+
987
+
988
+ # ────────────────────────────────────────────────────────────────────────────────────────
989
+ class ArgsCollection(_BaseClass):
990
+ """
991
+ A reusable collection of arguments that can be added to multiple commands.
992
+
993
+ Use collections to bundle related arguments that should be available
994
+ in multiple commands.
995
+
996
+ Example:
997
+ ```python
998
+ # Create a collection of common output options
999
+ output_opts = ArgsCollection()
1000
+ output_opts.add_argument("--format", choices=["json", "text", "yaml"])
1001
+ output_opts.add_argument("--output", "-o", help="Output file")
1002
+ output_opts.add_argument("--quiet", "-q", action="store_true")
1003
+
1004
+ # Add to multiple commands
1005
+ list_cmd.add(output_opts)
1006
+ show_cmd.add(output_opts)
1007
+ export_cmd.add(output_opts)
1008
+ ```
1009
+ """
1010
+
1011
+ ...
1012
+
1013
+
1014
+ # ────────────────────────────────────────────────────────────────────────────────────────
1015
+ class ArgsCommandGroup:
1016
+ """
1017
+ A group of commands for organisational display in help output.
1018
+
1019
+ This is purely cosmetic - it affects how commands appear in --help
1020
+ but doesn't change parsing behaviour. Commands are grouped under
1021
+ headings instead of being listed in one flat section.
1022
+
1023
+ Note:
1024
+ Create command groups via `ArgsParser.add_command_group()`, not directly.
1025
+
1026
+ Args:
1027
+ name: The group heading shown in help output.
1028
+ description: Optional description shown below the heading.
1029
+ parent: The parent ArgsParser (set automatically).
1030
+
1031
+ Example:
1032
+ ```python
1033
+ parser = ArgsParser("My app")
1034
+
1035
+ # Create groups for organised help
1036
+ basic = parser.add_command_group(
1037
+ "Basic Commands", description="Common operations"
1038
+ )
1039
+ basic.add_command("init", help="Initialise project")
1040
+ basic.add_command("status", help="Show project status")
1041
+
1042
+ advanced = parser.add_command_group("Advanced Commands")
1043
+ advanced.add_command("migrate", help="Run database migrations")
1044
+ ```
1045
+ """
1046
+
1047
+ # ────────────────────────────────────────────────────────────────────────────────────
1048
+ def __init__(
1049
+ self,
1050
+ name: str,
1051
+ *,
1052
+ description: str | None = None,
1053
+ parent: ArgsParser | None = None,
1054
+ ):
1055
+ self.__group_name = name
1056
+ self.__description = description
1057
+ self.__commands: list[ArgsCommand] = []
1058
+ self.__parent = parent
1059
+
1060
+ # ────────────────────────────────────────────────────────────────────────────────────
1061
+ @property
1062
+ def name(self) -> str:
1063
+ """The group heading name."""
1064
+ return self.__group_name
1065
+
1066
+ # ────────────────────────────────────────────────────────────────────────────────────
1067
+ @property
1068
+ def description(self) -> str | None:
1069
+ """Optional description shown below the heading."""
1070
+ return self.__description
1071
+
1072
+ # ────────────────────────────────────────────────────────────────────────────────────
1073
+ @property
1074
+ def commands(self) -> list[ArgsCommand]:
1075
+ """List of commands in this group."""
1076
+ return self.__commands
1077
+
1078
+ # ────────────────────────────────────────────────────────────────────────────────────
1079
+ def add_command(
1080
+ self,
1081
+ name: str,
1082
+ *,
1083
+ items: list[ArgsItem] | None = None,
1084
+ help: str | None = None,
1085
+ description: str | None = None,
1086
+ exclude_common: bool = False,
1087
+ ) -> ArgsCommand:
1088
+ """Add a command to this group.
1089
+
1090
+ Args:
1091
+ name: The command name used on the command line.
1092
+ items: Optional list of arguments/groups to add to this command.
1093
+ help: Short help text shown in parent's command list.
1094
+ description: Longer description shown in command's own --help.
1095
+ exclude_common: If True, excludes common options from this command.
1096
+
1097
+ Returns:
1098
+ The created ArgsCommand instance.
1099
+ """
1100
+ new_command = ArgsCommand(
1101
+ name=name, items=items, help=help, description=description
1102
+ )
1103
+ self.__commands.append(new_command)
1104
+ if self.__parent:
1105
+ self.__parent._add_item(new_command)
1106
+ if self.__parent.__common_collection__ and not exclude_common:
1107
+ if self.__parent._common_options_first:
1108
+ # Insert at the beginning of items list
1109
+ new_command._prepend_item(self.__parent.__common_collection__)
1110
+ else:
1111
+ # Store for later application at the end
1112
+ new_command._common_collection = self.__parent.__common_collection__
1113
+ return new_command
1114
+
1115
+ # ────────────────────────────────────────────────────────────────────────────────────
1116
+ def __add_to_parser__(self, _parser: ParserLike) -> None:
1117
+ # Commands are added via parent, this is just for interface compatibility
1118
+ pass
1119
+
1120
+
1121
+ # ────────────────────────────────────────────────────────────────────────────────────────
1122
+ class ArgsGroup(_BaseClass):
1123
+ """
1124
+ A named group of arguments for organisational display in help output.
1125
+
1126
+ Groups arguments under a heading in the command's --help output.
1127
+
1128
+ Args:
1129
+ name: The group heading shown in help output.
1130
+ items: Optional list of arguments to add to this group.
1131
+
1132
+ Example:
1133
+ ```python
1134
+ cmd = parser.add_command("run")
1135
+
1136
+ # Group related arguments
1137
+ output = cmd.add_group("Output Options")
1138
+ output.add_argument("--format", choices=["json", "text"])
1139
+ output.add_argument("--output", "-o", help="Output file")
1140
+
1141
+ debug = cmd.add_group("Debug Options")
1142
+ debug.add_argument("--verbose", "-v", action="store_true")
1143
+ debug.add_argument("--trace", action="store_true")
1144
+ ```
1145
+ """
1146
+
1147
+ # ────────────────────────────────────────────────────────────────────────────────────
1148
+ def __init__(self, name: str, *, items: list[ArgsItem] | None = None):
1149
+ super().__init__()
1150
+ self.__group_name = name
1151
+ if items:
1152
+ self.add(*items)
1153
+
1154
+ # ────────────────────────────────────────────────────────────────────────────────────
1155
+ def __add_to_parser__(self, parser: ParserLike) -> None:
1156
+ group_parser = parser.add_argument_group(title=self.__group_name)
1157
+ for item in self.__all_items__:
1158
+ item.__add_to_parser__(group_parser)
1159
+
1160
+
1161
+ # ────────────────────────────────────────────────────────────────────────────────────────
1162
+ class ArgsMutexGroup(_BaseClass):
1163
+ """
1164
+ A mutually exclusive group of arguments.
1165
+
1166
+ Only one argument from this group can be specified at a time.
1167
+
1168
+ Args:
1169
+ required: If True, one of the arguments must be provided.
1170
+ items: Optional list of arguments to add to this group.
1171
+
1172
+ Example:
1173
+ ```python
1174
+ cmd = parser.add_command("output")
1175
+
1176
+ # User must choose exactly one format
1177
+ format_group = cmd.add_mutex_group(required=True)
1178
+ format_group.add_argument("--json", action="store_true", help="JSON output")
1179
+ format_group.add_argument("--xml", action="store_true", help="XML output")
1180
+ format_group.add_argument("--csv", action="store_true", help="CSV output")
1181
+
1182
+ # Optional mutex: at most one can be specified
1183
+ verbosity = cmd.add_mutex_group()
1184
+ verbosity.add_argument("--quiet", "-q", action="store_true")
1185
+ verbosity.add_argument("--verbose", "-v", action="store_true")
1186
+ ```
1187
+ """
1188
+
1189
+ # ────────────────────────────────────────────────────────────────────────────────────
1190
+ def __init__(self, *, required: bool = False, items: list[ArgsItem] | None = None):
1191
+ super().__init__()
1192
+ if items:
1193
+ self.add(*items)
1194
+ self.__mutex_group_required = required
1195
+
1196
+ # ────────────────────────────────────────────────────────────────────────────────────
1197
+ def __add_to_parser__(self, parser: ParserLike) -> None:
1198
+ mutex_group_parser = parser.add_mutually_exclusive_group(
1199
+ required=self.__mutex_group_required
1200
+ )
1201
+ for item in self.__all_items__:
1202
+ item.__add_to_parser__(mutex_group_parser)
1203
+
1204
+
1205
+ # ────────────────────────────────────────────────────────────────────────────────────────
1206
+ # Private Classes (internal implementation details)
1207
+ # ────────────────────────────────────────────────────────────────────────────────────────
1208
+
1209
+
1210
+ # ────────────────────────────────────────────────────────────────────────────────────────
1211
+ class _FixedHelpFormatter(argparse.HelpFormatter):
1212
+ """HelpFormatter with Python 3.12 double metavar fix and newline preservation.
1213
+
1214
+ Python 3.12 shows: --value VALUE, -v VALUE
1215
+ This fixes it to: --value, -v VALUE
1216
+ (Fixed upstream in Python 3.13+)
1217
+
1218
+ Also preserves explicit newlines in description and help text.
1219
+ """
1220
+
1221
+ def _fill_text(self, text: str, width: int, indent: str) -> str:
1222
+ """Fill text while preserving explicit line breaks.
1223
+
1224
+ This handles the description text at the top of command help.
1225
+ Processes each paragraph (separated by \n\n) independently.
1226
+ """
1227
+ paragraphs = text.split("\n\n")
1228
+ result_paragraphs: list[str] = []
1229
+
1230
+ for paragraph in paragraphs:
1231
+ # For each paragraph, handle explicit single newlines
1232
+ lines: list[str] = []
1233
+ for segment in paragraph.split("\n"):
1234
+ if segment.strip():
1235
+ # Wrap this segment to the width
1236
+ wrapped: str = super()._fill_text(segment, width, indent)
1237
+ lines.append(wrapped)
1238
+ else:
1239
+ lines.append("")
1240
+ result_paragraphs.append("\n".join(lines))
1241
+
1242
+ return "\n\n".join(result_paragraphs)
1243
+
1244
+ def _split_lines(self, text: str, width: int) -> list[str]: # type: ignore[override]
1245
+ """Split lines while preserving explicit newlines.
1246
+
1247
+ This handles the help text for individual options.
1248
+ """
1249
+ lines: list[str] = []
1250
+ for segment in text.split("\n"):
1251
+ if segment == "":
1252
+ lines.append("") # keep blank lines
1253
+ else:
1254
+ lines.extend(super()._split_lines(segment, width))
1255
+ return lines
1256
+
1257
+ def _format_action_invocation(self, action: Action) -> str:
1258
+ if sys.version_info < (3, 13) and action.option_strings:
1259
+ if action.nargs != 0:
1260
+ default = self._get_default_metavar_for_optional(action)
1261
+ args_string = self._format_args(action, default)
1262
+ return ", ".join(action.option_strings) + " " + args_string
1263
+ return super()._format_action_invocation(action)
1264
+
1265
+
1266
+ # ────────────────────────────────────────────────────────────────────────────────────────
1267
+ class _GroupedCommandsHelpFormatter(_FixedHelpFormatter):
1268
+ """Custom formatter that displays subcommands grouped under headings."""
1269
+
1270
+ def __init__(
1271
+ self,
1272
+ prog: str,
1273
+ indent_increment: int = 2,
1274
+ max_help_position: int = 24,
1275
+ width: int | None = None,
1276
+ command_groups: dict[str, list[tuple[str, str | None]]] | None = None,
1277
+ group_descriptions: dict[str, str | None] | None = None,
1278
+ **kwargs: Any,
1279
+ ):
1280
+ super().__init__(prog, indent_increment, max_help_position, width, **kwargs)
1281
+ self._command_groups = command_groups or {}
1282
+ self._group_descriptions = group_descriptions or {}
1283
+
1284
+ def _format_actions_usage(
1285
+ self,
1286
+ actions: Iterable[Action],
1287
+ groups: Iterable[argparse._MutuallyExclusiveGroup],
1288
+ ) -> str:
1289
+ return super()._format_actions_usage(actions, groups)
1290
+
1291
+ def _metavar_formatter(
1292
+ self, action: Action, default_metavar: str
1293
+ ) -> Callable[[int], tuple[str, ...]]:
1294
+ return super()._metavar_formatter(action, default_metavar)
1295
+
1296
+ def _format_action(self, action: Action) -> str:
1297
+ # Intercept subparser action to control how commands are displayed
1298
+ if isinstance(action, argparse._SubParsersAction):
1299
+ return self._format_grouped_subparsers(
1300
+ cast("argparse._SubParsersAction[argparse.ArgumentParser]", action)
1301
+ )
1302
+ # Cast needed: isinstance check leaves type as Action | _SubParsersAction[Unknown]
1303
+ return super()._format_action(action)
1304
+
1305
+ def _format_grouped_subparsers(
1306
+ self,
1307
+ action: argparse._SubParsersAction[argparse.ArgumentParser],
1308
+ ) -> str:
1309
+ """Format subcommands organised under group headings."""
1310
+ parts: list[str] = []
1311
+ width = self._width or 80
1312
+ help_position = self._max_help_position
1313
+ name_indent = " "
1314
+ help_indent = " " * help_position
1315
+
1316
+ # Build dict of all subparsers with their help text
1317
+ # (used to track which commands aren't in any group)
1318
+ subparsers: dict[str, str | None] = {}
1319
+ for name in action._name_parser_map:
1320
+ help_text: str | None = None
1321
+ for choice_action in action._choices_actions:
1322
+ if choice_action.dest == name:
1323
+ help_text = choice_action.help
1324
+ break
1325
+ subparsers[name] = help_text
1326
+
1327
+ # Python 3.14+ supports coloured terminal output via _theme
1328
+ theme: Any = getattr(self, "_theme", None)
1329
+ has_colors = theme is not None
1330
+
1331
+ if has_colors:
1332
+ heading_style: str = getattr(theme, "heading", "")
1333
+ action_style: str = getattr(theme, "action", "")
1334
+ reset_style: str = getattr(theme, "reset", "")
1335
+ else:
1336
+ heading_style = action_style = reset_style = ""
1337
+
1338
+ def format_command(
1339
+ name: str, help_text: str | None, *, coloured_name: str
1340
+ ) -> str:
1341
+ # Align help text at help_position and wrap long descriptions
1342
+ visible_name_len = len(name_indent) + len(name)
1343
+
1344
+ if not help_text:
1345
+ return f"{name_indent}{coloured_name}\n"
1346
+
1347
+ wrapped_help = self._split_lines(help_text, max(1, width - help_position))
1348
+ if not wrapped_help:
1349
+ return f"{name_indent}{coloured_name}\n"
1350
+
1351
+ lines: list[str] = []
1352
+ if visible_name_len >= help_position:
1353
+ lines.append(f"{name_indent}{coloured_name}\n")
1354
+ lines.append(f"{help_indent}{wrapped_help[0]}\n")
1355
+ else:
1356
+ padding = " " * (help_position - visible_name_len)
1357
+ lines.append(
1358
+ f"{name_indent}{coloured_name}{padding}{wrapped_help[0]}\n"
1359
+ )
1360
+
1361
+ for line in wrapped_help[1:]:
1362
+ lines.append(f"{help_indent}{line}\n")
1363
+
1364
+ return "".join(lines)
1365
+
1366
+ def format_group_description(text: str) -> str:
1367
+ wrapped = self._split_lines(text, max(1, width - len(name_indent)))
1368
+ return "".join(f"{name_indent}{line}\n" for line in wrapped)
1369
+
1370
+ # Format each command group
1371
+ for group_name, commands in self._command_groups.items():
1372
+ if not commands:
1373
+ continue
1374
+
1375
+ parts.append(f"\n{heading_style}{group_name}:{reset_style}\n")
1376
+
1377
+ group_desc = self._group_descriptions.get(group_name)
1378
+ if group_desc:
1379
+ parts.append(format_group_description(group_desc))
1380
+ parts.append("\n")
1381
+
1382
+ for cmd_name, cmd_help in commands:
1383
+ coloured_name = (
1384
+ f"{action_style}{cmd_name}{reset_style}" if has_colors else cmd_name
1385
+ )
1386
+ parts.append(
1387
+ format_command(cmd_name, cmd_help, coloured_name=coloured_name)
1388
+ )
1389
+ # Mark as processed
1390
+ subparsers.pop(cmd_name, None)
1391
+
1392
+ # Commands not assigned to any group go under generic "Commands:" heading
1393
+ if subparsers:
1394
+ for name, help_text in subparsers.items():
1395
+ coloured_name = (
1396
+ f"{action_style}{name}{reset_style}" if has_colors else name
1397
+ )
1398
+ parts.append(
1399
+ format_command(name, help_text, coloured_name=coloured_name)
1400
+ )
1401
+
1402
+ return "".join(parts)
1403
+
1404
+
1405
+ # ────────────────────────────────────────────────────────────────────────────────────────
1406
+ class _RawVersionAction(argparse._VersionAction):
1407
+ """Version action that preserves newlines in version strings."""
1408
+
1409
+ version: str | None
1410
+
1411
+ def __call__(
1412
+ self,
1413
+ parser: argparse.ArgumentParser,
1414
+ namespace: Namespace,
1415
+ values: str | Sequence[Any] | None,
1416
+ option_string: str | None = None,
1417
+ ) -> None:
1418
+ parser_version: Any = getattr(parser, "version", None)
1419
+ version_value = self.version or parser_version or parser.prog or ""
1420
+ version_str = str(version_value)
1421
+ if not version_str.endswith("\n"):
1422
+ version_str += "\n"
1423
+ parser._print_message(version_str, sys.stdout)
1424
+ parser.exit()