Add parser stages for the planned new command-line argument parser
Marco Ricci

Marco Ricci commited on 2026-08-16 16:05:46
Zeige 1 geänderte Dateien mit 252 Einfügungen und 0 Löschungen.


These are the actual parser steps.  They must be run in sequence, and
generally only act on the current "section" of the command-line (up to
the next subcommand boundary).

  - The first step normalizes the command-line, inserting the `--`
    pseudo-option, unclustering clustered options, using canonical
    option and subcommand names, and appending option arguments to the
    argument in `--opt=arg` style.  This step relies on the `canon_map`
    property of CLISubcommand objects.

  - The second step scans for eager options, if necessary replacing the
    whole command-line with the eager option.  This step too relies on
    the `canon_map` property of CLISubcommand objects.

  - The third step locates the subcommand boundary, if any, and returns
    the partial command-line up to that boundary, and a new parser state
    for the remaining section.  This step too relies on the `canon_map`
    property of CLISubcommand objects.

  - An additional "complete" step calls the first three steps, in order,
    on each section of the command-line, and returns the accumulated
    results.  This step relies on the `canon_map` property of
    CLISubcommand objects, indirectly, via the first three steps.
... ...
@@ -17,6 +17,7 @@ from __future__ import annotations
17 17
 import abc
18 18
 import collections
19 19
 import dataclasses
20
+import enum
20 21
 import importlib.metadata
21 22
 import inspect
22 23
 import logging
... ...
@@ -52,6 +53,7 @@ VERSION_OUTPUT_WRAPPING_WIDTH = 72
52 53
 NOT_AN_INTEGER = "not an integer"
53 54
 NOT_A_NONNEGATIVE_INTEGER = "not a non-negative integer"
54 55
 NOT_A_POSITIVE_INTEGER = "not a positive integer"
56
+COMMAND_LINE_NOT_NORMALIZED = "command-line not normalized"
55 57
 
56 58
 
57 59
 # CLI parsing machinery
... ...
@@ -312,6 +314,50 @@ class CLICompositeSubcommand:
312 314
         return _canon_map_cache[self]
313 315
 
314 316
 
317
+ParsedCommandLineToken: TypeAlias = tuple[str, ...]
318
+ParsedCommandLineSection: TypeAlias = tuple[ParsedCommandLineToken, ...]
319
+ParsedCommandLine: TypeAlias = tuple[
320
+    tuple[CLISubcommand, ParsedCommandLineSection], ...
321
+]
322
+
323
+
324
+class CommandLineTokenType(str, enum.Enum):
325
+    """The type of a command-line token.
326
+
327
+    Attributes:
328
+        LONG_OPTION:
329
+            A long option, perhaps with an attached option value.
330
+        SHORT_OPTION:
331
+            A (perhaps trivial) cluster of short options, perhaps
332
+            including an option value.
333
+        POSITIONAL:
334
+            A positional argument.
335
+        END_OF_OPTIONS:
336
+            The `--` pseudo-argument.
337
+
338
+    """
339
+
340
+    LONG_OPTION = "LONG_OPTION"
341
+    """"""
342
+    SHORT_OPTION = "SHORT_OPTION"
343
+    """"""
344
+    POSITIONAL = "POSITIONAL"
345
+    """"""
346
+    END_OF_OPTIONS = "END_OF_OPTIONS"
347
+    """"""
348
+
349
+    @classmethod
350
+    def classify(cls, token: str, /) -> CommandLineTokenType:
351
+        """Return the type of the given token."""
352
+        if token == "--":  # noqa: S105
353
+            return cls.END_OF_OPTIONS
354
+        if token.startswith("--"):
355
+            return cls.LONG_OPTION
356
+        if token.startswith("-"):
357
+            return cls.SHORT_OPTION
358
+        return cls.POSITIONAL
359
+
360
+
315 361
 class ParseState(NamedTuple):
316 362
     """The internal state of a command-line parser.
317 363
 
... ...
@@ -472,6 +518,212 @@ class ParseFailure(ParseResult):
472 518
 # --------------
473 519
 
474 520
 
521
+def _is_valid_option_argument(
522
+    option: CLIOption,
523
+    argument: str,
524
+    /,
525
+) -> bool:
526
+    if isinstance(option.has_argument, bool):
527
+        # If any argument value doesn't matter, then the argument is
528
+        # valid if and only if the option takes an argument.
529
+        return option.has_argument
530
+    return argument in option.has_argument
531
+
532
+
533
+def _handle_option(
534
+    args: list[str],
535
+    /,
536
+    *,
537
+    state: ParseState,
538
+    old_command_line: collections.deque[str],
539
+) -> ParseFailure | str:
540
+    canon_map = state.subcommand.canon_map
541
+    arg_queue = list(args)
542
+    opt_ = arg_queue.pop(0)
543
+    if opt_ not in canon_map:
544
+        error = f"Unknown option {opt_!r}."
545
+        return ParseFailure(state, error)
546
+    option = cast("CLIOption", canon_map[opt_])
547
+    if option.has_argument:
548
+        if not arg_queue:
549
+            if not old_command_line:
550
+                error = f"Missing argument for option {opt_!r}."
551
+                return ParseFailure(state, error)
552
+            arg_queue.append(old_command_line.popleft())
553
+        value = arg_queue.pop(0)
554
+        if not _is_valid_option_argument(option, value):
555
+            error = f"Invalid argument {value!r} for option {opt_!r}."
556
+            return ParseFailure(state, error)
557
+        return "=".join([option.names[0], value])
558
+    return option.names[0]
559
+
560
+
561
+# TODO(the-13th-letter): Find a way to include debug logging in this
562
+# parser step.
563
+def normalize_options(
564
+    state: ParseState,
565
+    /,
566
+) -> ParseFailure | ParseSuccess[None]:
567
+    old_command_line = collections.deque(state.command_line)
568
+    new_command_line: collections.deque[str] = collections.deque()
569
+    while old_command_line:
570
+        arg_ = old_command_line.popleft()
571
+        token_type = CommandLineTokenType.classify(arg_)
572
+        # TODO(the-13th-letter): Rewrite using structural pattern matching.
573
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
574
+        if token_type == CommandLineTokenType.LONG_OPTION:
575
+            arg_queue = arg_.split("=", 1) if "=" in arg_ else [arg_]
576
+            result = _handle_option(  # may consume from old_command_line
577
+                arg_queue,
578
+                state=state,
579
+                old_command_line=old_command_line,
580
+            )
581
+            if isinstance(result, ParseResult):
582
+                return result
583
+            new_command_line.append(result)
584
+        elif token_type == CommandLineTokenType.SHORT_OPTION:
585
+            replacement_args: collections.deque[str] = collections.deque()
586
+            letters = collections.deque(arg_[1:])
587
+            while letters:
588
+                letter = letters.popleft()
589
+                arg_queue = (
590
+                    [f"-{letter}", "".join(letters)]
591
+                    if letters
592
+                    else [f"-{letter}"]
593
+                )
594
+                result = _handle_option(
595
+                    arg_queue,
596
+                    state=state,
597
+                    old_command_line=old_command_line,
598
+                )
599
+                if isinstance(result, ParseResult):
600
+                    return result
601
+                replacement_args.append(result)
602
+                if "=" in result:  # pragma: no cover [external]
603
+                    letters.clear()
604
+            old_command_line.extendleft(reversed(replacement_args))
605
+        else:
606
+            new_command_line.extend([arg_] if arg_ == "--" else ["--", arg_])
607
+            new_command_line.extend(old_command_line)
608
+            old_command_line.clear()
609
+            break
610
+    else:
611
+        new_command_line.append("--")
612
+    return ParseSuccess(
613
+        state._replace(command_line=tuple(new_command_line)), None
614
+    )
615
+
616
+
617
+def scan_for_eager_options(
618
+    state: ParseState,
619
+    /,
620
+) -> ParseEarlyExit | ParseFailure | ParseSuccess[None]:
621
+    option_groups = state.subcommand.contents[0]
622
+    eager_option_objects = {
623
+        option
624
+        for group in option_groups
625
+        for option in group.options
626
+        if option.eager
627
+    }
628
+    eager_option_names = {
629
+        name for option in eager_option_objects for name in option.names
630
+    }
631
+    command_line = collections.deque(state.command_line)
632
+    canon_map = state.subcommand.canon_map
633
+    while command_line:
634
+        arg_ = command_line.popleft()
635
+        token_type = CommandLineTokenType.classify(arg_)
636
+        if token_type == CommandLineTokenType.LONG_OPTION:
637
+            arg_queue = arg_.split("=", 1) if "=" in arg_ else [arg_]
638
+            long_opt_name = arg_queue.pop(0)
639
+            if long_opt_name not in canon_map:
640
+                # Unknown option.  Either the command-line is faulty,
641
+                # or we default to the default subcommand and let *it*
642
+                # make sense of this option.  Regardless, no early exit
643
+                # has been detected so far, so we are done.
644
+                return ParseSuccess(state, None)
645
+            option = cast("CLIOption", canon_map[long_opt_name])
646
+            if long_opt_name in eager_option_names:
647
+                canon_opt = "=".join([option.names[0], *arg_queue])
648
+                new_state = state._replace(command_line=(canon_opt,))
649
+                return ParseEarlyExit(new_state, canon_opt)
650
+        elif token_type == CommandLineTokenType.END_OF_OPTIONS:
651
+            return ParseSuccess(state, None)
652
+        else:  # pragma: no cover [failsafe]
653
+            return ParseFailure(state, COMMAND_LINE_NOT_NORMALIZED)
654
+    return ParseSuccess(state, None)
655
+
656
+
657
+def parse_until_subcommand_boundary(
658
+    state: ParseState,
659
+    /,
660
+) -> (
661
+    ParseFailure
662
+    | ParseSuccess[tuple[ParsedCommandLineSection, ParseState | None]]
663
+):
664
+    canon_map = state.subcommand.canon_map
665
+    command_line = collections.deque(state.command_line)
666
+    result: collections.deque[ParsedCommandLineToken] = collections.deque()
667
+    while command_line:
668
+        arg_ = command_line.popleft()
669
+        token_type = CommandLineTokenType.classify(arg_)
670
+        if token_type == CommandLineTokenType.LONG_OPTION:
671
+            arg_queue = arg_.split("=", 1) if "=" in arg_ else [arg_]
672
+            result.append(tuple(arg_queue))
673
+        elif token_type == CommandLineTokenType.END_OF_OPTIONS:
674
+            break
675
+        else:  # pragma: no cover [failsafe]
676
+            return ParseFailure(state, COMMAND_LINE_NOT_NORMALIZED)
677
+    if isinstance(state.subcommand, CLITerminalSubcommand):
678
+        return ParseSuccess(state, (tuple(result), None))
679
+    next_command_line = tuple(command_line)
680
+    next_subcommand_name = command_line.popleft() if command_line else None
681
+    if (
682
+        isinstance(next_subcommand_name, str)
683
+        and next_subcommand_name not in canon_map
684
+        and isinstance(state.subcommand, CLICompositeSubcommand)
685
+        and state.subcommand.contents[2] is not None
686
+    ):
687
+        # Unknown subcommand name; fallback exists. Run the
688
+        # default subcommand and reinterpret the argument
689
+        # (positional/subcommand) within the context of the default
690
+        # subcommand.
691
+        command_line.appendleft(next_subcommand_name)
692
+        next_subcommand_name = None
693
+    if next_subcommand_name not in canon_map:
694
+        # Unknown subcommand name; no fallback exists.
695
+        error = f"Unknown subcommand {next_subcommand_name!r}."
696
+        return ParseFailure(state, error)
697
+    next_subcommand = cast(
698
+        "CLISubcommand", canon_map.get(next_subcommand_name)
699
+    )
700
+    new_state = ParseState(next_command_line, next_subcommand)
701
+    return ParseSuccess(state, (tuple(result), new_state))
702
+
703
+
704
+def complete(
705
+    state: ParseState,
706
+    /,
707
+) -> ParseSuccess[ParsedCommandLine] | ParseFailure | ParseEarlyExit:
708
+    result: collections.deque[
709
+        tuple[CLISubcommand, ParsedCommandLineSection]
710
+    ] = collections.deque()
711
+    step_state = state
712
+    while True:
713
+        subcommand = step_state.subcommand
714
+        tmp3_result = ParseResult.unit(step_state)
715
+        tmp2_result = tmp3_result.bind(normalize_options)
716
+        tmp1_result = tmp2_result.bind(scan_for_eager_options)
717
+        step_result = tmp1_result.bind(parse_until_subcommand_boundary)
718
+        if isinstance(step_result, (ParseEarlyExit, ParseFailure)):
719
+            return step_result
720
+        section_data, next_state = step_result.result
721
+        result.append((subcommand, section_data))
722
+        if next_state is None:
723
+            return ParseSuccess(step_state, tuple(result))
724
+        step_state = next_state
725
+
726
+
475 727
 # Logging
476 728
 # =======
477 729
 
478 730