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:
@@ -1,5 +1,7 @@
|
||||
import pytest
|
||||
from rich.text import Text
|
||||
|
||||
from falyx.console import console as falyx_console
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||
from falyx.signals import HelpSignal
|
||||
@@ -825,4 +827,11 @@ async def test_render_help():
|
||||
parser.add_argument("--foo", type=str, help="Foo help")
|
||||
parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help")
|
||||
|
||||
assert parser.render_help() is None
|
||||
with falyx_console.capture() as capture:
|
||||
parser.render_help()
|
||||
output = Text.from_ansi(capture.get()).plain
|
||||
assert "usage:" in output
|
||||
assert "--foo" in output
|
||||
assert "Foo help" in output
|
||||
assert "--bar" in output
|
||||
assert "Bar help" in output
|
||||
|
||||
@@ -17,7 +17,7 @@ def fake_falyx():
|
||||
help_command=SimpleNamespace(key="H", aliases=["HELP"]),
|
||||
history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]),
|
||||
commands={"R": fake_command},
|
||||
_name_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
|
||||
_entry_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
|
||||
)
|
||||
|
||||
|
||||
|
||||
30
tests/test_execution_option.py
Normal file
30
tests/test_execution_option.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from falyx.execution_option import ExecutionOption
|
||||
|
||||
|
||||
def test_execution_option_accepts_valid_string_values():
|
||||
assert ExecutionOption("summary") == ExecutionOption.SUMMARY
|
||||
assert ExecutionOption("retry") == ExecutionOption.RETRY
|
||||
assert ExecutionOption("confirm") == ExecutionOption.CONFIRM
|
||||
|
||||
|
||||
def test_execution_option_rejects_invalid_string():
|
||||
with pytest.raises(ValueError, match="Invalid ExecutionOption: 'invalid'"):
|
||||
ExecutionOption("invalid")
|
||||
|
||||
|
||||
def test_execution_option_normalizes_case_and_whitespace():
|
||||
assert ExecutionOption(" SUMMARY ") == ExecutionOption.SUMMARY
|
||||
assert ExecutionOption("ReTrY") == ExecutionOption.RETRY
|
||||
assert ExecutionOption("\tconfirm\n") == ExecutionOption.CONFIRM
|
||||
|
||||
|
||||
def test_execution_option_rejects_non_string():
|
||||
with pytest.raises(ValueError, match="Invalid ExecutionOption: 123"):
|
||||
ExecutionOption(123)
|
||||
|
||||
|
||||
def test_execution_option_error_lists_valid_values():
|
||||
with pytest.raises(ValueError, match="Must be one of: summary, retry, confirm"):
|
||||
ExecutionOption("invalid")
|
||||
@@ -51,7 +51,7 @@ async def test_render_help(capsys):
|
||||
aliases=["SC"],
|
||||
help_text="This is a sample command.",
|
||||
)
|
||||
await flx._render_help()
|
||||
await flx.render_help()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "This is a sample command." in captured.out
|
||||
@@ -75,7 +75,6 @@ async def test_help_command_by_tag(capsys):
|
||||
await flx.execute_command("H -t tag1")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
print(captured.out)
|
||||
text = Text.from_ansi(captured.out)
|
||||
assert "tag1" in text.plain
|
||||
assert "This command is tagged." in text.plain
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from falyx import Falyx
|
||||
from falyx.parser.falyx_parser import FalyxParser, RootOptions
|
||||
|
||||
|
||||
def get_falyx_parser():
|
||||
falyx = Falyx()
|
||||
return FalyxParser(falyx=falyx)
|
||||
return FalyxParser()
|
||||
|
||||
|
||||
def test_parse_root_options_empty():
|
||||
|
||||
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")
|
||||
516
tests/test_runner/test_command_runner.py
Normal file
516
tests/test_runner/test_command_runner.py
Normal file
@@ -0,0 +1,516 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from falyx.action import Action
|
||||
from falyx.command import Command
|
||||
from falyx.command_runner import CommandRunner
|
||||
from falyx.console import console as falyx_console
|
||||
from falyx.exceptions import CommandArgumentError, FalyxError, NotAFalyxError
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||
|
||||
|
||||
async def ok_action(*args, **kwargs):
|
||||
falyx_console.print("Action executed with args:", args, "and kwargs:", kwargs)
|
||||
return "ok"
|
||||
|
||||
|
||||
async def failing_action(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
async def throw_error_action(error: str):
|
||||
if error == "QuitSignal":
|
||||
raise QuitSignal("Quit signal triggered.")
|
||||
elif error == "BackSignal":
|
||||
raise BackSignal("Back signal triggered.")
|
||||
elif error == "CancelSignal":
|
||||
raise CancelSignal("Cancel signal triggered.")
|
||||
elif error == "ValueError":
|
||||
raise ValueError("This is a ValueError.")
|
||||
elif error == "HelpSignal":
|
||||
raise HelpSignal("Help signal triggered.")
|
||||
elif error == "FalyxError":
|
||||
raise FalyxError("This is a FalyxError.")
|
||||
else:
|
||||
raise asyncio.CancelledError("An error occurred in the action.")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_throwing_error():
|
||||
command = Command(
|
||||
key="E",
|
||||
description="Error Command",
|
||||
action=Action("throw_error", throw_error_action),
|
||||
execution_options=["retry"],
|
||||
)
|
||||
return command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_with_parser():
|
||||
command = Command(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=ok_action,
|
||||
)
|
||||
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||
return command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_with_no_parser():
|
||||
command = Command(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=ok_action,
|
||||
execution_options=["summary"],
|
||||
)
|
||||
command.arg_parser = None
|
||||
return command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_with_custom_parser():
|
||||
def parse_args_split(arg_list):
|
||||
return (arg_list,), {}, {"custom_execution_arg": True}
|
||||
|
||||
command = Command(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=ok_action,
|
||||
execution_options=["summary"],
|
||||
)
|
||||
command.custom_parser = parse_args_split
|
||||
return command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_with_failing_action():
|
||||
command = Command(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=failing_action,
|
||||
execution_options=["summary", "retry"],
|
||||
)
|
||||
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
|
||||
return command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_build_with_all_execution_options():
|
||||
return Command.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=ok_action,
|
||||
execution_options=["summary", "retry", "confirm"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console():
|
||||
return Console(record=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_initialization(
|
||||
command_with_parser,
|
||||
command_with_no_parser,
|
||||
command_with_custom_parser,
|
||||
):
|
||||
runner = CommandRunner(command_with_parser)
|
||||
assert runner.command == command_with_parser
|
||||
assert isinstance(runner.options, OptionsManager)
|
||||
assert isinstance(runner.runner_hooks, HookManager)
|
||||
assert runner.console == falyx_console
|
||||
assert runner.command.options_manager == runner.options
|
||||
assert runner.command.arg_parser.options_manager == runner.options
|
||||
assert runner.command.options_manager == runner.options
|
||||
assert runner.executor.options == runner.options
|
||||
assert runner.executor.hooks == runner.runner_hooks
|
||||
assert runner.executor.console == runner.console
|
||||
assert runner.options.get("summary", namespace_name="execution") is None
|
||||
|
||||
runner_no_parser = CommandRunner(command_with_no_parser)
|
||||
assert runner_no_parser.command == command_with_no_parser
|
||||
assert runner_no_parser.command.arg_parser is None
|
||||
|
||||
CommandRunner(command_with_no_parser)
|
||||
with pytest.raises(
|
||||
NotAFalyxError,
|
||||
match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
|
||||
):
|
||||
await runner_no_parser.run("--summary")
|
||||
|
||||
runner_custom_parser = CommandRunner(command_with_custom_parser)
|
||||
assert runner_custom_parser.command == command_with_custom_parser
|
||||
assert runner_custom_parser.command.custom_parser is not None
|
||||
|
||||
|
||||
def test_command_runner_initialization_with_custom_options(command_with_parser):
|
||||
custom_options = OptionsManager([("default", {"summary": True})])
|
||||
runner = CommandRunner(command_with_parser, options=custom_options)
|
||||
assert runner.options == custom_options
|
||||
assert runner.options.get("summary", namespace_name="default") is True
|
||||
assert runner.command.options_manager == runner.options
|
||||
assert runner.command.arg_parser.options_manager == runner.options
|
||||
assert runner.command.options_manager == runner.options
|
||||
|
||||
|
||||
def test_command_runner_initialization_with_custom_console(command_with_parser):
|
||||
custom_console = Console()
|
||||
runner = CommandRunner(command_with_parser, console=custom_console)
|
||||
assert runner.console == custom_console
|
||||
assert runner.executor.console == custom_console
|
||||
|
||||
|
||||
def test_command_runner_initialization_with_custom_hooks(command_with_parser):
|
||||
custom_hooks = HookManager()
|
||||
custom_hooks.register("before", lambda context: print("Before hook"))
|
||||
runner = CommandRunner(command_with_parser, runner_hooks=custom_hooks)
|
||||
assert runner.runner_hooks == custom_hooks
|
||||
assert runner.executor.hooks == custom_hooks
|
||||
assert runner.runner_hooks._hooks[HookType.BEFORE]
|
||||
|
||||
|
||||
def test_command_runner_initialization_with_all_bad_components(command_with_parser):
|
||||
custom_options = "Not an OptionsManager"
|
||||
custom_console = 23456
|
||||
custom_hooks = "Not a HookManager"
|
||||
|
||||
with pytest.raises(
|
||||
NotAFalyxError, match="options must be an instance of OptionsManager"
|
||||
):
|
||||
CommandRunner(
|
||||
command_with_parser,
|
||||
options=custom_options,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
NotAFalyxError, match="console must be an instance of rich.Console"
|
||||
):
|
||||
CommandRunner(
|
||||
command_with_parser,
|
||||
console=custom_console,
|
||||
)
|
||||
|
||||
with pytest.raises(NotAFalyxError, match="hooks must be an instance of HookManager"):
|
||||
CommandRunner(
|
||||
command_with_parser,
|
||||
runner_hooks=custom_hooks,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_run(command_with_parser):
|
||||
runner = CommandRunner(command_with_parser)
|
||||
with falyx_console.capture() as capture:
|
||||
result = await runner.run("--foo 42")
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert result == "ok"
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "{'foo': 42}" in captured
|
||||
|
||||
falyx_console.clear()
|
||||
with falyx_console.capture() as capture:
|
||||
result = await runner.run(["--foo", "123"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert result == "ok"
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "{'foo': 123}" in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_run_with_failing_action(command_with_failing_action):
|
||||
runner = CommandRunner(command_with_failing_action)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await runner.run("--foo 42")
|
||||
|
||||
with pytest.raises(FalyxError, match="boom"):
|
||||
await runner.run("--foo 42", wrap_errors=True)
|
||||
|
||||
assert await runner.run("--foo 42", wrap_errors=False, raise_on_error=False) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_debug_statement(command_with_parser, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
runner = CommandRunner(command_with_parser)
|
||||
await runner.run("--foo 42")
|
||||
assert (
|
||||
"Executing command 'Test Command' with args=(), kwargs={'foo': 42}" in caplog.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_run_with_retries_non_action(
|
||||
command_with_failing_action, caplog
|
||||
):
|
||||
runner = CommandRunner(command_with_failing_action)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await runner.run("--foo 42 --retries 2")
|
||||
|
||||
assert "Retry requested, but action is not an Action instance." in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_run_with_retries_with_action(
|
||||
command_throwing_error, caplog
|
||||
):
|
||||
runner = CommandRunner(command_throwing_error)
|
||||
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
|
||||
await runner.run("Other")
|
||||
|
||||
with pytest.raises(ValueError, match="This is a ValueError."):
|
||||
await runner.run("ValueError --retries 2")
|
||||
|
||||
assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text
|
||||
assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text
|
||||
assert "[throw_error] All 2 retries failed." in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_run_from_command_build_with_all_execution_options(
|
||||
command_build_with_all_execution_options,
|
||||
):
|
||||
runner = CommandRunner.from_command(command_build_with_all_execution_options)
|
||||
with falyx_console.capture() as capture:
|
||||
result = await runner.run("--summary")
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert result == "ok"
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "Execution History" in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
result = await runner.run("--summary", summary_last_result=True)
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert result == "ok"
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "Command(key='T', description='Test Command' action=" in captured
|
||||
assert "ok" in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
result = await runner.run("--summary", summary_last_result=False)
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert result == "ok"
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "Execution History" in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_from_command_bad_command():
|
||||
with pytest.raises(NotAFalyxError, match="command must be an instance of Command"):
|
||||
CommandRunner.from_command("Not a Command")
|
||||
|
||||
with pytest.raises(
|
||||
NotAFalyxError, match="runner_hooks must be an instance of HookManager"
|
||||
):
|
||||
CommandRunner.from_command(
|
||||
Command(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=ok_action,
|
||||
),
|
||||
runner_hooks="Not a HookManager",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_build():
|
||||
runner = CommandRunner.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=ok_action,
|
||||
execution_options=["summary", "retry"],
|
||||
)
|
||||
assert isinstance(runner, CommandRunner)
|
||||
with falyx_console.capture() as capture:
|
||||
result = await runner.run("--summary --retries 2")
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert result == "ok"
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "Execution History" in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_build_with_bad_execution_options():
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Invalid ExecutionOption: 'invalid_option'. Must be one of:",
|
||||
):
|
||||
CommandRunner.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=ok_action,
|
||||
execution_options=["summary", "invalid_option"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_build_with_bad_runner_hooks():
|
||||
with pytest.raises(
|
||||
NotAFalyxError, match="runner_hooks must be an instance of HookManager"
|
||||
):
|
||||
CommandRunner.build(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=ok_action,
|
||||
runner_hooks="Not a HookManager",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_uses_sys_argv(command_with_parser, monkeypatch):
|
||||
runner = CommandRunner(command_with_parser)
|
||||
test_args = ["program_name", "--foo", "42"]
|
||||
monkeypatch.setattr(sys, "argv", test_args)
|
||||
with falyx_console.capture() as capture:
|
||||
result = await runner.run()
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert result == "ok"
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "{'foo': 42}" in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_cli(command_with_parser):
|
||||
runner = CommandRunner(command_with_parser)
|
||||
with falyx_console.capture() as capture:
|
||||
await runner.cli("--foo 42")
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "{'foo': 42}" in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runnner_run_propogates_exeptions(command_throwing_error):
|
||||
runner = CommandRunner(command_throwing_error)
|
||||
|
||||
with pytest.raises(QuitSignal, match="Quit signal triggered."):
|
||||
await runner.run("QuitSignal")
|
||||
|
||||
with pytest.raises(BackSignal, match="Back signal triggered."):
|
||||
await runner.run("BackSignal")
|
||||
|
||||
with pytest.raises(CancelSignal, match="Cancel signal triggered."):
|
||||
await runner.run("CancelSignal")
|
||||
|
||||
with pytest.raises(ValueError, match="This is a ValueError."):
|
||||
await runner.run("ValueError")
|
||||
|
||||
with pytest.raises(HelpSignal, match="Help signal triggered."):
|
||||
await runner.run("HelpSignal")
|
||||
|
||||
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
|
||||
await runner.run("Other")
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError,
|
||||
match=r"\[E\] Failed to parse arguments: No closing quotation",
|
||||
):
|
||||
await runner.run("Mismatched'")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_cli_with_failing_action(command_with_failing_action):
|
||||
runner = CommandRunner(command_with_failing_action)
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli("--foo 42")
|
||||
|
||||
with pytest.raises(SystemExit, match="2"):
|
||||
await runner.cli("--foo 42 --bar 123")
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await runner.cli(["--help"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
|
||||
assert "usage: falyx T" in captured
|
||||
assert "--foo" in captured
|
||||
assert "summary" in captured
|
||||
assert "retries" in captured
|
||||
assert "A business argument." in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_cli_exceptions(command_throwing_error):
|
||||
runner = CommandRunner(command_throwing_error)
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await runner.cli(["--help"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "falyx E [--help]" in captured
|
||||
assert "usage:" in captured
|
||||
assert "positional:" in captured
|
||||
assert "options:" in captured
|
||||
assert "❌" not in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="2"):
|
||||
await runner.cli(["--not-an-arg"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "falyx E [--help]" in captured
|
||||
assert "usage:" in captured
|
||||
assert "positional:" in captured
|
||||
assert "options:" in captured
|
||||
assert "❌" in captured
|
||||
falyx_console.clear()
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli(["FalyxError"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "This is a FalyxError." in captured
|
||||
assert "❌ Error:" in captured
|
||||
falyx_console.clear()
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="130"):
|
||||
await runner.cli(["QuitSignal"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "❌" not in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli(["BackSignal"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "❌" not in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli(["CancelSignal"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "❌" not in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli(["Other"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "❌" not in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch):
|
||||
runner = CommandRunner(command_with_parser)
|
||||
test_args = ["program_name", "--foo", "42"]
|
||||
monkeypatch.setattr(sys, "argv", test_args)
|
||||
with falyx_console.capture() as capture:
|
||||
await runner.cli()
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "{'foo': 42}" in captured
|
||||
@@ -1,42 +1,49 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.validation import ValidationError
|
||||
|
||||
from falyx.routing import RouteKind
|
||||
from falyx.validators import CommandValidator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_validator_validates_command():
|
||||
fake_falyx = AsyncMock()
|
||||
fake_falyx.get_command.return_value = (False, object(), (), {}, {})
|
||||
fake_route = SimpleNamespace()
|
||||
fake_route.is_preview = False
|
||||
fake_route.kind = RouteKind.NAMESPACE_HELP
|
||||
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
|
||||
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||
|
||||
await validator.validate_async(Document("valid"))
|
||||
fake_falyx.get_command.assert_awaited_once()
|
||||
fake_falyx.prepare_route.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_validator_rejects_invalid_command():
|
||||
fake_falyx = AsyncMock()
|
||||
fake_falyx.get_command.return_value = (False, None, (), {}, {})
|
||||
fake_falyx.prepare_route.return_value = (None, (), {}, {})
|
||||
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
await validator.validate_async(Document("not_a_command"))
|
||||
await validator.validate_async(Document(""))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
await validator.validate_async(Document(""))
|
||||
await validator.validate_async(Document("not_a_command"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_validator_is_preview():
|
||||
fake_falyx = AsyncMock()
|
||||
fake_falyx.get_command.return_value = (True, None, (), {}, {})
|
||||
fake_route = SimpleNamespace()
|
||||
fake_route.is_preview = True
|
||||
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
|
||||
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||
|
||||
await validator.validate_async(Document("?preview_command"))
|
||||
fake_falyx.get_command.assert_awaited_once_with(
|
||||
fake_falyx.prepare_route.assert_awaited_once_with(
|
||||
"?preview_command", from_validate=True
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user