feat(core): advance options/state handling and workflow execution integration

- extend OptionsManager to support multi-namespace option resolution and toggling
- integrate OptionsManager more deeply across Action, ChainedAction, and ActionGroup
- propagate shared runtime configuration through execution layers
- refine action composition model (sequential + parallel execution semantics)
- improve lifecycle consistency across BaseAction, Action, ChainedAction, and ActionGroup
- begin aligning execution flow with centralized context and options handling

wip: routing and root option parsing behavior still in progress
This commit is contained in:
2026-05-10 13:48:06 -04:00
parent cce92cca09
commit 8db7a9e6dc
47 changed files with 2886 additions and 1089 deletions

View File

@@ -0,0 +1,100 @@
import pytest
from rich.text import Text
from falyx.action import LoadFileAction
from falyx.console import console as falyx_console
@pytest.mark.asyncio
async def test_load_json_file_action(tmp_path):
mock_data = '{"key": "value"}'
file = tmp_path / "test.json"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
result = await action()
assert result == {"key": "value"}
@pytest.mark.asyncio
async def test_load_yaml_file_action(tmp_path):
mock_data = "key: value"
file = tmp_path / "test.yaml"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="yaml")
result = await action()
assert result == {"key": "value"}
@pytest.mark.asyncio
async def test_load_toml_file_action(tmp_path):
mock_data = 'key = "value"'
file = tmp_path / "test.toml"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="toml")
result = await action()
assert result == {"key": "value"}
@pytest.mark.asyncio
async def test_load_csv_file_action(tmp_path):
mock_data = "key,value\nfoo,bar"
file = tmp_path / "test.csv"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="csv")
result = await action()
print(result)
assert result == [["key", "value"], ["foo", "bar"]]
@pytest.mark.asyncio
async def test_load_tsv_file_action(tmp_path):
mock_data = "key\tvalue\nfoo\tbar"
file = tmp_path / "test.tsv"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="tsv")
result = await action()
assert result == [["key", "value"], ["foo", "bar"]]
@pytest.mark.asyncio
async def test_load_file_action_invalid_path():
action = LoadFileAction(
name="load-file", file_path="non_existent_file.json", file_type="json"
)
with pytest.raises(FileNotFoundError):
await action()
@pytest.mark.asyncio
async def test_load_file_action_invalid_json(tmp_path):
invalid_json = '{"key": "value"' # Missing closing brace
file = tmp_path / "invalid.json"
file.write_text(invalid_json)
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
with pytest.raises(ValueError):
await action()
@pytest.mark.asyncio
async def test_load_file_action_unsupported_type(tmp_path):
file = tmp_path / "test.txt"
file.write_text("Just some text")
with pytest.raises(ValueError):
LoadFileAction(name="load-file", file_path=file, file_type="unsupported")
@pytest.mark.asyncio
async def test_preview_of_load_file_action(tmp_path):
mock_data = '{"key": "value"}'
file = tmp_path / "test.json"
file.write_text(mock_data)
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
with falyx_console.capture() as capture:
await action.preview()
captured = Text.from_ansi(capture.get()).plain
assert "LoadFileAction" in captured
assert "test.json" in captured
assert "load-file" in captured
assert "JSON" in captured
assert "key" in captured
assert "value" in captured

View File

@@ -1,5 +1,6 @@
# test_command.py
import pytest
from pydantic import ValidationError
from falyx.action import Action, BaseIOAction, ChainedAction
from falyx.command import Command
@@ -172,3 +173,15 @@ def test_command_bad_action():
with pytest.raises(TypeError) as exc_info:
Command(key="TEST", description="Test Command", action="not_callable")
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"
def test_command_bad_options_manager():
"""Test if Command raises an exception when options_manager is not a dict or callable."""
with pytest.raises(ValidationError) as exc_info:
Command(
key="TEST",
description="Test Command",
action=dummy_action,
options_manager="not_a_dict_or_callable",
)
assert "Input should be an instance of OptionsManager" in str(exc_info.value)

View File

@@ -118,6 +118,19 @@ def test_get_completions_namespace_boundary_suggests_help_flags(falyx):
results = list(completer.get_completions(Document("OPS -"), None))
texts = completion_texts(results)
assert "-h" in texts
assert "--help" in texts
assert "-T" not in texts
assert "--tldr" not in texts
falyx.add_tldr_example(
entry_key="R",
usage="",
description="This is a TLDR example for the R command.",
)
results = list(completer.get_completions(Document("-"), None))
texts = completion_texts(results)
assert "-h" in texts
assert "--help" in texts
assert "-T" in texts
@@ -247,3 +260,46 @@ def test_ensure_quote_wraps_whitespace(falyx):
assert completer._ensure_quote("hello world") == '"hello world"'
assert completer._ensure_quote("hello") == "hello"
def test_command_suggestions_are_case_insensitive(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("r"), None))
texts = completion_texts(results)
assert "r" in texts
assert "run" in texts
results = list(completer.get_completions(Document("R"), None))
texts = completion_texts(results)
assert "R" in texts
assert "RUN" in texts
def test_namespace_suggestions_are_case_insensitive(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("op"), None))
texts = completion_texts(results)
assert "ops" in texts
assert "operations" in texts
results = list(completer.get_completions(Document("OP"), None))
texts = completion_texts(results)
assert "OPS" in texts
assert "OPERATIONS" in texts
def test_command_completions_after_namespace(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("OPS D --"), None))
texts = completion_texts(results)
assert "--target" in texts
assert "--region" in texts
assert "--help" in texts

View File

@@ -2,7 +2,7 @@ import pytest
from rich.text import Text
from falyx import Falyx
from falyx.console import console
from falyx.exceptions import CommandArgumentError
@pytest.mark.asyncio
@@ -82,17 +82,14 @@ async def test_help_command_by_tag(capsys):
@pytest.mark.asyncio
async def test_help_command_empty_tags(capsys):
async def test_help_command_bad_argument(capsys):
flx = Falyx()
async def untagged_command(falyx: Falyx):
pass
flx.add_command(
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
)
await flx.execute_command("H nonexistent_tag")
captured = capsys.readouterr()
text = Text.from_ansi(captured.out)
assert "Unexpected positional argument: nonexistent_tag" in text.plain
flx.add_command("U", "Untagged Command", untagged_command)
with pytest.raises(
CommandArgumentError, match="Unexpected positional argument: nonexistent_tag"
):
await flx.execute_command("H nonexistent_tag")

View File

View File

@@ -1,8 +1,72 @@
import asyncio
import sys
import pytest
from rich.text import Text
from falyx import Falyx
from falyx.console import console as falyx_console
from falyx.exceptions import FalyxError
from falyx.parser import ParseResult
from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
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.")
elif error == "FlowSignal":
raise FlowSignal("Flow signal triggered.")
else:
raise asyncio.CancelledError("An error occurred in the action.")
@pytest.fixture
def flx() -> Falyx:
sys.argv = ["falyx", "T"]
flx = Falyx()
flx.add_command(
"T",
"Test",
action=lambda: "hello",
)
flx.add_tldr_example(
entry_key="T",
usage="",
description="This is a TLDR example for the T command.",
)
return flx
@pytest.fixture
def flx_with_submenu() -> Falyx:
flx = Falyx()
submenu = Falyx("Submenu")
submenu.add_command(
"T",
"Test",
action=lambda: "hello from submenu",
)
submenu.add_tldr_example(
entry_key="T",
usage="",
description="This is a TLDR example for the T command in the submenu.",
)
flx.add_submenu(
"S",
"Submenu",
submenu=submenu,
)
return flx
@pytest.mark.asyncio
@@ -14,3 +78,178 @@ async def test_run_basic(capsys):
captured = capsys.readouterr()
assert "Show this help menu." in captured.out
@pytest.mark.asyncio
async def test_run_default_to_menu(flx):
sys.argv = ["falyx", "T"]
flx.default_to_menu = False
with pytest.raises(SystemExit):
await flx.run()
await flx.run(always_start_menu=True)
@pytest.mark.asyncio
async def test_run_default_to_menu_help(flx):
sys.argv = ["falyx"]
flx.default_to_menu = False
with pytest.raises(SystemExit, match="0"):
with falyx_console.capture() as capture:
await flx.run()
captured = Text.from_ansi(capture.get()).plain
assert "Show this help menu." in captured
@pytest.mark.asyncio
async def test_run_debug_hooks(flx):
sys.argv = ["falyx", "--debug-hooks", "T"]
assert flx.options.get("debug_hooks") is False
with pytest.raises(SystemExit):
await flx.run()
assert flx.options.get("debug_hooks") is True
@pytest.mark.asyncio
async def test_run_never_prompt(flx):
sys.argv = ["falyx", "--never-prompt", "T"]
assert flx.options.get("never_prompt") is False
with pytest.raises(SystemExit):
await flx.run()
falyx_console.print(flx.options.get_namespace_dict("default"))
assert flx.options.get("debug_hooks") is False
assert flx.options.get("never_prompt") is True
@pytest.mark.asyncio
async def test_run_bad_args(flx):
sys.argv = ["falyx", "T", "--unknown-arg"]
with pytest.raises(SystemExit, match="2"):
await flx.run()
@pytest.mark.asyncio
async def test_run_help(flx):
sys.argv = ["falyx", "T", "--help"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
sys.argv = ["falyx", "--help"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
sys.argv = ["falyx", "-h"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
sys.argv = ["falyx", "--tldr"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
sys.argv = ["falyx", "-T"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
@pytest.mark.asyncio
async def test_run_entry_not_found(flx):
sys.argv = ["falyx", "UNKNOWN_COMMAND"]
with pytest.raises(SystemExit, match="2"):
await flx.run()
@pytest.mark.asyncio
async def test_run_test_exceptions(flx):
flx.add_command(
"E",
"Throw Error",
action=throw_error_action,
)
sys.argv = ["falyx", "E", "ValueError"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "QuitSignal"]
with pytest.raises(SystemExit, match="130"):
await flx.run()
sys.argv = ["falyx", "E", "BackSignal"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "CancelSignal"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "HelpSignal"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "FlowSignal"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "--verbose", "E", "FalyxError"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "UnknownError"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
@pytest.mark.asyncio
async def test_run_no_args(flx):
sys.argv = ["falyx"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
@pytest.mark.asyncio
async def test_run_submenu(flx_with_submenu):
sys.argv = ["falyx", "S", "T"]
with pytest.raises(SystemExit, match="0"):
await flx_with_submenu.run()
@pytest.mark.asyncio
async def test_run_submenu_help(flx_with_submenu):
sys.argv = ["falyx", "S", "--help"]
with pytest.raises(SystemExit, match="0"):
await flx_with_submenu.run()
@pytest.mark.asyncio
async def test_run_submenu_tldr(flx_with_submenu):
sys.argv = ["falyx", "S", "--tldr"]
with pytest.raises(SystemExit, match="0"):
await flx_with_submenu.run()
@pytest.mark.asyncio
async def test_run_preview(flx):
sys.argv = ["falyx", "preview", "T"]
with pytest.raises(SystemExit, match="0"):
with falyx_console.capture() as capture:
await flx.run()
captured = Text.from_ansi(capture.get()).plain
assert "Command: 'T'" in captured
assert "Would call: <lambda>(args=(), kwargs={})" in captured

View File

@@ -1,45 +0,0 @@
from falyx.parser.falyx_parser import FalyxParser, RootOptions
def get_falyx_parser():
return FalyxParser()
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"]

View File

@@ -1,8 +1,10 @@
import pytest
from rich.text import Text
from falyx.action import Action
from falyx.console import console as falyx_console
from falyx.exceptions import CommandArgumentError
from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.options_manager import OptionsManager
from falyx.parser import ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal
@@ -835,3 +837,175 @@ async def test_render_help():
assert "Foo help" in output
assert "--bar" in output
assert "Bar help" in output
def test_command_argument_parser_set_options_manager_invalid():
parser = CommandArgumentParser()
with pytest.raises(NotAFalyxError):
parser.set_options_manager("not_a_options_manager")
with pytest.raises(NotAFalyxError):
parser.set_options_manager(123)
with pytest.raises(NotAFalyxError):
parser.set_options_manager(None)
def test_command_argument_parser_set_options_manager_valid():
parser = CommandArgumentParser()
options_manager = OptionsManager([("new_namespace", {"foo": "bar"})])
parser.set_options_manager(options_manager)
assert parser.options_manager == options_manager
assert parser.options_manager.get("foo", namespace_name="new_namespace") == "bar"
def test_add_argument_invalid_required():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action=ArgumentAction.STORE_TRUE, required=True)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action=ArgumentAction.STORE_FALSE, required=True)
with pytest.raises(CommandArgumentError):
parser.add_argument(
"--foo", action=ArgumentAction.STORE_BOOL_OPTIONAL, required=True
)
def test_add_argument_invalid_choices():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_true", choices="not_a_list")
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", choices=123)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", choices={"a": 1, "b": 2})
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", choices=["a", "b"], type=int)
def test_add_argument_resolver_invalid():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", resolver=lambda x: x)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", resolver=123)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="action", resolver="not_a_function")
def test_add_argument_resolver_valid():
parser = CommandArgumentParser()
parser.add_argument(
"--foo", action="action", resolver=Action("test", lambda x: x.upper())
)
def test_add_argument_resolve_invalid_default():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_true", default="any value")
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_false", default=False)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_true", default=True)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_bool_optional", default=False)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="count", default=500)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="append", default="not a list")
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="extend", default="not a list")
with pytest.raises(CommandArgumentError):
parser.add_argument("--count", action="count", default=0)
@pytest.mark.asyncio
async def test_add_argument_resolve_valid_default():
parser = CommandArgumentParser()
parser.add_argument("--foo", action="store_true", default=False)
parser.add_argument("--bar", action="store_false", default=True)
parser.add_argument("--baz", action="store_bool_optional", default=None)
parser.add_argument("--items", action="append", default=[])
parser.add_argument("--values", action="extend", default=[])
parser.add_argument("--number", action="store", nargs=1, type=int, default=0)
result = await parser.parse_args(["--number", "5"])
assert result["foo"] is False
assert result["bar"] is True
assert result["baz"] is None
assert result["items"] == []
assert result["values"] == []
assert result["number"] == 5
def test_add_argument_in_reserved_dests():
parser = CommandArgumentParser()
with pytest.raises(
CommandArgumentError,
match="invalid dest .*'help' is reserved and cannot be used.",
):
parser.add_argument("--help")
with pytest.raises(
CommandArgumentError,
match="invalid dest .*'tldr' is reserved and cannot be used.",
):
parser.add_argument("--tldr")
def test_add_argument_in_reserved_dests_positional():
parser = CommandArgumentParser()
with pytest.raises(
CommandArgumentError,
match="invalid dest .*'help' is reserved and cannot be used.",
):
parser.add_argument("help")
with pytest.raises(
CommandArgumentError,
match="invalid dest .*'tldr' is reserved and cannot be used.",
):
parser.add_argument("tldr")
def test_add_argument_invalid_suggestions():
parser = CommandArgumentParser()
with pytest.raises(
CommandArgumentError, match="suggestions must be a list or None, got int"
):
parser.add_argument("--valid", suggestions=112445)
def test_add_argument_invalid_lazy_resolver():
parser = CommandArgumentParser()
with pytest.raises(
CommandArgumentError, match="lazy_resolver must be a boolean, got int"
):
parser.add_argument("--valid", lazy_resolver=123)

View File

@@ -31,6 +31,21 @@ def test_enable_execution_options_registers_retry_flags():
assert "retry_backoff" in parser._execution_dests
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,
match="destination 'summary' is already registered as an execution argument",
):
parser._register_execution_dest("summary")
def test_enable_execution_options_registers_confirm_flags():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM}))
@@ -48,12 +63,12 @@ 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"
CommandArgumentError, match="destination 'summary' is already defined"
):
parser.add_argument("--summary", action="store_true")
with pytest.raises(
CommandArgumentError, match="Destination 'summary' is already defined"
CommandArgumentError, match="destination 'summary' is already defined"
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
@@ -138,6 +153,6 @@ 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"
CommandArgumentError, match="destination 'summary' is already defined"
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))

View File

@@ -0,0 +1,96 @@
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parser import CommandArgumentParser
from falyx.parser.command_argument_parser import _GroupBuilder
def test_group_builder():
parser = CommandArgumentParser(program="test_program")
group_builder = _GroupBuilder(parser, group_name="test_group")
assert group_builder.group_name == "test_group"
assert "group='test_group'" in str(group_builder)
group_builder = _GroupBuilder(
parser,
mutex_name="test_group",
)
assert group_builder.mutex_name == "test_group"
assert "mutex_group='test_group'" in str(group_builder)
with pytest.raises(CommandArgumentError):
_GroupBuilder(parser, group_name="test_group", mutex_name="test_group")
with pytest.raises(CommandArgumentError):
_GroupBuilder(parser)
with pytest.raises(AssertionError):
builder = _GroupBuilder(parser, group_name="test_group")
builder.group_name = None
builder.mutex_name = None
str(builder)
def test_adding_arguments_to_group():
parser = CommandArgumentParser(program="test_program")
group = parser.add_argument_group("test_group")
assert group.group_name == "test_group"
group.add_argument("--foo", type=str, help="Foo argument")
group.add_argument("--bar", type=int, help="Bar argument")
with pytest.raises(CommandArgumentError):
parser.add_argument_group("test_group")
def test_adding_arguments_to_mutex_group():
parser = CommandArgumentParser(program="test_program")
mutex_group = parser.add_mutually_exclusive_group("test_mutex_group")
assert mutex_group.mutex_name == "test_mutex_group"
mutex_group.add_argument("--foo", type=str, help="Foo argument")
mutex_group.add_argument("--bar", type=int, help="Bar argument")
with pytest.raises(CommandArgumentError):
parser.add_mutually_exclusive_group("test_mutex_group")
def test_adding_arguments_to_group_with_invalid_group():
parser = CommandArgumentParser(program="test_program")
with pytest.raises(CommandArgumentError):
parser.add_argument(
"--foo", type=str, help="Foo argument", group="non_existent_group"
)
with pytest.raises(CommandArgumentError):
parser.add_argument(
"--bar", type=int, help="Bar argument", mutex_group="non_existent_group"
)
def test_adding_positional_arguments_to_mutex_group():
parser = CommandArgumentParser(program="test_program")
group = parser.add_mutually_exclusive_group("test_group")
with pytest.raises(CommandArgumentError):
group.add_argument(
"positional_arg", type=str, help="This should fail because it's positional"
)
def test_adding_required_arguments_to_mutex_group():
parser = CommandArgumentParser(program="test_program")
group = parser.add_mutually_exclusive_group("test_group")
with pytest.raises(CommandArgumentError):
group.add_argument(
"--foo",
type=str,
help="This should fail because it's required",
required=True,
)

View File

@@ -69,14 +69,14 @@ async def test_resolve_args_raises_on_conflicting_execution_option():
execution_options=["summary"],
)
with pytest.raises(
CommandArgumentError, match="Destination 'summary' is already defined"
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"
CommandArgumentError, match="destination 'summary' is already defined"
):
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))

View File

@@ -2,6 +2,7 @@ import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.parser.parser_types import TLDRExample
@pytest.mark.asyncio
@@ -45,3 +46,27 @@ async def test_add_tldr_examples_in_init():
assert parser._tldr_examples[0].description == "This is the first example."
assert parser._tldr_examples[1].usage == "example2"
assert parser._tldr_examples[1].description == "This is the second example."
def test_add_tldr_example():
parser = CommandArgumentParser()
parser.add_tldr_example("example1", "This is the first example.")
assert len(parser._tldr_examples) == 1
assert parser._tldr_examples[0].usage == "example1"
assert parser._tldr_examples[0].description == "This is the first example."
def test_add_tldr_example_bad_args():
parser = CommandArgumentParser()
with pytest.raises(TypeError):
parser.add_tldr_example("example1", "This is the first example.", "extra_arg")
def test_add_tldr_examples_with_tldr_example_objects():
parser = CommandArgumentParser()
example1 = TLDRExample(usage="example1", description="This is the first example.")
example2 = TLDRExample(usage="example2", description="This is the second example.")
parser.add_tldr_examples([example1, example2])
assert len(parser._tldr_examples) == 2
assert parser._tldr_examples[0] == example1
assert parser._tldr_examples[1] == example2

View File

@@ -9,7 +9,13 @@ 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.console import error_console
from falyx.exceptions import (
CommandArgumentError,
FalyxError,
InvalidHookError,
NotAFalyxError,
)
from falyx.hook_manager import HookManager, HookType
from falyx.options_manager import OptionsManager
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
@@ -123,8 +129,10 @@ async def test_command_runner_initialization(
command_with_no_parser,
command_with_custom_parser,
):
runner = CommandRunner(command_with_parser)
runner = CommandRunner(command_with_parser, program="test_program")
assert runner.command == command_with_parser
assert runner.program == "test_program"
assert runner.command.arg_parser.program == "test_program"
assert isinstance(runner.options, OptionsManager)
assert isinstance(runner.runner_hooks, HookManager)
assert runner.console == falyx_console
@@ -133,7 +141,6 @@ async def test_command_runner_initialization(
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)
@@ -166,7 +173,6 @@ 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):
@@ -199,7 +205,9 @@ def test_command_runner_initialization_with_all_bad_components(command_with_pars
console=custom_console,
)
with pytest.raises(NotAFalyxError, match="hooks must be an instance of HookManager"):
with pytest.raises(
InvalidHookError, match="hooks must be an instance of HookManager"
):
CommandRunner(
command_with_parser,
runner_hooks=custom_hooks,
@@ -236,8 +244,6 @@ async def test_command_runner_run_with_failing_action(command_with_failing_actio
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):
@@ -276,6 +282,22 @@ async def test_command_runner_run_with_retries_with_action(
assert "[throw_error] All 2 retries failed." in caplog.text
@pytest.mark.asyncio
async def test_command_runner_run_with_retries_delay_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 --retry-delay 1.0 --retry-backoff 2.0")
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,
@@ -313,7 +335,7 @@ async def test_command_runner_from_command_bad_command():
CommandRunner.from_command("Not a Command")
with pytest.raises(
NotAFalyxError, match="runner_hooks must be an instance of HookManager"
InvalidHookError, match="runner_hooks must be an instance of HookManager"
):
CommandRunner.from_command(
Command(
@@ -360,7 +382,7 @@ async def test_command_runner_build_with_bad_execution_options():
@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"
InvalidHookError, match="runner_hooks must be an instance of HookManager"
):
CommandRunner.build(
key="T",
@@ -438,7 +460,7 @@ async def test_command_runner_cli_with_failing_action(command_with_failing_actio
await runner.cli(["--help"])
captured = Text.from_ansi(capture.get()).plain
assert "usage: falyx T" in captured
assert "usage: falyx" in captured
assert "--foo" in captured
assert "summary" in captured
assert "retries" in captured
@@ -453,54 +475,48 @@ async def test_command_runner_cli_exceptions(command_throwing_error):
with pytest.raises(SystemExit, match="0"):
await runner.cli(["--help"])
captured = Text.from_ansi(capture.get()).plain
assert "falyx E [--help]" in captured
assert "falyx [--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 "falyx [--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 error_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
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
@@ -514,3 +530,12 @@ async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch
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_run_error(command_with_parser):
runner = CommandRunner(command_with_parser)
with pytest.raises(FalyxError, match="requires either"):
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=False)
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True)
await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False)

View File

@@ -40,6 +40,7 @@ async def test_command_validator_is_preview():
fake_falyx = AsyncMock()
fake_route = SimpleNamespace()
fake_route.is_preview = True
fake_route.command = SimpleNamespace()
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
validator = CommandValidator(fake_falyx, "Invalid!")