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 |