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
374 lines
13 KiB
Python
374 lines
13 KiB
Python
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
|