feat: add recursive namespace routing and standalone runner polish
- introduce namespace-aware routing with RootParseResult, RouteResult, and InvocationContext - register submenus as FalyxNamespace entries and resolve them through _entry_map - refactor FalyxParser to parse only root options and leave recursive routing to Falyx - add prepare_route, resolve_route, and route dispatch flow to Falyx - update validator and completer to understand namespace entries and route results - unify help/TLDR rendering APIs and add custom_tldr support on Command - tighten Command.resolve_args error handling and parser type validation - improve CommandRunner dependency validation and argv handling - add BottomBar.has_items and improve wrapped executor error messages - add tests for execution options, resolve_args, command runner, and route-aware validation
This commit is contained in:
143
tests/test_parsers/test_execution_option_registration.py
Normal file
143
tests/test_parsers/test_execution_option_registration.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.execution_option import ExecutionOption
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
def test_enable_execution_options_registers_summary_flag():
|
||||
parser = CommandArgumentParser()
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
assert "--summary" in parser._flag_map
|
||||
assert "--summary" in parser._keyword
|
||||
assert "--summary" in parser._flag_map
|
||||
assert "summary" in parser._execution_dests
|
||||
|
||||
|
||||
def test_enable_execution_options_registers_retry_flags():
|
||||
parser = CommandArgumentParser()
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.RETRY}))
|
||||
assert "--retries" in parser._flag_map
|
||||
assert "--retries" in parser._keyword
|
||||
assert "--retries" in parser._flag_map
|
||||
assert "retries" in parser._execution_dests
|
||||
assert "--retry-delay" in parser._flag_map
|
||||
assert "--retry-delay" in parser._keyword
|
||||
assert "--retry-delay" in parser._flag_map
|
||||
assert "retry_delay" in parser._execution_dests
|
||||
assert "--retry-backoff" in parser._flag_map
|
||||
assert "--retry-backoff" in parser._keyword
|
||||
assert "--retry-backoff" in parser._flag_map
|
||||
assert "retry_backoff" in parser._execution_dests
|
||||
|
||||
|
||||
def test_enable_execution_options_registers_confirm_flags():
|
||||
parser = CommandArgumentParser()
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM}))
|
||||
assert "--confirm" in parser._flag_map
|
||||
assert "--confirm" in parser._keyword
|
||||
assert "--confirm" in parser._flag_map
|
||||
assert "force_confirm" in parser._execution_dests
|
||||
assert "--skip-confirm" in parser._flag_map
|
||||
assert "--skip-confirm" in parser._keyword
|
||||
assert "--skip-confirm" in parser._flag_map
|
||||
assert "skip_confirm" in parser._execution_dests
|
||||
|
||||
|
||||
def test_register_execution_dest_rejects_duplicates():
|
||||
parser = CommandArgumentParser()
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
):
|
||||
parser.add_argument("--summary", action="store_true")
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
):
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_split_with_execution_options_returns_correct_execution_args():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("foo", type=int, help="A business argument.")
|
||||
parser.add_argument("--bar", type=int, help="A business argument.")
|
||||
parser.enable_execution_options(
|
||||
frozenset({ExecutionOption.SUMMARY, ExecutionOption.RETRY})
|
||||
)
|
||||
|
||||
args, kwargs, execution_args = await parser.parse_args_split(
|
||||
["50", "--bar", "42", "--summary", "--retries", "3"]
|
||||
)
|
||||
|
||||
assert args == (50,)
|
||||
assert kwargs == {"bar": 42}
|
||||
assert execution_args == {
|
||||
"summary": True,
|
||||
"retries": 3,
|
||||
"retry_delay": 0.0,
|
||||
"retry_backoff": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_split_with_all_execution_options_returns_correct_execution_args():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("foo", type=int, help="A business argument.")
|
||||
parser.add_argument("--bar", type=int, help="A business argument.")
|
||||
parser.enable_execution_options(
|
||||
frozenset(
|
||||
{
|
||||
ExecutionOption.SUMMARY,
|
||||
ExecutionOption.RETRY,
|
||||
ExecutionOption.CONFIRM,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
args, kwargs, execution_args = await parser.parse_args_split(
|
||||
[
|
||||
"50",
|
||||
"--bar",
|
||||
"42",
|
||||
"--summary",
|
||||
"--retries",
|
||||
"3",
|
||||
"--confirm",
|
||||
]
|
||||
)
|
||||
|
||||
assert args == (50,)
|
||||
assert kwargs == {"bar": 42}
|
||||
assert execution_args == {
|
||||
"summary": True,
|
||||
"retries": 3,
|
||||
"retry_delay": 0.0,
|
||||
"retry_backoff": 0.0,
|
||||
"force_confirm": True,
|
||||
"skip_confirm": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_split_with_no_execution_options_returns_empty_execution_args():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("foo", type=int, help="A business argument.")
|
||||
parser.add_argument("--bar", type=int, help="A business argument.")
|
||||
|
||||
args, kwargs, execution_args = await parser.parse_args_split(["50", "--bar", "42"])
|
||||
|
||||
assert args == (50,)
|
||||
assert kwargs == {"bar": 42}
|
||||
assert execution_args == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_split_with_conflicting_execution_option_raises():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--summary", action="store_true", help="A conflicting argument.")
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
):
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
241
tests/test_parsers/test_resolve_args.py
Normal file
241
tests/test_parsers/test_resolve_args.py
Normal file
@@ -0,0 +1,241 @@
|
||||
import pytest
|
||||
|
||||
from falyx.command import Command
|
||||
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||
from falyx.execution_option import ExecutionOption
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_separates_business_and_execution_options():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["summary", "retry"],
|
||||
)
|
||||
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||
|
||||
args, kwargs, execution_args = await command.resolve_args(
|
||||
["--foo", "42", "--summary", "--retries", "3"]
|
||||
)
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {"foo": 42}
|
||||
assert execution_args == {
|
||||
"summary": True,
|
||||
"retries": 3,
|
||||
"retry_delay": 0.0,
|
||||
"retry_backoff": 0.0,
|
||||
}
|
||||
|
||||
args, kwargs, execution_args = await command.arg_parser.parse_args_split(
|
||||
["--foo", "42", "--summary", "--retries", "3"]
|
||||
)
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {"foo": 42}
|
||||
assert execution_args == {
|
||||
"summary": True,
|
||||
"retries": 3,
|
||||
"retry_delay": 0.0,
|
||||
"retry_backoff": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_args_split_with_no_execution_options_returns_empty_execution_args():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
)
|
||||
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||
|
||||
args, kwargs, execution_args = await command.arg_parser.parse_args_split(
|
||||
["--foo", "42"]
|
||||
)
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {"foo": 42}
|
||||
assert execution_args == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_raises_on_conflicting_execution_option():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["summary"],
|
||||
)
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
):
|
||||
command.arg_parser.add_argument(
|
||||
"--summary", action="store_true", help="A conflicting argument."
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
):
|
||||
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_mix_of_business_and_execution_options():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["retry"],
|
||||
)
|
||||
command.arg_parser.add_argument("--summary", type=str, help="A business argument.")
|
||||
|
||||
args, kwargs, execution_args = await command.resolve_args(
|
||||
["--summary", "test", "--retries", "5", "--retry-delay", "2"]
|
||||
)
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {"summary": "test"}
|
||||
assert execution_args == {"retries": 5, "retry_delay": 2.0, "retry_backoff": 0.0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_with_no_arguments():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["summary"],
|
||||
)
|
||||
|
||||
args, kwargs, execution_args = await command.resolve_args([])
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {}
|
||||
assert execution_args == {"summary": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_with_confirmation_options():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["confirm"],
|
||||
)
|
||||
|
||||
args, kwargs, execution_args = await command.resolve_args(["--confirm"])
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {}
|
||||
assert execution_args == {"force_confirm": True, "skip_confirm": False}
|
||||
|
||||
args, kwargs, execution_args = await command.resolve_args(["--skip-confirm"])
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {}
|
||||
assert execution_args == {"force_confirm": False, "skip_confirm": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_with_all_execution_options():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["summary", "retry", "confirm"],
|
||||
)
|
||||
|
||||
args, kwargs, execution_args = await command.resolve_args(
|
||||
["--summary", "--retries", "3", "--confirm"]
|
||||
)
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {}
|
||||
assert execution_args == {
|
||||
"summary": True,
|
||||
"retries": 3,
|
||||
"retry_delay": 0.0,
|
||||
"retry_backoff": 0.0,
|
||||
"force_confirm": True,
|
||||
"skip_confirm": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_with_raw_string_input():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["summary"],
|
||||
)
|
||||
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||
|
||||
args, kwargs, execution_args = await command.resolve_args("--foo 42 --summary")
|
||||
|
||||
assert args == ()
|
||||
assert kwargs == {"foo": 42}
|
||||
assert execution_args == {"summary": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_with_no_arg_parser():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["summary"],
|
||||
)
|
||||
command.arg_parser = None
|
||||
|
||||
with pytest.raises(
|
||||
NotAFalyxError,
|
||||
match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
|
||||
):
|
||||
await command.resolve_args("--summary")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_with_custom_parser():
|
||||
def parse_args_split(arg_list):
|
||||
return (arg_list,), {}, {"custom_execution_arg": True}
|
||||
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["summary"],
|
||||
)
|
||||
command.custom_parser = parse_args_split
|
||||
|
||||
args, kwargs, execution_args = await command.resolve_args("--summary")
|
||||
|
||||
assert args == (["--summary"],)
|
||||
assert kwargs == {}
|
||||
assert execution_args == {"custom_execution_arg": True}
|
||||
|
||||
# TODO: is this the right behavior? Should we expect the custom parser to handle non string inputs as well? Does this actually happen?
|
||||
args, kwargs, execution_args = await command.resolve_args(2235235)
|
||||
|
||||
assert args == (2235235,)
|
||||
assert kwargs == {}
|
||||
assert execution_args == {"custom_execution_arg": True}
|
||||
|
||||
with pytest.raises(CommandArgumentError, match="Failed to parse arguments:"):
|
||||
args, kwargs, execution_args = await command.resolve_args("unbalanced 'quotes")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_args_str_unbalanced_quotes():
|
||||
command = Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: None,
|
||||
execution_options=["summary"],
|
||||
)
|
||||
command.arg_parser.add_argument("--foo", type=str, help="A business argument.")
|
||||
|
||||
with pytest.raises(CommandArgumentError, match="Failed to parse arguments:"):
|
||||
await command.resolve_args("--foo 'unbalanced quotes")
|
||||
Reference in New Issue
Block a user