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
857 lines
25 KiB
Python
857 lines
25 KiB
Python
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
|