feat(core): clone commands and actions when binding runtimes
Add clone support across Action types and Command so commands can be safely registered or runner-bound without mutating the original instances. - clone BaseAction implementations across simple, composite, IO, prompt, file, HTTP, process, and signal actions - bind cloned commands in Falyx.add_command_from_command() and CommandRunner - preserve local never_prompt settings when cloning actions - rename shared runtime state from options to options_manager for consistency - seed root and execution option namespaces consistently - apply scoped root and namespace option overrides during routing and dispatch - improve namespace completion by delegating option suggestions to FalyxParser - enrich missing-value errors and error hints
This commit is contained in:
55
tests/test_falyx/test_builtin_root_options.py
Normal file
55
tests/test_falyx/test_builtin_root_options.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action
|
||||
from falyx.debug import log_after, log_before, log_error, log_success
|
||||
from falyx.hook_manager import HookType
|
||||
|
||||
|
||||
def test_apply_root_options_sets_falyx_logger_level_from_root_verbose():
|
||||
flx = Falyx()
|
||||
|
||||
falyx_logger = logging.getLogger("falyx")
|
||||
original_level = falyx_logger.level
|
||||
try:
|
||||
flx.options_manager.set("verbose", True, "root")
|
||||
flx._apply_root_options()
|
||||
assert falyx_logger.level == logging.DEBUG
|
||||
|
||||
flx.options_manager.set("verbose", False, "root")
|
||||
flx._apply_root_options()
|
||||
assert falyx_logger.level == logging.WARNING
|
||||
finally:
|
||||
falyx_logger.setLevel(original_level)
|
||||
|
||||
|
||||
def test_apply_root_options_registers_debug_hooks_across_command_and_action_graph():
|
||||
action = Action("deploy-action", lambda: "ok")
|
||||
flx = Falyx()
|
||||
command = flx.add_command(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=action,
|
||||
)
|
||||
|
||||
assert flx.hooks._hooks[HookType.BEFORE] == []
|
||||
assert command.hooks._hooks[HookType.BEFORE] == []
|
||||
assert action.hooks._hooks[HookType.BEFORE] == []
|
||||
|
||||
flx.options_manager.set("debug_hooks", True, "root")
|
||||
flx._apply_root_options()
|
||||
|
||||
assert flx.hooks._hooks[HookType.BEFORE] == [log_before]
|
||||
assert flx.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
|
||||
assert flx.hooks._hooks[HookType.ON_ERROR] == [log_error]
|
||||
assert flx.hooks._hooks[HookType.AFTER] == [log_after]
|
||||
|
||||
assert command.hooks._hooks[HookType.BEFORE] == [log_before]
|
||||
assert command.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
|
||||
assert command.hooks._hooks[HookType.ON_ERROR] == [log_error]
|
||||
assert command.hooks._hooks[HookType.AFTER] == [log_after]
|
||||
|
||||
assert action.hooks._hooks[HookType.BEFORE] == [log_before]
|
||||
assert action.hooks._hooks[HookType.ON_SUCCESS] == [log_success]
|
||||
assert action.hooks._hooks[HookType.ON_ERROR] == [log_error]
|
||||
assert action.hooks._hooks[HookType.AFTER] == [log_after]
|
||||
138
tests/test_falyx/test_command_clone_contract.py
Normal file
138
tests/test_falyx/test_command_clone_contract.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import pytest
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ChainedAction
|
||||
from falyx.command import Command
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
def test_add_command_from_command_returns_bound_clone():
|
||||
source = Falyx(program="source")
|
||||
target = Falyx(program="target")
|
||||
|
||||
original = source.add_command(
|
||||
"D",
|
||||
"Deploy",
|
||||
action=lambda: "ok",
|
||||
aliases=["deploy"],
|
||||
help_text="Deploy something.",
|
||||
)
|
||||
|
||||
bound = target.add_command_from_command(original)
|
||||
|
||||
assert bound is target.commands["D"]
|
||||
assert bound is not original
|
||||
assert bound.key == original.key
|
||||
assert bound.description == original.description
|
||||
assert bound.aliases == original.aliases
|
||||
assert bound.program == target.program
|
||||
|
||||
|
||||
def test_add_command_from_command_does_not_reuse_original_options_manager():
|
||||
source = Falyx(program="source")
|
||||
target = Falyx(program="target")
|
||||
|
||||
original = source.add_command("D", "Deploy", action=lambda: "ok")
|
||||
bound = target.add_command_from_command(original)
|
||||
|
||||
assert original.options_manager is source.options_manager
|
||||
assert bound.options_manager is target.options_manager
|
||||
assert bound.options_manager is not original.options_manager
|
||||
|
||||
|
||||
def test_add_command_from_command_returns_isolated_clone():
|
||||
flx1 = Falyx(program="one")
|
||||
flx2 = Falyx(program="two")
|
||||
|
||||
original = flx1.add_command("D", "Deploy", action=Action("deploy", lambda: "ok"))
|
||||
bound = flx2.add_command_from_command(original)
|
||||
|
||||
assert bound is not original
|
||||
assert bound.options_manager is flx2.options_manager
|
||||
assert original.options_manager is flx1.options_manager
|
||||
|
||||
if bound.arg_parser and original.arg_parser:
|
||||
assert bound.arg_parser is not original.arg_parser
|
||||
assert bound.arg_parser.options_manager is flx2.options_manager
|
||||
assert original.arg_parser.options_manager is flx1.options_manager
|
||||
|
||||
assert bound.action is not original.action
|
||||
|
||||
|
||||
def test_clone_with_overrides_clones_arg_parser_and_base_action_graph():
|
||||
original_options = OptionsManager()
|
||||
cloned_options = OptionsManager()
|
||||
|
||||
parser = CommandArgumentParser(
|
||||
command_key="D",
|
||||
command_description="Deploy",
|
||||
options_manager=original_options,
|
||||
)
|
||||
parser.add_argument("--region", default="us-east")
|
||||
|
||||
action = ChainedAction(
|
||||
name="deploy-flow",
|
||||
actions=[
|
||||
Action("step-one", lambda: "one"),
|
||||
Action("step-two", lambda: "two"),
|
||||
],
|
||||
)
|
||||
|
||||
command = Command.build(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=action,
|
||||
arg_parser=parser,
|
||||
options_manager=original_options,
|
||||
program="source",
|
||||
)
|
||||
|
||||
cloned = command.clone_with_overrides(
|
||||
options_manager=cloned_options,
|
||||
program="target",
|
||||
)
|
||||
|
||||
assert cloned is not command
|
||||
assert cloned.program == "target"
|
||||
assert cloned.options_manager is cloned_options
|
||||
assert command.options_manager is original_options
|
||||
|
||||
assert cloned.arg_parser is not command.arg_parser
|
||||
assert cloned.arg_parser.options_manager is cloned_options
|
||||
assert command.arg_parser.options_manager is original_options
|
||||
|
||||
assert cloned.action is not command.action
|
||||
assert isinstance(cloned.action, ChainedAction)
|
||||
assert isinstance(command.action, ChainedAction)
|
||||
|
||||
assert cloned.action.actions is not command.action.actions
|
||||
assert len(cloned.action.actions) == len(command.action.actions)
|
||||
|
||||
for cloned_child, original_child in zip(
|
||||
cloned.action.actions,
|
||||
command.action.actions,
|
||||
strict=True,
|
||||
):
|
||||
assert cloned_child is not original_child
|
||||
assert cloned_child.name == original_child.name
|
||||
|
||||
cloned.arg_parser.add_argument("--profile", default="dev")
|
||||
assert command.arg_parser.get_argument("profile") is None
|
||||
|
||||
|
||||
def test_clone_with_overrides_preserves_boolean_contract_flags():
|
||||
command = Command.build(
|
||||
"H",
|
||||
"Hidden-ish helper",
|
||||
lambda: None,
|
||||
auto_args=False,
|
||||
simple_help_signature=True,
|
||||
ignore_in_history=True,
|
||||
)
|
||||
|
||||
cloned = command.clone_with_overrides()
|
||||
|
||||
assert cloned.auto_args is False
|
||||
assert cloned.simple_help_signature is True
|
||||
assert cloned.ignore_in_history is True
|
||||
197
tests/test_falyx/test_command_prompt_contract.py
Normal file
197
tests/test_falyx/test_command_prompt_contract.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from falyx.action import Action
|
||||
from falyx.command import Command
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.prompt_utils import should_prompt_user
|
||||
from falyx.signals import CancelSignal
|
||||
|
||||
|
||||
def _make_options() -> OptionsManager:
|
||||
options = OptionsManager()
|
||||
options.from_mapping({}, "root")
|
||||
options.from_mapping({}, "execution")
|
||||
return options
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_handle_prompt_respects_action_local_never_prompt(monkeypatch):
|
||||
options = _make_options()
|
||||
|
||||
command = Command.build(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=Action("deploy-action", lambda: "ok", never_prompt=True),
|
||||
confirm=True,
|
||||
preview_before_confirm=True,
|
||||
options_manager=options,
|
||||
)
|
||||
|
||||
calls = {
|
||||
"preview": 0,
|
||||
"confirm": 0,
|
||||
"should_prompt": 0,
|
||||
"action_never_prompt": None,
|
||||
}
|
||||
|
||||
async def fake_preview(self):
|
||||
calls["preview"] += 1
|
||||
|
||||
async def fake_confirm(*args, **kwargs):
|
||||
calls["confirm"] += 1
|
||||
return True
|
||||
|
||||
def fake_should_prompt_user(*, confirm, options, action_never_prompt=None, **kwargs):
|
||||
calls["should_prompt"] += 1
|
||||
calls["action_never_prompt"] = action_never_prompt
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(Command, "preview", fake_preview)
|
||||
monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
|
||||
monkeypatch.setattr("falyx.command.should_prompt_user", fake_should_prompt_user)
|
||||
|
||||
await command._handle_prompt_user()
|
||||
|
||||
assert calls["should_prompt"] == 1
|
||||
assert calls["action_never_prompt"] is True
|
||||
assert calls["preview"] == 0
|
||||
assert calls["confirm"] == 0
|
||||
|
||||
|
||||
def test_should_prompt_user_precedence_execution_over_root():
|
||||
options = _make_options()
|
||||
options.set("force_confirm", True, "root")
|
||||
options.set("skip_confirm", True, "execution")
|
||||
|
||||
assert should_prompt_user(confirm=False, options=options) is False
|
||||
|
||||
options = _make_options()
|
||||
options.set("never_prompt", False, "root")
|
||||
options.set("force_confirm", True, "execution")
|
||||
|
||||
assert should_prompt_user(confirm=False, options=options) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_local_never_prompt_overrides_root_prompt_behavior(monkeypatch):
|
||||
options = _make_options()
|
||||
options.set("force_confirm", True, "root")
|
||||
|
||||
command = Command.build(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=Action("deploy-action", lambda: "ok", never_prompt=True),
|
||||
confirm=False,
|
||||
preview_before_confirm=True,
|
||||
options_manager=options,
|
||||
)
|
||||
|
||||
calls = {
|
||||
"preview": 0,
|
||||
"confirm": 0,
|
||||
}
|
||||
|
||||
async def fake_preview(self):
|
||||
calls["preview"] += 1
|
||||
|
||||
async def fake_confirm(*args, **kwargs):
|
||||
calls["confirm"] += 1
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(Command, "preview", fake_preview)
|
||||
monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
|
||||
|
||||
await command._handle_prompt_user()
|
||||
|
||||
assert calls["preview"] == 0
|
||||
assert calls["confirm"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_call_invokes_handle_prompt_user(monkeypatch):
|
||||
options = OptionsManager()
|
||||
options.from_mapping({}, "root")
|
||||
options.from_mapping({}, "execution")
|
||||
|
||||
command = Command.build(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=Action("deploy-action", lambda: "ok"),
|
||||
confirm=True,
|
||||
options_manager=options,
|
||||
)
|
||||
|
||||
mocked_handle_prompt = AsyncMock()
|
||||
monkeypatch.setattr(command, "_handle_prompt_user", mocked_handle_prompt)
|
||||
|
||||
result = await command()
|
||||
|
||||
mocked_handle_prompt.assert_awaited_once()
|
||||
assert result == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_call_invokes_handle_prompt_user_before_action(monkeypatch) -> None:
|
||||
trace: list[str] = []
|
||||
|
||||
async def run_action():
|
||||
trace.append("action")
|
||||
return "ok"
|
||||
|
||||
options = OptionsManager()
|
||||
options.from_mapping({}, "root")
|
||||
options.from_mapping({}, "execution")
|
||||
|
||||
command = Command.build(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=Action("deploy-action", run_action),
|
||||
confirm=True,
|
||||
options_manager=options,
|
||||
)
|
||||
|
||||
async def fake_handle_prompt_user():
|
||||
trace.append("prompt")
|
||||
|
||||
monkeypatch.setattr(command, "_handle_prompt_user", fake_handle_prompt_user)
|
||||
|
||||
result = await command()
|
||||
|
||||
assert result == "ok"
|
||||
assert trace == ["prompt", "action"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_call_cancels_before_action_when_handle_prompt_user_raises(
|
||||
monkeypatch,
|
||||
):
|
||||
trace: list[str] = []
|
||||
|
||||
async def run_action():
|
||||
trace.append("action")
|
||||
return "ok"
|
||||
|
||||
options = OptionsManager()
|
||||
options.from_mapping({}, "root")
|
||||
options.from_mapping({}, "execution")
|
||||
|
||||
command = Command.build(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=Action("deploy-action", run_action),
|
||||
confirm=True,
|
||||
options_manager=options,
|
||||
)
|
||||
|
||||
async def fake_handle_prompt_user():
|
||||
trace.append("prompt")
|
||||
raise CancelSignal("cancelled during confirmation")
|
||||
|
||||
monkeypatch.setattr(command, "_handle_prompt_user", fake_handle_prompt_user)
|
||||
|
||||
with pytest.raises(CancelSignal, match="cancelled during confirmation"):
|
||||
await command()
|
||||
|
||||
assert trace == ["prompt"]
|
||||
121
tests/test_falyx/test_completion_contract.py
Normal file
121
tests/test_falyx/test_completion_contract.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.completer import FalyxCompleter
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
def completion_texts(completions) -> list[str]:
|
||||
return [c.text for c in completions]
|
||||
|
||||
|
||||
def make_completion_app() -> tuple[Falyx, FalyxCompleter]:
|
||||
flx = Falyx(program="falyx")
|
||||
|
||||
flx.add_option(
|
||||
"--profile",
|
||||
suggestions=["dev", "prod", "staging"],
|
||||
help="Runtime profile",
|
||||
)
|
||||
|
||||
flx.add_option(
|
||||
"--region",
|
||||
choices=["us-east", "us-west"],
|
||||
help="Deployment region",
|
||||
)
|
||||
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--name")
|
||||
parser.add_argument("--env", choices=["dev", "prod"])
|
||||
|
||||
flx.add_command(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=lambda name, env: f"deploy {name} to {env}",
|
||||
aliases=["deploy"],
|
||||
arg_parser=parser,
|
||||
)
|
||||
|
||||
return flx, FalyxCompleter(flx)
|
||||
|
||||
|
||||
def test_completion_suggests_namespace_flags():
|
||||
_, completer = make_completion_app()
|
||||
|
||||
completions = list(
|
||||
completer.get_completions(Document(text="--pr", cursor_position=4), None)
|
||||
)
|
||||
|
||||
texts = completion_texts(completions)
|
||||
assert "--profile" in texts
|
||||
|
||||
|
||||
def test_completion_suggests_namespace_option_values():
|
||||
_, completer = make_completion_app()
|
||||
|
||||
completions = list(
|
||||
completer.get_completions(
|
||||
Document(text="--profile pr", cursor_position=len("--profile pr")),
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
texts = completion_texts(completions)
|
||||
assert "prod" in texts
|
||||
assert "dev" not in texts
|
||||
|
||||
|
||||
def test_completion_after_committed_namespace_option_returns_namespace_entries():
|
||||
_, completer = make_completion_app()
|
||||
|
||||
completions = list(
|
||||
completer.get_completions(
|
||||
Document(text="--profile prod de", cursor_position=len("--profile prod de")),
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
texts = completion_texts(completions)
|
||||
assert "deploy" in texts
|
||||
|
||||
|
||||
def test_completion_preview_mode_prefixes_namespace_entry_suggestions():
|
||||
_, completer = make_completion_app()
|
||||
|
||||
completions = list(
|
||||
completer.get_completions(Document(text="?de", cursor_position=3), None)
|
||||
)
|
||||
|
||||
texts = completion_texts(completions)
|
||||
assert "?deploy" in texts
|
||||
|
||||
|
||||
def test_resolve_completion_route_unresolved_entry_with_trailing_input_stops_namespace_entry_mode():
|
||||
flx, _ = make_completion_app()
|
||||
|
||||
route = flx.resolve_completion_route(
|
||||
["wat"],
|
||||
stub="--na",
|
||||
cursor_at_end_of_token=False,
|
||||
invocation_context=flx.get_current_invocation_context(),
|
||||
is_preview=False,
|
||||
)
|
||||
|
||||
assert route.command is None
|
||||
assert route.expecting_entry is False
|
||||
assert route.remaining_argv == ["wat", "--na"]
|
||||
assert route.stub == ""
|
||||
|
||||
|
||||
def test_completion_delegates_to_command_parser_after_leaf_command_is_resolved():
|
||||
_, completer = make_completion_app()
|
||||
|
||||
completions = list(
|
||||
completer.get_completions(
|
||||
Document(text="D --na", cursor_position=len("D --na")),
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
texts = completion_texts(completions)
|
||||
assert "--name" in texts
|
||||
120
tests/test_falyx/test_dispatch_contract.py
Normal file
120
tests/test_falyx/test_dispatch_contract.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import pytest
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.routing import RouteKind, RouteResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_seeds_namespace_defaults_into_default_namespace(
|
||||
monkeypatch,
|
||||
):
|
||||
flx = Falyx(program="falyx")
|
||||
command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
route = RouteResult(
|
||||
kind=RouteKind.COMMAND,
|
||||
namespace=flx,
|
||||
context=flx.get_current_invocation_context(),
|
||||
command=command,
|
||||
namespace_defaults={"region": "us-east"},
|
||||
namespace_overrides={},
|
||||
)
|
||||
|
||||
seen = {}
|
||||
|
||||
async def fake_execute(*, command, args, kwargs, execution_args, **_):
|
||||
seen["region"] = flx.options_manager.get("region", None, "default")
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(flx._executor, "execute", fake_execute)
|
||||
|
||||
result = await flx._dispatch_route(
|
||||
route=route,
|
||||
args=(),
|
||||
kwargs={},
|
||||
execution_args={},
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert seen["region"] == "us-east"
|
||||
|
||||
assert flx.options_manager.get("region", None, "default") == "us-east"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_applies_namespace_overrides_temporarily_in_default_namespace(
|
||||
monkeypatch,
|
||||
):
|
||||
flx = Falyx(program="falyx")
|
||||
command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
flx.options_manager.set("region", "us-east", "default")
|
||||
|
||||
route = RouteResult(
|
||||
kind=RouteKind.COMMAND,
|
||||
namespace=flx,
|
||||
context=flx.get_current_invocation_context(),
|
||||
command=command,
|
||||
namespace_defaults={},
|
||||
namespace_overrides={"region": "us-west"},
|
||||
)
|
||||
|
||||
seen = {}
|
||||
|
||||
async def fake_execute(*, command, args, kwargs, execution_args, **_):
|
||||
seen["region"] = flx.options_manager.get("region", None, "default")
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(flx._executor, "execute", fake_execute)
|
||||
|
||||
result = await flx._dispatch_route(
|
||||
route=route,
|
||||
args=(),
|
||||
kwargs={},
|
||||
execution_args={},
|
||||
raise_on_error=False,
|
||||
wrap_errors=True,
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert seen["region"] == "us-west"
|
||||
|
||||
assert flx.options_manager.get("region", None, "default") == "us-east"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_namespace_overrides_do_not_leak_after_command_execution(monkeypatch):
|
||||
flx = Falyx(program="falyx")
|
||||
command = flx.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
flx.options_manager.set("profile", "dev", "default")
|
||||
|
||||
route = RouteResult(
|
||||
kind=RouteKind.COMMAND,
|
||||
namespace=flx,
|
||||
context=flx.get_current_invocation_context(),
|
||||
command=command,
|
||||
namespace_defaults={"region": "us-east"},
|
||||
namespace_overrides={"profile": "prod"},
|
||||
)
|
||||
|
||||
async def fake_execute(*, command, args, kwargs, execution_args, **_):
|
||||
assert flx.options_manager.get("region", None, "default") == "us-east"
|
||||
assert flx.options_manager.get("profile", None, "default") == "prod"
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(flx._executor, "execute", fake_execute)
|
||||
|
||||
result = await flx._dispatch_route(
|
||||
route=route,
|
||||
args=(),
|
||||
kwargs={},
|
||||
execution_args={},
|
||||
raise_on_error=False,
|
||||
wrap_errors=True,
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
|
||||
assert flx.options_manager.get("region", None, "default") == "us-east"
|
||||
assert flx.options_manager.get("profile", None, "default") == "dev"
|
||||
68
tests/test_falyx/test_exceptions.py
Normal file
68
tests/test_falyx/test_exceptions.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import pytest
|
||||
|
||||
from falyx.console import print_error
|
||||
from falyx.exceptions import CommandArgumentError, MissingValueError
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
async def test_missing_value_error_has_user_facing_message():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--pair", type=int, nargs=2)
|
||||
|
||||
with pytest.raises(MissingValueError) as exc:
|
||||
await parser.parse_args(["--pair", "1"])
|
||||
|
||||
assert "pair" in str(exc.value)
|
||||
assert "expected" in str(exc.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_value_error_for_fixed_nargs_has_message_and_hint():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--pair", type=int, nargs=2)
|
||||
|
||||
with pytest.raises(MissingValueError) as exc:
|
||||
await parser.parse_args(["--pair", "1"])
|
||||
|
||||
error = exc.value
|
||||
|
||||
assert str(error) == "missing values for '--pair': expected 2, got 1"
|
||||
assert error.hint == "provide 2 values for '--pair'."
|
||||
assert error.show_short_usage is True
|
||||
assert error.dest == "pair"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_value_error_for_plus_nargs_has_message_and_hint():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--items", nargs="+")
|
||||
|
||||
with pytest.raises(MissingValueError) as exc:
|
||||
await parser.parse_args(["--items"])
|
||||
|
||||
error = exc.value
|
||||
|
||||
assert str(error) == "missing value for '--items'"
|
||||
assert error.hint == "provide one or more values for '--items'."
|
||||
|
||||
|
||||
def test_print_error_uses_exception_hint(monkeypatch) -> None:
|
||||
printed: list[str] = []
|
||||
|
||||
class FakeConsole:
|
||||
def print(self, value):
|
||||
printed.append(value)
|
||||
|
||||
monkeypatch.setattr("falyx.console.error_console", FakeConsole())
|
||||
|
||||
error = CommandArgumentError(
|
||||
"invalid command argument",
|
||||
hint="use --help to see available options",
|
||||
)
|
||||
|
||||
print_error(error)
|
||||
|
||||
assert any("error:" in line for line in printed)
|
||||
assert any("invalid command argument" in line for line in printed)
|
||||
assert any("hint:" in line for line in printed)
|
||||
assert any("use --help to see available options" in line for line in printed)
|
||||
@@ -2,6 +2,8 @@ import pytest
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action
|
||||
from falyx.command_runner import CommandRunner
|
||||
from falyx.parser import CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -9,18 +11,32 @@ async def test_execute_command():
|
||||
"""Test if Falyx can run in run key mode."""
|
||||
falyx = Falyx("Run Key Test")
|
||||
|
||||
# Add a simple command
|
||||
falyx.add_command(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: "Hello, World!",
|
||||
)
|
||||
|
||||
# Run the CLI
|
||||
result = await falyx.execute_command("T")
|
||||
assert result == "Hello, World!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_accepts_alias():
|
||||
"""Falyx.execute_command should resolve command aliases."""
|
||||
falyx = Falyx("Alias Test")
|
||||
|
||||
falyx.add_command(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: "Hello, Alias!",
|
||||
aliases=["test"],
|
||||
)
|
||||
|
||||
result = await falyx.execute_command("test")
|
||||
assert result == "Hello, Alias!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_recover():
|
||||
"""Test if Falyx can recover from a failure in run key mode."""
|
||||
@@ -34,7 +50,6 @@ async def test_execute_command_recover():
|
||||
raise RuntimeError("Random failure!")
|
||||
return "ok"
|
||||
|
||||
# Add a command that raises an exception
|
||||
falyx.add_command(
|
||||
key="E",
|
||||
description="Error Command",
|
||||
@@ -44,3 +59,66 @@ async def test_execute_command_recover():
|
||||
|
||||
result = await falyx.execute_command("E")
|
||||
assert result == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_with_argument_parsing():
|
||||
"""Falyx.execute_command should parse command-local arguments before execution."""
|
||||
falyx = Falyx("Argument Parsing Test")
|
||||
|
||||
falyx.add_command(
|
||||
key="G",
|
||||
description="Greet",
|
||||
action=lambda name: f"hello {name}",
|
||||
)
|
||||
|
||||
result = await falyx.execute_command("G Roland")
|
||||
assert result == "hello Roland"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_and_falyx_execute_same_command_with_same_result():
|
||||
"""CommandRunner and Falyx should produce the same result for equivalent input."""
|
||||
falyx = Falyx("Parity Test")
|
||||
|
||||
command = falyx.add_command(
|
||||
key="G",
|
||||
description="Greet",
|
||||
action=lambda name: f"hello {name}",
|
||||
aliases=["greet"],
|
||||
)
|
||||
|
||||
runner = CommandRunner.from_command(command)
|
||||
|
||||
falyx_result = await falyx.execute_command("G Roland")
|
||||
runner_result = await runner.run(["Roland"])
|
||||
|
||||
assert falyx_result == "hello Roland"
|
||||
assert runner_result == "hello Roland"
|
||||
assert falyx_result == runner_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_from_command_clones_and_preserves_parity():
|
||||
"""Runner parity should hold even though from_command binds a clone."""
|
||||
falyx = Falyx("Clone Parity Test")
|
||||
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("x", type=int)
|
||||
parser.add_argument("y", type=int)
|
||||
|
||||
command = falyx.add_command(
|
||||
key="A",
|
||||
description="Add",
|
||||
action=lambda x, y: x + y,
|
||||
arg_parser=parser,
|
||||
)
|
||||
|
||||
runner = CommandRunner.from_command(command)
|
||||
|
||||
result_from_falyx = await falyx.execute_command("A 2 3")
|
||||
result_from_runner = await runner.run(["2", "3"])
|
||||
|
||||
assert result_from_falyx == 5
|
||||
assert result_from_runner == 5
|
||||
assert runner.command is not command
|
||||
|
||||
856
tests/test_falyx/test_extra.py
Normal file
856
tests/test_falyx/test_extra.py
Normal file
@@ -0,0 +1,856 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.validation import ValidationError
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
import falyx.falyx as falyx_module
|
||||
from falyx import Falyx
|
||||
from falyx.command import Command
|
||||
from falyx.exceptions import (
|
||||
CommandAlreadyExistsError,
|
||||
CommandArgumentError,
|
||||
EntryNotFoundError,
|
||||
FalyxError,
|
||||
InvalidActionError,
|
||||
InvalidHookError,
|
||||
NotAFalyxError,
|
||||
UsageError,
|
||||
)
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.mode import FalyxMode
|
||||
from falyx.namespace import FalyxNamespace
|
||||
from falyx.parser.parser_types import FalyxTLDRExample
|
||||
from falyx.routing import RouteKind, RouteResult
|
||||
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||
|
||||
|
||||
class RecordingConsole:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[tuple, dict]] = []
|
||||
|
||||
def print(self, *args, **kwargs) -> None:
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
@property
|
||||
def rendered(self) -> str:
|
||||
parts: list[str] = []
|
||||
for args, _ in self.calls:
|
||||
if args:
|
||||
value = args[0]
|
||||
if isinstance(value, Text):
|
||||
parts.append(value.plain)
|
||||
else:
|
||||
parts.append(str(value))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def make_falyx(**overrides) -> Falyx:
|
||||
defaults = {
|
||||
"program": "fx",
|
||||
"description": "Test CLI",
|
||||
"enable_help_tips": False,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Falyx(**defaults)
|
||||
|
||||
|
||||
def add_deploy(
|
||||
flx: Falyx, *, key: str = "D", aliases: list[str] | None = None
|
||||
) -> Command:
|
||||
return flx.add_command(
|
||||
key,
|
||||
description="Deploy",
|
||||
action=lambda: "deployed",
|
||||
aliases=aliases if aliases is not None else ["deploy"],
|
||||
help_text="Deploy things.",
|
||||
)
|
||||
|
||||
|
||||
def route_for(flx: Falyx, kind: RouteKind, **overrides) -> RouteResult:
|
||||
values = {
|
||||
"kind": kind,
|
||||
"namespace": flx,
|
||||
"context": flx.get_current_invocation_context(),
|
||||
}
|
||||
values.update(overrides)
|
||||
return RouteResult(**values)
|
||||
|
||||
|
||||
def test_init_with_prompt_history_sanitizes_program_name(tmp_path) -> None:
|
||||
flx = Falyx(
|
||||
program="my app.cli",
|
||||
prompt_history_base_dir=tmp_path,
|
||||
enable_prompt_history=True,
|
||||
)
|
||||
|
||||
assert flx.history_path == tmp_path / ".my_app_history"
|
||||
assert flx.history is not None
|
||||
|
||||
|
||||
def test_str_and_repr_include_identity_fields() -> None:
|
||||
flx = Falyx(program="fx", title="Deployments", description="Deploy CLI")
|
||||
|
||||
expected = "Falyx(program='fx', title='Deployments', description='Deploy CLI')"
|
||||
assert str(flx) == expected
|
||||
assert repr(flx) == expected
|
||||
|
||||
|
||||
def test_add_tldr_examples_delegates_to_root_parser() -> None:
|
||||
flx = make_falyx()
|
||||
add_deploy(flx)
|
||||
|
||||
flx.add_tldr_examples([("deploy", "--region us-east", "Deploy east")])
|
||||
|
||||
assert flx.parser.tldr_option is not None
|
||||
assert flx.parser._tldr_examples[-1].entry_key == "deploy"
|
||||
|
||||
|
||||
def test_rejects_invalid_options_manager() -> None:
|
||||
with pytest.raises(NotAFalyxError, match="options_manager"):
|
||||
Falyx(options_manager=object())
|
||||
|
||||
|
||||
def test_entry_map_rejects_identifier_collision_with_distinct_entries() -> None:
|
||||
flx = make_falyx()
|
||||
add_deploy(flx)
|
||||
flx.namespaces["N"] = FalyxNamespace(
|
||||
key="N",
|
||||
description="Nested",
|
||||
namespace=make_falyx(),
|
||||
aliases=["Deploy"],
|
||||
)
|
||||
|
||||
with pytest.raises(CommandAlreadyExistsError, match="identifier 'DEPLOY'"):
|
||||
_ = flx._entry_map
|
||||
|
||||
|
||||
def test_get_tip_adds_menu_specific_tips(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
flx.options_manager.set("mode", FalyxMode.MENU)
|
||||
seen: dict[str, list[str]] = {}
|
||||
|
||||
def choose_last(tips: list[str]) -> str:
|
||||
seen["tips"] = tips
|
||||
return tips[-1]
|
||||
|
||||
monkeypatch.setattr(falyx_module, "choice", choose_last)
|
||||
|
||||
assert flx.get_tip() == "Use '[X]' in menu mode to exit."
|
||||
assert "'[Y]' opens the command history viewer." in seen["tips"]
|
||||
|
||||
|
||||
def test_command_key_usage_in_menu_includes_history_and_exit() -> None:
|
||||
flx = make_falyx()
|
||||
add_deploy(flx)
|
||||
flx.options_manager.set("mode", FalyxMode.MENU)
|
||||
|
||||
usage = flx._get_command_keys_usage_string()
|
||||
|
||||
assert "D" in usage
|
||||
assert "Y" in usage
|
||||
assert "X" in usage
|
||||
|
||||
|
||||
def test_simple_usage_mentions_namespace_when_visible_namespace_exists() -> None:
|
||||
flx = make_falyx(simple_usage=True)
|
||||
flx.add_submenu("OPS", "Operations", make_falyx())
|
||||
|
||||
fragment = flx._get_usage_fragment(flx.get_current_invocation_context())
|
||||
|
||||
assert "<command or namespace>" in fragment
|
||||
|
||||
|
||||
def test_get_usage_omits_invocation_path_in_menu_mode() -> None:
|
||||
flx = make_falyx(usage="custom [args]")
|
||||
flx.options_manager.set("mode", FalyxMode.MENU)
|
||||
|
||||
usage = flx._get_usage()
|
||||
|
||||
assert usage == "[bold]usage:[/bold] [white]custom [args][/white]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_command_tldr_prints_tip_when_examples_render(monkeypatch) -> None:
|
||||
flx = make_falyx(enable_help_tips=True)
|
||||
flx.console = RecordingConsole()
|
||||
monkeypatch.setattr(flx, "get_tip", lambda: "remember aliases")
|
||||
|
||||
command = SimpleNamespace(
|
||||
description="Deploy",
|
||||
render_tldr=lambda invocation_context: True,
|
||||
)
|
||||
|
||||
await flx._render_command_tldr(command)
|
||||
|
||||
assert "remember aliases" in flx.console.rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_command_tldr_prints_error_when_no_examples(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
messages: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
falyx_module, "print_error", lambda message, **_: messages.append(str(message))
|
||||
)
|
||||
command = SimpleNamespace(
|
||||
description="Deploy",
|
||||
render_tldr=lambda invocation_context: False,
|
||||
)
|
||||
|
||||
await flx._render_command_tldr(command)
|
||||
|
||||
assert messages == ["No TLDR examples available for 'Deploy'."]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_command_help_delegates_to_tldr(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
command = SimpleNamespace(description="Deploy")
|
||||
context = flx.get_current_invocation_context()
|
||||
called: dict[str, object] = {}
|
||||
|
||||
async def fake_tldr(rendered_command, invocation_context=None) -> None:
|
||||
called["command"] = rendered_command
|
||||
called["context"] = invocation_context
|
||||
|
||||
monkeypatch.setattr(flx, "_render_command_tldr", fake_tldr)
|
||||
|
||||
await flx._render_command_help(command, tldr=True, invocation_context=context)
|
||||
|
||||
assert called == {"command": command, "context": context}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_command_help_prints_tip_when_help_renders(monkeypatch) -> None:
|
||||
flx = make_falyx(enable_help_tips=True)
|
||||
flx.console = RecordingConsole()
|
||||
monkeypatch.setattr(flx, "get_tip", lambda: "read the usage line")
|
||||
command = SimpleNamespace(
|
||||
description="Deploy",
|
||||
render_help=lambda invocation_context: True,
|
||||
)
|
||||
|
||||
await flx._render_command_help(command)
|
||||
|
||||
assert "read the usage line" in flx.console.rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_command_help_prints_error_when_no_help(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
messages: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
falyx_module, "print_error", lambda message, **_: messages.append(str(message))
|
||||
)
|
||||
command = SimpleNamespace(
|
||||
description="Deploy",
|
||||
render_help=lambda invocation_context: False,
|
||||
)
|
||||
|
||||
await flx._render_command_help(command)
|
||||
|
||||
assert messages == ["No detailed help available for 'Deploy'."]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_tag_help_prints_empty_tag_message() -> None:
|
||||
flx = make_falyx()
|
||||
flx.console = RecordingConsole()
|
||||
|
||||
await flx._render_tag_help("missing")
|
||||
|
||||
assert "Nothing to show here" in flx.console.rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_menu_help_includes_namespaces_and_epilog(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
FalyxNamespace,
|
||||
"get_help_signature",
|
||||
lambda self, context: (self.key, self.description, ""),
|
||||
raising=False,
|
||||
)
|
||||
flx = make_falyx(epilog="Menu epilog")
|
||||
flx.console = RecordingConsole()
|
||||
flx.add_submenu("OPS", "Operations namespace", make_falyx())
|
||||
|
||||
await flx._render_menu_help(flx.get_current_invocation_context())
|
||||
|
||||
assert "namespaces" in flx.console.rendered
|
||||
assert "Menu epilog" in flx.console.rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_cli_help_includes_namespaces_aliases_and_epilog() -> None:
|
||||
flx = make_falyx(epilog="CLI epilog")
|
||||
flx.console = RecordingConsole()
|
||||
flx.add_submenu("OPS", "Operations namespace", make_falyx(), aliases=["operations"])
|
||||
|
||||
await flx._render_cli_help(flx.get_current_invocation_context())
|
||||
|
||||
rendered = flx.console.rendered
|
||||
assert "namespaces" in rendered
|
||||
assert "OPS | operations" in rendered
|
||||
assert "Operations namespace" in rendered
|
||||
assert "CLI epilog" in rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_namespace_tldr_prints_empty_message_without_examples() -> None:
|
||||
flx = make_falyx(title="Root Menu")
|
||||
flx.console = RecordingConsole()
|
||||
|
||||
await flx._render_namespace_tldr_help(flx.get_current_invocation_context())
|
||||
|
||||
assert "No TLDR examples available for 'Root Menu'" in flx.console.rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_namespace_tldr_rejects_stale_unknown_example() -> None:
|
||||
flx = make_falyx()
|
||||
flx.parser.tldr_option = object()
|
||||
flx.parser._tldr_examples.append(
|
||||
FalyxTLDRExample(
|
||||
entry_key="missing",
|
||||
usage="",
|
||||
description="Stale example",
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(EntryNotFoundError) as error:
|
||||
await flx._render_namespace_tldr_help(flx.get_current_invocation_context())
|
||||
|
||||
assert error.value.unknown_name == "missing"
|
||||
|
||||
|
||||
def test_help_target_base_context_handles_empty_and_help_command_path() -> None:
|
||||
flx = make_falyx()
|
||||
base_context = flx.get_current_invocation_context()
|
||||
|
||||
assert flx._help_target_base_context(base_context) is base_context
|
||||
|
||||
help_context = base_context.with_path_segment("H", style=flx.help_command.style)
|
||||
stripped = flx._help_target_base_context(help_context)
|
||||
|
||||
assert stripped.typed_path == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_help_dispatches_to_specific_command(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
command = add_deploy(flx)
|
||||
called: dict[str, object] = {}
|
||||
|
||||
async def fake_command_help(command, tldr=False, invocation_context=None) -> None:
|
||||
called["command"] = command
|
||||
called["tldr"] = tldr
|
||||
called["path"] = list(invocation_context.typed_path)
|
||||
|
||||
monkeypatch.setattr(flx, "_render_command_help", fake_command_help)
|
||||
|
||||
await flx.render_help(key="deploy", tldr=True)
|
||||
|
||||
assert called == {"command": command, "tldr": True, "path": ["deploy"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_help_dispatches_to_specific_namespace(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
submenu = make_falyx()
|
||||
called: dict[str, object] = {}
|
||||
flx.add_submenu("OPS", "Operations", submenu)
|
||||
|
||||
async def fake_namespace_help(invocation_context=None, tldr=False) -> None:
|
||||
called["tldr"] = tldr
|
||||
called["path"] = list(invocation_context.typed_path)
|
||||
|
||||
monkeypatch.setattr(submenu, "render_namespace_help", fake_namespace_help)
|
||||
|
||||
await flx.render_help(key="OPS", tldr=True)
|
||||
|
||||
assert called == {"tldr": True, "path": ["OPS"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_help_renders_namespace_then_raises_for_unknown_key(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
flx = make_falyx()
|
||||
rendered: list[InvocationContext] = []
|
||||
|
||||
async def fake_namespace_help(invocation_context=None, tldr=False) -> None:
|
||||
rendered.append(invocation_context)
|
||||
|
||||
monkeypatch.setattr(flx, "render_namespace_help", fake_namespace_help)
|
||||
|
||||
with pytest.raises(EntryNotFoundError) as error:
|
||||
await flx.render_help(key="depoy")
|
||||
|
||||
assert rendered
|
||||
assert error.value.unknown_name == "depoy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_help_without_key_tldr_renders_help_command_tldr(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
flx = make_falyx()
|
||||
called: dict[str, object] = {}
|
||||
|
||||
async def fake_command_help(command, tldr=False, invocation_context=None) -> None:
|
||||
called["command"] = command
|
||||
called["tldr"] = tldr
|
||||
|
||||
monkeypatch.setattr(flx, "_render_command_help", fake_command_help)
|
||||
|
||||
await flx.render_help(tldr=True)
|
||||
|
||||
assert called == {"command": flx.help_command, "tldr": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_rejects_namespaces_and_unknown_entries() -> None:
|
||||
flx = make_falyx()
|
||||
flx.add_submenu("OPS", "Operations", make_falyx())
|
||||
|
||||
with pytest.raises(FalyxError, match="preview mode"):
|
||||
await flx._preview("OPS")
|
||||
|
||||
with pytest.raises(EntryNotFoundError) as error:
|
||||
await flx._preview("missing")
|
||||
|
||||
assert error.value.unknown_name == "missing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_version_prints_program_version() -> None:
|
||||
flx = make_falyx(program="fx", version="9.9.9")
|
||||
flx.console = RecordingConsole()
|
||||
|
||||
await flx._render_version()
|
||||
|
||||
assert "fx v9.9.9" in flx.console.rendered
|
||||
|
||||
|
||||
def test_invalidate_prompt_session_cache_deletes_cached_property_value() -> None:
|
||||
flx = make_falyx()
|
||||
flx.__dict__["prompt_session"] = object()
|
||||
flx._prompt_session = object()
|
||||
|
||||
flx._invalidate_prompt_session_cache()
|
||||
|
||||
assert "prompt_session" not in flx.__dict__
|
||||
assert flx._prompt_session is None
|
||||
|
||||
|
||||
def test_bottom_bar_accepts_instance_string_callable_and_rejects_invalid() -> None:
|
||||
flx = make_falyx()
|
||||
existing_bottom_bar = flx.bottom_bar
|
||||
|
||||
flx.bottom_bar = existing_bottom_bar
|
||||
assert flx.bottom_bar is existing_bottom_bar
|
||||
assert flx.bottom_bar.key_bindings is flx.key_bindings
|
||||
|
||||
flx.bottom_bar = "static toolbar"
|
||||
assert flx._get_bottom_bar_render() == "static toolbar"
|
||||
|
||||
renderer = lambda: "dynamic toolbar"
|
||||
flx.bottom_bar = renderer
|
||||
assert flx._get_bottom_bar_render() is renderer
|
||||
|
||||
with pytest.raises(FalyxError, match="bottom_bar"):
|
||||
flx.bottom_bar = object()
|
||||
|
||||
|
||||
def test_default_bottom_bar_render_is_returned_when_items_exist() -> None:
|
||||
flx = make_falyx()
|
||||
render = flx._get_bottom_bar_render()
|
||||
|
||||
if flx.bottom_bar.has_items:
|
||||
assert render is flx.bottom_bar.render
|
||||
else:
|
||||
assert render is None
|
||||
|
||||
|
||||
def test_register_all_hooks_rejects_non_callable_hook() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(InvalidHookError, match="callable"):
|
||||
flx.register_all_hooks(HookType.BEFORE, object())
|
||||
|
||||
|
||||
def test_validate_command_aliases_rejects_duplicate_aliases() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(CommandAlreadyExistsError, match="duplicate aliases"):
|
||||
flx.add_command(
|
||||
"D", description="Deploy", action=lambda: None, aliases=["deploy", "DEPLOY"]
|
||||
)
|
||||
|
||||
|
||||
def test_validate_command_aliases_rejects_key_as_alias() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(CommandAlreadyExistsError, match="cannot also be an alias"):
|
||||
flx.add_command("D", description="Deploy", action=lambda: None, aliases=["D"])
|
||||
|
||||
|
||||
def test_validate_command_aliases_rejects_existing_identifier_collision() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(CommandAlreadyExistsError, match="already exist"):
|
||||
flx.add_command("H", description="Duplicate Help", action=lambda: None)
|
||||
|
||||
|
||||
def test_update_exit_command_rejects_non_callable_action() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(InvalidActionError, match="callable"):
|
||||
flx.update_exit_command(key="Q", action="quit")
|
||||
|
||||
|
||||
def test_add_submenu_rejects_non_falyx_submenu() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(NotAFalyxError, match="submenu"):
|
||||
flx.add_submenu("OPS", "Operations", object())
|
||||
|
||||
|
||||
def test_add_commands_accepts_dicts_and_command_instances() -> None:
|
||||
flx = make_falyx()
|
||||
reusable = Command(key="B", description="Build", action=lambda: "built")
|
||||
|
||||
commands = flx.add_commands(
|
||||
[
|
||||
{"key": "D", "description": "Deploy", "action": lambda: "deployed"},
|
||||
reusable,
|
||||
]
|
||||
)
|
||||
|
||||
assert [command.key for command in commands] == ["D", "B"]
|
||||
assert flx.commands["D"].description == "Deploy"
|
||||
assert flx.commands["B"].description == "Build"
|
||||
|
||||
|
||||
def test_add_commands_rejects_invalid_items() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(FalyxError, match="dictionary or an instance of Command"):
|
||||
flx.add_commands([object()])
|
||||
|
||||
|
||||
def test_add_command_from_command_rejects_non_command() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(FalyxError, match="instance of Command"):
|
||||
flx.add_command_from_command(object())
|
||||
|
||||
|
||||
def test_iter_visible_entries_can_include_builtins() -> None:
|
||||
flx = make_falyx()
|
||||
visible = flx._iter_visible_entries(include_builtins=True)
|
||||
|
||||
assert any(entry.key == "H" for entry in visible)
|
||||
assert any(entry.key == "PVW" for entry in visible)
|
||||
assert any(entry.key == "VER" for entry in visible)
|
||||
|
||||
|
||||
def test_build_placeholder_menu_returns_empty_placeholder_without_user_commands() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
assert flx.build_placeholder_menu() == [("", "")]
|
||||
|
||||
|
||||
def test_table_uses_callable_custom_table_and_rejects_invalid_factory() -> None:
|
||||
good = make_falyx(custom_table=lambda app: Table(title=app.title))
|
||||
assert isinstance(good.table, Table)
|
||||
|
||||
bad = make_falyx(custom_table=lambda app: "not a table")
|
||||
with pytest.raises(FalyxError, match="custom_table"):
|
||||
_ = bad.table
|
||||
|
||||
|
||||
def test_table_uses_prebuilt_custom_table_instance() -> None:
|
||||
table = Table(title="Prebuilt")
|
||||
flx = make_falyx(custom_table=table)
|
||||
|
||||
assert flx.table is table
|
||||
|
||||
|
||||
def test_resolve_entry_accepts_unique_prefix_matches() -> None:
|
||||
flx = make_falyx()
|
||||
command = add_deploy(flx, key="DEPLOY", aliases=[])
|
||||
|
||||
entry, suggestions = flx.resolve_entry("depl")
|
||||
|
||||
assert entry is command
|
||||
assert suggestions == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_route_converts_bad_shell_string_to_validation_error() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
await flx.prepare_route('"unterminated', from_validate=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_route_converts_bad_shell_string_to_usage_error() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(UsageError, match="No closing quotation"):
|
||||
await flx.prepare_route('"unterminated')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_route_rejects_invalid_raw_argument_type() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(AssertionError, match="Validator can only pass"):
|
||||
await flx.prepare_route(object(), from_validate=True)
|
||||
|
||||
with pytest.raises(UsageError, match="raw_arguments"):
|
||||
await flx.prepare_route(object())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_route_preserves_preview_route_without_resolving_command_args() -> (
|
||||
None
|
||||
):
|
||||
flx = make_falyx()
|
||||
add_deploy(flx)
|
||||
|
||||
route, args, kwargs, execution_args = await flx.prepare_route("?D")
|
||||
|
||||
assert route.is_preview is True
|
||||
assert route.kind is RouteKind.COMMAND
|
||||
assert args == ()
|
||||
assert kwargs == {}
|
||||
assert execution_args == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_route_wraps_route_errors_for_validation(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
async def fake_resolve_route(*args, **kwargs):
|
||||
raise FalyxError("bad route", hint="try deploy")
|
||||
|
||||
monkeypatch.setattr(flx, "resolve_route", fake_resolve_route)
|
||||
|
||||
with pytest.raises(ValidationError) as error:
|
||||
await flx.prepare_route("D", from_validate=True)
|
||||
|
||||
assert "try deploy" in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_route_wraps_command_argument_errors_for_validation(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
flx = make_falyx()
|
||||
command = add_deploy(flx)
|
||||
|
||||
async def fake_resolve_route(*args, **kwargs):
|
||||
return route_for(flx, RouteKind.COMMAND, command=command, leaf_argv=["--bad"])
|
||||
|
||||
async def fake_resolve_args(*args, **kwargs):
|
||||
raise CommandArgumentError("bad args", hint="use --help")
|
||||
|
||||
monkeypatch.setattr(flx, "resolve_route", fake_resolve_route)
|
||||
monkeypatch.setattr(Command, "resolve_args", fake_resolve_args)
|
||||
|
||||
with pytest.raises(ValidationError) as error:
|
||||
await flx.prepare_route(["D"], from_validate=True)
|
||||
|
||||
assert "use --help" in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_unknown_route_rejects_preview_namespace_menu() -> None:
|
||||
flx = make_falyx()
|
||||
route = route_for(flx, RouteKind.NAMESPACE_MENU)
|
||||
|
||||
with pytest.raises(FalyxError, match="preview mode"):
|
||||
await flx._render_unknown_route(route)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_route_previews_command_and_unknown_preview(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
command = SimpleNamespace(key="D", preview=AsyncMock())
|
||||
command_route = route_for(
|
||||
flx,
|
||||
RouteKind.COMMAND,
|
||||
command=command,
|
||||
is_preview=True,
|
||||
)
|
||||
|
||||
await flx._dispatch_route(route=command_route)
|
||||
command.preview.assert_awaited_once()
|
||||
|
||||
unknown_route = route_for(
|
||||
flx, RouteKind.UNKNOWN, current_head="missing", is_preview=True
|
||||
)
|
||||
rendered: list[RouteResult] = []
|
||||
|
||||
async def fake_unknown(route):
|
||||
rendered.append(route)
|
||||
|
||||
monkeypatch.setattr(flx, "_render_unknown_route", fake_unknown)
|
||||
|
||||
await flx._dispatch_route(route=unknown_route)
|
||||
assert rendered == [unknown_route]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_route_unknown_returns_after_rendering(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
route = route_for(flx, RouteKind.UNKNOWN, current_head="missing")
|
||||
rendered: list[RouteResult] = []
|
||||
|
||||
async def fake_unknown(route):
|
||||
rendered.append(route)
|
||||
|
||||
monkeypatch.setattr(flx, "_render_unknown_route", fake_unknown)
|
||||
|
||||
assert await flx._dispatch_route(route=route) is None
|
||||
assert rendered == [route]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_route_rejects_command_route_without_command() -> None:
|
||||
flx = make_falyx()
|
||||
route = route_for(flx, RouteKind.COMMAND, command=None)
|
||||
|
||||
with pytest.raises(FalyxError, match="command expected"):
|
||||
await flx._dispatch_route(route=route)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_requires_error_policy() -> None:
|
||||
flx = make_falyx()
|
||||
|
||||
with pytest.raises(FalyxError, match="requires either"):
|
||||
await flx.execute_command("D", raise_on_error=False, wrap_errors=False)
|
||||
|
||||
|
||||
def test_resolve_completion_route_returns_entry_completion_for_unknown_committed_token() -> (
|
||||
None
|
||||
):
|
||||
flx = make_falyx()
|
||||
context = flx.get_current_invocation_context()
|
||||
|
||||
route = flx.resolve_completion_route(
|
||||
["depoy"],
|
||||
stub="",
|
||||
cursor_at_end_of_token=False,
|
||||
invocation_context=context,
|
||||
)
|
||||
|
||||
assert route.expecting_entry is True
|
||||
assert route.stub == "depoy"
|
||||
assert route.command is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_command_executes_prompt_input_and_reports_falyx_error(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
flx = make_falyx()
|
||||
errors: list[object] = []
|
||||
invalidated: list[bool] = []
|
||||
|
||||
class FakeApp:
|
||||
def invalidate(self) -> None:
|
||||
invalidated.append(True)
|
||||
|
||||
class FakeSession:
|
||||
async def prompt_async(self) -> str:
|
||||
return "D"
|
||||
|
||||
async def fake_execute_command(*args, **kwargs):
|
||||
raise FalyxError("boom")
|
||||
|
||||
monkeypatch.setattr(falyx_module, "get_app", lambda: FakeApp())
|
||||
monkeypatch.setattr(falyx_module.asyncio, "sleep", AsyncMock())
|
||||
monkeypatch.setattr(falyx_module, "patch_stdout", lambda raw=True: nullcontext())
|
||||
monkeypatch.setattr(
|
||||
falyx_module, "print_error", lambda message, **_: errors.append(message)
|
||||
)
|
||||
monkeypatch.setattr(flx, "execute_command", fake_execute_command)
|
||||
flx.__dict__["prompt_session"] = FakeSession()
|
||||
|
||||
await flx._process_command()
|
||||
|
||||
assert invalidated == [True]
|
||||
assert isinstance(errors[0], FalyxError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_menu_handles_flow_signals_and_prints_welcome_and_exit(monkeypatch) -> None:
|
||||
rendered: list[Falyx] = []
|
||||
flx = make_falyx(
|
||||
welcome_message="welcome",
|
||||
exit_message="goodbye",
|
||||
render_menu=lambda app: rendered.append(app),
|
||||
)
|
||||
flx.console = RecordingConsole()
|
||||
signals: list[BaseException] = [
|
||||
HelpSignal(),
|
||||
BackSignal(),
|
||||
CancelSignal(),
|
||||
asyncio.CancelledError(),
|
||||
QuitSignal(),
|
||||
]
|
||||
|
||||
async def fake_process_command() -> None:
|
||||
raise signals.pop(0)
|
||||
|
||||
monkeypatch.setattr(flx, "_process_command", fake_process_command)
|
||||
|
||||
await flx.menu()
|
||||
|
||||
assert rendered == [flx, flx, flx, flx, flx]
|
||||
assert "welcome" in flx.console.rendered
|
||||
assert "goodbye" in flx.console.rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_logs_verbose_unhandled_errors_before_exit(monkeypatch) -> None:
|
||||
flx = make_falyx()
|
||||
flx.options_manager.set("verbose", True, "root")
|
||||
context = flx.get_current_invocation_context()
|
||||
route = RouteResult(kind=RouteKind.NAMESPACE_MENU, namespace=flx, context=context)
|
||||
logged: list[tuple[tuple, dict]] = []
|
||||
|
||||
async def fake_prepare_route(*args, **kwargs):
|
||||
return route, (), {}, {}
|
||||
|
||||
async def fake_dispatch_route(*args, **kwargs):
|
||||
raise FalyxError("boom")
|
||||
|
||||
monkeypatch.setattr(flx, "prepare_route", fake_prepare_route)
|
||||
monkeypatch.setattr(flx, "_dispatch_route", fake_dispatch_route)
|
||||
monkeypatch.setattr(falyx_module.sys, "argv", ["fx", "D"])
|
||||
monkeypatch.setattr(falyx_module, "print_error", lambda message, **_: None)
|
||||
monkeypatch.setattr(
|
||||
falyx_module.logger,
|
||||
"error",
|
||||
lambda *args, **kwargs: logged.append((args, kwargs)),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as error:
|
||||
await flx.run()
|
||||
|
||||
assert error.value.code == 1
|
||||
assert logged
|
||||
assert logged[0][1]["exc_info"] is True
|
||||
@@ -90,6 +90,6 @@ async def test_help_command_bad_argument(capsys):
|
||||
|
||||
flx.add_command("U", "Untagged Command", untagged_command)
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Unexpected positional argument: nonexistent_tag"
|
||||
CommandArgumentError, match="unexpected positional argument: nonexistent_tag"
|
||||
):
|
||||
await flx.execute_command("H nonexistent_tag")
|
||||
|
||||
219
tests/test_falyx/test_options_manager_contract.py
Normal file
219
tests/test_falyx/test_options_manager_contract.py
Normal file
@@ -0,0 +1,219 @@
|
||||
import pytest
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.action import Action, ChainedAction
|
||||
from falyx.command import Command
|
||||
from falyx.options_manager import OptionsManager
|
||||
|
||||
|
||||
def test_seed_missing_and_override_namespace_do_not_leak():
|
||||
options = OptionsManager()
|
||||
options.set("verbose", True, "root")
|
||||
|
||||
options.seed_missing({"verbose": False, "debug_hooks": False}, "root")
|
||||
assert options.get("verbose", namespace_name="root") is True
|
||||
assert options.get("debug_hooks", namespace_name="root") is False
|
||||
|
||||
with options.override_namespace({"verbose": False}, "root"):
|
||||
assert options.get("verbose", namespace_name="root") is False
|
||||
|
||||
assert options.get("verbose", namespace_name="root") is True
|
||||
|
||||
|
||||
def test_command_and_action_read_options_from_expected_namespace():
|
||||
options = OptionsManager()
|
||||
options.from_mapping({"region": "us-east"}, "default")
|
||||
options.from_mapping({"never_prompt": True, "verbose": True}, "root")
|
||||
|
||||
action = Action("deploy-action", lambda: "ok")
|
||||
command = Command.build(
|
||||
key="D",
|
||||
description="Deploy",
|
||||
action=action,
|
||||
options_manager=options,
|
||||
)
|
||||
|
||||
command._inject_options_manager()
|
||||
|
||||
assert command.get_option("region") == "us-east"
|
||||
assert command.get_option("verbose", namespace_name="root") is True
|
||||
|
||||
assert action.get_option("region") == "us-east"
|
||||
assert action.get_option("verbose", namespace_name="root") is True
|
||||
assert action.never_prompt is True
|
||||
assert action.local_never_prompt is None
|
||||
|
||||
|
||||
def test_all_objects_in_one_namespace_share_same_options_manager():
|
||||
flx = Falyx(program="root")
|
||||
|
||||
chain = ChainedAction(
|
||||
name="deploy-flow",
|
||||
actions=[
|
||||
Action("step-one", lambda: "one"),
|
||||
Action("step-two", lambda: "two"),
|
||||
],
|
||||
)
|
||||
command = flx.add_command("D", "Deploy", action=chain)
|
||||
|
||||
command._inject_options_manager()
|
||||
|
||||
assert flx._executor.options_manager is flx.options_manager
|
||||
assert flx.exit_command.options_manager is flx.options_manager
|
||||
assert flx.help_command.options_manager is flx.options_manager
|
||||
|
||||
if flx.history_command:
|
||||
assert flx.history_command.options_manager is flx.options_manager
|
||||
assert flx.history_command.arg_parser.options_manager is flx.options_manager
|
||||
|
||||
for builtin in flx.builtins.values():
|
||||
assert builtin.options_manager is flx.options_manager
|
||||
if builtin.arg_parser:
|
||||
assert builtin.arg_parser.options_manager is flx.options_manager
|
||||
|
||||
assert command.options_manager is flx.options_manager
|
||||
assert command.arg_parser.options_manager is flx.options_manager
|
||||
|
||||
assert chain.options_manager is flx.options_manager
|
||||
for child_action in chain.actions:
|
||||
assert child_action.options_manager is flx.options_manager
|
||||
|
||||
|
||||
def test_nested_namespace_may_keep_distinct_options_manager_if_intended():
|
||||
root_options = OptionsManager()
|
||||
child_options = OptionsManager()
|
||||
|
||||
root = Falyx(program="root", options_manager=root_options)
|
||||
child = Falyx(program="child", options_manager=child_options)
|
||||
child_command = child.add_command("D", "Deploy", action=lambda: "ok")
|
||||
|
||||
root.add_submenu(
|
||||
key="C",
|
||||
description="Child Menu",
|
||||
submenu=child,
|
||||
)
|
||||
|
||||
assert root.options_manager is root_options
|
||||
assert child.options_manager is child_options
|
||||
assert root.options_manager is not child.options_manager
|
||||
|
||||
assert root._executor.options_manager is root_options
|
||||
assert child._executor.options_manager is child_options
|
||||
|
||||
assert child_command.options_manager is child_options
|
||||
assert child_command.arg_parser.options_manager is child_options
|
||||
|
||||
assert child.exit_command.options_manager is child_options
|
||||
assert child.help_command.options_manager is child_options
|
||||
if child.history_command:
|
||||
assert child.history_command.options_manager is child_options
|
||||
|
||||
assert root.namespaces["C"].namespace is child
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_namespace_receives_temporary_root_overrides_during_routed_execution():
|
||||
root_options = OptionsManager()
|
||||
child_options = OptionsManager()
|
||||
|
||||
root = Falyx(program="root", options_manager=root_options)
|
||||
child = Falyx(program="child", options_manager=child_options)
|
||||
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
child.options_manager.set("verbose", False, "root")
|
||||
|
||||
root.add_submenu(
|
||||
key="C",
|
||||
description="Child Menu",
|
||||
submenu=child,
|
||||
)
|
||||
|
||||
seen_during_dispatch = {}
|
||||
|
||||
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
|
||||
assert route.namespace is child
|
||||
seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
|
||||
"verbose", False, "root"
|
||||
)
|
||||
assert seen_during_dispatch["verbose"] is True
|
||||
return "ok"
|
||||
|
||||
root._dispatch_route = fake_dispatch_route
|
||||
|
||||
result = await root.execute_command("--verbose C D")
|
||||
|
||||
assert result == "ok"
|
||||
assert seen_during_dispatch["verbose"] is True
|
||||
|
||||
result = await root.execute_command("C --verbose D")
|
||||
|
||||
assert result == "ok"
|
||||
assert seen_during_dispatch["verbose"] is True
|
||||
|
||||
assert child.options_manager is child_options
|
||||
assert child.options_manager.get("verbose", False, "root") is False
|
||||
assert root.options_manager is root_options
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_applies_root_defaults_without_overwriting_existing_root_values():
|
||||
child = Falyx(program="child")
|
||||
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
child.options_manager.set("verbose", True, "root")
|
||||
|
||||
root = Falyx(program="root")
|
||||
root.add_submenu(
|
||||
key="C",
|
||||
description="Child Menu",
|
||||
submenu=child,
|
||||
)
|
||||
|
||||
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
|
||||
assert route.namespace is child
|
||||
assert route.root_overrides == {}
|
||||
assert route.root_defaults["verbose"] is False
|
||||
assert route.namespace.options_manager.get("verbose", False, "root") is True
|
||||
return "ok"
|
||||
|
||||
root._dispatch_route = fake_dispatch_route
|
||||
|
||||
result = await root.execute_command("C D")
|
||||
|
||||
assert result == "ok"
|
||||
assert child.options_manager.get("verbose", False, "root") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_applies_root_overrides_temporarily_and_restores_root_namespace():
|
||||
child = Falyx(program="child")
|
||||
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
child.options_manager.set("verbose", False, "root")
|
||||
|
||||
root = Falyx(program="root")
|
||||
root.add_submenu(
|
||||
key="C",
|
||||
description="Child Menu",
|
||||
submenu=child,
|
||||
)
|
||||
|
||||
seen_during_dispatch = {}
|
||||
|
||||
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
|
||||
assert route.namespace is child
|
||||
assert route.root_overrides == {"verbose": True}
|
||||
seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
|
||||
"verbose", False, "root"
|
||||
)
|
||||
assert seen_during_dispatch["verbose"] is True
|
||||
return "ok"
|
||||
|
||||
root._dispatch_route = fake_dispatch_route
|
||||
|
||||
result = await root.execute_command("--verbose C D")
|
||||
|
||||
assert result == "ok"
|
||||
assert seen_during_dispatch["verbose"] is True
|
||||
|
||||
assert child.options_manager.get("verbose", False, "root") is False
|
||||
21
tests/test_falyx/test_prompt_contract.py
Normal file
21
tests/test_falyx/test_prompt_contract.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from falyx.action import Action
|
||||
from falyx.command import Command
|
||||
|
||||
|
||||
async def test_action_local_never_prompt_bypasses_command_confirmation(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def fake_confirm(*args, **kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("falyx.command.confirm_async", fake_confirm)
|
||||
|
||||
action = Action("Do Thing", lambda: "ok", never_prompt=True)
|
||||
command = Command.build("D", "Do Thing", action=action, confirm=True)
|
||||
|
||||
result = await command()
|
||||
|
||||
assert result == "ok"
|
||||
assert called is False
|
||||
92
tests/test_falyx/test_routing_contract.py
Normal file
92
tests/test_falyx/test_routing_contract.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import pytest
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.routing import RouteKind
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_route_carries_root_options_through_nested_namespace():
|
||||
child = Falyx(program="child")
|
||||
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
root = Falyx(program="root")
|
||||
root.add_submenu(
|
||||
key="C",
|
||||
description="Child Menu",
|
||||
submenu=child,
|
||||
)
|
||||
|
||||
route = await root.resolve_route(
|
||||
["--verbose", "C", "D"],
|
||||
invocation_context=root.get_current_invocation_context(),
|
||||
)
|
||||
|
||||
assert route.context.typed_path[-2:] == ["C", "D"]
|
||||
|
||||
assert route.kind is RouteKind.COMMAND
|
||||
assert route.namespace is child
|
||||
assert route.command is child.commands["D"]
|
||||
assert route.leaf_argv == []
|
||||
|
||||
assert route.root_overrides == {"verbose": True}
|
||||
assert route.root_defaults["verbose"] is False
|
||||
assert route.root_defaults["debug_hooks"] is False
|
||||
assert route.root_defaults["never_prompt"] is False
|
||||
|
||||
assert route.namespace_overrides == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_route_returns_unknown_when_only_namespace_options_are_provided():
|
||||
flx = Falyx(program="falyx")
|
||||
flx.add_option("--profile", default="dev")
|
||||
|
||||
route = await flx.resolve_route(
|
||||
["--profile", "prod"],
|
||||
invocation_context=flx.get_current_invocation_context(),
|
||||
)
|
||||
|
||||
assert route.kind is RouteKind.UNKNOWN
|
||||
assert route.namespace is flx
|
||||
assert route.command is None
|
||||
assert route.current_head == ""
|
||||
assert route.is_preview is False
|
||||
assert route.root_defaults == {}
|
||||
assert route.root_overrides == {}
|
||||
assert route.namespace_defaults == {}
|
||||
assert route.namespace_overrides == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_route_returns_unknown_when_only_root_options_are_provided():
|
||||
flx = Falyx(program="falyx")
|
||||
|
||||
route = await flx.resolve_route(
|
||||
["--verbose"],
|
||||
invocation_context=flx.get_current_invocation_context(),
|
||||
)
|
||||
|
||||
assert route.kind is RouteKind.UNKNOWN
|
||||
assert route.namespace is flx
|
||||
assert route.command is None
|
||||
assert route.current_head == ""
|
||||
assert route.is_preview is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_route_returns_unknown_when_nested_namespace_consumes_only_options():
|
||||
child = Falyx(program="child")
|
||||
child.add_option("--region", default="us-east")
|
||||
|
||||
root = Falyx(program="root")
|
||||
root.add_submenu(key="C", description="Child", submenu=child)
|
||||
|
||||
route = await root.resolve_route(
|
||||
["C", "--region", "us-west"],
|
||||
invocation_context=root.get_current_invocation_context(),
|
||||
)
|
||||
|
||||
assert route.kind is RouteKind.UNKNOWN
|
||||
assert route.namespace is child
|
||||
assert route.command is None
|
||||
assert route.context.typed_path[-1] == "C"
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
|
||||
|
||||
@@ -107,27 +106,25 @@ async def test_run_default_to_menu_help(flx):
|
||||
async def test_run_debug_hooks(flx):
|
||||
sys.argv = ["falyx", "--debug-hooks", "T"]
|
||||
|
||||
assert flx.options.get("debug_hooks") is False
|
||||
assert flx.options_manager.get("debug_hooks", namespace_name="root") is False
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
await flx.run()
|
||||
|
||||
assert flx.options.get("debug_hooks") is True
|
||||
assert flx.options_manager.get("debug_hooks", namespace_name="root") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_never_prompt(flx):
|
||||
sys.argv = ["falyx", "--never-prompt", "T"]
|
||||
|
||||
assert flx.options.get("never_prompt") is False
|
||||
assert flx.options_manager.get("never_prompt", namespace_name="root") 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
|
||||
assert flx.options_manager.get("debug_hooks", namespace_name="root") is False
|
||||
assert flx.options_manager.get("never_prompt", namespace_name="root") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -253,3 +250,70 @@ async def test_run_preview(flx):
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "Command: 'T'" in captured
|
||||
assert "Would call: <lambda>(args=(), kwargs={})" in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_applies_root_defaults_without_overwriting_existing_root_values():
|
||||
child = Falyx(program="child")
|
||||
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
child.options_manager.set("verbose", True, "root")
|
||||
|
||||
root = Falyx(program="root")
|
||||
root.add_submenu(
|
||||
key="C",
|
||||
description="Child Menu",
|
||||
submenu=child,
|
||||
)
|
||||
|
||||
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
|
||||
assert route.namespace is child
|
||||
assert route.root_overrides == {}
|
||||
assert route.root_defaults["verbose"] is False
|
||||
assert route.namespace.options_manager.get("verbose", False, "root") is True
|
||||
|
||||
root._dispatch_route = fake_dispatch_route
|
||||
|
||||
sys.argv = ["falyx", "C", "D"]
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
await root.run()
|
||||
|
||||
assert excinfo.value.code == 0
|
||||
|
||||
assert child.options_manager.get("verbose", False, "root") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_applies_root_overrides_temporarily_and_restores_root_namespace():
|
||||
child = Falyx(program="child")
|
||||
child.add_command("D", "Deploy", action=lambda: "ok", aliases=["deploy"])
|
||||
|
||||
child.options_manager.set("verbose", False, "root")
|
||||
|
||||
root = Falyx(program="root")
|
||||
root.add_submenu(
|
||||
key="C",
|
||||
description="Child Menu",
|
||||
submenu=child,
|
||||
)
|
||||
|
||||
seen_during_dispatch = {}
|
||||
|
||||
async def fake_dispatch_route(*, route, args, kwargs, execution_args, **_):
|
||||
seen_during_dispatch["verbose"] = route.namespace.options_manager.get(
|
||||
"verbose", False, "root"
|
||||
)
|
||||
assert route.namespace is child
|
||||
assert route.root_overrides == {"verbose": True}
|
||||
assert seen_during_dispatch["verbose"] is True
|
||||
|
||||
root._dispatch_route = fake_dispatch_route
|
||||
|
||||
sys.argv = ["falyx", "--verbose", "C", "D"]
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
await root.run()
|
||||
|
||||
assert excinfo.value.code == 0
|
||||
assert seen_during_dispatch["verbose"] is True
|
||||
|
||||
assert child.options_manager.get("verbose", False, "root") is False
|
||||
|
||||
15
tests/test_falyx/test_signals.py
Normal file
15
tests/test_falyx/test_signals.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from falyx import Falyx
|
||||
|
||||
|
||||
async def test_run_quit_signal_exits_130(monkeypatch):
|
||||
flx = Falyx(default_to_menu=False)
|
||||
monkeypatch.setattr(sys, "argv", ["prog", "X"])
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
await flx.run()
|
||||
|
||||
assert exc.value.code == 130
|
||||
Reference in New Issue
Block a user