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:
2026-06-07 13:04:35 -04:00
parent 8db7a9e6dc
commit efe3f5fd99
78 changed files with 9513 additions and 433 deletions

View 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]

View 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

View 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"]

View 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

View 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"

View 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)

View File

@@ -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

View 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

View File

@@ -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")

View 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

View 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

View 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"

View File

@@ -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

View 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