Files
falyx/tests/test_falyx/test_extra.py
Roland Thomas efe3f5fd99 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
2026-06-07 13:04:35 -04:00

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