falyx 0.1.27__py3-none-any.whl → 0.1.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,756 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ from copy import deepcopy
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Any, Iterable
6
+
7
+ from rich.console import Console
8
+ from rich.markup import escape
9
+ from rich.text import Text
10
+
11
+ from falyx.exceptions import CommandArgumentError
12
+ from falyx.signals import HelpSignal
13
+
14
+
15
+ class ArgumentAction(Enum):
16
+ """Defines the action to be taken when the argument is encountered."""
17
+
18
+ STORE = "store"
19
+ STORE_TRUE = "store_true"
20
+ STORE_FALSE = "store_false"
21
+ APPEND = "append"
22
+ EXTEND = "extend"
23
+ COUNT = "count"
24
+ HELP = "help"
25
+
26
+
27
+ @dataclass
28
+ class Argument:
29
+ """Represents a command-line argument."""
30
+
31
+ flags: list[str]
32
+ dest: str # Destination name for the argument
33
+ action: ArgumentAction = (
34
+ ArgumentAction.STORE
35
+ ) # Action to be taken when the argument is encountered
36
+ type: Any = str # Type of the argument (e.g., str, int, float) or callable
37
+ default: Any = None # Default value if the argument is not provided
38
+ choices: list[str] | None = None # List of valid choices for the argument
39
+ required: bool = False # True if the argument is required
40
+ help: str = "" # Help text for the argument
41
+ nargs: int | str = 1 # int, '?', '*', '+'
42
+ positional: bool = False # True if no leading - or -- in flags
43
+
44
+ def get_positional_text(self) -> str:
45
+ """Get the positional text for the argument."""
46
+ text = ""
47
+ if self.positional:
48
+ if self.choices:
49
+ text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
50
+ else:
51
+ text = self.dest
52
+ return text
53
+
54
+ def get_choice_text(self) -> str:
55
+ """Get the choice text for the argument."""
56
+ choice_text = ""
57
+ if self.choices:
58
+ choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
59
+ elif (
60
+ self.action
61
+ in (
62
+ ArgumentAction.STORE,
63
+ ArgumentAction.APPEND,
64
+ ArgumentAction.EXTEND,
65
+ )
66
+ and not self.positional
67
+ ):
68
+ choice_text = self.dest.upper()
69
+ elif isinstance(self.nargs, str):
70
+ choice_text = self.dest
71
+
72
+ if self.nargs == "?":
73
+ choice_text = f"[{choice_text}]"
74
+ elif self.nargs == "*":
75
+ choice_text = f"[{choice_text} ...]"
76
+ elif self.nargs == "+":
77
+ choice_text = f"{choice_text} [{choice_text} ...]"
78
+ return choice_text
79
+
80
+ def __eq__(self, other: object) -> bool:
81
+ if not isinstance(other, Argument):
82
+ return False
83
+ return (
84
+ self.flags == other.flags
85
+ and self.dest == other.dest
86
+ and self.action == other.action
87
+ and self.type == other.type
88
+ and self.choices == other.choices
89
+ and self.required == other.required
90
+ and self.nargs == other.nargs
91
+ and self.positional == other.positional
92
+ )
93
+
94
+ def __hash__(self) -> int:
95
+ return hash(
96
+ (
97
+ tuple(self.flags),
98
+ self.dest,
99
+ self.action,
100
+ self.type,
101
+ tuple(self.choices or []),
102
+ self.required,
103
+ self.nargs,
104
+ self.positional,
105
+ )
106
+ )
107
+
108
+
109
+ class CommandArgumentParser:
110
+ """
111
+ Custom argument parser for Falyx Commands.
112
+ It is used to create a command-line interface for Falyx
113
+ commands, allowing users to specify options and arguments
114
+ when executing commands.
115
+ It is not intended to be a full-featured replacement for
116
+ argparse, but rather a lightweight alternative for specific use
117
+ cases within the Falyx framework.
118
+
119
+ Features:
120
+ - Customizable argument parsing.
121
+ - Type coercion for arguments.
122
+ - Support for positional and keyword arguments.
123
+ - Support for default values.
124
+ - Support for boolean flags.
125
+ - Exception handling for invalid arguments.
126
+ - Render Help using Rich library.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ command_key: str = "",
132
+ command_description: str = "",
133
+ command_style: str = "bold",
134
+ help_text: str = "",
135
+ help_epilogue: str = "",
136
+ aliases: list[str] | None = None,
137
+ ) -> None:
138
+ """Initialize the CommandArgumentParser."""
139
+ self.command_key: str = command_key
140
+ self.command_description: str = command_description
141
+ self.command_style: str = command_style
142
+ self.help_text: str = help_text
143
+ self.help_epilogue: str = help_epilogue
144
+ self.aliases: list[str] = aliases or []
145
+ self._arguments: list[Argument] = []
146
+ self._positional: list[Argument] = []
147
+ self._keyword: list[Argument] = []
148
+ self._flag_map: dict[str, Argument] = {}
149
+ self._dest_set: set[str] = set()
150
+ self._add_help()
151
+ self.console = Console(color_system="auto")
152
+
153
+ def _add_help(self):
154
+ """Add help argument to the parser."""
155
+ self.add_argument(
156
+ "-h",
157
+ "--help",
158
+ action=ArgumentAction.HELP,
159
+ help="Show this help message.",
160
+ dest="help",
161
+ )
162
+
163
+ def _is_positional(self, flags: tuple[str, ...]) -> bool:
164
+ """Check if the flags are positional."""
165
+ positional = False
166
+ if any(not flag.startswith("-") for flag in flags):
167
+ positional = True
168
+
169
+ if positional and len(flags) > 1:
170
+ raise CommandArgumentError("Positional arguments cannot have multiple flags")
171
+ return positional
172
+
173
+ def _get_dest_from_flags(
174
+ self, flags: tuple[str, ...], dest: str | None
175
+ ) -> str | None:
176
+ """Convert flags to a destination name."""
177
+ if dest:
178
+ if not dest.replace("_", "").isalnum():
179
+ raise CommandArgumentError(
180
+ "dest must be a valid identifier (letters, digits, and underscores only)"
181
+ )
182
+ if dest[0].isdigit():
183
+ raise CommandArgumentError("dest must not start with a digit")
184
+ return dest
185
+ dest = None
186
+ for flag in flags:
187
+ if flag.startswith("--"):
188
+ dest = flag.lstrip("-").replace("-", "_").lower()
189
+ break
190
+ elif flag.startswith("-"):
191
+ dest = flag.lstrip("-").replace("-", "_").lower()
192
+ else:
193
+ dest = flag.replace("-", "_").lower()
194
+ assert dest is not None, "dest should not be None"
195
+ if not dest.replace("_", "").isalnum():
196
+ raise CommandArgumentError(
197
+ "dest must be a valid identifier (letters, digits, and underscores only)"
198
+ )
199
+ if dest[0].isdigit():
200
+ raise CommandArgumentError("dest must not start with a digit")
201
+ return dest
202
+
203
+ def _determine_required(
204
+ self, required: bool, positional: bool, nargs: int | str
205
+ ) -> bool:
206
+ """Determine if the argument is required."""
207
+ if required:
208
+ return True
209
+ if positional:
210
+ if isinstance(nargs, int):
211
+ return nargs > 0
212
+ elif isinstance(nargs, str):
213
+ if nargs in ("+"):
214
+ return True
215
+ elif nargs in ("*", "?"):
216
+ return False
217
+ else:
218
+ raise CommandArgumentError(f"Invalid nargs value: {nargs}")
219
+
220
+ return required
221
+
222
+ def _validate_nargs(self, nargs: int | str) -> int | str:
223
+ allowed_nargs = ("?", "*", "+")
224
+ if isinstance(nargs, int):
225
+ if nargs <= 0:
226
+ raise CommandArgumentError("nargs must be a positive integer")
227
+ elif isinstance(nargs, str):
228
+ if nargs not in allowed_nargs:
229
+ raise CommandArgumentError(f"Invalid nargs value: {nargs}")
230
+ else:
231
+ raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
232
+ return nargs
233
+
234
+ def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]:
235
+ if choices is not None:
236
+ if isinstance(choices, dict):
237
+ raise CommandArgumentError("choices cannot be a dict")
238
+ try:
239
+ choices = list(choices)
240
+ except TypeError:
241
+ raise CommandArgumentError(
242
+ "choices must be iterable (like list, tuple, or set)"
243
+ )
244
+ else:
245
+ choices = []
246
+ for choice in choices:
247
+ if not isinstance(choice, expected_type):
248
+ try:
249
+ expected_type(choice)
250
+ except Exception:
251
+ raise CommandArgumentError(
252
+ f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}"
253
+ )
254
+ return choices
255
+
256
+ def _validate_default_type(
257
+ self, default: Any, expected_type: type, dest: str
258
+ ) -> None:
259
+ """Validate the default value type."""
260
+ if default is not None and not isinstance(default, expected_type):
261
+ try:
262
+ expected_type(default)
263
+ except Exception:
264
+ raise CommandArgumentError(
265
+ f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
266
+ )
267
+
268
+ def _validate_default_list_type(
269
+ self, default: list[Any], expected_type: type, dest: str
270
+ ) -> None:
271
+ if isinstance(default, list):
272
+ for item in default:
273
+ if not isinstance(item, expected_type):
274
+ try:
275
+ expected_type(item)
276
+ except Exception:
277
+ raise CommandArgumentError(
278
+ f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
279
+ )
280
+
281
+ def _resolve_default(
282
+ self, action: ArgumentAction, default: Any, nargs: str | int
283
+ ) -> Any:
284
+ """Get the default value for the argument."""
285
+ if default is None:
286
+ if action == ArgumentAction.STORE_TRUE:
287
+ return False
288
+ elif action == ArgumentAction.STORE_FALSE:
289
+ return True
290
+ elif action == ArgumentAction.COUNT:
291
+ return 0
292
+ elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
293
+ return []
294
+ elif nargs in ("+", "*"):
295
+ return []
296
+ else:
297
+ return None
298
+ return default
299
+
300
+ def _validate_flags(self, flags: tuple[str, ...]) -> None:
301
+ """Validate the flags provided for the argument."""
302
+ if not flags:
303
+ raise CommandArgumentError("No flags provided")
304
+ for flag in flags:
305
+ if not isinstance(flag, str):
306
+ raise CommandArgumentError(f"Flag '{flag}' must be a string")
307
+ if flag.startswith("--") and len(flag) < 3:
308
+ raise CommandArgumentError(
309
+ f"Flag '{flag}' must be at least 3 characters long"
310
+ )
311
+ if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
312
+ raise CommandArgumentError(
313
+ f"Flag '{flag}' must be a single character or start with '--'"
314
+ )
315
+
316
+ def add_argument(self, *flags, **kwargs):
317
+ """Add an argument to the parser.
318
+ Args:
319
+ name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
320
+ action: The action to be taken when the argument is encountered.
321
+ nargs: The number of arguments expected.
322
+ default: The default value if the argument is not provided.
323
+ type: The type to which the command-line argument should be converted.
324
+ choices: A container of the allowable values for the argument.
325
+ required: Whether or not the argument is required.
326
+ help: A brief description of the argument.
327
+ dest: The name of the attribute to be added to the object returned by parse_args().
328
+ """
329
+ self._validate_flags(flags)
330
+ positional = self._is_positional(flags)
331
+ dest = self._get_dest_from_flags(flags, kwargs.get("dest"))
332
+ if dest in self._dest_set:
333
+ raise CommandArgumentError(
334
+ f"Destination '{dest}' is already defined.\n"
335
+ "Merging multiple arguments into the same dest (e.g. positional + flagged) "
336
+ "is not supported. Define a unique 'dest' for each argument."
337
+ )
338
+ self._dest_set.add(dest)
339
+ action = kwargs.get("action", ArgumentAction.STORE)
340
+ if not isinstance(action, ArgumentAction):
341
+ try:
342
+ action = ArgumentAction(action)
343
+ except ValueError:
344
+ raise CommandArgumentError(
345
+ f"Invalid action '{action}' is not a valid ArgumentAction"
346
+ )
347
+ flags = list(flags)
348
+ nargs = self._validate_nargs(kwargs.get("nargs", 1))
349
+ default = self._resolve_default(action, kwargs.get("default"), nargs)
350
+ expected_type = kwargs.get("type", str)
351
+ if (
352
+ action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND)
353
+ and default is not None
354
+ ):
355
+ if isinstance(default, list):
356
+ self._validate_default_list_type(default, expected_type, dest)
357
+ else:
358
+ self._validate_default_type(default, expected_type, dest)
359
+ choices = self._normalize_choices(kwargs.get("choices"), expected_type)
360
+ if default is not None and choices and default not in choices:
361
+ raise CommandArgumentError(
362
+ f"Default value '{default}' not in allowed choices: {choices}"
363
+ )
364
+ required = self._determine_required(
365
+ kwargs.get("required", False), positional, nargs
366
+ )
367
+ argument = Argument(
368
+ flags=flags,
369
+ dest=dest,
370
+ action=action,
371
+ type=expected_type,
372
+ default=default,
373
+ choices=choices,
374
+ required=required,
375
+ help=kwargs.get("help", ""),
376
+ nargs=nargs,
377
+ positional=positional,
378
+ )
379
+ for flag in flags:
380
+ if flag in self._flag_map:
381
+ existing = self._flag_map[flag]
382
+ raise CommandArgumentError(
383
+ f"Flag '{flag}' is already used by argument '{existing.dest}'"
384
+ )
385
+ self._flag_map[flag] = argument
386
+ self._arguments.append(argument)
387
+ if positional:
388
+ self._positional.append(argument)
389
+ else:
390
+ self._keyword.append(argument)
391
+
392
+ def get_argument(self, dest: str) -> Argument | None:
393
+ return next((a for a in self._arguments if a.dest == dest), None)
394
+
395
+ def to_definition_list(self) -> list[dict[str, Any]]:
396
+ defs = []
397
+ for arg in self._arguments:
398
+ defs.append(
399
+ {
400
+ "flags": arg.flags,
401
+ "dest": arg.dest,
402
+ "action": arg.action,
403
+ "type": arg.type,
404
+ "choices": arg.choices,
405
+ "required": arg.required,
406
+ "nargs": arg.nargs,
407
+ "positional": arg.positional,
408
+ }
409
+ )
410
+ return defs
411
+
412
+ def _consume_nargs(
413
+ self, args: list[str], start: int, spec: Argument
414
+ ) -> tuple[list[str], int]:
415
+ values = []
416
+ i = start
417
+ if isinstance(spec.nargs, int):
418
+ # assert i + spec.nargs <= len(
419
+ # args
420
+ # ), "Not enough arguments provided: shouldn't happen"
421
+ values = args[i : i + spec.nargs]
422
+ return values, i + spec.nargs
423
+ elif spec.nargs == "+":
424
+ if i >= len(args):
425
+ raise CommandArgumentError(
426
+ f"Expected at least one value for '{spec.dest}'"
427
+ )
428
+ while i < len(args) and not args[i].startswith("-"):
429
+ values.append(args[i])
430
+ i += 1
431
+ assert values, "Expected at least one value for '+' nargs: shouldn't happen"
432
+ return values, i
433
+ elif spec.nargs == "*":
434
+ while i < len(args) and not args[i].startswith("-"):
435
+ values.append(args[i])
436
+ i += 1
437
+ return values, i
438
+ elif spec.nargs == "?":
439
+ if i < len(args) and not args[i].startswith("-"):
440
+ return [args[i]], i + 1
441
+ return [], i
442
+ else:
443
+ assert False, "Invalid nargs value: shouldn't happen"
444
+
445
+ def _consume_all_positional_args(
446
+ self,
447
+ args: list[str],
448
+ result: dict[str, Any],
449
+ positional_args: list[Argument],
450
+ consumed_positional_indicies: set[int],
451
+ ) -> int:
452
+ remaining_positional_args = [
453
+ (j, spec)
454
+ for j, spec in enumerate(positional_args)
455
+ if j not in consumed_positional_indicies
456
+ ]
457
+ i = 0
458
+
459
+ for j, spec in remaining_positional_args:
460
+ # estimate how many args the remaining specs might need
461
+ is_last = j == len(positional_args) - 1
462
+ remaining = len(args) - i
463
+ min_required = 0
464
+ for next_spec in positional_args[j + 1 :]:
465
+ if isinstance(next_spec.nargs, int):
466
+ min_required += next_spec.nargs
467
+ elif next_spec.nargs == "+":
468
+ min_required += 1
469
+ elif next_spec.nargs == "?":
470
+ min_required += 0
471
+ elif next_spec.nargs == "*":
472
+ min_required += 0
473
+ else:
474
+ assert False, "Invalid nargs value: shouldn't happen"
475
+
476
+ slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)]
477
+ values, new_i = self._consume_nargs(slice_args, 0, spec)
478
+ i += new_i
479
+
480
+ try:
481
+ typed = [spec.type(v) for v in values]
482
+ except Exception:
483
+ raise CommandArgumentError(
484
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
485
+ )
486
+
487
+ if spec.action == ArgumentAction.APPEND:
488
+ assert result.get(spec.dest) is not None, "dest should not be None"
489
+ if spec.nargs in (None, 1):
490
+ result[spec.dest].append(typed[0])
491
+ else:
492
+ result[spec.dest].append(typed)
493
+ elif spec.action == ArgumentAction.EXTEND:
494
+ assert result.get(spec.dest) is not None, "dest should not be None"
495
+ result[spec.dest].extend(typed)
496
+ elif spec.nargs in (None, 1, "?"):
497
+ result[spec.dest] = typed[0] if len(typed) == 1 else typed
498
+ else:
499
+ result[spec.dest] = typed
500
+
501
+ if spec.nargs not in ("*", "+"):
502
+ consumed_positional_indicies.add(j)
503
+
504
+ if i < len(args):
505
+ raise CommandArgumentError(f"Unexpected positional argument: {args[i:]}")
506
+
507
+ return i
508
+
509
+ def parse_args(
510
+ self, args: list[str] | None = None, from_validate: bool = False
511
+ ) -> dict[str, Any]:
512
+ """Parse Falyx Command arguments."""
513
+ if args is None:
514
+ args = []
515
+
516
+ result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
517
+ positional_args = [arg for arg in self._arguments if arg.positional]
518
+ consumed_positional_indices: set[int] = set()
519
+
520
+ consumed_indices: set[int] = set()
521
+ i = 0
522
+ while i < len(args):
523
+ token = args[i]
524
+ if token in self._flag_map:
525
+ spec = self._flag_map[token]
526
+ action = spec.action
527
+
528
+ if action == ArgumentAction.HELP:
529
+ if not from_validate:
530
+ self.render_help()
531
+ raise HelpSignal()
532
+ elif action == ArgumentAction.STORE_TRUE:
533
+ result[spec.dest] = True
534
+ consumed_indices.add(i)
535
+ i += 1
536
+ elif action == ArgumentAction.STORE_FALSE:
537
+ result[spec.dest] = False
538
+ consumed_indices.add(i)
539
+ i += 1
540
+ elif action == ArgumentAction.COUNT:
541
+ result[spec.dest] = result.get(spec.dest, 0) + 1
542
+ consumed_indices.add(i)
543
+ i += 1
544
+ elif action == ArgumentAction.APPEND:
545
+ assert result.get(spec.dest) is not None, "dest should not be None"
546
+ values, new_i = self._consume_nargs(args, i + 1, spec)
547
+ try:
548
+ typed_values = [spec.type(value) for value in values]
549
+ except ValueError:
550
+ raise CommandArgumentError(
551
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
552
+ )
553
+ if spec.nargs in (None, 1):
554
+ try:
555
+ result[spec.dest].append(spec.type(values[0]))
556
+ except ValueError:
557
+ raise CommandArgumentError(
558
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
559
+ )
560
+ else:
561
+ result[spec.dest].append(typed_values)
562
+ consumed_indices.update(range(i, new_i))
563
+ i = new_i
564
+ elif action == ArgumentAction.EXTEND:
565
+ assert result.get(spec.dest) is not None, "dest should not be None"
566
+ values, new_i = self._consume_nargs(args, i + 1, spec)
567
+ try:
568
+ typed_values = [spec.type(value) for value in values]
569
+ except ValueError:
570
+ raise CommandArgumentError(
571
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
572
+ )
573
+ result[spec.dest].extend(typed_values)
574
+ consumed_indices.update(range(i, new_i))
575
+ i = new_i
576
+ else:
577
+ values, new_i = self._consume_nargs(args, i + 1, spec)
578
+ try:
579
+ typed_values = [spec.type(v) for v in values]
580
+ except ValueError:
581
+ raise CommandArgumentError(
582
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
583
+ )
584
+ if (
585
+ spec.nargs in (None, 1, "?")
586
+ and spec.action != ArgumentAction.APPEND
587
+ ):
588
+ result[spec.dest] = (
589
+ typed_values[0] if len(typed_values) == 1 else typed_values
590
+ )
591
+ else:
592
+ result[spec.dest] = typed_values
593
+ consumed_indices.update(range(i, new_i))
594
+ i = new_i
595
+ else:
596
+ # Get the next flagged argument index if it exists
597
+ next_flagged_index = -1
598
+ for index, arg in enumerate(args[i:], start=i):
599
+ if arg.startswith("-"):
600
+ next_flagged_index = index
601
+ break
602
+ if next_flagged_index == -1:
603
+ next_flagged_index = len(args)
604
+
605
+ args_consumed = self._consume_all_positional_args(
606
+ args[i:next_flagged_index],
607
+ result,
608
+ positional_args,
609
+ consumed_positional_indices,
610
+ )
611
+ i += args_consumed
612
+
613
+ # Required validation
614
+ for spec in self._arguments:
615
+ if spec.dest == "help":
616
+ continue
617
+ if spec.required and not result.get(spec.dest):
618
+ raise CommandArgumentError(f"Missing required argument: {spec.dest}")
619
+
620
+ if spec.choices and result.get(spec.dest) not in spec.choices:
621
+ raise CommandArgumentError(
622
+ f"Invalid value for {spec.dest}: must be one of {spec.choices}"
623
+ )
624
+
625
+ if isinstance(spec.nargs, int) and spec.nargs > 1:
626
+ if not isinstance(result.get(spec.dest), list):
627
+ raise CommandArgumentError(
628
+ f"Invalid value for {spec.dest}: expected a list"
629
+ )
630
+ if spec.action == ArgumentAction.APPEND:
631
+ if not isinstance(result[spec.dest], list):
632
+ raise CommandArgumentError(
633
+ f"Invalid value for {spec.dest}: expected a list"
634
+ )
635
+ for group in result[spec.dest]:
636
+ if len(group) % spec.nargs != 0:
637
+ raise CommandArgumentError(
638
+ f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
639
+ )
640
+ elif spec.action == ArgumentAction.EXTEND:
641
+ if not isinstance(result[spec.dest], list):
642
+ raise CommandArgumentError(
643
+ f"Invalid value for {spec.dest}: expected a list"
644
+ )
645
+ if len(result[spec.dest]) % spec.nargs != 0:
646
+ raise CommandArgumentError(
647
+ f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
648
+ )
649
+ elif len(result[spec.dest]) != spec.nargs:
650
+ raise CommandArgumentError(
651
+ f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}"
652
+ )
653
+
654
+ result.pop("help", None)
655
+ return result
656
+
657
+ def parse_args_split(
658
+ self, args: list[str], from_validate: bool = False
659
+ ) -> tuple[tuple[Any, ...], dict[str, Any]]:
660
+ """
661
+ Returns:
662
+ tuple[args, kwargs] - Positional arguments in defined order,
663
+ followed by keyword argument mapping.
664
+ """
665
+ parsed = self.parse_args(args, from_validate)
666
+ args_list = []
667
+ kwargs_dict = {}
668
+ for arg in self._arguments:
669
+ if arg.dest == "help":
670
+ continue
671
+ if arg.positional:
672
+ args_list.append(parsed[arg.dest])
673
+ else:
674
+ kwargs_dict[arg.dest] = parsed[arg.dest]
675
+ return tuple(args_list), kwargs_dict
676
+
677
+ def render_help(self) -> None:
678
+ # Options
679
+ # Add all keyword arguments to the options list
680
+ options_list = []
681
+ for arg in self._keyword:
682
+ choice_text = arg.get_choice_text()
683
+ if choice_text:
684
+ options_list.extend([f"[{arg.flags[0]} {choice_text}]"])
685
+ else:
686
+ options_list.extend([f"[{arg.flags[0]}]"])
687
+
688
+ # Add positional arguments to the options list
689
+ for arg in self._positional:
690
+ choice_text = arg.get_choice_text()
691
+ if isinstance(arg.nargs, int):
692
+ choice_text = " ".join([choice_text] * arg.nargs)
693
+ options_list.append(escape(choice_text))
694
+
695
+ options_text = " ".join(options_list)
696
+ command_keys = " | ".join(
697
+ [f"[{self.command_style}]{self.command_key}[/{self.command_style}]"]
698
+ + [
699
+ f"[{self.command_style}]{alias}[/{self.command_style}]"
700
+ for alias in self.aliases
701
+ ]
702
+ )
703
+
704
+ usage = f"usage: {command_keys} {options_text}"
705
+ self.console.print(f"[bold]{usage}[/bold]\n")
706
+
707
+ # Description
708
+ if self.help_text:
709
+ self.console.print(self.help_text + "\n")
710
+
711
+ # Arguments
712
+ if self._arguments:
713
+ if self._positional:
714
+ self.console.print("[bold]positional:[/bold]")
715
+ for arg in self._positional:
716
+ flags = arg.get_positional_text()
717
+ arg_line = Text(f" {flags:<30} ")
718
+ help_text = arg.help or ""
719
+ arg_line.append(help_text)
720
+ self.console.print(arg_line)
721
+ self.console.print("[bold]options:[/bold]")
722
+ for arg in self._keyword:
723
+ flags = ", ".join(arg.flags)
724
+ flags_choice = f"{flags} {arg.get_choice_text()}"
725
+ arg_line = Text(f" {flags_choice:<30} ")
726
+ help_text = arg.help or ""
727
+ arg_line.append(help_text)
728
+ self.console.print(arg_line)
729
+
730
+ # Epilogue
731
+ if self.help_epilogue:
732
+ self.console.print("\n" + self.help_epilogue, style="dim")
733
+
734
+ def __eq__(self, other: object) -> bool:
735
+ if not isinstance(other, CommandArgumentParser):
736
+ return False
737
+
738
+ def sorted_args(parser):
739
+ return sorted(parser._arguments, key=lambda a: a.dest)
740
+
741
+ return sorted_args(self) == sorted_args(other)
742
+
743
+ def __hash__(self) -> int:
744
+ return hash(tuple(sorted(self._arguments, key=lambda a: a.dest)))
745
+
746
+ def __str__(self) -> str:
747
+ positional = sum(arg.positional for arg in self._arguments)
748
+ required = sum(arg.required for arg in self._arguments)
749
+ return (
750
+ f"CommandArgumentParser(args={len(self._arguments)}, "
751
+ f"flags={len(self._flag_map)}, dests={len(self._dest_set)}, "
752
+ f"required={required}, positional={positional})"
753
+ )
754
+
755
+ def __repr__(self) -> str:
756
+ return str(self)