Add a test for the new command-line parser normalization step
Marco Ricci

Marco Ricci commited on 2026-08-16 18:04:13
Zeige 1 geänderte Dateien mit 988 Einfügungen und 4 Löschungen.


Add a property-based test for the "normalize_options" step of the new
command-line parser.  In particular, add the whole necessary
infrastructure for generating random option, option group and subcommand
objects, random command-lines, and random parser states.

Later stages will likely impose additional constraints on the drawn
objects and command-lines, e.g., that the command-line is normalized, or
that no eager option is included in the subcommand objects.  Such
constraints are already anticipated in the code as well, but the code is
not tested against these contraints, so there are likely still bugs
left.

To further keep the entropy drain low while generating random parser
states, the options, option groups and the subcommand names are
pre-generated into pools of objects/names, and the strategies draw from
those pools.
... ...
@@ -11,23 +11,29 @@ subcommands.
11 11
 
12 12
 from __future__ import annotations
13 13
 
14
+import collections
14 15
 import contextlib
15 16
 import enum
17
+import functools
18
+import operator
16 19
 import re
20
+import string
17 21
 import types
18
-from typing import TYPE_CHECKING
22
+from typing import TYPE_CHECKING, TypeVar, cast
19 23
 
20 24
 import exceptiongroup
25
+import hypothesis
21 26
 import pytest
22
-from typing_extensions import NamedTuple
27
+from hypothesis import strategies
28
+from typing_extensions import NamedTuple, overload
23 29
 
24 30
 from derivepassphrase import _types, cli, ssh_agent
25
-from derivepassphrase._internals import cli_messages
31
+from derivepassphrase._internals import cli_machinery, cli_messages
26 32
 from tests import machinery
27 33
 from tests.machinery import pytest as pytest_machinery
28 34
 
29 35
 if TYPE_CHECKING:
30
-    from collections.abc import Generator
36
+    from collections.abc import Generator, Iterable, Sequence
31 37
 
32 38
 
33 39
 class VersionOutputData(NamedTuple):
... ...
@@ -70,6 +76,11 @@ class KnownLineType(str, enum.Enum):
70 76
     ENABLED_EXTRAS = _label_text(cli_messages.Label.ENABLED_PEP508_EXTRAS)
71 77
 
72 78
 
79
+class ParseStateAndExpectedResult(NamedTuple):
80
+    starting_state: cli_machinery.ParseState
81
+    expected_result: cli_machinery.ParseResult
82
+
83
+
73 84
 class Parametrize(types.SimpleNamespace):
74 85
     """Common test parametrizations."""
75 86
 
... ...
@@ -329,6 +340,964 @@ Supported subcommands: export, spectre ({aliases!s} master-password, mpw),
329 340
     """Sample data for [`parse_version_output`][]."""
330 341
 
331 342
 
343
+def roman_numerals() -> Generator[str, None, None]:
344
+    """Generate lowercase roman numerals, up to 3999."""
345
+    ones = ["", "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix"]
346
+    tens = ["", "x", "xx", "xxx", "xl", "l", "lx", "lxx", "lxxx", "xc"]
347
+    huns = ["", "c", "cc", "ccc", "cd", "d", "dc", "dcc", "dccc", "cm"]
348
+    thou = ["", "m", "mm", "mmm"]
349
+
350
+    # Start at "i".
351
+    for i in range(1, 4000):
352
+        yield "".join([
353
+            thou[(i // 1000) % len(thou)],
354
+            huns[(i // 100) % len(huns)],
355
+            tens[(i // 10) % len(tens)],
356
+            ones[(i // 1) % len(ones)],
357
+        ])
358
+
359
+
360
+def make_dummy_options(
361
+    name_counter: Iterable[str],
362
+    short_name_counter: Iterable[str],
363
+    /,
364
+) -> Generator[cli_machinery.CLIOption, None, None]:
365
+    """Generate dummy CLIOption objects.
366
+
367
+    Args:
368
+        name_counter:
369
+            An iterable for unique names, to be used as long option
370
+            names.
371
+        short_name_counter:
372
+            An iterable of short option "letters", to be used for short
373
+            option names.
374
+
375
+    Yields:
376
+        CLIOption objects with hitherto unseen option names.  Some
377
+        options will be eager, some will take an argument, some will do
378
+        both.
379
+
380
+    """
381
+    has_argument_list: list[bool | tuple[str, ...]] = [
382
+        False,
383
+        True,
384
+        ("arg1", "arg2", "arg3", "arg4", "arg5", "arg6"),
385
+    ]
386
+    eager_list = [False, True]
387
+    for i, (name, letter) in enumerate(zip(name_counter, short_name_counter)):
388
+        i_has_argument = i // len(eager_list)
389
+        i_eager = i
390
+        names = (
391
+            f"--{name}",
392
+            f"-{letter}",
393
+            f"--alias-{name}",
394
+            f"--alternate-{name}",
395
+        )
396
+        help = f"Help for option --{name}."  # noqa: A001
397
+        has_argument = has_argument_list[
398
+            i_has_argument % len(has_argument_list)
399
+        ]
400
+        eager = eager_list[i_eager % len(eager_list)]
401
+        yield cli_machinery.CLIOption(
402
+            names=names, help=help, has_argument=has_argument, eager=eager
403
+        )
404
+
405
+
406
+def make_dummy_option_groups(
407
+    name_counter: Iterable[str],
408
+    short_name_counter: Iterable[str],
409
+    /,
410
+) -> Generator[cli_machinery.CLIOptionGroup, None, None]:
411
+    """Generate dummy CLIOptionGroup objects.
412
+
413
+    The option objects are generated via [`make_dummy_options`][].
414
+    They are grouped such that each option has a unique combination of
415
+    eagerness and argument requirements.
416
+
417
+    Args:
418
+        name_counter:
419
+            An iterable for unique names, to be used as long option
420
+            names.  Passed to [`make_dummy_options`][].
421
+        short_name_counter:
422
+            An iterable of short option "letters", to be used for short
423
+            option names.  Passed to [`make_dummy_options`][].
424
+
425
+    Yields:
426
+        CLIOptionGroup objects.  The embedded CLIOption objects will
427
+        have hitherto unseen option names.  Some options will be eager,
428
+        some will take an argument, some will do both.
429
+
430
+    """
431
+    option_states_seen: set[tuple[bool, bool | tuple[str, ...]]] = set()
432
+    options_collected: list[cli_machinery.CLIOption] = []
433
+    i = 1
434
+    for option in make_dummy_options(name_counter, short_name_counter):
435
+        option_state = (
436
+            option.eager,
437
+            option.has_argument
438
+            if isinstance(option.has_argument, bool)
439
+            else tuple(option.has_argument),
440
+        )
441
+        if option_state in option_states_seen:
442
+            yield cli_machinery.CLIOptionGroup(
443
+                options=tuple(options_collected),
444
+                title=f"Group {i}",
445
+                epilog=f"Group {i} epilog.",
446
+            )
447
+            option_states_seen.clear()
448
+            options_collected.clear()
449
+            i += 1
450
+        options_collected.append(option)
451
+        option_states_seen.add(option_state)
452
+    if options_collected:
453
+        yield cli_machinery.CLIOptionGroup(
454
+            options=tuple(options_collected),
455
+            title=f"Group {i}",
456
+            epilog=f"Group {i} epilog.",
457
+        )
458
+
459
+
460
+def make_dummy_command_names() -> Generator[str, None, None]:
461
+    """Generate dummy subcommand names.
462
+
463
+    The names are drawn from actual program subcommands.
464
+
465
+    Yields:
466
+        Unique names from actual program subcommands.
467
+
468
+    """
469
+    subpools = [
470
+        (
471
+            "add rm status checkout branch switch merge diff log push pull "
472
+            "revert reset rebase bisect bundle cherry-pick gc clone init "
473
+            "stash worktree tag remote blame"
474
+        ),  # git
475
+        "vault",  # derivepassphrase
476
+        "all check install test clean",  # make
477
+        (
478
+            "bye cd chgrp chmod chown copy cp df exit get help lcd lls "
479
+            "lmkdir ln lpwd ls lumask mkdir progress put pwd quit "
480
+            "reget reput rename rm rmdir symlink version"
481
+        ),  # OpenSSH sftp
482
+        (
483
+            "alias bg cd chdir command echo eval exec exit export fc "
484
+            "fg getopts hash jobs kill pwd read readonly printf set shift "
485
+            "test times trap type ulimit umask unalias unset wait"
486
+        ),  # dash/POSIX sh(1)
487
+        (
488
+            "bind builtin caller compgen complete compopt declare disown "
489
+            "enable history let local logout mapfile popd pushd return "
490
+            "shopt source suspend"
491
+        ),  # bash
492
+    ]
493
+    seen: set[str] = set()
494
+    for subpool in subpools:
495
+        for arg in subpool.split():
496
+            if arg not in seen:
497
+                seen.add(arg)
498
+                yield arg
499
+
500
+
501
+OPTION_GROUP_POOL = tuple(
502
+    make_dummy_option_groups(roman_numerals(), sorted(string.ascii_letters))
503
+)
504
+"""A pool of disjoint option group objects from which individual option
505
+groups can be drawn without consuming too much entropy."""
506
+OPTION_POOL = tuple(
507
+    option for group in OPTION_GROUP_POOL for option in group.options
508
+)
509
+"""A pool of disjoint option objects from which individual options can be
510
+drawn without consuming too much entropy."""
511
+COMMAND_NAMES_POOL = tuple(make_dummy_command_names())
512
+"""A pool of unique subcommand names from which individual names can be
513
+drawn without consuming too much entropy."""
514
+
515
+
516
+def _is_short_option(name: str) -> bool:
517
+    return not name.startswith("--")
518
+
519
+
520
+def _name_complexity(name: str) -> tuple[int, str]:
521
+    return len(name), name
522
+
523
+
524
+def _option_name_complexity(opt: cli_machinery.CLIOption) -> tuple[int, int]:
525
+    has_short_option = any(_is_short_option(name) for name in opt.names)
526
+    return (
527
+        0 if has_short_option else 1,
528
+        0 if opt.has_argument else 1,
529
+    )
530
+
531
+
532
+def _option_group_complexity(group: cli_machinery.CLIOptionGroup) -> int:
533
+    option_complexities = [
534
+        _option_name_complexity(opt) for opt in group.options
535
+    ]
536
+    penalty_no_short_option = sum(cplx[0] for cplx in option_complexities)
537
+    penalty_argument = sum(cplx[1] for cplx in option_complexities)
538
+    size_penalty = (
539
+        len(option_complexities[0]) * len(option_complexities)
540
+        if option_complexities
541
+        else 0
542
+    )
543
+    return size_penalty + penalty_no_short_option + penalty_argument
544
+
545
+
546
+def _options_are_unique(
547
+    groups: Sequence[cli_machinery.CLIOptionGroup],
548
+    /,
549
+) -> bool:
550
+    all_option_names = [
551
+        name for group in groups for opt in group.options for name in opt.names
552
+    ]
553
+    option_names = set(all_option_names)
554
+    return len(all_option_names) == len(option_names)
555
+
556
+
557
+T = TypeVar("T")
558
+
559
+
560
+@overload
561
+def _flatten(nested_list: Sequence[list[T]], /) -> list[T]: ...
562
+
563
+
564
+@overload
565
+def _flatten(nested_list: Sequence[tuple[T, ...]], /) -> tuple[T, ...]: ...
566
+
567
+
568
+@overload
569
+def _flatten(
570
+    nested_list: Sequence[collections.deque[T]], /
571
+) -> collections.deque[T]: ...
572
+
573
+
574
+def _flatten(
575
+    nested_list: Sequence[list | tuple | collections.deque],
576
+    /,
577
+) -> list | tuple | collections.deque:
578
+    if not nested_list:
579
+        raise ValueError(  # noqa: TRY003
580
+            "Cannot flatten empty sequence without constructor factory"  # noqa: EM101
581
+        )
582
+    first = nested_list[0]
583
+    if isinstance(first, list):
584
+        return functools.reduce(operator.add, nested_list, [])
585
+    if isinstance(first, tuple):
586
+        return functools.reduce(operator.add, nested_list, ())
587
+    if isinstance(first, collections.deque):
588
+        return functools.reduce(operator.add, nested_list, collections.deque())
589
+    raise ValueError(  # noqa: TRY003
590
+        "Cannot flatten things that aren't tuples, lists or deques"  # noqa: EM101
591
+    )
592
+
593
+
594
+class Strategies(types.SimpleNamespace):
595
+    """Common hypothesis strategies."""
596
+
597
+    WORDS = strategies.text(string.ascii_lowercase, min_size=1)
598
+    METAVARS = strategies.text(string.ascii_uppercase, min_size=1, max_size=7)
599
+    COMMAND_NAMES = strategies.sampled_from(
600
+        sorted(COMMAND_NAMES_POOL, key=len)
601
+    )
602
+
603
+    NUM_NAMES = 4
604
+    NUM_OPTIONS_PER_GROUP = 2
605
+
606
+    @staticmethod
607
+    def option_pool() -> tuple[cli_machinery.CLIOption, ...]:
608
+        return OPTION_POOL
609
+
610
+    @staticmethod
611
+    def options() -> strategies.SearchStrategy[cli_machinery.CLIOption]:
612
+        pool = Strategies.option_pool()
613
+        return strategies.sampled_from(pool)
614
+
615
+    @staticmethod
616
+    def option_group_pool(
617
+        *,
618
+        allow_eager: bool = False,
619
+    ) -> tuple[cli_machinery.CLIOptionGroup, ...]:
620
+        pool1 = [
621
+            cli_machinery.CLIOptionGroup(
622
+                options=tuple(
623
+                    opt
624
+                    for opt in group.options
625
+                    if not opt.eager or allow_eager
626
+                ),
627
+                title=group.title,
628
+                epilog=group.epilog,
629
+            )
630
+            for group in OPTION_GROUP_POOL
631
+        ]
632
+        pool2 = [group for group in pool1 if group.options]
633
+        return tuple(pool2)
634
+
635
+    @staticmethod
636
+    def option_groups(
637
+        *,
638
+        allow_eager: bool = False,
639
+    ) -> strategies.SearchStrategy[cli_machinery.CLIOptionGroup]:
640
+        pool = Strategies.option_group_pool(allow_eager=allow_eager)
641
+        return strategies.sampled_from(pool)
642
+
643
+    @staticmethod
644
+    @strategies.composite
645
+    def terminal_subcommands(
646
+        draw: strategies.DrawFn,
647
+        /,
648
+        *,
649
+        allow_eager: bool = False,
650
+        max_option_groups: int = NUM_NAMES,
651
+        max_positionals: int = NUM_NAMES,
652
+    ) -> cli_machinery.CLITerminalSubcommand:
653
+        names = draw(
654
+            strategies.lists(
655
+                Strategies.COMMAND_NAMES,
656
+                min_size=1,
657
+                max_size=Strategies.NUM_NAMES,
658
+                unique=True,
659
+            ),
660
+            "names",
661
+        )
662
+        option_groups = draw(
663
+            strategies.lists(
664
+                Strategies.option_groups(allow_eager=allow_eager),
665
+                min_size=1,
666
+                max_size=max_option_groups,
667
+            ).filter(_options_are_unique),
668
+            "option_groups",
669
+        )
670
+        positionals = draw(
671
+            strategies.lists(
672
+                Strategies.METAVARS.map(cli_machinery.CLIArgument),
673
+                max_size=max_positionals,
674
+                unique_by=lambda arg: arg.name,
675
+            ),
676
+            "positionals",
677
+        )
678
+        return cli_machinery.CLITerminalSubcommand(
679
+            names=tuple(names),
680
+            contents=(tuple(option_groups), tuple(positionals)),
681
+            prolog=("Subcommand prolog goes here.",),
682
+            epilog=("Subcommand epilog goes here.",),
683
+        )
684
+
685
+    @staticmethod
686
+    def clustered_options_with_final_argument(
687
+        *options: cli_machinery.CLIOption,
688
+    ) -> strategies.SearchStrategy[list[str]]:
689
+        if not options:  # pragma: no cover [failsafe]
690
+            msg = "No options given!"
691
+            raise ValueError(msg)
692
+        for opt in options:  # pragma: no cover [failsafe]
693
+            if not any(_is_short_option(name) for name in opt.names):
694
+                msg = f"Option object has no short options: {opt!r}"
695
+                raise ValueError(msg)
696
+        if not options[-1].has_argument:  # pragma: no cover [failsafe]
697
+            msg = f"Option does not accept an argument: {opt!r}"
698
+            raise ValueError(msg)
699
+
700
+        def cluster_options(args: tuple[str, ...]) -> list[str]:
701
+            first = args[0]  # in full
702
+            middle = "".join(opt[-1:] for opt in args[1:-1])  # no "-"
703
+            last = args[-1]  # maybe an argument
704
+            return [f"{first}{middle}{last}"]
705
+
706
+        eligible_options = [
707
+            tuple(name for name in opt.names if _is_short_option(name))
708
+            for opt in options
709
+        ]
710
+        strategies_ = [
711
+            strategies.just(names[0])
712
+            if len(names) == 1
713
+            else strategies.sampled_from(names)
714
+            for names in eligible_options
715
+        ]
716
+        strategies_.append(Strategies.WORDS)
717
+        return strategies.tuples(*strategies_).map(cluster_options)
718
+
719
+    @staticmethod
720
+    def option_and_argument(
721
+        opt: cli_machinery.CLIOption,
722
+        free_arguments_strategy: strategies.SearchStrategy[str] | None = None,
723
+    ) -> strategies.SearchStrategy[list[str]]:
724
+        # shrink to less complex option names
725
+        option_names = sorted(opt.names, key=_name_complexity)
726
+        option_strategy = strategies.sampled_from(option_names)
727
+        if not isinstance(opt.has_argument, bool):
728
+            # shrink to less complex arguments
729
+            arguments = sorted(opt.has_argument, key=_name_complexity)
730
+            return strategies.tuples(
731
+                option_strategy, strategies.sampled_from(arguments)
732
+            ).map(list)
733
+        if opt.has_argument:
734
+            return strategies.tuples(
735
+                option_strategy,
736
+                free_arguments_strategy
737
+                if free_arguments_strategy is not None
738
+                else Strategies.WORDS,
739
+            ).map(list)
740
+        return strategies.tuples(option_strategy).map(list)
741
+
742
+    @staticmethod
743
+    @strategies.composite
744
+    def maybe_connect_options_and_arguments(
745
+        draw: strategies.DrawFn,
746
+        pairs: list[list[str]],
747
+        /,
748
+    ) -> list[list[str]]:
749
+        return [
750
+            ["=".join(pair)]
751
+            if len(pair) > 1
752
+            and pair[0].startswith("--")
753
+            and draw(strategies.booleans(), f"connect pair {i}")
754
+            else pair
755
+            for i, pair in enumerate(pairs)
756
+        ]
757
+
758
+    @staticmethod
759
+    @strategies.composite
760
+    def choose_clusters(
761
+        draw: strategies.DrawFn,
762
+        snippet: list[str],
763
+        /,
764
+    ) -> list[str]:
765
+        result: list[str] = []
766
+        for i, token in enumerate(snippet):
767
+            if i == 0:
768
+                result.append(token)
769
+                continue
770
+            previous_token = snippet[i - 1]
771
+            if (
772
+                previous_token.startswith("--")
773
+                or not previous_token.startswith("-")
774
+                or token.startswith("--")
775
+            ):
776
+                result.append(token)
777
+            else:
778
+                # token is an argument, or a short option
779
+                extend_cluster = draw(
780
+                    strategies.booleans(), f"cluster[{i - 1}, {i}]"
781
+                )
782
+                if extend_cluster:
783
+                    result[-1] += token[1] if token.startswith("-") else token
784
+                else:
785
+                    result.append(token)
786
+        return result
787
+
788
+    @staticmethod
789
+    def arrange_options(
790
+        *options: cli_machinery.CLIOption,
791
+        free_arguments_strategy: strategies.SearchStrategy[str] | None = None,
792
+    ) -> strategies.SearchStrategy[list[list[str]]]:
793
+        pairs_strategies = [
794
+            Strategies.option_and_argument(opt, free_arguments_strategy)
795
+            for opt in options
796
+        ]
797
+        return strategies.tuples(*pairs_strategies).map(list)
798
+
799
+    @staticmethod
800
+    def choose_option(
801
+        options: Sequence[cli_machinery.CLIOption],
802
+        /,
803
+    ) -> strategies.SearchStrategy[cli_machinery.CLIOption]:
804
+        # shrink to less complex options
805
+        return strategies.sampled_from(
806
+            sorted(options, key=_option_name_complexity)
807
+        )
808
+
809
+    @staticmethod
810
+    def choose_options(
811
+        *options: cli_machinery.CLIOption,
812
+        free_arguments_strategy: strategies.SearchStrategy[str] | None = None,
813
+    ) -> strategies.SearchStrategy[list[list[str]]]:
814
+        def _option_and_argument(
815
+            opt: cli_machinery.CLIOption,
816
+            /,
817
+        ) -> strategies.SearchStrategy[list[str]]:
818
+            return Strategies.option_and_argument(opt, free_arguments_strategy)
819
+
820
+        return strategies.lists(
821
+            Strategies.choose_option(options).flatmap(_option_and_argument),
822
+            max_size=2 * len(options),
823
+        )
824
+
825
+    @staticmethod
826
+    def any_option_and_argument_pairs(
827
+        *options: cli_machinery.CLIOption,
828
+        free_arguments_strategy: strategies.SearchStrategy[str] | None = None,
829
+        in_order: bool = True,
830
+    ) -> strategies.SearchStrategy[list[list[str]]]:
831
+        if not options:  # pragma: no cover [failsafe]
832
+            msg = "No options given!"
833
+            raise ValueError(msg)
834
+        return (
835
+            Strategies.arrange_options(
836
+                *options, free_arguments_strategy=free_arguments_strategy
837
+            )
838
+            if in_order
839
+            else Strategies.choose_options(
840
+                *options, free_arguments_strategy=free_arguments_strategy
841
+            )
842
+        )
843
+
844
+    @staticmethod
845
+    @strategies.composite
846
+    def any_options(
847
+        draw: strategies.DrawFn,
848
+        option_and_argument_pairs_strategy: strategies.SearchStrategy[
849
+            list[list[str]]
850
+        ],
851
+        /,
852
+        *,
853
+        normalized: bool = True,
854
+    ) -> list[str]:
855
+
856
+        def choose_clusters(
857
+            tokens: list[str],
858
+        ) -> strategies.SearchStrategy[list[str]]:
859
+            return (
860
+                strategies.just(tokens)
861
+                if normalized
862
+                else Strategies.choose_clusters(tokens)
863
+            )
864
+
865
+        raw_option_argument_pairs = draw(
866
+            option_and_argument_pairs_strategy, "raw option/argument pairs"
867
+        )
868
+        connected_option_argument_pairs = (
869
+            [
870
+                ["=".join(pair)]
871
+                if len(pair) > 1 and pair[0].startswith("--")
872
+                else pair
873
+                for pair in raw_option_argument_pairs
874
+            ]
875
+            if normalized
876
+            else draw(
877
+                Strategies.maybe_connect_options_and_arguments(
878
+                    raw_option_argument_pairs
879
+                ),
880
+                "connected option/argument pairs",
881
+            )
882
+        )
883
+        unclustered_arguments = _flatten(connected_option_argument_pairs)
884
+        return draw(
885
+            choose_clusters(unclustered_arguments), "clustered command-line"
886
+        )
887
+
888
+    @staticmethod
889
+    @strategies.composite
890
+    def composite_subcommands(
891
+        draw: strategies.DrawFn,
892
+        subcommands: strategies.SearchStrategy[cli_machinery.CLISubcommand],
893
+        /,
894
+        wrapped_subcommand: cli_machinery.CLISubcommand | None = None,
895
+        *,
896
+        allow_default_subcommand: bool = True,
897
+        allow_eager: bool = False,
898
+        max_option_groups: int = NUM_NAMES,
899
+        max_subcommands: int = NUM_NAMES,
900
+    ) -> cli_machinery.CLICompositeSubcommand:
901
+        k = max_option_groups
902
+        n = max_subcommands
903
+        names = draw(
904
+            strategies.lists(
905
+                Strategies.COMMAND_NAMES,
906
+                min_size=1,
907
+                max_size=Strategies.NUM_NAMES,
908
+                unique=True,
909
+            ),
910
+            "names",
911
+        )
912
+        option_groups = draw(
913
+            strategies.lists(
914
+                Strategies.option_groups(allow_eager=allow_eager),
915
+                min_size=1,
916
+                max_size=k,
917
+            ).filter(_options_are_unique),
918
+            "option_groups",
919
+        )
920
+
921
+        def subcommand_names_are_unique(
922
+            subcommands: list[cli_machinery.CLISubcommand],
923
+        ) -> bool:
924
+            names = [name for cmd in subcommands for name in cmd.names]
925
+            return len(names) == len(set(names))
926
+
927
+        subcommand_list_strategy = (
928
+            strategies.lists(subcommands, max_size=n - 1).map(
929
+                lambda others: [wrapped_subcommand, *others]
930
+            )
931
+            if wrapped_subcommand is not None
932
+            else strategies.lists(subcommands, min_size=1, max_size=n)
933
+        )
934
+        subcommand_list = draw(
935
+            subcommand_list_strategy.filter(subcommand_names_are_unique),
936
+            "subcommands",
937
+        )
938
+        default_subcommand = (
939
+            draw(
940
+                strategies.one_of(
941
+                    strategies.none(),
942
+                    strategies.just(subcommand_list[0].names[0]),
943
+                ),
944
+                "default_subcommand",
945
+            )
946
+            if allow_default_subcommand
947
+            else None
948
+        )
949
+        return cli_machinery.CLICompositeSubcommand(
950
+            names=tuple(names),
951
+            contents=(
952
+                tuple(option_groups),
953
+                tuple(subcommand_list),
954
+                default_subcommand,
955
+            ),
956
+            prolog=("Subcommand prolog goes here.",),
957
+            epilog=("Subcommand epilog goes here.",),
958
+        )
959
+
960
+    @staticmethod
961
+    def subcommands(
962
+        *,
963
+        allow_eager: bool = False,
964
+        max_option_groups: int = NUM_NAMES,
965
+        max_positionals: int = NUM_NAMES,
966
+    ) -> strategies.SearchStrategy[cli_machinery.CLISubcommand]:
967
+        terminals = Strategies.terminal_subcommands(
968
+            allow_eager=allow_eager,
969
+            max_option_groups=max_option_groups,
970
+            max_positionals=max_positionals,
971
+        )
972
+        return strategies.recursive(
973
+            terminals,
974
+            lambda strat: Strategies.composite_subcommands(
975
+                strat,
976
+                allow_default_subcommand=True,
977
+                allow_eager=allow_eager,
978
+                max_option_groups=max_option_groups,
979
+                max_subcommands=max_positionals,
980
+            ),
981
+        )
982
+
983
+    @staticmethod
984
+    @strategies.composite
985
+    def subcommand_and_symbolic_command_line(
986
+        draw: strategies.DrawFn,
987
+        /,
988
+        *,
989
+        allow_eager: bool = False,
990
+        max_option_groups: int = NUM_NAMES,
991
+        max_positionals: int = NUM_NAMES,
992
+    ) -> tuple[
993
+        cli_machinery.CLISubcommand,
994
+        list[
995
+            tuple[
996
+                tuple[cli_machinery.CLIOption, ...],
997
+                tuple[cli_machinery.CLIArgument, ...]
998
+                | cli_machinery.CLISubcommand,
999
+            ]
1000
+        ],
1001
+    ]:
1002
+
1003
+        @strategies.composite
1004
+        def options_strategy(
1005
+            draw: strategies.DrawFn,
1006
+            options: Sequence[cli_machinery.CLIOption],
1007
+            /,
1008
+            *,
1009
+            force_clusterable: bool = False,
1010
+        ) -> tuple[cli_machinery.CLIOption, ...]:
1011
+            n = draw(
1012
+                strategies.integers(0, Strategies.NUM_NAMES), "num_options"
1013
+            )
1014
+            if not n:
1015
+                return ()
1016
+            with_args = [opt for opt in options if opt.has_argument]
1017
+            without_args = [opt for opt in options if not opt.has_argument]
1018
+            options_without_arguments = strategies.sampled_from(without_args)
1019
+            options_with_arguments = strategies.sampled_from(with_args)
1020
+            options_maybe_with_arguments = strategies.sampled_from(options)
1021
+            if not force_clusterable:
1022
+                return draw(
1023
+                    strategies.lists(
1024
+                        options_maybe_with_arguments, min_size=n, max_size=n
1025
+                    ).map(tuple),
1026
+                    "options",
1027
+                )
1028
+            last_opt = draw(options_with_arguments, "last_option")
1029
+            preceding_opts = draw(
1030
+                strategies.lists(
1031
+                    options_without_arguments, min_size=n - 1, max_size=n - 1
1032
+                ),
1033
+                "preceding_options",
1034
+            )
1035
+            return (*preceding_opts, last_opt)
1036
+
1037
+        terminal_subcommands = Strategies.terminal_subcommands(
1038
+            allow_eager=allow_eager
1039
+        )
1040
+        subcommand_stack = collections.deque([
1041
+            cast(
1042
+                "cli_machinery.CLISubcommand",
1043
+                draw(terminal_subcommands, "terminal_subcommand"),
1044
+            )
1045
+        ])
1046
+        subcommand_depth = draw(strategies.integers(1, 4), "subcommand_depth")
1047
+        for counter in range(1, subcommand_depth):
1048
+            subcommand_stack.appendleft(
1049
+                draw(
1050
+                    Strategies.composite_subcommands(
1051
+                        terminal_subcommands,
1052
+                        wrapped_subcommand=subcommand_stack[0],
1053
+                        allow_default_subcommand=True,
1054
+                        allow_eager=allow_eager,
1055
+                        max_option_groups=max_option_groups,
1056
+                        max_subcommands=max_positionals,
1057
+                    ),
1058
+                    f"parent_subcommand #{counter}",
1059
+                )
1060
+            )
1061
+        command_line_symbolic_sections: list[
1062
+            tuple[
1063
+                tuple[cli_machinery.CLIOption, ...],
1064
+                tuple[cli_machinery.CLIArgument, ...]
1065
+                | cli_machinery.CLISubcommand,
1066
+            ]
1067
+        ] = []
1068
+        for i, subcommand in enumerate(subcommand_stack):
1069
+            counter = i + 1
1070
+            eligible_options = [
1071
+                opt
1072
+                for group in subcommand.contents[0]
1073
+                for opt in group.options
1074
+            ]
1075
+            section_options = draw(
1076
+                options_strategy(eligible_options),
1077
+                f"options #{counter}",
1078
+            )
1079
+            section: tuple[
1080
+                tuple[cli_machinery.CLIOption, ...],
1081
+                cli_machinery.CLISubcommand
1082
+                | tuple[cli_machinery.CLIArgument, ...],
1083
+            ]
1084
+            if isinstance(subcommand, cli_machinery.CLITerminalSubcommand):
1085
+                arguments = subcommand.contents[1]
1086
+                section = (tuple(section_options), tuple(arguments))
1087
+            else:
1088
+                next_subcommand = subcommand_stack[i + 1]
1089
+                section = (tuple(section_options), next_subcommand)
1090
+            command_line_symbolic_sections.append(section)
1091
+        return (subcommand_stack[0], command_line_symbolic_sections)
1092
+
1093
+    @staticmethod
1094
+    @strategies.composite
1095
+    def parse_states(
1096
+        draw: strategies.DrawFn,
1097
+        /,
1098
+        subcommand_and_symbolic_command_line: tuple[
1099
+            cli_machinery.CLISubcommand,
1100
+            list[
1101
+                tuple[
1102
+                    tuple[cli_machinery.CLIOption, ...],
1103
+                    tuple[cli_machinery.CLIArgument, ...]
1104
+                    | cli_machinery.CLISubcommand,
1105
+                ]
1106
+            ],
1107
+        ]
1108
+        | None = None,
1109
+        *,
1110
+        allow_eager: bool = False,
1111
+        normalized: bool = True,
1112
+        max_option_groups: int = NUM_NAMES,
1113
+        max_positionals: int = NUM_NAMES,
1114
+    ) -> cli_machinery.ParseState:
1115
+        subcommand, command_line_symbolic_sections = (
1116
+            subcommand_and_symbolic_command_line
1117
+            if subcommand_and_symbolic_command_line is not None
1118
+            else draw(
1119
+                Strategies.subcommand_and_symbolic_command_line(
1120
+                    allow_eager=allow_eager,
1121
+                    max_option_groups=max_option_groups,
1122
+                    max_positionals=max_positionals,
1123
+                ),
1124
+                "subcommand/symbolic command-line",
1125
+            )
1126
+        )
1127
+        command_line: list[str] = []
1128
+        for i, section in enumerate(command_line_symbolic_sections):
1129
+            options, subcommand_or_arguments = section
1130
+            command_line.extend(
1131
+                draw(
1132
+                    Strategies.any_options(
1133
+                        Strategies.any_option_and_argument_pairs(
1134
+                            *options,
1135
+                            in_order=True,
1136
+                        ),
1137
+                        normalized=normalized,
1138
+                    ),
1139
+                    f"section {i} options",
1140
+                )
1141
+                if options
1142
+                else []
1143
+            )
1144
+            if isinstance(
1145
+                subcommand_or_arguments,
1146
+                (
1147
+                    cli_machinery.CLITerminalSubcommand,
1148
+                    cli_machinery.CLICompositeSubcommand,
1149
+                ),
1150
+            ):
1151
+                command_line.append(subcommand_or_arguments.names[0])
1152
+            else:
1153
+                arguments_strategies = [
1154
+                    strategies.sampled_from(arg.choices)
1155
+                    if arg.choices is not None
1156
+                    else Strategies.WORDS
1157
+                    for arg in subcommand_or_arguments
1158
+                ]
1159
+                command_line.extend(
1160
+                    draw(
1161
+                        strategies.tuples(*arguments_strategies),
1162
+                        f"section {i} arguments",
1163
+                    )
1164
+                )
1165
+        return cli_machinery.ParseState(
1166
+            tuple(command_line),
1167
+            subcommand,
1168
+        )
1169
+
1170
+    @staticmethod
1171
+    @strategies.composite
1172
+    def normalizable_parser_states(
1173
+        draw: strategies.DrawFn,
1174
+        /,
1175
+        subcommand_and_symbolic_command_line: tuple[
1176
+            cli_machinery.CLISubcommand,
1177
+            list[
1178
+                tuple[
1179
+                    tuple[cli_machinery.CLIOption, ...],
1180
+                    tuple[cli_machinery.CLIArgument, ...]
1181
+                    | cli_machinery.CLISubcommand,
1182
+                ]
1183
+            ],
1184
+        ]
1185
+        | None = None,
1186
+        *,
1187
+        allow_eager: bool = True,
1188
+        max_option_groups: int = NUM_NAMES,
1189
+        max_positionals: int = NUM_NAMES,
1190
+    ) -> ParseStateAndExpectedResult:
1191
+        subcommand, command_line_symbolic_sections = (
1192
+            subcommand_and_symbolic_command_line
1193
+            if subcommand_and_symbolic_command_line is not None
1194
+            else draw(
1195
+                Strategies.subcommand_and_symbolic_command_line(
1196
+                    allow_eager=allow_eager,
1197
+                    max_option_groups=max_option_groups,
1198
+                    max_positionals=max_positionals,
1199
+                ),
1200
+                "subcommand/symbolic command-line",
1201
+            )
1202
+        )
1203
+        command_line: list[str] = []
1204
+        first_section_normalized_command_line: list[str] = []
1205
+        for i, section in enumerate(command_line_symbolic_sections):
1206
+            options, subcommand_or_arguments = section
1207
+            if options:  # pragma: no branch
1208
+                option_and_argument_pairs = draw(
1209
+                    Strategies.any_option_and_argument_pairs(
1210
+                        *options,
1211
+                        in_order=True,
1212
+                    ),
1213
+                    f"section {i} raw option/argument pairs",
1214
+                )
1215
+                drawn_options_and_arguments = (
1216
+                    draw(
1217
+                        Strategies.any_options(
1218
+                            strategies.just(option_and_argument_pairs),
1219
+                            normalized=False,
1220
+                        ),
1221
+                        f"section {i} options",
1222
+                    )
1223
+                    if options
1224
+                    else []
1225
+                )
1226
+                command_line.extend(drawn_options_and_arguments)
1227
+                if i == 0:
1228
+                    assert len(option_and_argument_pairs) == len(options)
1229
+                    for opt, pair in zip(options, option_and_argument_pairs):
1230
+                        opt_name = opt.names[0]
1231
+                        if len(pair) > 1:
1232
+                            arg = pair[1]
1233
+                            first_section_normalized_command_line.append(
1234
+                                f"{opt_name}={arg}"
1235
+                            )
1236
+                        else:
1237
+                            first_section_normalized_command_line.append(
1238
+                                opt_name
1239
+                            )
1240
+                else:
1241
+                    first_section_normalized_command_line.extend(
1242
+                        drawn_options_and_arguments
1243
+                    )
1244
+            if isinstance(
1245
+                subcommand_or_arguments,
1246
+                (
1247
+                    cli_machinery.CLITerminalSubcommand,
1248
+                    cli_machinery.CLICompositeSubcommand,
1249
+                ),
1250
+            ):
1251
+                subcommand_name = subcommand_or_arguments.names[0]
1252
+                command_line.append(subcommand_name)
1253
+                if i == 0:
1254
+                    first_section_normalized_command_line.append("--")
1255
+                first_section_normalized_command_line.append(subcommand_name)
1256
+            else:
1257
+                arguments_strategies = [
1258
+                    strategies.sampled_from(arg.choices)
1259
+                    if arg.choices is not None
1260
+                    else Strategies.WORDS
1261
+                    for arg in subcommand_or_arguments
1262
+                ]
1263
+                argument_values = draw(
1264
+                    strategies.tuples(*arguments_strategies),
1265
+                    f"section {i} arguments",
1266
+                )
1267
+                command_line.extend(argument_values)
1268
+                if i == 0:
1269
+                    first_section_normalized_command_line.append("--")
1270
+                first_section_normalized_command_line.extend(argument_values)
1271
+        old_state = cli_machinery.ParseState(tuple(command_line), subcommand)
1272
+        new_state = cli_machinery.ParseState(
1273
+            tuple(first_section_normalized_command_line), subcommand
1274
+        )
1275
+        result = cli_machinery.ParseSuccess(new_state, None)
1276
+        return ParseStateAndExpectedResult(old_state, result)
1277
+
1278
+
1279
+strategies.register_type_strategy(
1280
+    cli_machinery.CLIOption,
1281
+    Strategies.options(),
1282
+)
1283
+strategies.register_type_strategy(
1284
+    cli_machinery.CLIOptionGroup,
1285
+    Strategies.option_groups(),
1286
+)
1287
+strategies.register_type_strategy(
1288
+    cli_machinery.CLITerminalSubcommand,
1289
+    Strategies.terminal_subcommands(),
1290
+)
1291
+strategies.register_type_strategy(
1292
+    cli_machinery.CLICompositeSubcommand,
1293
+    Strategies.composite_subcommands(Strategies.subcommands()),
1294
+)
1295
+strategies.register_type_strategy(
1296
+    cli_machinery.ParseState,
1297
+    Strategies.parse_states(),
1298
+)
1299
+
1300
+
332 1301
 def tokenize_version_output_item_listing(
333 1302
     line: str,
334 1303
     /,
... ...
@@ -575,6 +1544,21 @@ class Test001VersionOutputParser:
575 1544
         )
576 1545
 
577 1546
 
1547
+class Test010CLIMachinery:
1548
+    """Tests for the CLI machinery."""
1549
+
1550
+    @hypothesis.given(data_tuple=Strategies.normalizable_parser_states())
1551
+    def test_normalize_options_stage(
1552
+        self,
1553
+        data_tuple: ParseStateAndExpectedResult,
1554
+    ) -> None:
1555
+        actual_result = cli_machinery.normalize_options(
1556
+            data_tuple.starting_state
1557
+        )
1558
+        assert actual_result.state == data_tuple.expected_result.state
1559
+        assert actual_result == data_tuple.expected_result
1560
+
1561
+
578 1562
 class TestHelpOutput:
579 1563
     """Tests for all command-line interfaces' `--help` output."""
580 1564
 
581 1565