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 |