feat(core): centralize command execution and add standalone command runner
- add CommandExecutor to unify shared command execution lifecycle across Falyx and standalone command execution - add CommandRunner for running a single Command directly as a CLI or programmatic entrypoint - add Command.build() factory and rename parse_args() to resolve_args() to clarify the parsing-to-execution boundary - introduce ExecutionOption and wire execution-scoped flags into CommandArgumentParser and Command construction - refactor Falyx to use FalyxParser/ParseResult and CommandExecutor instead of the older argparse-based flow and run_key path - simplify __main__.py bootstrap by building a bootstrap Falyx instance directly and running flx.run() - improve completer support for preview commands and unique-prefix command resolution - default BottomBar toggle namespace to "default" - expand module/class docstrings to reflect the new execution architecture
This commit is contained in:
@@ -431,7 +431,6 @@ async def test_parse_args_flagged_nargs_plus():
|
||||
assert args["files"] == ["a", "b", "c"]
|
||||
|
||||
args = await parser.parse_args(["--files", "a"])
|
||||
print(args)
|
||||
assert args["files"] == ["a"]
|
||||
|
||||
args = await parser.parse_args([])
|
||||
@@ -666,7 +665,7 @@ async def test_parse_args_split_order():
|
||||
cap.add_argument("a")
|
||||
cap.add_argument("--x")
|
||||
cap.add_argument("b", nargs="*")
|
||||
args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"])
|
||||
args, kwargs, _ = await cap.parse_args_split(["1", "--x", "100", "2"])
|
||||
assert args == ("1", ["2"])
|
||||
assert kwargs == {"x": "100"}
|
||||
|
||||
|
||||
@@ -1,57 +1,65 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.completion import Completion
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.completer import FalyxCompleter
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_falyx():
|
||||
fake_arg_parser = SimpleNamespace(
|
||||
suggest_next=lambda tokens, end: ["--tag", "--name", "value with space"]
|
||||
def falyx():
|
||||
flx = Falyx()
|
||||
parser = CommandArgumentParser(
|
||||
command_key="R",
|
||||
command_description="Run Command",
|
||||
)
|
||||
fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser)
|
||||
return SimpleNamespace(
|
||||
exit_command=SimpleNamespace(key="X", aliases=["EXIT"]),
|
||||
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},
|
||||
parser.add_argument(
|
||||
"--tag",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
)
|
||||
flx.add_command(
|
||||
"R",
|
||||
"Run Command",
|
||||
lambda x: None,
|
||||
aliases=["RUN"],
|
||||
arg_parser=parser,
|
||||
)
|
||||
return flx
|
||||
|
||||
|
||||
def test_suggest_commands(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_suggest_commands(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
completions = list(completer._suggest_commands("R"))
|
||||
assert any(c.text == "R" for c in completions)
|
||||
assert any(c.text == "RUN" for c in completions)
|
||||
|
||||
|
||||
def test_suggest_commands_empty(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_suggest_commands_empty(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
completions = list(completer._suggest_commands(""))
|
||||
assert any(c.text == "X" for c in completions)
|
||||
assert any(c.text == "H" for c in completions)
|
||||
|
||||
|
||||
def test_suggest_commands_no_match(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_suggest_commands_no_match(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
completions = list(completer._suggest_commands("Z"))
|
||||
assert not completions
|
||||
|
||||
|
||||
def test_get_completions_no_input(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_get_completions_no_input(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
doc = Document("")
|
||||
results = list(completer.get_completions(doc, None))
|
||||
assert any(isinstance(c, Completion) for c in results)
|
||||
assert any(c.text == "X" for c in results)
|
||||
|
||||
|
||||
def test_get_completions_no_match(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_get_completions_no_match(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
doc = Document("Z")
|
||||
completions = list(completer.get_completions(doc, None))
|
||||
assert not completions
|
||||
@@ -60,38 +68,38 @@ def test_get_completions_no_match(fake_falyx):
|
||||
assert not completions
|
||||
|
||||
|
||||
def test_get_completions_partial_command(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_get_completions_partial_command(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
doc = Document("R")
|
||||
results = list(completer.get_completions(doc, None))
|
||||
assert any(c.text in ("R", "RUN") for c in results)
|
||||
|
||||
|
||||
def test_get_completions_with_flag(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_get_completions_with_flag(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
doc = Document("R ")
|
||||
results = list(completer.get_completions(doc, None))
|
||||
assert "--tag" in [c.text for c in results]
|
||||
|
||||
|
||||
def test_get_completions_partial_flag(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_get_completions_partial_flag(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
doc = Document("R --t")
|
||||
results = list(completer.get_completions(doc, None))
|
||||
assert all(c.start_position <= 0 for c in results)
|
||||
assert any(c.text.startswith("--t") or c.display == "--tag" for c in results)
|
||||
|
||||
|
||||
def test_get_completions_bad_input(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
def test_get_completions_bad_input(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
doc = Document('R "unclosed quote')
|
||||
results = list(completer.get_completions(doc, None))
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_get_completions_exception_handling(fake_falyx):
|
||||
completer = FalyxCompleter(fake_falyx)
|
||||
fake_falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0
|
||||
def test_get_completions_exception_handling(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0
|
||||
doc = Document("R --tag")
|
||||
results = list(completer.get_completions(doc, None))
|
||||
assert results == []
|
||||
|
||||
@@ -5,7 +5,7 @@ from falyx.action import Action
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_key():
|
||||
async def test_execute_command():
|
||||
"""Test if Falyx can run in run key mode."""
|
||||
falyx = Falyx("Run Key Test")
|
||||
|
||||
@@ -17,12 +17,12 @@ async def test_run_key():
|
||||
)
|
||||
|
||||
# Run the CLI
|
||||
result = await falyx.run_key("T")
|
||||
result = await falyx.execute_command("T")
|
||||
assert result == "Hello, World!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_key_recover():
|
||||
async def test_execute_command_recover():
|
||||
"""Test if Falyx can recover from a failure in run key mode."""
|
||||
falyx = Falyx("Run Key Recovery Test")
|
||||
|
||||
@@ -42,5 +42,5 @@ async def test_run_key_recover():
|
||||
retry=True,
|
||||
)
|
||||
|
||||
result = await falyx.run_key("E")
|
||||
result = await falyx.execute_command("E")
|
||||
assert result == "ok"
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
from rich.text import Text
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.console import console
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -8,7 +10,7 @@ async def test_help_command(capsys):
|
||||
flx = Falyx()
|
||||
assert flx.help_command.arg_parser.aliases[0] == "HELP"
|
||||
assert flx.help_command.arg_parser.command_key == "H"
|
||||
await flx.run_key("H")
|
||||
await flx.execute_command("H")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Show this help menu" in captured.out
|
||||
@@ -28,7 +30,7 @@ async def test_help_command_with_new_command(capsys):
|
||||
aliases=["TEST"],
|
||||
help_text="This is a new command.",
|
||||
)
|
||||
await flx.run_key("H")
|
||||
await flx.execute_command("H")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "This is a new command." in captured.out
|
||||
@@ -70,12 +72,14 @@ async def test_help_command_by_tag(capsys):
|
||||
tags=["tag1"],
|
||||
help_text="This command is tagged.",
|
||||
)
|
||||
await flx.run_key("H", args=("tag1",))
|
||||
await flx.execute_command("H -t tag1")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "tag1" in captured.out
|
||||
assert "This command is tagged." in captured.out
|
||||
assert "HELP" not in captured.out
|
||||
print(captured.out)
|
||||
text = Text.from_ansi(captured.out)
|
||||
assert "tag1" in text.plain
|
||||
assert "This command is tagged." in text.plain
|
||||
assert "HELP" not in text.plain
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -88,9 +92,8 @@ async def test_help_command_empty_tags(capsys):
|
||||
flx.add_command(
|
||||
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
|
||||
)
|
||||
await flx.run_key("H", args=("nonexistent_tag",))
|
||||
await flx.execute_command("H nonexistent_tag")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
print(captured.out)
|
||||
assert "nonexistent_tag" in captured.out
|
||||
assert "Nothing to show here" in captured.out
|
||||
text = Text.from_ansi(captured.out)
|
||||
assert "Unexpected positional argument: nonexistent_tag" in text.plain
|
||||
|
||||
@@ -3,17 +3,14 @@ import sys
|
||||
import pytest
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.parser import get_arg_parsers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_basic(capsys):
|
||||
sys.argv = ["falyx", "run", "-h"]
|
||||
falyx_parsers = get_arg_parsers()
|
||||
assert falyx_parsers is not None, "Falyx parsers should be initialized"
|
||||
sys.argv = ["falyx", "-h"]
|
||||
flx = Falyx()
|
||||
with pytest.raises(SystemExit):
|
||||
await flx.run(falyx_parsers)
|
||||
await flx.run()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Run a command by its key or alias." in captured.out
|
||||
assert "Show this help menu." in captured.out
|
||||
|
||||
47
tests/test_falyx_parser/test_root_options.py
Normal file
47
tests/test_falyx_parser/test_root_options.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from falyx import Falyx
|
||||
from falyx.parser.falyx_parser import FalyxParser, RootOptions
|
||||
|
||||
|
||||
def get_falyx_parser():
|
||||
falyx = Falyx()
|
||||
return FalyxParser(falyx=falyx)
|
||||
|
||||
|
||||
def test_parse_root_options_empty():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options([])
|
||||
assert opts == RootOptions()
|
||||
assert remaining == []
|
||||
|
||||
|
||||
def test_parse_root_options_consumes_known_leading_flags():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options(
|
||||
["--verbose", "--never-prompt", "deploy", "--env", "prod"]
|
||||
)
|
||||
assert opts.verbose is True
|
||||
assert opts.never_prompt is True
|
||||
assert remaining == ["deploy", "--env", "prod"]
|
||||
|
||||
|
||||
def test_parse_root_options_stops_at_first_non_root_token():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options(["deploy", "--verbose"])
|
||||
assert opts == RootOptions()
|
||||
assert remaining == ["deploy", "--verbose"]
|
||||
|
||||
|
||||
def test_parse_root_options_supports_help():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options(["--help"])
|
||||
assert opts.help is True
|
||||
assert remaining == []
|
||||
|
||||
|
||||
def test_parse_root_options_supports_double_dash_separator():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options(
|
||||
["--verbose", "--", "deploy", "--verbose"]
|
||||
)
|
||||
assert opts.verbose is True
|
||||
assert remaining == ["deploy", "--verbose"]
|
||||
@@ -1,19 +1,11 @@
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from falyx.__main__ import (
|
||||
bootstrap,
|
||||
find_falyx_config,
|
||||
get_parsers,
|
||||
init_callback,
|
||||
init_config,
|
||||
main,
|
||||
)
|
||||
from falyx.__main__ import bootstrap, find_falyx_config, init_config, main
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
@@ -94,38 +86,10 @@ async def test_init_config():
|
||||
assert args["name"] == "."
|
||||
|
||||
|
||||
def test_init_callback(tmp_path):
|
||||
"""Test if the init_callback function works correctly."""
|
||||
# Test project initialization
|
||||
args = Namespace(command="init", name=str(tmp_path))
|
||||
init_callback(args)
|
||||
assert (tmp_path / "falyx.yaml").exists()
|
||||
|
||||
|
||||
def test_init_global_callback():
|
||||
# Test global initialization
|
||||
args = Namespace(command="init_global")
|
||||
init_callback(args)
|
||||
assert (Path.home() / ".config" / "falyx" / "tasks.py").exists()
|
||||
assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists()
|
||||
|
||||
|
||||
def test_get_parsers():
|
||||
"""Test if the get_parsers function returns the correct parsers."""
|
||||
root_parser, subparsers = get_parsers()
|
||||
assert isinstance(root_parser, ArgumentParser)
|
||||
assert isinstance(subparsers, _SubParsersAction)
|
||||
|
||||
# Check if the 'init' command is available
|
||||
init_parser = subparsers.choices.get("init")
|
||||
assert init_parser is not None
|
||||
assert "name" == init_parser._get_positional_actions()[0].dest
|
||||
|
||||
|
||||
def test_main():
|
||||
"""Test if the main function runs with the correct arguments."""
|
||||
|
||||
sys.argv = ["falyx", "run", "?"]
|
||||
sys.argv = ["falyx", "?"]
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
@@ -71,22 +71,28 @@ async def test_action_with_nargs_positional():
|
||||
return int(a) * int(b)
|
||||
|
||||
action = Action("multiply", multiply)
|
||||
parser.add_argument("mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
|
||||
parser.add_argument(
|
||||
"mul",
|
||||
action=ArgumentAction.ACTION,
|
||||
resolver=action,
|
||||
nargs=2,
|
||||
type=int,
|
||||
)
|
||||
args = await parser.parse_args(["3", "4"])
|
||||
assert args["mul"] == 12
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["3"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args([])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["3", "4", "5"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["--mul", "3", "4"])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args([])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_with_nargs_positional_int():
|
||||
@@ -102,6 +108,9 @@ async def test_action_with_nargs_positional_int():
|
||||
args = await parser.parse_args(["3", "4"])
|
||||
assert args["mul"] == 12
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args([])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["3"])
|
||||
|
||||
@@ -209,11 +218,19 @@ async def test_action_with_default_and_value_not():
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_with_default_and_value_positional():
|
||||
parser = CommandArgumentParser()
|
||||
action = Action("default", lambda: "default_value")
|
||||
parser.add_argument("default", action=ArgumentAction.ACTION, resolver=action)
|
||||
action = Action("action", lambda x: x)
|
||||
parser.add_argument(
|
||||
"default",
|
||||
action=ArgumentAction.ACTION,
|
||||
resolver=action,
|
||||
default="default_value",
|
||||
)
|
||||
|
||||
args = await parser.parse_args([])
|
||||
assert args["default"] == "default_value"
|
||||
|
||||
args = await parser.parse_args(["be"])
|
||||
assert args["default"] == "be"
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args([])
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
await parser.parse_args(["be"])
|
||||
await parser.parse_args(["one", "new_value"])
|
||||
|
||||
@@ -10,7 +10,7 @@ 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_falyx.get_command.return_value = (False, object(), (), {}, {})
|
||||
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||
|
||||
await validator.validate_async(Document("valid"))
|
||||
@@ -20,7 +20,7 @@ async def test_command_validator_validates_command():
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_validator_rejects_invalid_command():
|
||||
fake_falyx = AsyncMock()
|
||||
fake_falyx.get_command.return_value = (False, None, (), {})
|
||||
fake_falyx.get_command.return_value = (False, None, (), {}, {})
|
||||
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
@@ -33,7 +33,7 @@ async def test_command_validator_rejects_invalid_command():
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_validator_is_preview():
|
||||
fake_falyx = AsyncMock()
|
||||
fake_falyx.get_command.return_value = (True, None, (), {})
|
||||
fake_falyx.get_command.return_value = (True, None, (), {}, {})
|
||||
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||
|
||||
await validator.validate_async(Document("?preview_command"))
|
||||
|
||||
Reference in New Issue
Block a user