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