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.
- cal_docs_client/__init__.py +20 -0
- cal_docs_client/__main__.py +21 -0
- cal_docs_client/_version.py +4 -0
- cal_docs_client/argbuilder.py +1424 -0
- cal_docs_client/cli.py +518 -0
- cal_docs_client/client.py +265 -0
- cal_docs_client/common/__init__.py +32 -0
- cal_docs_client/common/colour.py +107 -0
- cal_docs_client/version.py +32 -0
- cal_docs_client-1.0.0b1.dist-info/METADATA +120 -0
- cal_docs_client-1.0.0b1.dist-info/RECORD +14 -0
- cal_docs_client-1.0.0b1.dist-info/WHEEL +4 -0
- cal_docs_client-1.0.0b1.dist-info/entry_points.txt +2 -0
- cal_docs_client-1.0.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|