feat(core): clone commands and actions when binding runtimes
Add clone support across Action types and Command so commands can be safely registered or runner-bound without mutating the original instances. - clone BaseAction implementations across simple, composite, IO, prompt, file, HTTP, process, and signal actions - bind cloned commands in Falyx.add_command_from_command() and CommandRunner - preserve local never_prompt settings when cloning actions - rename shared runtime state from options to options_manager for consistency - seed root and execution option namespaces consistently - apply scoped root and namespace option overrides during routing and dispatch - improve namespace completion by delegating option suggestions to FalyxParser - enrich missing-value errors and error hints
This commit is contained in:
@@ -5,7 +5,7 @@ from falyx.action import Action
|
||||
from falyx.console import console as falyx_console
|
||||
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||
from falyx.parser import Argument, ArgumentAction, CommandArgumentParser
|
||||
from falyx.signals import HelpSignal
|
||||
|
||||
|
||||
@@ -1009,3 +1009,20 @@ def test_add_argument_invalid_lazy_resolver():
|
||||
CommandArgumentError, match="lazy_resolver must be a boolean, got int"
|
||||
):
|
||||
parser.add_argument("--valid", lazy_resolver=123)
|
||||
|
||||
|
||||
def test_add_argument_returns_registered_argument() -> None:
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
arg = parser.add_argument(
|
||||
"--retries",
|
||||
type=int,
|
||||
default="1",
|
||||
choices=["1", "2"],
|
||||
)
|
||||
|
||||
assert isinstance(arg, Argument)
|
||||
assert arg.dest == "retries"
|
||||
assert arg.default == 1
|
||||
assert arg.choices == [1, 2]
|
||||
assert parser.get_argument("retries") is arg
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
from falyx.console import console
|
||||
from falyx.execution_option import ExecutionOption
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser import CommandArgumentParser
|
||||
from falyx.parser.parser_types import TLDRExample
|
||||
|
||||
|
||||
def build_parser() -> CommandArgumentParser:
|
||||
parser = CommandArgumentParser(
|
||||
command_key="D",
|
||||
command_description="Deploy",
|
||||
help_text="Deploy something.",
|
||||
help_epilog="More help text.",
|
||||
aliases=["deploy"],
|
||||
program="source",
|
||||
options_manager=OptionsManager(),
|
||||
)
|
||||
|
||||
parser.add_argument("--region", choices=["us-east", "us-west"], default="us-east")
|
||||
parser.add_argument("target")
|
||||
|
||||
group = parser.add_argument_group("auth", description="Authentication options")
|
||||
group.add_argument("--profile", suggestions=["dev", "prod"])
|
||||
|
||||
mutex = parser.add_mutually_exclusive_group(
|
||||
"mode",
|
||||
required=False,
|
||||
description="Execution mode",
|
||||
)
|
||||
mutex.add_argument("--dry-run", action="store_true")
|
||||
mutex.add_argument("--apply", action="store_true")
|
||||
|
||||
parser.add_tldr_examples(
|
||||
[
|
||||
("target-1 --region us-east", "Deploy target-1 to us-east."),
|
||||
("target-2 --dry-run", "Preview target-2 without executing."),
|
||||
]
|
||||
)
|
||||
|
||||
parser.enable_execution_options(
|
||||
frozenset(
|
||||
{
|
||||
ExecutionOption.SUMMARY,
|
||||
ExecutionOption.RETRY,
|
||||
ExecutionOption.CONFIRM,
|
||||
}
|
||||
)
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def build_parser_with_tldr_examples() -> CommandArgumentParser:
|
||||
parser = build_parser()
|
||||
parser.add_tldr_examples(
|
||||
[
|
||||
("target-3 --profile dev", "Deploy target-3 using dev profile."),
|
||||
]
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def build_parser_with_groups() -> CommandArgumentParser:
|
||||
parser = build_parser()
|
||||
group = parser.add_argument_group("output", description="Output options")
|
||||
group.add_argument("--json", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def build_parser_with_execution_options() -> CommandArgumentParser:
|
||||
parser = build_parser()
|
||||
parser.enable_execution_options(
|
||||
frozenset(
|
||||
{
|
||||
ExecutionOption.SUMMARY,
|
||||
ExecutionOption.RETRY,
|
||||
ExecutionOption.CONFIRM,
|
||||
}
|
||||
)
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def test_clone_with_overrides_preserves_core_metadata():
|
||||
original = build_parser()
|
||||
new_options = OptionsManager()
|
||||
|
||||
cloned = original.clone_with_overrides(
|
||||
command_key="X",
|
||||
command_description="Execute",
|
||||
help_text="Execute something else.",
|
||||
help_epilog="Different epilog.",
|
||||
aliases=["execute"],
|
||||
program="target",
|
||||
options_manager=new_options,
|
||||
)
|
||||
|
||||
assert cloned is not original
|
||||
assert cloned.command_key == "X"
|
||||
assert cloned.command_description == "Execute"
|
||||
assert cloned.help_text == "Execute something else."
|
||||
assert cloned.help_epilog == "Different epilog."
|
||||
assert cloned.aliases == ["execute"]
|
||||
assert cloned.program == "target"
|
||||
assert cloned.options_manager is new_options
|
||||
|
||||
|
||||
def test_clone_with_overrides_keeps_execution_options_enabled_without_double_registration():
|
||||
original = build_parser()
|
||||
cloned = original.clone_with_overrides()
|
||||
|
||||
summary = cloned.get_argument("summary")
|
||||
retries = cloned.get_argument("retries")
|
||||
retry_delay = cloned.get_argument("retry_delay")
|
||||
retry_backoff = cloned.get_argument("retry_backoff")
|
||||
force_confirm = cloned.get_argument("force_confirm")
|
||||
skip_confirm = cloned.get_argument("skip_confirm")
|
||||
|
||||
assert summary is not None
|
||||
assert retries is not None
|
||||
assert retry_delay is not None
|
||||
assert retry_backoff is not None
|
||||
assert force_confirm is not None
|
||||
assert skip_confirm is not None
|
||||
|
||||
# Re-enabling on the clone should be idempotent, not duplicate flags/dests.
|
||||
cloned.enable_execution_options(
|
||||
frozenset(
|
||||
{
|
||||
ExecutionOption.SUMMARY,
|
||||
ExecutionOption.RETRY,
|
||||
ExecutionOption.CONFIRM,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1
|
||||
|
||||
|
||||
def test_clone_with_overrides_preserves_groups_and_mutex_groups():
|
||||
original = build_parser()
|
||||
cloned = original.clone_with_overrides()
|
||||
|
||||
assert "auth" in cloned._argument_groups
|
||||
assert "mode" in cloned._mutex_groups
|
||||
|
||||
assert cloned._arg_group_by_dest["profile"] == "auth"
|
||||
assert cloned._mutex_group_by_dest["dry_run"] == "mode"
|
||||
assert cloned._mutex_group_by_dest["apply"] == "mode"
|
||||
|
||||
assert cloned.get_argument("profile") is not None
|
||||
assert cloned.get_argument("dry_run") is not None
|
||||
assert cloned.get_argument("apply") is not None
|
||||
|
||||
|
||||
def test_clone_with_overrides_preserves_tldr_examples_and_help_flags():
|
||||
original = build_parser()
|
||||
cloned = original.clone_with_overrides()
|
||||
|
||||
assert cloned.help_text == original.help_text
|
||||
assert cloned.help_epilog == original.help_epilog
|
||||
assert cloned.get_argument("help") is not None
|
||||
assert cloned.get_argument("tldr") is not None
|
||||
assert cloned._tldr_examples == original._tldr_examples
|
||||
assert cloned._tldr_examples is not original._tldr_examples
|
||||
|
||||
|
||||
def test_clone_with_overrides_does_not_share_argument_registries_with_original():
|
||||
original = build_parser()
|
||||
cloned = original.clone_with_overrides()
|
||||
|
||||
assert cloned._arguments is not original._arguments
|
||||
assert cloned._positional is not original._positional
|
||||
assert cloned._keyword is not original._keyword
|
||||
assert cloned._keyword_list is not original._keyword_list
|
||||
assert cloned._flag_map is not original._flag_map
|
||||
assert cloned._dest_set is not original._dest_set
|
||||
assert cloned._execution_dests is not original._execution_dests
|
||||
|
||||
cloned.add_argument("--new-flag", default="x")
|
||||
|
||||
assert cloned.get_argument("new_flag") is not None
|
||||
assert original.get_argument("new_flag") is None
|
||||
|
||||
|
||||
def test_clone_with_overrides_does_not_share_group_registries_with_original():
|
||||
original = build_parser()
|
||||
cloned = original.clone_with_overrides()
|
||||
|
||||
assert cloned._argument_groups is not original._argument_groups
|
||||
assert cloned._mutex_groups is not original._mutex_groups
|
||||
assert cloned._arg_group_by_dest is not original._arg_group_by_dest
|
||||
assert cloned._mutex_group_by_dest is not original._mutex_group_by_dest
|
||||
|
||||
cloned_group = cloned.add_argument_group("output", description="Output options")
|
||||
cloned_group.add_argument("--json", action="store_true")
|
||||
|
||||
assert "output" in cloned._argument_groups
|
||||
assert "output" not in original._argument_groups
|
||||
assert cloned.get_argument("json") is not None
|
||||
assert original.get_argument("json") is None
|
||||
|
||||
|
||||
def test_clone_with_overrides_reuses_no_mutable_group_objects():
|
||||
original = build_parser()
|
||||
cloned = original.clone_with_overrides()
|
||||
|
||||
# These should ideally be distinct objects too, not just distinct dicts.
|
||||
assert cloned._argument_groups["auth"] is not original._argument_groups["auth"]
|
||||
assert cloned._mutex_groups["mode"] is not original._mutex_groups["mode"]
|
||||
|
||||
|
||||
def test_clone_with_overrides_reuses_no_mutable_argument_objects():
|
||||
original = build_parser()
|
||||
cloned = original.clone_with_overrides()
|
||||
|
||||
# Strict contract: cloned parser should not share Argument instances either.
|
||||
assert cloned.get_argument("region") is not original.get_argument("region")
|
||||
assert cloned.get_argument("target") is not original.get_argument("target")
|
||||
assert cloned.get_argument("profile") is not original.get_argument("profile")
|
||||
|
||||
|
||||
def test_clone_with_overrides_uses_new_options_manager():
|
||||
original = build_parser()
|
||||
new_options = OptionsManager()
|
||||
|
||||
cloned = original.clone_with_overrides(options_manager=new_options)
|
||||
|
||||
assert cloned.options_manager is new_options
|
||||
assert original.options_manager is not new_options
|
||||
|
||||
|
||||
def test_clone_with_overrides_has_single_help_and_single_tldr_argument():
|
||||
parser = build_parser_with_tldr_examples()
|
||||
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "help"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "tldr"]) == 1
|
||||
assert cloned.get_argument("help") is not None
|
||||
assert cloned.get_argument("tldr") is not None
|
||||
|
||||
|
||||
def test_clone_with_overrides_copies_tldr_examples():
|
||||
parser = build_parser_with_tldr_examples()
|
||||
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
assert cloned._tldr_examples == parser._tldr_examples
|
||||
assert cloned._tldr_examples is not parser._tldr_examples
|
||||
assert all(c is not o for c, o in zip(cloned._tldr_examples, parser._tldr_examples))
|
||||
|
||||
|
||||
def test_clone_with_overrides_copies_explicit_tldr_examples():
|
||||
parser = build_parser()
|
||||
examples = [TLDRExample("foo", "bar")]
|
||||
|
||||
cloned = parser.clone_with_overrides(tldr_examples=examples)
|
||||
|
||||
assert cloned._tldr_examples == examples
|
||||
assert cloned._tldr_examples is not examples
|
||||
|
||||
|
||||
def test_clone_with_overrides_does_not_share_aliases_list():
|
||||
parser = build_parser()
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
assert cloned.aliases == parser.aliases
|
||||
assert cloned.aliases is not parser.aliases
|
||||
|
||||
cloned.aliases.append("new-alias")
|
||||
assert "new-alias" not in parser.aliases
|
||||
|
||||
|
||||
def test_clone_with_overrides_rebuilds_group_membership_without_duplicates():
|
||||
parser = build_parser_with_groups()
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
assert cloned._argument_groups["auth"].dests == {"profile"}
|
||||
assert set(cloned._mutex_groups["mode"].dests) == {"dry_run", "apply"}
|
||||
assert len(cloned._mutex_groups["mode"].dests) == 2
|
||||
|
||||
|
||||
def test_clone_with_overrides_does_not_share_group_objects():
|
||||
parser = build_parser_with_groups()
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
assert cloned._argument_groups is not parser._argument_groups
|
||||
assert cloned._mutex_groups is not parser._mutex_groups
|
||||
assert cloned._argument_groups["auth"] is not parser._argument_groups["auth"]
|
||||
assert cloned._mutex_groups["mode"] is not parser._mutex_groups["mode"]
|
||||
|
||||
|
||||
def test_clone_with_overrides_does_not_share_argument_objects():
|
||||
parser = build_parser()
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
for original_arg in parser._arguments:
|
||||
cloned_arg = cloned.get_argument(original_arg.dest)
|
||||
console.print(original_arg)
|
||||
console.print(cloned_arg)
|
||||
assert cloned_arg is not None
|
||||
assert cloned_arg is not original_arg
|
||||
assert cloned_arg == original_arg
|
||||
|
||||
|
||||
def test_clone_with_overrides_internal_registries_point_to_cloned_arguments():
|
||||
parser = build_parser()
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
for arg in cloned._arguments:
|
||||
for flag in arg.flags:
|
||||
assert cloned._flag_map[flag] is arg
|
||||
if not arg.positional:
|
||||
assert cloned._keyword[flag] is arg
|
||||
|
||||
if arg.positional:
|
||||
assert cloned._positional[arg.dest] is arg
|
||||
else:
|
||||
assert arg in cloned._keyword_list
|
||||
|
||||
|
||||
def test_clone_with_overrides_preserves_execution_option_state_without_duplication():
|
||||
parser = build_parser_with_execution_options()
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
assert cloned._summary_enabled is True
|
||||
assert cloned._retries_enabled is True
|
||||
assert cloned._confirm_enabled is True
|
||||
assert cloned._execution_dests == parser._execution_dests
|
||||
assert cloned._execution_dests is not parser._execution_dests
|
||||
|
||||
cloned.enable_execution_options(
|
||||
frozenset(
|
||||
{
|
||||
ExecutionOption.SUMMARY,
|
||||
ExecutionOption.RETRY,
|
||||
ExecutionOption.CONFIRM,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1
|
||||
assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1
|
||||
|
||||
|
||||
def test_clone_with_overrides_preserves_runner_and_help_mode_flags():
|
||||
parser = build_parser()
|
||||
parser.is_runner_mode = True
|
||||
parser.mark_as_help_command()
|
||||
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
assert cloned.is_runner_mode is True
|
||||
assert cloned._is_help_command is True
|
||||
|
||||
|
||||
def test_clone_with_overrides_mutating_clone_does_not_mutate_original():
|
||||
parser = build_parser()
|
||||
cloned = parser.clone_with_overrides()
|
||||
|
||||
cloned.add_argument("--new-flag", default="x")
|
||||
|
||||
assert cloned.get_argument("new_flag") is not None
|
||||
assert parser.get_argument("new_flag") is None
|
||||
459
tests/test_parsers/test_command_argument_parser_extra.py
Normal file
459
tests/test_parsers/test_command_argument_parser_extra.py
Normal file
@@ -0,0 +1,459 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.action.action import Action
|
||||
from falyx.exceptions import CommandArgumentError, InvalidValueError
|
||||
from falyx.execution_option import ExecutionOption
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
from falyx.signals import HelpSignal
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser() -> CommandArgumentParser:
|
||||
return CommandArgumentParser(
|
||||
command_key="D",
|
||||
command_description="Deploy service",
|
||||
help_text="Deploy a service.",
|
||||
help_epilog="Deployment epilog.",
|
||||
aliases=["deploy"],
|
||||
program="flx",
|
||||
)
|
||||
|
||||
|
||||
def capture_console(parser: CommandArgumentParser) -> StringIO:
|
||||
stream = StringIO()
|
||||
parser.console = Console(
|
||||
file=stream,
|
||||
force_terminal=False,
|
||||
color_system=None,
|
||||
width=120,
|
||||
)
|
||||
return stream
|
||||
|
||||
|
||||
def test_add_argument_rejects_suggestions_with_non_string_members(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="suggestions must be a list of strings"
|
||||
):
|
||||
parser.add_argument("--region", suggestions=["dev", 1])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_accepts_multi_value_choice_list_when_all_values_are_valid(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument(
|
||||
"--ports",
|
||||
type=int,
|
||||
nargs="+",
|
||||
choices=["80", 443],
|
||||
default=[],
|
||||
)
|
||||
|
||||
result = await parser.parse_args(["--ports", "80", "443"])
|
||||
|
||||
assert result["ports"] == [80, 443]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positional_action_wraps_resolver_failure(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
async def fail_resolver(value: str) -> str:
|
||||
raise RuntimeError(f"cannot resolve {value}")
|
||||
|
||||
parser.add_argument(
|
||||
"target",
|
||||
action="action",
|
||||
resolver=Action("Resolve target", fail_resolver),
|
||||
lazy_resolver=False,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match=r"\[target\] action failed: cannot resolve web"
|
||||
):
|
||||
await parser.parse_args(["web"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dash_prefixed_numeric_token_can_be_a_positional_value(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("delta", type=int)
|
||||
|
||||
result = await parser.parse_args(["-3"])
|
||||
|
||||
assert result["delta"] == -3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_option_without_value_raises_type_specific_prompt(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("--count", type=int, help="Number of instances.")
|
||||
|
||||
with pytest.raises(CommandArgumentError, match="enter a int value for 'count'"):
|
||||
await parser.parse_args(["--count"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_option_without_value_raises_type_specific_prompt(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("--tag", action="append", help="Deployment tag.")
|
||||
|
||||
with pytest.raises(CommandArgumentError, match="enter a str value for 'tag'"):
|
||||
await parser.parse_args(["--tag"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tldr_flag_on_help_command_is_parsed_as_a_normal_value(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.mark_as_help_command()
|
||||
parser.add_tldr_example("D --region us-east", "Deploy to us-east")
|
||||
|
||||
result = await parser.parse_args(["--tldr"])
|
||||
|
||||
assert result["tldr"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tldr_flag_renders_examples_and_raises_help_signal(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
stream = capture_console(parser)
|
||||
parser.add_tldr_example("--region us-east", "Deploy to us-east")
|
||||
|
||||
with pytest.raises(HelpSignal):
|
||||
await parser.parse_args(["--tldr"])
|
||||
|
||||
output = stream.getvalue()
|
||||
assert "usage:" in output
|
||||
assert "examples:" in output
|
||||
assert "Deploy to us-east" in output
|
||||
assert "--region us-east" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_required_mutex_group_requires_one_member(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
mode = parser.add_mutually_exclusive_group("mode", required=True)
|
||||
mode.add_argument("--dry-run", action="store_true")
|
||||
mode.add_argument("--apply", action="store_true")
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="one of the following is required for group 'mode'"
|
||||
):
|
||||
await parser.parse_args([])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mutex_group_rejects_multiple_present_members(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
mode = parser.add_mutually_exclusive_group("mode")
|
||||
mode.add_argument("--dry-run", action="store_true")
|
||||
mode.add_argument("--apply", action="store_true")
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError,
|
||||
match="cannot be used together: (dry_run, apply|apply, dry_run)",
|
||||
):
|
||||
await parser.parse_args(["--dry-run", "--apply"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("argument_kwargs", "argv"),
|
||||
[
|
||||
({"flags": ("--enabled",), "action": "store_true"}, ["--enabled", "--other"]),
|
||||
({"flags": ("--disabled",), "action": "store_false"}, ["--disabled", "--other"]),
|
||||
(
|
||||
{"flags": ("--feature",), "action": "store_bool_optional"},
|
||||
["--no-feature", "--other"],
|
||||
),
|
||||
({"flags": ("-v", "--verbose"), "action": "count"}, ["-v", "--other"]),
|
||||
({"flags": ("--tag",), "action": "append"}, ["--tag", "beta", "--other"]),
|
||||
(
|
||||
{"flags": ("--item",), "action": "extend", "nargs": "+"},
|
||||
["--item", "a", "--other"],
|
||||
),
|
||||
({"flags": ("--name",)}, ["--name", "web", "--other"]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_mutex_presence_detection_handles_all_supported_action_shapes(
|
||||
argument_kwargs: dict,
|
||||
argv: list[str],
|
||||
) -> None:
|
||||
parser = CommandArgumentParser(command_key="D")
|
||||
only_one = parser.add_mutually_exclusive_group("only-one")
|
||||
kwargs = argument_kwargs.copy()
|
||||
flags = kwargs.pop("flags")
|
||||
only_one.add_argument(*flags, **kwargs)
|
||||
only_one.add_argument("--other", action="store_true")
|
||||
|
||||
with pytest.raises(CommandArgumentError, match="cannot be used together"):
|
||||
await parser.parse_args(argv)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_split_separates_execution_options_from_command_inputs(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("service")
|
||||
parser.add_argument("--region", default="us-east")
|
||||
parser.enable_execution_options(
|
||||
frozenset(
|
||||
{
|
||||
ExecutionOption.SUMMARY,
|
||||
ExecutionOption.RETRY,
|
||||
ExecutionOption.CONFIRM,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
args, kwargs, execution_args = await parser.parse_args_split(
|
||||
[
|
||||
"api",
|
||||
"--region",
|
||||
"us-west",
|
||||
"--summary",
|
||||
"--retries",
|
||||
"3",
|
||||
"--retry-delay",
|
||||
"0.5",
|
||||
"--retry-backoff",
|
||||
"2.0",
|
||||
"--skip-confirm",
|
||||
]
|
||||
)
|
||||
|
||||
assert args == ("api",)
|
||||
assert kwargs == {"region": "us-west"}
|
||||
assert execution_args == {
|
||||
"summary": True,
|
||||
"retries": 3,
|
||||
"retry_delay": 0.5,
|
||||
"retry_backoff": 2.0,
|
||||
"force_confirm": False,
|
||||
"skip_confirm": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_action_required_argument_is_deferred_during_validation(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def resolve(value: str) -> str:
|
||||
calls.append(value)
|
||||
return value.upper()
|
||||
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
action="action",
|
||||
resolver=Action("Resolve target", resolve),
|
||||
required=True,
|
||||
)
|
||||
|
||||
result = await parser.parse_args(["--target", "web"], from_validate=True)
|
||||
|
||||
assert result["target"] is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_action_required_argument_still_errors_when_no_tokens_are_present(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
async def resolve(value: str) -> str:
|
||||
return value.upper()
|
||||
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
action="action",
|
||||
resolver=Action("Resolve target", resolve),
|
||||
required=True,
|
||||
)
|
||||
|
||||
with pytest.raises(CommandArgumentError, match="missing required argument 'target'"):
|
||||
await parser.parse_args([], from_validate=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_list_with_wrong_fixed_nargs_arity_is_invalid(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("--pair", nargs=2, default=["only-one"])
|
||||
|
||||
with pytest.raises(InvalidValueError) as exc_info:
|
||||
await parser.parse_args([])
|
||||
|
||||
assert exc_info.value.dest == "pair"
|
||||
assert "expected 2" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_required_plus_nargs_option_requires_at_least_one_value(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("--item", nargs="+", required=True)
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="argument 'item' requires at least one value"
|
||||
):
|
||||
await parser.parse_args([])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_next_filters_mutex_siblings_after_one_member_is_consumed(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
mode = parser.add_mutually_exclusive_group("mode")
|
||||
mode.add_argument("--dry-run", action="store_true")
|
||||
mode.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--region", choices=["us-east", "us-west"])
|
||||
|
||||
await parser.parse_args(["--dry-run"])
|
||||
|
||||
suggestions = parser.suggest_next(["--dry-run"], cursor_at_end_of_token=True)
|
||||
|
||||
assert "--apply" not in suggestions
|
||||
assert "--region" in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optional_choice_argument_can_be_omitted(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--region", choices=["us-east", "us-west"])
|
||||
|
||||
result = await parser.parse_args(["--dry-run"])
|
||||
|
||||
assert result["dry_run"] is True
|
||||
assert result["region"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_next_returns_no_values_after_invalid_choice_is_committed(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("--region", choices=["dev", "prod"])
|
||||
|
||||
with pytest.raises(InvalidValueError):
|
||||
await parser.parse_args(["--region", "qa"])
|
||||
|
||||
assert parser.suggest_next(["--region", "qa"], cursor_at_end_of_token=True) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_next_suggests_value_for_keyword_when_stub_starts_with_dash(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("--profile", suggestions=["-prod", "-stage", "dev"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["--profile"], from_validate=True)
|
||||
|
||||
assert parser.suggest_next(["--profile", "-"], cursor_at_end_of_token=False) == [
|
||||
"-prod",
|
||||
"-stage",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_next_returns_empty_for_missing_path_base(
|
||||
parser: CommandArgumentParser,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
missing = tmp_path / "missing-dir" / "config.toml"
|
||||
parser.add_argument("--config", type=Path)
|
||||
|
||||
await parser.parse_args(["--config", str(missing)], from_validate=True)
|
||||
|
||||
assert (
|
||||
parser.suggest_next(["--config", str(missing)], cursor_at_end_of_token=False)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_get_options_text_repeats_fixed_width_positional_nargs(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.add_argument("coords", nargs=2)
|
||||
|
||||
assert "coords coords" in parser.get_options_text()
|
||||
|
||||
|
||||
def test_get_usage_uses_program_only_when_parser_is_in_runner_mode(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
parser.is_runner_mode = True
|
||||
parser.program = "deploy-tool"
|
||||
|
||||
usage = parser.get_usage()
|
||||
|
||||
assert "deploy-tool" in usage
|
||||
assert "[bold]D[/bold]" not in usage
|
||||
assert "[bold]deploy[/bold]" not in usage
|
||||
|
||||
|
||||
def test_render_help_includes_grouped_keywords_bool_optional_pair_and_epilog(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
stream = capture_console(parser)
|
||||
parser.add_argument("environment", help="Target environment.")
|
||||
deploy = parser.add_argument_group("deploy", "Deployment options.")
|
||||
deploy.add_argument("--region", help="Target region.")
|
||||
mode = parser.add_mutually_exclusive_group("mode")
|
||||
mode.add_argument("--dry-run", action="store_true", help="Preview only.")
|
||||
parser.add_argument("--cache", action="store_bool_optional", help="Use cache.")
|
||||
|
||||
parser.render_help()
|
||||
|
||||
output = stream.getvalue()
|
||||
assert "usage:" in output
|
||||
assert "Deploy a service." in output
|
||||
assert "positional:" in output
|
||||
assert "environment" in output
|
||||
assert "deploy:" in output
|
||||
assert "Deployment options." in output
|
||||
assert "--cache, --no-cache" in output
|
||||
assert "Deployment epilog." in output
|
||||
|
||||
|
||||
def test_render_tldr_without_examples_prints_empty_state_message(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
stream = capture_console(parser)
|
||||
|
||||
parser.render_tldr()
|
||||
|
||||
assert "No TLDR examples available for D" in stream.getvalue()
|
||||
|
||||
|
||||
def test_render_tldr_with_examples_prints_usage_help_and_example_panel(
|
||||
parser: CommandArgumentParser,
|
||||
) -> None:
|
||||
stream = capture_console(parser)
|
||||
parser.add_tldr_example("--region us-east", "Deploy east")
|
||||
|
||||
parser.render_tldr()
|
||||
|
||||
output = stream.getvalue()
|
||||
assert "usage:" in output
|
||||
assert "Deploy a service." in output
|
||||
assert "examples:" in output
|
||||
assert "Deploy east" in output
|
||||
assert "--region us-east" in output
|
||||
@@ -34,10 +34,6 @@ def test_enable_execution_options_registers_retry_flags():
|
||||
def test_enable_execution_options_invalid_double_registration_raises():
|
||||
parser = CommandArgumentParser()
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
):
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError,
|
||||
@@ -68,9 +64,10 @@ def test_register_execution_dest_rejects_duplicates():
|
||||
parser.add_argument("--summary", action="store_true")
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
CommandArgumentError,
|
||||
match="destination 'summary' is already registered as an execution argument",
|
||||
):
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
parser._register_execution_dest("summary")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -76,9 +76,10 @@ async def test_resolve_args_raises_on_conflicting_execution_option():
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
CommandArgumentError,
|
||||
match="destination 'summary' is already registered as an execution argument",
|
||||
):
|
||||
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
command.arg_parser._register_execution_dest("summary")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user