Add a test for the new command-line parser's eager option scan step
Marco Ricci

Marco Ricci commited on 2026-08-16 22:10:18
Zeige 1 geänderte Dateien mit 196 Einfügungen und 5 Löschungen.


Add a property-based test for the "scan_for_eager_options" step of the
new command-line parser.  This only works for normalized command-lines,
so add additional options and property-based tests for generating parser
states with normalized command-lines.

Some default behaviors of the underlying helper strategies need amending
to generate normalized command-lines by default (or at all).

The strategies for generating general parser states, normalizable
parser states and eager option scanning parser states are very similar
to each other, but still use more copy-and-paste than I would like.
... ...
@@ -719,10 +719,17 @@ class Strategies(types.SimpleNamespace):
719 719
     @staticmethod
720 720
     def option_and_argument(
721 721
         opt: cli_machinery.CLIOption,
722
+        /,
722 723
         free_arguments_strategy: strategies.SearchStrategy[str] | None = None,
724
+        *,
725
+        normalized: bool = True,
723 726
     ) -> strategies.SearchStrategy[list[str]]:
724 727
         # shrink to less complex option names
725
-        option_names = sorted(opt.names, key=_name_complexity)
728
+        option_names = (
729
+            [opt.names[0]]
730
+            if normalized
731
+            else sorted(opt.names, key=_name_complexity)
732
+        )
726 733
         option_strategy = strategies.sampled_from(option_names)
727 734
         if not isinstance(opt.has_argument, bool):
728 735
             # shrink to less complex arguments
... ...
@@ -789,9 +796,12 @@ class Strategies(types.SimpleNamespace):
789 796
     def arrange_options(
790 797
         *options: cli_machinery.CLIOption,
791 798
         free_arguments_strategy: strategies.SearchStrategy[str] | None = None,
799
+        normalized: bool = True,
792 800
     ) -> strategies.SearchStrategy[list[list[str]]]:
793 801
         pairs_strategies = [
794
-            Strategies.option_and_argument(opt, free_arguments_strategy)
802
+            Strategies.option_and_argument(
803
+                opt, free_arguments_strategy, normalized=normalized
804
+            )
795 805
             for opt in options
796 806
         ]
797 807
         return strategies.tuples(*pairs_strategies).map(list)
... ...
@@ -810,12 +820,15 @@ class Strategies(types.SimpleNamespace):
810 820
     def choose_options(
811 821
         *options: cli_machinery.CLIOption,
812 822
         free_arguments_strategy: strategies.SearchStrategy[str] | None = None,
823
+        normalized: bool = True,
813 824
     ) -> strategies.SearchStrategy[list[list[str]]]:
814 825
         def _option_and_argument(
815 826
             opt: cli_machinery.CLIOption,
816 827
             /,
817 828
         ) -> strategies.SearchStrategy[list[str]]:
818
-            return Strategies.option_and_argument(opt, free_arguments_strategy)
829
+            return Strategies.option_and_argument(
830
+                opt, free_arguments_strategy, normalized=normalized
831
+            )
819 832
 
820 833
         return strategies.lists(
821 834
             Strategies.choose_option(options).flatmap(_option_and_argument),
... ...
@@ -827,17 +840,22 @@ class Strategies(types.SimpleNamespace):
827 840
         *options: cli_machinery.CLIOption,
828 841
         free_arguments_strategy: strategies.SearchStrategy[str] | None = None,
829 842
         in_order: bool = True,
843
+        normalized: bool = True,
830 844
     ) -> strategies.SearchStrategy[list[list[str]]]:
831 845
         if not options:  # pragma: no cover [failsafe]
832 846
             msg = "No options given!"
833 847
             raise ValueError(msg)
834 848
         return (
835 849
             Strategies.arrange_options(
836
-                *options, free_arguments_strategy=free_arguments_strategy
850
+                *options,
851
+                free_arguments_strategy=free_arguments_strategy,
852
+                normalized=normalized,
837 853
             )
838 854
             if in_order
839 855
             else Strategies.choose_options(
840
-                *options, free_arguments_strategy=free_arguments_strategy
856
+                *options,
857
+                free_arguments_strategy=free_arguments_strategy,
858
+                normalized=normalized,
841 859
             )
842 860
         )
843 861
 
... ...
@@ -1133,6 +1151,7 @@ class Strategies(types.SimpleNamespace):
1133 1151
                         Strategies.any_option_and_argument_pairs(
1134 1152
                             *options,
1135 1153
                             in_order=True,
1154
+                            normalized=normalized,
1136 1155
                         ),
1137 1156
                         normalized=normalized,
1138 1157
                     ),
... ...
@@ -1141,6 +1160,12 @@ class Strategies(types.SimpleNamespace):
1141 1160
                 if options
1142 1161
                 else []
1143 1162
             )
1163
+            command_line.extend(
1164
+                ["--"]
1165
+                if normalized
1166
+                or draw(strategies.booleans(), "end_of_options_marker")
1167
+                else []
1168
+            )
1144 1169
             if isinstance(
1145 1170
                 subcommand_or_arguments,
1146 1171
                 (
... ...
@@ -1209,6 +1234,7 @@ class Strategies(types.SimpleNamespace):
1209 1234
                     Strategies.any_option_and_argument_pairs(
1210 1235
                         *options,
1211 1236
                         in_order=True,
1237
+                        normalized=False,
1212 1238
                     ),
1213 1239
                     f"section {i} raw option/argument pairs",
1214 1240
                 )
... ...
@@ -1275,6 +1301,121 @@ class Strategies(types.SimpleNamespace):
1275 1301
         result = cli_machinery.ParseSuccess(new_state, None)
1276 1302
         return ParseStateAndExpectedResult(old_state, result)
1277 1303
 
1304
+    @staticmethod
1305
+    @strategies.composite
1306
+    def scan_for_eager_options_parser_states(
1307
+        draw: strategies.DrawFn,
1308
+        /,
1309
+        subcommand_and_symbolic_command_line: tuple[
1310
+            cli_machinery.CLISubcommand,
1311
+            list[
1312
+                tuple[
1313
+                    tuple[cli_machinery.CLIOption, ...],
1314
+                    tuple[cli_machinery.CLIArgument, ...]
1315
+                    | cli_machinery.CLISubcommand,
1316
+                ]
1317
+            ],
1318
+        ]
1319
+        | None = None,
1320
+        *,
1321
+        allow_eager: bool = True,
1322
+        max_option_groups: int = NUM_NAMES,
1323
+        max_positionals: int = NUM_NAMES,
1324
+    ) -> ParseStateAndExpectedResult:
1325
+        subcommand, command_line_symbolic_sections = (
1326
+            subcommand_and_symbolic_command_line
1327
+            if subcommand_and_symbolic_command_line is not None
1328
+            else draw(
1329
+                Strategies.subcommand_and_symbolic_command_line(
1330
+                    allow_eager=allow_eager,
1331
+                    max_option_groups=max_option_groups,
1332
+                    max_positionals=max_positionals,
1333
+                ),
1334
+                "subcommand/symbolic command-line",
1335
+            )
1336
+        )
1337
+        command_line: list[str] = []
1338
+        equivalent_command_line: list[str] = []
1339
+        parse_result: str | None = None
1340
+        for i, section in enumerate(command_line_symbolic_sections):
1341
+            options, subcommand_or_arguments = section
1342
+            if options:  # pragma: no branch
1343
+                option_and_argument_pairs = draw(
1344
+                    Strategies.any_option_and_argument_pairs(
1345
+                        *options,
1346
+                        in_order=True,
1347
+                        normalized=True,
1348
+                    ),
1349
+                    f"section {i} raw option/argument pairs",
1350
+                )
1351
+                drawn_options_and_arguments = (
1352
+                    draw(
1353
+                        Strategies.any_options(
1354
+                            strategies.just(option_and_argument_pairs),
1355
+                            normalized=True,
1356
+                        ),
1357
+                        f"section {i} options",
1358
+                    )
1359
+                    if options
1360
+                    else []
1361
+                )
1362
+                command_line.extend(drawn_options_and_arguments)
1363
+                if i == 0:
1364
+                    assert len(option_and_argument_pairs) == len(options)
1365
+                    for opt, pair in zip(options, option_and_argument_pairs):
1366
+                        token = (
1367
+                            f"{opt.names[0]}={pair[1]}"
1368
+                            if len(pair) > 1
1369
+                            else opt.names[0]
1370
+                        )
1371
+                        if opt.eager:
1372
+                            equivalent_command_line.clear()
1373
+                            parse_result = token
1374
+                        equivalent_command_line.append(token)
1375
+                        if parse_result is not None:
1376
+                            break
1377
+                elif parse_result is None:
1378
+                    equivalent_command_line.extend(drawn_options_and_arguments)
1379
+            if parse_result is not None:
1380
+                pass
1381
+            elif isinstance(
1382
+                subcommand_or_arguments,
1383
+                (
1384
+                    cli_machinery.CLITerminalSubcommand,
1385
+                    cli_machinery.CLICompositeSubcommand,
1386
+                ),
1387
+            ):
1388
+                command_line.append("--")
1389
+                equivalent_command_line.append("--")
1390
+                subcommand_name = subcommand_or_arguments.names[0]
1391
+                command_line.append(subcommand_name)
1392
+                equivalent_command_line.append(subcommand_name)
1393
+            else:
1394
+                command_line.append("--")
1395
+                equivalent_command_line.append("--")
1396
+                arguments_strategies = [
1397
+                    strategies.sampled_from(arg.choices)
1398
+                    if arg.choices is not None
1399
+                    else Strategies.WORDS
1400
+                    for arg in subcommand_or_arguments
1401
+                ]
1402
+                argument_values = draw(
1403
+                    strategies.tuples(*arguments_strategies),
1404
+                    f"section {i} arguments",
1405
+                )
1406
+                command_line.extend(argument_values)
1407
+                equivalent_command_line.extend(argument_values)
1408
+        old_state = cli_machinery.ParseState(tuple(command_line), subcommand)
1409
+        new_state = cli_machinery.ParseState(
1410
+            tuple(equivalent_command_line), subcommand
1411
+        )
1412
+        result = (
1413
+            cli_machinery.ParseEarlyExit(new_state, parse_result)
1414
+            if parse_result is not None
1415
+            else cli_machinery.ParseSuccess(new_state, parse_result)
1416
+        )
1417
+        return ParseStateAndExpectedResult(old_state, result)
1418
+
1278 1419
 
1279 1420
 strategies.register_type_strategy(
1280 1421
     cli_machinery.CLIOption,
... ...
@@ -1547,6 +1688,43 @@ class Test001VersionOutputParser:
1547 1688
 class Test010CLIMachinery:
1548 1689
     """Tests for the CLI machinery."""
1549 1690
 
1691
+    @hypothesis.given(starting_state=Strategies.parse_states(normalized=True))
1692
+    def test_normalized_starting_states_are_normalized(
1693
+        self,
1694
+        starting_state: cli_machinery.ParseState,
1695
+    ) -> None:
1696
+        command_line = starting_state.command_line
1697
+        canon_map = starting_state.subcommand.canon_map
1698
+        for token in command_line:
1699
+            tokentype = cli_machinery.CommandLineTokenType.classify(token)
1700
+            assert (
1701
+                tokentype != cli_machinery.CommandLineTokenType.SHORT_OPTION
1702
+            ), f"Short option {token!r} not allowed in normalized command-line"
1703
+            assert (
1704
+                tokentype != cli_machinery.CommandLineTokenType.POSITIONAL
1705
+            ), (
1706
+                f"Positional argument {token!r} not allowed "
1707
+                "in normalized command-line without preceding "
1708
+                "end-of-options token"
1709
+            )
1710
+            if tokentype == cli_machinery.CommandLineTokenType.END_OF_OPTIONS:
1711
+                break
1712
+            assert tokentype == cli_machinery.CommandLineTokenType.LONG_OPTION
1713
+            if "=" not in token:
1714
+                opt = cast("cli_machinery.CLIOption", canon_map[token])
1715
+                assert not opt.has_argument, (
1716
+                    f"Separated argument for option {token!r}=... not allowed "
1717
+                    "in normalized command-line"
1718
+                )
1719
+
1720
+    @hypothesis.given(starting_state=Strategies.parse_states(normalized=True))
1721
+    def test_normalize_options_stage_on_normalized_command_lines(
1722
+        self,
1723
+        starting_state: cli_machinery.ParseState,
1724
+    ) -> None:
1725
+        result = cli_machinery.normalize_options(starting_state)
1726
+        assert result == cli_machinery.ParseResult.unit(starting_state)
1727
+
1550 1728
     @hypothesis.given(data_tuple=Strategies.normalizable_parser_states())
1551 1729
     def test_normalize_options_stage(
1552 1730
         self,
... ...
@@ -1558,6 +1736,19 @@ class Test010CLIMachinery:
1558 1736
         assert actual_result.state == data_tuple.expected_result.state
1559 1737
         assert actual_result == data_tuple.expected_result
1560 1738
 
1739
+    @hypothesis.given(
1740
+        data_tuple=Strategies.scan_for_eager_options_parser_states()
1741
+    )
1742
+    def test_scan_for_eager_options_parser_stage(
1743
+        self,
1744
+        data_tuple: ParseStateAndExpectedResult,
1745
+    ) -> None:
1746
+        actual_result = cli_machinery.scan_for_eager_options(
1747
+            data_tuple.starting_state
1748
+        )
1749
+        assert actual_result.state == data_tuple.expected_result.state
1750
+        assert actual_result == data_tuple.expected_result
1751
+
1561 1752
 
1562 1753
 class TestHelpOutput:
1563 1754
     """Tests for all command-line interfaces' `--help` output."""
1564 1755