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,334 @@
import pytest
from falyx.action.action import Action
from falyx.action.action_group import ActionGroup
from falyx.action.chained_action import ChainedAction
from falyx.action.http_action import HTTPAction
from falyx.action.menu_action import MenuAction
from falyx.action.process_action import ProcessAction
from falyx.hook_manager import HookType
from falyx.menu import MenuOption, MenuOptionMap
from falyx.retry import RetryHandler, RetryPolicy
def _retry_hooks(action) -> list:
return [
hook
for hook in action.hooks._hooks[HookType.ON_ERROR]
if isinstance(getattr(hook, "__self__", None), RetryHandler)
]
def _non_retry_error_hooks(action) -> list:
return [
hook
for hook in action.hooks._hooks[HookType.ON_ERROR]
if not isinstance(getattr(hook, "__self__", None), RetryHandler)
]
def _before_hooks(action) -> list:
return list(action.hooks._hooks[HookType.BEFORE])
def test_action_group_clone_recursively_isolates_nested_action_graph():
nested_chain = ChainedAction(
name="nested-chain",
actions=[
Action("step-two", lambda: "two"),
Action("step-three", lambda: "three"),
],
)
original = ActionGroup(
name="group",
actions=[
Action("step-one", lambda: "one"),
nested_chain,
],
)
cloned = original.clone()
assert cloned is not original
assert cloned.actions is not original.actions
assert len(cloned.actions) == len(original.actions)
# Top-level children are cloned.
assert cloned.actions[0] is not original.actions[0]
assert cloned.actions[1] is not original.actions[1]
# Nested action graph is also cloned.
assert isinstance(cloned.actions[1], ChainedAction)
assert cloned.actions[1].actions is not original.actions[1].actions
for cloned_child, original_child in zip(
cloned.actions[1].actions,
original.actions[1].actions,
strict=True,
):
assert cloned_child is not original_child
assert cloned_child.name == original_child.name
# Mutating the clone does not mutate the original.
cloned.actions.append(Action("step-four", lambda: "four"))
assert len(cloned.actions) == 3
assert len(original.actions) == 2
cloned.actions[1].actions.append(Action("step-five", lambda: "five"))
assert len(cloned.actions[1].actions) == 3
assert len(original.actions[1].actions) == 2
def test_menu_action_clone_copies_menu_option_map_and_clones_contained_actions():
menu_options = MenuOptionMap(disable_reserved=True)
menu_options["A"] = MenuOption(
description="Alpha",
action=Action("alpha-action", lambda: "alpha"),
)
original = MenuAction(
name="main-menu",
menu_options=menu_options,
title="Main Menu",
)
cloned = original.clone()
assert cloned is not original
assert cloned.menu_options is not original.menu_options
assert cloned.menu_options["A"] is not original.menu_options["A"]
assert cloned.menu_options["A"].description == original.menu_options["A"].description
# Contained action should also be cloned.
assert cloned.menu_options["A"].action is not original.menu_options["A"].action
assert cloned.menu_options["A"].action.name == original.menu_options["A"].action.name
# Mutating the clone should not affect the original.
cloned.menu_options["A"].description = "Changed"
assert original.menu_options["A"].description == "Alpha"
cloned.menu_options["B"] = MenuOption(
description="Beta",
action=Action("beta-action", lambda: "beta"),
)
assert "B" in cloned.menu_options
assert "B" not in original.menu_options
def test_process_action_clone_does_not_reuse_runtime_only_executor_state():
original = ProcessAction(
name="proc",
action=lambda x: x + 1,
args=(1,),
kwargs={"y": 2},
)
original.executor = object()
cloned = original.clone()
assert cloned is not original
assert cloned.hooks is not original.hooks
assert cloned.args == original.args
assert cloned.kwargs == original.kwargs
assert cloned.executor is not original.executor
def test_http_action_clone_preserves_retry_policy_without_duplicating_spinner_hooks():
retry_policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
retry_policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
headers={"Authorization": "Bearer token"},
params={"page": 1},
retry_policy=retry_policy,
spinner=True,
)
before_count = len(original.hooks._hooks[HookType.BEFORE])
teardown_count = len(original.hooks._hooks[HookType.ON_TEARDOWN])
error_count = len(original.hooks._hooks[HookType.ON_ERROR])
cloned = original.clone()
assert cloned is not original
assert cloned.hooks is not original.hooks
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
assert len(cloned.hooks._hooks[HookType.BEFORE]) == before_count
assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == teardown_count
assert len(cloned.hooks._hooks[HookType.ON_ERROR]) == error_count
@pytest.mark.asyncio
async def test_action_clone_registers_exactly_one_retry_hook():
async def flaky():
return "ok"
policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
policy.enable_policy()
original = Action(
"flaky",
flaky,
retry_policy=policy,
)
cloned = original.clone()
original_retry_hooks = _retry_hooks(original)
cloned_retry_hooks = _retry_hooks(cloned)
assert len(original_retry_hooks) == 1
assert len(cloned_retry_hooks) == 1
assert cloned_retry_hooks[0] is not original_retry_hooks[0]
assert getattr(cloned_retry_hooks[0], "__self__", None) is not getattr(
original_retry_hooks[0], "__self__", None
)
def test_action_clone_preserves_non_retry_hooks_without_duplication():
calls = []
async def custom_error_hook(context):
calls.append(context.name)
original = Action("demo", lambda: "ok")
original.hooks.register(HookType.BEFORE, lambda context: None)
original.hooks.register(HookType.ON_ERROR, custom_error_hook)
cloned = original.clone()
assert len(_before_hooks(cloned)) == len(_before_hooks(original))
assert len(_non_retry_error_hooks(cloned)) == len(_non_retry_error_hooks(original))
assert cloned.hooks is not original.hooks
def test_action_clone_copies_retry_policy_without_sharing_it():
policy = RetryPolicy(max_retries=2, delay=0.25, backoff=3.0)
policy.enable_policy()
original = Action(
"demo",
lambda: "ok",
retry_policy=policy,
)
cloned = original.clone()
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
cloned.retry_policy.max_retries = 9
assert original.retry_policy.max_retries == 2
@pytest.mark.asyncio
async def test_action_clone_retry_behavior_still_works_independently():
state = {"original": 0, "clone": 0}
async def flaky_original():
if state["original"] == 0:
state["original"] += 1
raise RuntimeError("boom")
return "original-ok"
async def flaky_clone():
if state["clone"] == 0:
state["clone"] += 1
raise RuntimeError("boom")
return "clone-ok"
policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
policy.enable_policy()
original = Action("orig", flaky_original, retry_policy=policy)
cloned = original.clone()
cloned.action = flaky_clone
original_result = await original()
cloned_result = await cloned()
assert original_result == "original-ok"
assert cloned_result == "clone-ok"
assert state["original"] == 1
assert state["clone"] == 1
def test_http_action_clone_registers_exactly_one_retry_hook():
policy = RetryPolicy(max_retries=2, delay=0.1, backoff=2.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
spinner=True,
)
cloned = original.clone()
original_retry_hooks = _retry_hooks(original)
cloned_retry_hooks = _retry_hooks(cloned)
assert len(original_retry_hooks) == 1
assert len(cloned_retry_hooks) == 1
assert cloned_retry_hooks[0] is not original_retry_hooks[0]
def test_http_action_clone_copies_retry_policy_without_sharing_it():
policy = RetryPolicy(max_retries=3, delay=1.5, backoff=2.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
)
cloned = original.clone()
assert cloned.retry_policy is not original.retry_policy
assert cloned.retry_policy.enabled is original.retry_policy.enabled
assert cloned.retry_policy.max_retries == original.retry_policy.max_retries
assert cloned.retry_policy.delay == original.retry_policy.delay
assert cloned.retry_policy.backoff == original.retry_policy.backoff
def test_http_action_clone_does_not_duplicate_spinner_hooks():
policy = RetryPolicy(max_retries=1, delay=0.0, backoff=1.0)
policy.enable_policy()
original = HTTPAction(
name="get-users",
method="GET",
url="https://example.com/api/users",
retry_policy=policy,
spinner=True,
)
cloned = original.clone()
assert len(cloned.hooks._hooks[HookType.BEFORE]) == len(
original.hooks._hooks[HookType.BEFORE]
)
assert len(cloned.hooks._hooks[HookType.ON_TEARDOWN]) == len(
original.hooks._hooks[HookType.ON_TEARDOWN]
)

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
import csv
import json
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
import pytest
import toml
import yaml
from rich.tree import Tree
from falyx.action.action_types import FileType
from falyx.action.save_file_action import SaveFileAction
from falyx.hook_manager import HookType
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def make_action(file_path: Path | str | None, **overrides: Any) -> SaveFileAction:
defaults: dict[str, Any] = {
"name": "SaveOutput",
"file_path": file_path,
}
defaults.update(overrides)
return SaveFileAction(**defaults)
def register_lifecycle_hooks(action: SaveFileAction) -> list[tuple[HookType, Any]]:
calls: list[tuple[HookType, Any]] = []
def make_hook(hook_type: HookType):
def hook(context: Any) -> None:
calls.append((hook_type, context))
return hook
for hook_type in HookType:
action.hooks.register(hook_type, make_hook(hook_type))
return calls
def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
return [hook_type for hook_type, _ in calls]
def test_init_normalizes_configuration_and_string_file_type(tmp_path: Path) -> None:
target = tmp_path / "output.json"
action = SaveFileAction(
name="SaveJson",
file_path=str(target),
file_type="json",
mode="a",
encoding="utf-8",
data={"name": "falyx"},
overwrite=False,
create_dirs=False,
inject_last_result=True,
inject_into="payload",
never_prompt=True,
)
assert action.name == "SaveJson"
assert action.file_path == target
assert action.file_type == FileType.JSON
assert action.mode == "a"
assert action.encoding == "utf-8"
assert action.data == {"name": "falyx"}
assert action.overwrite is False
assert action.create_dirs is False
assert action.inject_last_result is True
assert action.inject_into == "payload"
assert action.local_never_prompt is True
assert "SaveFileAction" in str(action)
assert "output.json" in str(action)
def test_file_path_property_coerces_string_path_and_none(tmp_path: Path) -> None:
action = make_action(None)
assert action.file_path is None
target = tmp_path / "later.txt"
action.file_path = str(target)
assert action.file_path == target
action.file_path = target
assert action.file_path == target
def test_file_path_rejects_unsupported_values(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.txt")
with pytest.raises(TypeError, match="file_path must be a string or Path object"):
action.file_path = 123 # type: ignore[assignment]
def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.txt")
assert action.get_infer_target() == (None, None)
def test_dict_to_xml_serializes_nested_dicts_lists_and_scalars(tmp_path: Path) -> None:
action = make_action(tmp_path / "out.xml", file_type=FileType.XML)
root = ET.Element("root")
action._dict_to_xml(
{
"name": "falyx",
"metadata": {"version": "0.2.0"},
"tags": ["cli", "framework"],
"commands": [{"name": "run"}, {"name": "help"}],
},
root,
)
assert root.findtext("name") == "falyx"
assert root.find("metadata") is not None
assert root.find("metadata/version") is not None
assert root.findtext("metadata/version") == "0.2.0"
assert [element.text for element in root.findall("tags")] == ["cli", "framework"]
assert [element.findtext("name") for element in root.findall("commands")] == [
"run",
"help",
]
@pytest.mark.asyncio
async def test_save_file_requires_file_path_before_saving() -> None:
action = make_action(None, data="hello")
with pytest.raises(ValueError, match="file_path must be set"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_refuses_to_overwrite_existing_file_when_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "existing.txt"
target.write_text("original", encoding="UTF-8")
action = make_action(target, overwrite=False)
with pytest.raises(FileExistsError, match="File already exists"):
await action.save_file("replacement")
assert target.read_text(encoding="UTF-8") == "original"
@pytest.mark.asyncio
async def test_save_file_requires_parent_directory_when_create_dirs_is_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "missing" / "out.txt"
action = make_action(target, create_dirs=False)
with pytest.raises(FileNotFoundError, match="Directory does not exist"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_creates_missing_parent_directories(tmp_path: Path) -> None:
target = tmp_path / "nested" / "out.txt"
action = make_action(target, file_type=FileType.TEXT, create_dirs=True)
await action.save_file("hello")
assert target.read_text(encoding="UTF-8") == "hello"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("file_type", "filename", "data"),
[
(FileType.TEXT, "note.txt", "hello"),
(FileType.JSON, "data.json", {"name": "falyx", "count": 2}),
(FileType.YAML, "data.yaml", {"name": "falyx", "enabled": True}),
(FileType.TOML, "data.toml", {"name": "falyx", "count": 2}),
(FileType.CSV, "rows.csv", [["name", "count"], ["falyx", "2"]]),
(FileType.TSV, "rows.tsv", [["name", "count"], ["falyx", "2"]]),
(
FileType.XML,
"data.xml",
{
"name": "falyx",
"metadata": {"version": "0.2.0"},
"tags": ["cli", "framework"],
},
),
],
)
async def test_save_file_writes_supported_file_types(
tmp_path: Path,
file_type: FileType,
filename: str,
data: Any,
) -> None:
target = tmp_path / filename
action = make_action(target, file_type=file_type)
await action.save_file(data)
if file_type == FileType.TEXT:
assert target.read_text(encoding="UTF-8") == data
elif file_type == FileType.JSON:
assert json.loads(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.YAML:
assert yaml.safe_load(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.TOML:
assert toml.loads(target.read_text(encoding="UTF-8")) == data
elif file_type == FileType.CSV:
with target.open(newline="", encoding="UTF-8") as file:
assert list(csv.reader(file)) == data
elif file_type == FileType.TSV:
with target.open(newline="", encoding="UTF-8") as file:
assert list(csv.reader(file, delimiter="\t")) == data
elif file_type == FileType.XML:
root = ET.parse(target).getroot()
assert root.tag == "root"
assert root.findtext("name") == "falyx"
assert root.findtext("metadata/version") == "0.2.0"
assert [element.text for element in root.findall("tags")] == [
"cli",
"framework",
]
@pytest.mark.asyncio
@pytest.mark.parametrize("file_type", [FileType.CSV, FileType.TSV])
@pytest.mark.parametrize(
"data",
[
{"name": "falyx"},
["name", "count"],
[["name", "count"], "not-a-row"],
],
)
async def test_save_file_requires_list_of_lists_for_delimited_formats(
tmp_path: Path,
file_type: FileType,
data: Any,
) -> None:
target = tmp_path / "rows.data"
action = make_action(target, file_type=file_type)
with pytest.raises(ValueError, match="requires a list of lists"):
await action.save_file(data)
@pytest.mark.asyncio
async def test_save_file_requires_dict_for_xml(tmp_path: Path) -> None:
target = tmp_path / "data.xml"
action = make_action(target, file_type=FileType.XML)
with pytest.raises(
ValueError, match="XML file type requires data to be a dictionary"
):
await action.save_file(["not", "a", "dict"])
@pytest.mark.asyncio
async def test_save_file_raises_for_unsupported_internal_file_type(
tmp_path: Path,
) -> None:
target = tmp_path / "data.out"
action = make_action(target, file_type=FileType.TEXT)
action._file_type = object() # Force the defensive unsupported-type branch.
with pytest.raises(ValueError, match="Unsupported file type"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_save_file_reraises_write_errors(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT)
def fake_write_text(self: Path, data: str, *, encoding: str | None = None) -> int:
raise OSError("disk is unavailable")
monkeypatch.setattr(Path, "write_text", fake_write_text)
with pytest.raises(OSError, match="disk is unavailable"):
await action.save_file("hello")
@pytest.mark.asyncio
async def test_run_saves_configured_data_and_triggers_success_lifecycle(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT, data="hello")
calls = register_lifecycle_hooks(action)
result = await action("positional", ignored="kwarg")
assert result == str(target)
assert target.read_text(encoding="UTF-8") == "hello"
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert calls[0][1].args == ("positional",)
assert calls[0][1].kwargs == {"ignored": "kwarg"}
assert calls[0][1].action is action
@pytest.mark.asyncio
async def test_run_uses_data_from_kwargs_when_no_static_data_is_configured(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.TEXT, data=None)
result = await action(data="from kwargs")
assert result == str(target)
assert target.read_text(encoding="UTF-8") == "from kwargs"
@pytest.mark.asyncio
async def test_run_triggers_error_lifecycle_and_reraises(tmp_path: Path) -> None:
action = make_action(None, data="hello")
calls = register_lifecycle_hooks(action)
with pytest.raises(ValueError, match="file_path must be set"):
await action()
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert isinstance(calls[1][1].exception, ValueError)
@pytest.mark.asyncio
async def test_preview_prints_tree_for_existing_file_when_overwrite_enabled(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
target.write_text("existing", encoding="UTF-8")
action = make_action(target, file_type=FileType.TEXT, overwrite=True)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_prints_tree_for_existing_file_when_overwrite_disabled(
tmp_path: Path,
) -> None:
target = tmp_path / "out.txt"
target.write_text("existing", encoding="UTF-8")
action = make_action(target, file_type=FileType.TEXT, overwrite=False)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_adds_to_existing_parent_without_printing(tmp_path: Path) -> None:
target = tmp_path / "out.txt"
action = make_action(target, file_type=FileType.JSON)
action.console = CaptureConsole()
parent = Tree("root")
await action.preview(parent=parent)
assert action.console.printed == []
assert len(parent.children) == 1
def test_clone_preserves_configuration_but_returns_distinct_action(
tmp_path: Path,
) -> None:
target = tmp_path / "out.json"
action = make_action(
target,
file_type=FileType.JSON,
mode="a",
encoding="utf-8",
data={"name": "falyx"},
overwrite=False,
create_dirs=False,
inject_last_result=True,
inject_into="payload",
never_prompt=True,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.file_path == action.file_path
assert clone.file_type == action.file_type
assert clone.mode == action.mode
assert clone.encoding == action.encoding
assert clone.data == action.data
assert clone.overwrite is action.overwrite
assert clone.create_dirs is action.create_dirs
assert clone.inject_last_result is action.inject_last_result
assert clone.inject_into == action.inject_into
assert clone.local_never_prompt is True

View File

@@ -1,7 +1,83 @@
import pytest
from __future__ import annotations
from falyx.action import SelectionAction
from falyx.selection import SelectionOption
from typing import Any
import pytest
from rich.tree import Tree
import falyx.action.selection_action as selection_action_module
from falyx.action.action_types import SelectionReturnType
from falyx.action.selection_action import SelectionAction
from falyx.hook_manager import HookType
from falyx.selection import SelectionOption, SelectionOptionMap
from falyx.signals import CancelSignal
class DummyPromptSession:
pass
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
class FakeSharedContext:
def __init__(self, value: Any) -> None:
self.value = value
def last_result(self) -> Any:
return self.value
class SizedButUnsupportedSelections:
def __len__(self) -> int:
return 0
def make_action(selections: Any | None = None, **overrides: Any) -> SelectionAction:
defaults: dict[str, Any] = {
"name": "ChooseThing",
"selections": (
selections if selections is not None else ["alpha", "beta", "gamma"]
),
"prompt_session": DummyPromptSession(),
}
defaults.update(overrides)
return SelectionAction(**defaults)
def make_option_map_action(**overrides: Any) -> SelectionAction:
return make_action(
{
"0": SelectionOption("Development", "dev"),
"1": SelectionOption("Production", "prod"),
"2": SelectionOption("Staging", "stage"),
},
**overrides,
)
def register_lifecycle_hooks(action: SelectionAction) -> list[tuple[HookType, Any]]:
calls: list[tuple[HookType, Any]] = []
def make_hook(hook_type: HookType):
def hook(context: Any) -> None:
calls.append((hook_type, context))
return hook
for hook_type in HookType:
action.hooks.register(hook_type, make_hook(hook_type))
return calls
def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
return [hook_type for hook_type, _ in calls]
@pytest.mark.asyncio
@@ -285,3 +361,586 @@ async def test_selection_prompt_map_never_prompt_by_value_wildcard():
result = await action()
assert result == ["Beta Service", "Alpha Service"]
def test_init_normalizes_list_tuple_set_and_basic_configuration() -> None:
session = DummyPromptSession()
tuple_action = SelectionAction(
name="TupleChoice",
selections=("red", "blue"),
title="Colors",
columns=2,
prompt_message="[bold]Pick >[/] ",
default_selection="1",
number_selections=1,
separator=";",
allow_duplicates=True,
return_type="value",
prompt_session=session,
never_prompt=True,
show_table=False,
)
assert tuple_action.selections == ["red", "blue"]
assert tuple_action.return_type is SelectionReturnType.VALUE
assert tuple_action.title == "Colors"
assert tuple_action.columns == 2
assert tuple_action.default_selection == "1"
assert tuple_action.separator == ";"
assert tuple_action.allow_duplicates is True
assert tuple_action.prompt_session is session
assert tuple_action.local_never_prompt is True
assert tuple_action.show_table is False
set_action = make_action({"red", "blue"})
assert sorted(set_action.selections) == ["blue", "red"]
def test_init_converts_plain_dict_to_selection_option_map() -> None:
action = make_action({"dev": "Development", "prod": "Production"})
assert isinstance(action.selections, SelectionOptionMap)
assert list(action.selections) == ["0", "1"]
assert action.selections["0"] == SelectionOption("dev", "Development")
assert action.selections["1"] == SelectionOption("prod", "Production")
def test_init_preserves_selection_option_map_values() -> None:
action = make_action(
{
"D": SelectionOption("Development", "dev", style="green"),
"P": SelectionOption("Production", "prod", style="red"),
}
)
assert isinstance(action.selections, SelectionOptionMap)
assert action.selections["D"].description == "Development"
assert action.selections["P"].value == "prod"
@pytest.mark.parametrize("number_selections", [1, 2, "*"])
def test_number_selections_accepts_positive_ints_and_star(
number_selections: int | str,
) -> None:
action = make_action(number_selections=number_selections)
assert action.number_selections == number_selections
@pytest.mark.parametrize("number_selections", [0, -1, "many", object()])
def test_number_selections_rejects_invalid_values(number_selections: Any) -> None:
action = make_action()
with pytest.raises(ValueError, match="number_selections"):
action.number_selections = number_selections
@pytest.mark.parametrize(
("selections", "error_type", "match"),
[
({1: SelectionOption("One", 1)}, ValueError, "Invalid dictionary format"),
(123, TypeError, "selections"),
],
)
def test_selections_setter_rejects_invalid_inputs(
selections: Any,
error_type: type[BaseException],
match: str,
) -> None:
with pytest.raises(error_type, match=match):
make_action(selections)
def test_find_cancel_key_returns_numeric_gap_for_dict_and_next_index_for_list() -> None:
dict_action = make_action(
{
"0": SelectionOption("Zero", 0),
"2": SelectionOption("Two", 2),
}
)
list_action = make_action(["zero", "one"])
assert dict_action._find_cancel_key() == "1"
assert list_action._find_cancel_key() == "2"
def test_cancel_key_setter_rejects_non_string_values() -> None:
action = make_action()
with pytest.raises(TypeError, match="Cancel key must be a string"):
action.cancel_key = 1 # type: ignore[assignment]
def test_cancel_key_setter_rejects_existing_dict_key() -> None:
action = make_action({"A": SelectionOption("Alpha", "alpha")})
with pytest.raises(
ValueError, match="Cancel key cannot be one of the selection keys"
):
action.cancel_key = "A"
@pytest.mark.parametrize("cancel_key", ["x", "3"])
def test_cancel_key_setter_rejects_invalid_list_cancel_key(cancel_key: str) -> None:
action = make_action(["alpha", "beta"])
with pytest.raises(ValueError, match="cancel_key must be a digit"):
action.cancel_key = cancel_key
def test_cancel_formatter_marks_cancel_key_and_formats_regular_items() -> None:
action = make_action(["alpha", "beta"])
action.cancel_key = "2"
assert "Cancel" in action.cancel_formatter(2, "Cancel")
assert action.cancel_formatter(1, "beta").endswith("beta")
def test_get_infer_target_disables_signature_inference() -> None:
action = make_action()
assert action.get_infer_target() == (None, None)
@pytest.mark.parametrize(
("return_type", "keys", "expected"),
[
(SelectionReturnType.KEY, "0", "0"),
(SelectionReturnType.KEY, ["0", "2"], ["0", "2"]),
(SelectionReturnType.VALUE, "1", "prod"),
(SelectionReturnType.VALUE, ["0", "2"], ["dev", "stage"]),
(SelectionReturnType.DESCRIPTION, "0", "Development"),
(
SelectionReturnType.DESCRIPTION,
["0", "2"],
["Development", "Staging"],
),
(
SelectionReturnType.DESCRIPTION_VALUE,
"1",
{"Production": "prod"},
),
(
SelectionReturnType.DESCRIPTION_VALUE,
["0", "2"],
{"Development": "dev", "Staging": "stage"},
),
],
)
def test_get_result_from_keys_returns_configured_shape(
return_type: SelectionReturnType,
keys: str | list[str],
expected: Any,
) -> None:
action = make_option_map_action(return_type=return_type)
assert action._get_result_from_keys(keys) == expected
@pytest.mark.parametrize("keys", ["0", ["0", "1"]])
def test_get_result_from_keys_returns_items_mapping(keys: str | list[str]) -> None:
action = make_option_map_action(return_type=SelectionReturnType.ITEMS)
result = action._get_result_from_keys(keys)
assert isinstance(result, dict)
assert set(result) == ({keys} if isinstance(keys, str) else set(keys))
assert all(isinstance(option, SelectionOption) for option in result.values())
def test_get_result_from_keys_requires_dict_selections() -> None:
action = make_action(["alpha", "beta"])
with pytest.raises(TypeError, match="Selections must be a dictionary"):
action._get_result_from_keys("0")
def test_get_result_from_keys_rejects_unsupported_return_type() -> None:
action = make_option_map_action()
action.return_type = object() # Force defensive branch unreachable through __init__.
with pytest.raises(ValueError, match="Unsupported return type"):
action._get_result_from_keys("0")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("maybe_result", "expected"),
[
("1", "1"),
("prod", "1"),
("Production", "1"),
],
)
async def test_resolve_single_default_maps_dict_key_value_and_description(
maybe_result: str,
expected: str,
) -> None:
action = make_option_map_action()
assert await action._resolve_single_default(maybe_result) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
("maybe_result", "expected"),
[
("1", "1"),
("beta", "1"),
("missing", ""),
],
)
async def test_resolve_single_default_maps_list_index_or_value(
maybe_result: str,
expected: str,
) -> None:
action = make_action(["alpha", "beta"])
assert await action._resolve_single_default(maybe_result) == expected
@pytest.mark.asyncio
async def test_resolve_effective_default_uses_first_value_for_single_selection_defaults() -> (
None
):
action = make_action(["alpha", "beta"], default_selection=["beta"])
assert await action._resolve_effective_default() == "1"
@pytest.mark.asyncio
async def test_resolve_effective_default_uses_first_last_result_for_single_selection() -> (
None
):
action = make_action(["alpha", "beta"])
action.shared_context = FakeSharedContext(["beta"])
assert await action._resolve_effective_default() == "1"
@pytest.mark.asyncio
async def test_resolve_effective_default_joins_multi_selection_defaults() -> None:
action = make_action(
["alpha", "beta", "gamma"],
default_selection=["alpha", "gamma"],
number_selections=2,
)
assert await action._resolve_effective_default() == "0,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_joins_multi_selection_last_result() -> None:
action = make_action(["alpha", "beta", "gamma"], number_selections=2)
action.shared_context = FakeSharedContext(["alpha", "gamma"])
assert await action._resolve_effective_default() == "0,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_allows_unbounded_multi_selection_last_result() -> (
None
):
action = make_action(["alpha", "beta", "gamma"], number_selections="*")
action.shared_context = FakeSharedContext(["alpha", "beta", "gamma"])
assert await action._resolve_effective_default() == "0,1,2"
@pytest.mark.asyncio
async def test_resolve_effective_default_rejects_default_length_mismatch() -> None:
action = make_action(
["alpha", "beta", "gamma"],
default_selection=["alpha"],
number_selections=2,
)
with pytest.raises(ValueError, match="default_selection has a different length"):
await action._resolve_effective_default()
@pytest.mark.asyncio
async def test_resolve_effective_default_rejects_last_result_length_mismatch() -> None:
action = make_action(["alpha", "beta", "gamma"], number_selections=2)
action.shared_context = FakeSharedContext(["alpha"])
with pytest.raises(ValueError, match="last_result has a different length"):
await action._resolve_effective_default()
@pytest.mark.asyncio
async def test_resolve_effective_default_warns_when_injected_result_is_unusable(
caplog: pytest.LogCaptureFixture,
) -> None:
action = make_action(
["alpha", "beta"],
inject_last_result=True,
number_selections=2,
)
action.shared_context = FakeSharedContext("missing")
assert await action._resolve_effective_default() == ""
assert "Injected last result" in caplog.text
@pytest.mark.asyncio
async def test_run_list_headless_single_selection_uses_default() -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="1")
result = await action()
assert result == "beta"
@pytest.mark.asyncio
async def test_run_list_headless_multi_selection_uses_default_list() -> None:
action = make_action(
["alpha", "beta", "gamma"],
never_prompt=True,
default_selection=["alpha", "gamma"],
number_selections=2,
)
result = await action()
assert result == ["alpha", "gamma"]
@pytest.mark.asyncio
async def test_run_dict_headless_single_selection_returns_value() -> None:
action = make_option_map_action(never_prompt=True, default_selection="1")
result = await action()
assert result == "prod"
@pytest.mark.asyncio
async def test_run_dict_headless_multi_selection_returns_configured_shape() -> None:
action = make_option_map_action(
never_prompt=True,
default_selection=["0", "2"],
number_selections=2,
return_type=SelectionReturnType.DESCRIPTION_VALUE,
)
result = await action()
assert result == {"Development": "dev", "Staging": "stage"}
@pytest.mark.asyncio
async def test_run_list_interactive_uses_prompt_for_index(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha", "beta"], never_prompt=False, show_table=False)
async def fake_prompt_for_index(*args: Any, **kwargs: Any) -> int:
assert kwargs["prompt_session"] is action.prompt_session
assert kwargs["show_table"] is False
assert kwargs["cancel_key"] == "2"
return 1
monkeypatch.setattr(
selection_action_module, "prompt_for_index", fake_prompt_for_index
)
result = await action()
assert result == "beta"
@pytest.mark.asyncio
async def test_run_dict_interactive_uses_prompt_for_selection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_option_map_action(never_prompt=False, show_table=False)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str:
assert kwargs["prompt_session"] is action.prompt_session
assert kwargs["show_table"] is False
assert kwargs["cancel_key"] == "3"
return "2"
monkeypatch.setattr(
selection_action_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
assert result == "stage"
@pytest.mark.asyncio
async def test_run_raises_when_never_prompt_has_no_effective_default() -> None:
action = make_action(["alpha", "beta"], never_prompt=True)
with pytest.raises(ValueError, match="never_prompt"):
await action()
@pytest.mark.asyncio
async def test_run_list_cancel_triggers_error_and_teardown_hooks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0")
calls = register_lifecycle_hooks(action)
async def fake_resolve_effective_default() -> str:
return "4"
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(IndexError):
await action()
assert HookType.BEFORE in hook_types(calls)
assert HookType.ON_ERROR in hook_types(calls)
assert HookType.AFTER in hook_types(calls)
assert HookType.ON_TEARDOWN in hook_types(calls)
error_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_ERROR
]
assert isinstance(error_contexts[0].exception, IndexError)
@pytest.mark.asyncio
async def test_run_dict_cancel_triggers_cancel_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_option_map_action(never_prompt=True, default_selection="0")
async def fake_resolve_effective_default() -> str:
return "3"
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(CancelSignal):
await action()
@pytest.mark.asyncio
async def test_run_unsupported_selection_storage_triggers_error_lifecycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(["alpha"], never_prompt=False)
action._selections = SizedButUnsupportedSelections() # type: ignore[assignment]
calls = register_lifecycle_hooks(action)
async def fake_resolve_effective_default() -> str:
return ""
monkeypatch.setattr(
action, "_resolve_effective_default", fake_resolve_effective_default
)
with pytest.raises(TypeError, match="selections"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
error_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_ERROR
]
assert isinstance(error_contexts[0].exception, TypeError)
@pytest.mark.asyncio
async def test_run_success_triggers_success_after_and_teardown_hooks() -> None:
action = make_action(["alpha", "beta"], never_prompt=True, default_selection="0")
calls = register_lifecycle_hooks(action)
result = await action()
assert result == "alpha"
assert hook_types(calls).count(HookType.BEFORE) == 1
assert hook_types(calls).count(HookType.ON_SUCCESS) == 1
assert hook_types(calls).count(HookType.AFTER) == 1
assert hook_types(calls).count(HookType.ON_TEARDOWN) == 1
success_contexts = [
context for hook_type, context in calls if hook_type is HookType.ON_SUCCESS
]
assert success_contexts[0].result == "alpha"
@pytest.mark.asyncio
async def test_preview_prints_tree_when_no_parent() -> None:
action = make_option_map_action(default_selection="1", never_prompt=True)
console = CaptureConsole()
action.console = console # type: ignore[assignment]
await action.preview()
assert len(console.printed) == 1
assert "SelectionAction" in str(console.printed[0][0][0].label)
@pytest.mark.asyncio
async def test_preview_adds_to_parent_when_parent_is_provided() -> None:
action = make_action(["alpha", "beta"], default_selection="0")
parent = Tree("Root")
console = CaptureConsole()
action.console = console # type: ignore[assignment]
await action.preview(parent=parent)
assert console.printed == []
assert len(parent.children) == 1
assert "SelectionAction" in str(parent.children[0].label)
def test_str_includes_action_configuration() -> None:
action = make_action(["alpha", "beta"], return_type=SelectionReturnType.KEY)
text = str(action)
assert "SelectionAction" in text
assert "ChooseThing" in text
assert "KEY" in text or "key" in text
def test_clone_copies_selection_action_configuration() -> None:
session = DummyPromptSession()
action = SelectionAction(
name="CloneMe",
selections={"A": SelectionOption("Alpha", "alpha", style="green")},
title="Letters",
columns=3,
prompt_message="Choose letter > ",
default_selection="A",
number_selections="*",
separator=";",
allow_duplicates=True,
inject_last_result=True,
inject_into="choice",
return_type=SelectionReturnType.DESCRIPTION,
prompt_session=session,
never_prompt=True,
show_table=False,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.title == action.title
assert clone.columns == action.columns
assert clone.prompt_message == action.prompt_message
assert clone.default_selection == action.default_selection
assert clone.number_selections == action.number_selections
assert clone.separator == action.separator
assert clone.allow_duplicates == action.allow_duplicates
assert clone.inject_last_result is True
assert clone.inject_into == "choice"
assert clone.return_type is SelectionReturnType.DESCRIPTION
assert clone.prompt_session is session
assert clone.local_never_prompt is True
assert clone.show_table is False
assert clone.selections is not action.selections
assert clone.selections["A"].description == "Alpha"

View File

@@ -0,0 +1,598 @@
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
import pytest
import toml
import yaml
from rich.tree import Tree
import falyx.action.select_file_action as select_file_module
from falyx.action.action_types import FileType
from falyx.action.select_file_action import SelectFileAction
from falyx.hook_manager import HookType
from falyx.selection import SelectionOption
from falyx.signals import CancelSignal
class DummyPromptSession:
pass
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def make_action(directory: Path, **overrides: Any) -> SelectFileAction:
defaults: dict[str, Any] = {
"name": "ChooseFile",
"directory": directory,
"prompt_session": DummyPromptSession(),
}
defaults.update(overrides)
return SelectFileAction(**defaults)
def write_sample_files(directory: Path) -> dict[str, Path]:
paths = {
"text": directory / "note.txt",
"json": directory / "config.json",
"yaml": directory / "config.yaml",
"toml": directory / "config.toml",
"csv": directory / "rows.csv",
"tsv": directory / "rows.tsv",
"xml": directory / "doc.xml",
}
paths["text"].write_text("hello\n", encoding="UTF-8")
paths["json"].write_text('{"name": "falyx", "count": 2}', encoding="UTF-8")
paths["yaml"].write_text("name: falyx\nenabled: true\n", encoding="UTF-8")
paths["toml"].write_text('name = "falyx"\ncount = 2\n', encoding="UTF-8")
paths["csv"].write_text("name,count\nfalyx,2\n", encoding="UTF-8")
paths["tsv"].write_text("name\tcount\nfalyx\t2\n", encoding="UTF-8")
paths["xml"].write_text("<root><name>falyx</name></root>", encoding="UTF-8")
return paths
def register_lifecycle_hooks(action: SelectFileAction) -> list[tuple[HookType, Any]]:
calls: list[tuple[HookType, Any]] = []
def make_hook(hook_type: HookType):
def hook(context):
calls.append((hook_type, context))
return hook
for hook_type in HookType:
action.hooks.register(hook_type, make_hook(hook_type))
return calls
def hook_types(calls: list[tuple[HookType, Any]]) -> list[HookType]:
return [hook_type for hook_type, _ in calls]
def test_init_normalizes_configuration_and_string_return_type(tmp_path: Path) -> None:
session = DummyPromptSession()
action = SelectFileAction(
"ChooseConfig",
tmp_path,
title="Configs",
columns=4,
prompt_message="[bold]Pick >[/] ",
style="green",
suffix_filter=".json",
return_type="json",
encoding="utf-8",
number_selections="*",
separator=";",
allow_duplicates=True,
prompt_session=session,
never_prompt=True,
)
assert action.name == "ChooseConfig"
assert action.directory == tmp_path.resolve()
assert action.title == "Configs"
assert action.columns == 4
assert action.suffix_filter == ".json"
assert action.return_type == FileType.JSON
assert action.encoding == "utf-8"
assert action.number_selections == "*"
assert action.separator == ";"
assert action.allow_duplicates is True
assert action.prompt_session is session
assert action.local_never_prompt is True
assert "ChooseConfig" in str(action)
assert ".json" in str(action)
@pytest.mark.parametrize("number_selections", [1, 2, "*"])
def test_number_selections_accepts_positive_ints_and_star(
tmp_path: Path,
number_selections: int | str,
) -> None:
action = make_action(tmp_path, number_selections=number_selections)
assert action.number_selections == number_selections
@pytest.mark.parametrize("number_selections", [0, -1, "many", object()])
def test_number_selections_rejects_invalid_values(
tmp_path: Path,
number_selections: Any,
) -> None:
action = make_action(tmp_path)
with pytest.raises(ValueError, match="number_selections"):
action.number_selections = number_selections
def test_get_options_uses_numeric_keys_and_selection_options(tmp_path: Path) -> None:
first = tmp_path / "a.txt"
second = tmp_path / "b.txt"
first.write_text("a", encoding="UTF-8")
second.write_text("b", encoding="UTF-8")
action = make_action(tmp_path, style="cyan")
options = action.get_options([first, second])
assert list(options) == ["0", "1"]
assert options["0"] == SelectionOption(
description="a.txt",
value=first,
style="cyan",
)
assert options["1"].description == "b.txt"
assert options["1"].value == second
def test_find_cancel_key_returns_first_numeric_gap_or_next_index(tmp_path: Path) -> None:
action = make_action(tmp_path)
assert action._find_cancel_key({"0": object(), "2": object()}) == "1"
assert action._find_cancel_key({"0": object(), "1": object()}) == "2"
assert action._find_cancel_key({}) == "0"
def test_get_infer_target_disables_signature_inference(tmp_path: Path) -> None:
action = make_action(tmp_path)
assert action.get_infer_target() == (None, None)
@pytest.mark.parametrize(
("return_type", "file_key", "expected"),
[
(FileType.TEXT, "text", "hello\n"),
(FileType.PATH, "text", "PATH"),
(FileType.JSON, "json", {"name": "falyx", "count": 2}),
(FileType.YAML, "yaml", {"name": "falyx", "enabled": True}),
(FileType.TOML, "toml", {"name": "falyx", "count": 2}),
(FileType.CSV, "csv", [["name", "count"], ["falyx", "2"]]),
(FileType.TSV, "tsv", [["name", "count"], ["falyx", "2"]]),
],
)
def test_parse_file_returns_requested_representation(
tmp_path: Path,
return_type: FileType,
file_key: str,
expected: Any,
) -> None:
files = write_sample_files(tmp_path)
action = make_action(tmp_path, return_type=return_type)
result = action.parse_file(files[file_key])
if expected == "PATH":
assert result == files[file_key]
else:
assert result == expected
def test_parse_file_returns_xml_root(tmp_path: Path) -> None:
files = write_sample_files(tmp_path)
action = make_action(tmp_path, return_type=FileType.XML)
result = action.parse_file(files["xml"])
assert isinstance(result, ET.Element)
assert result.tag == "root"
assert result.findtext("name") == "falyx"
def test_clone_preserves_configuration_but_returns_distinct_action(
tmp_path: Path,
) -> None:
session = DummyPromptSession()
action = make_action(
tmp_path,
title="Pick a data file",
columns=2,
prompt_message="Select > ",
style="magenta",
suffix_filter=".json",
return_type=FileType.JSON,
encoding="utf-8",
number_selections=2,
separator="|",
allow_duplicates=True,
prompt_session=session,
never_prompt=True,
)
clone = action.clone()
assert clone is not action
assert clone.name == action.name
assert clone.directory == action.directory
assert clone.title == action.title
assert clone.columns == action.columns
assert clone.prompt_message == action.prompt_message
assert clone.style == action.style
assert clone.suffix_filter == action.suffix_filter
assert clone.return_type == action.return_type
assert clone.encoding == action.encoding
assert clone.number_selections == action.number_selections
assert clone.separator == action.separator
assert clone.allow_duplicates == action.allow_duplicates
assert clone.prompt_session is session
assert clone.local_never_prompt is True
@pytest.mark.asyncio
async def test_preview_prints_tree_when_no_parent_is_given(tmp_path: Path) -> None:
write_sample_files(tmp_path)
action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON)
action.console = CaptureConsole()
await action.preview()
assert len(action.console.printed) == 1
printed_tree = action.console.printed[0][0][0]
assert isinstance(printed_tree, Tree)
@pytest.mark.asyncio
async def test_preview_adds_to_existing_parent_and_limits_file_sample(
tmp_path: Path,
) -> None:
for index in range(12):
(tmp_path / f"config-{index}.json").write_text("{}", encoding="UTF-8")
(tmp_path / "ignore.txt").write_text("ignored", encoding="UTF-8")
action = make_action(tmp_path, suffix_filter=".json", return_type=FileType.JSON)
parent = Tree("root")
await action.preview(parent=parent)
assert len(parent.children) == 1
action_tree = parent.children[0]
rendered_labels = [str(child.label) for child in action_tree.children]
assert any("Suffix filter" in label and ".json" in label for label in rendered_labels)
file_list = next(
child for child in action_tree.children if str(child.label) == "[dim]Files:[/]"
)
assert len(file_list.children) == 11
assert "... (2 more)" in str(file_list.children[-1].label)
@pytest.mark.asyncio
async def test_preview_reports_directory_scan_errors(tmp_path: Path) -> None:
missing_dir = tmp_path / "missing"
action = make_action(missing_dir)
parent = Tree("root")
await action.preview(parent=parent)
action_tree = parent.children[0]
assert any(
"Error scanning directory" in str(child.label) for child in action_tree.children
)
@pytest.mark.asyncio
async def test_run_raises_for_missing_directory_and_triggers_error_lifecycle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = make_action(tmp_path / "missing")
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
with pytest.raises(FileNotFoundError, match="does not exist"):
await action("arg", flag=True)
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded
assert isinstance(recorded[0].exception, FileNotFoundError)
assert recorded[0].args == ("arg",)
assert recorded[0].kwargs == {"flag": True}
@pytest.mark.asyncio
async def test_run_raises_when_directory_path_is_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
directory_path = tmp_path / "not-a-dir.txt"
directory_path.write_text("not a directory", encoding="UTF-8")
action = make_action(directory_path)
calls = register_lifecycle_hooks(action)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
with pytest.raises(NotADirectoryError, match="is not a directory"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
@pytest.mark.asyncio
async def test_run_raises_when_suffix_filter_matches_no_files(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
(tmp_path / "note.txt").write_text("hello", encoding="UTF-8")
action = make_action(tmp_path, suffix_filter=".json")
calls = register_lifecycle_hooks(action)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
with pytest.raises(FileNotFoundError, match="No files found"):
await action()
assert HookType.ON_ERROR in hook_types(calls)
@pytest.mark.asyncio
async def test_run_single_selection_returns_parsed_file_and_passes_prompt_options(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
selected = tmp_path / "note.txt"
selected.write_text("selected", encoding="UTF-8")
(tmp_path / "other.json").write_text("{}", encoding="UTF-8")
action = make_action(
tmp_path,
suffix_filter=".txt",
return_type=FileType.TEXT,
number_selections=1,
separator=";",
allow_duplicates=True,
)
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
prompt_calls: list[dict[str, Any]] = []
render_calls: list[dict[str, Any]] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
def fake_render_selection_dict_table(**kwargs: Any) -> object:
render_calls.append(kwargs)
return object()
async def fake_prompt_for_selection(valid_keys, table, **kwargs: Any) -> str:
prompt_calls.append({"valid_keys": list(valid_keys), "table": table, **kwargs})
return "0"
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
fake_render_selection_dict_table,
)
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
assert result == "selected"
assert hook_types(calls) == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded[0].result == "selected"
assert render_calls[0]["title"] == action.title
assert render_calls[0]["columns"] == action.columns
assert set(render_calls[0]["selections"]) == {"0", "1"}
assert prompt_calls[0]["valid_keys"] == ["0", "1"]
assert prompt_calls[0]["prompt_session"] is action.prompt_session
assert prompt_calls[0]["prompt_message"] == action.prompt_message
assert prompt_calls[0]["number_selections"] == 1
assert prompt_calls[0]["separator"] == ";"
assert prompt_calls[0]["allow_duplicates"] is True
assert prompt_calls[0]["cancel_key"] == "1"
@pytest.mark.asyncio
async def test_run_multi_selection_returns_results_for_each_selected_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = tmp_path / "a.txt"
second = tmp_path / "b.txt"
first.write_text("a", encoding="UTF-8")
second.write_text("b", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.PATH, number_selections=2)
monkeypatch.setattr(select_file_module.er, "record", lambda context: None)
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
lambda **kwargs: object(),
)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> list[str]:
return ["0", "1"]
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
result = await action()
print(result)
assert result == [first, second] or result == [second, first]
@pytest.mark.asyncio
async def test_run_cancel_selection_raises_cancel_signal_and_skips_error_hook(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
(tmp_path / "a.txt").write_text("a", encoding="UTF-8")
action = make_action(tmp_path)
calls = register_lifecycle_hooks(action)
recorded: list[Any] = []
monkeypatch.setattr(select_file_module.er, "record", recorded.append)
monkeypatch.setattr(
select_file_module,
"render_selection_dict_table",
lambda **kwargs: object(),
)
async def fake_prompt_for_selection(*args: Any, **kwargs: Any) -> str:
return kwargs["cancel_key"]
monkeypatch.setattr(
select_file_module,
"prompt_for_selection",
fake_prompt_for_selection,
)
with pytest.raises(CancelSignal, match="User canceled"):
await action()
assert hook_types(calls) == [
HookType.BEFORE,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert recorded
assert recorded[0].exception is None
def assert_parse_file_value_error(
action: SelectFileAction,
file: Path,
*,
expected_cause_type: (
type[BaseException] | tuple[type[BaseException], ...] | None
) = None,
) -> ValueError:
with pytest.raises(ValueError) as exc_info:
action.parse_file(file)
error = exc_info.value
assert f"Failed to parse {file.name} as" in str(error)
assert error.__cause__ is not None
if expected_cause_type is not None:
assert isinstance(error.__cause__, expected_cause_type)
return error
def test_parse_file_wraps_invalid_json_errors(tmp_path: Path) -> None:
import json
broken = tmp_path / "broken.json"
broken.write_text('{"name": ', encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.JSON)
assert_parse_file_value_error(
action, broken, expected_cause_type=json.JSONDecodeError
)
def test_parse_file_wraps_invalid_toml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.toml"
broken.write_text('name = "falyx"\ncount = ', encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.TOML)
assert_parse_file_value_error(
action, broken, expected_cause_type=toml.TomlDecodeError
)
def test_parse_file_wraps_invalid_yaml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.yaml"
broken.write_text("name: [unterminated\n", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.YAML)
assert_parse_file_value_error(action, broken, expected_cause_type=yaml.YAMLError)
def test_parse_file_wraps_invalid_xml_errors(tmp_path: Path) -> None:
broken = tmp_path / "broken.xml"
broken.write_text("<root><name>falyx</root>", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.XML)
assert_parse_file_value_error(action, broken, expected_cause_type=ET.ParseError)
@pytest.mark.parametrize(
"return_type",
[
FileType.TEXT,
FileType.JSON,
FileType.YAML,
FileType.TOML,
FileType.CSV,
FileType.TSV,
FileType.XML,
],
)
def test_parse_file_wraps_missing_file_errors(
tmp_path: Path, return_type: FileType
) -> None:
missing = tmp_path / "missing.data"
action = make_action(tmp_path, return_type=return_type)
assert_parse_file_value_error(action, missing, expected_cause_type=FileNotFoundError)
@pytest.mark.parametrize("return_type", [FileType.CSV, FileType.TSV])
def test_parse_file_wraps_csv_style_open_errors(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
return_type: FileType,
) -> None:
data_file = tmp_path / "rows.data"
data_file.write_text("name,count\nfalyx,2\n", encoding="UTF-8")
action = make_action(tmp_path, return_type=return_type)
def fake_open(*args: Any, **kwargs: Any) -> Any:
raise OSError("cannot open test file")
monkeypatch.setattr("builtins.open", fake_open)
error = assert_parse_file_value_error(action, data_file, expected_cause_type=OSError)
assert "cannot open test file" in str(error)
def test_parse_file_wraps_unsupported_return_type_errors(tmp_path: Path) -> None:
data_file = tmp_path / "note.txt"
data_file.write_text("hello", encoding="UTF-8")
action = make_action(tmp_path, return_type=FileType.TEXT)
action.return_type = object() # Force the defensive unsupported-type branch.
error = assert_parse_file_value_error(
action, data_file, expected_cause_type=ValueError
)
assert "Unsupported return type" in str(error.__cause__)

View File

@@ -1,16 +1,89 @@
# test_command.py
import logging
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any
import pytest
from pydantic import ValidationError
from falyx.action import Action, BaseIOAction, ChainedAction
import falyx.command as command_module
from falyx.action import Action, BaseAction, BaseIOAction, ChainedAction
from falyx.command import Command
from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError
from falyx.execution_option import ExecutionOption
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.retry import RetryPolicy
from falyx.signals import CancelSignal
asyncio_default_fixture_loop_scope = "function"
# --- Fixtures ---
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
class FakeBaseAction(BaseAction):
def __init__(
self,
name: str = "FakeAction",
*,
result: Any = "ok",
infer_target: Callable[..., Any] | None = None,
metadata: dict[str, Any] | None = None,
never_prompt: bool | None = None,
) -> None:
super().__init__(name, never_prompt=never_prompt)
self.result = result
self.infer_target = infer_target or (lambda: None)
self.metadata = metadata
self.preview_calls = 0
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
async def _run(self, *args: Any, **kwargs: Any) -> Any:
self.calls.append((args, kwargs))
return self.result
async def preview(self, parent=None):
self.preview_calls += 1
if parent is not None:
parent.add("fake preview")
return None
def get_infer_target(self):
return self.infer_target, self.metadata
def clone(self) -> "FakeBaseAction":
return FakeBaseAction(
self.name,
result=self.result,
infer_target=self.infer_target,
metadata=self.metadata,
never_prompt=self.local_never_prompt,
)
def make_command(**overrides: Any) -> Command:
defaults = dict(
key="D",
description="Deploy command",
action=lambda *args, **kwargs: {"args": args, "kwargs": kwargs},
auto_args=False,
)
defaults.update(overrides)
return Command.build(**defaults)
def formatted_plain_text(formatted_text) -> str:
return "".join(fragment for _, fragment in list(formatted_text))
@pytest.fixture(autouse=True)
def clean_registry():
er.clear()
@@ -18,12 +91,10 @@ def clean_registry():
er.clear()
# --- Dummy Action ---
async def dummy_action():
return "ok"
# --- Dummy IO Action ---
class DummyInputAction(BaseIOAction):
async def _run(self, *args, **kwargs):
return "needs input"
@@ -32,7 +103,6 @@ class DummyInputAction(BaseIOAction):
pass
# --- Tests ---
@pytest.mark.asyncio
async def test_command_creation():
"""Test if Command can be created with a callable."""
@@ -185,3 +255,642 @@ def test_command_bad_options_manager():
options_manager="not_a_dict_or_callable",
)
assert "Input should be an instance of OptionsManager" in str(exc_info.value)
@pytest.mark.asyncio
async def test_resolve_args_uses_custom_parser_and_splits_string_input() -> None:
seen: list[list[str]] = []
def custom_parser(tokens: list[str]):
seen.append(tokens)
return (("parsed",), {"tokens": tokens}, {"summary": True})
command = make_command(custom_parser=custom_parser)
args, kwargs, execution_args = await command.resolve_args("--name 'Ada Lovelace'")
assert seen == [["--name", "Ada Lovelace"]]
assert args == ("parsed",)
assert kwargs == {"tokens": ["--name", "Ada Lovelace"]}
assert execution_args == {"summary": True}
@pytest.mark.asyncio
async def test_resolve_args_rejects_non_callable_custom_parser() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.custom_parser = object()
with pytest.raises(NotAFalyxError, match="custom_parser must be a callable"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_resolve_args_wraps_bad_shell_input_for_custom_parser() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
await command.resolve_args("'unterminated")
@pytest.mark.asyncio
async def test_resolve_args_wraps_bad_shell_input_for_command_argument_parser() -> None:
command = make_command()
with pytest.raises(CommandArgumentError, match="Failed to parse arguments"):
await command.resolve_args("'unterminated")
@pytest.mark.asyncio
async def test_resolve_args_rejects_missing_parser_when_no_custom_parser_exists() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.custom_parser = None
command.arg_parser = None
with pytest.raises(NotAFalyxError, match="Command has no parser configured"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_resolve_args_rejects_invalid_arg_parser_instance() -> None:
command = make_command()
command.arg_parser = object()
with pytest.raises(NotAFalyxError, match="arg_parser must be an instance"):
await command.resolve_args([])
@pytest.mark.asyncio
async def test_explicit_argument_definitions_are_added_to_default_parser() -> None:
command = make_command(
arguments=[
{
"flags": ("target",),
"help": "Deployment target",
},
{
"flags": ("--region",),
"default": "us-east",
},
]
)
args, kwargs, execution_args = await command.resolve_args(
["api", "--region", "us-west"]
)
assert args == ("api",)
assert kwargs == {"region": "us-west"}
assert execution_args == {}
@pytest.mark.asyncio
async def test_argument_config_callback_configures_existing_parser() -> None:
def configure(parser: CommandArgumentParser) -> None:
parser.add_argument("--region", default="us-east")
command = make_command(argument_config=configure)
args, kwargs, execution_args = await command.resolve_args(["--region", "us-west"])
assert args == ()
assert kwargs == {"region": "us-west"}
assert execution_args == {}
def test_base_action_inference_merges_action_metadata() -> None:
def deploy(region: str) -> None:
return None
action = FakeBaseAction(
infer_target=deploy,
metadata={"region": {"help": "Region from action metadata"}},
)
command = Command.build(
key="D",
description="Deploy command",
action=action,
auto_args=True,
)
assert command.arg_metadata["region"] == {"help": "Region from action metadata"}
assert isinstance(command.arg_parser, CommandArgumentParser)
assert "region" in command.arg_parser._positional
def test_build_validates_parser_runtime_dependencies_and_retry_policy() -> None:
with pytest.raises(NotAFalyxError, match="arg_parser"):
make_command(arg_parser=object())
with pytest.raises(NotAFalyxError, match="options_manager"):
make_command(options_manager=object())
with pytest.raises(InvalidHookError, match="HookManager"):
make_command(hooks=object())
with pytest.raises(NotAFalyxError, match="retry_policy"):
make_command(retry_policy=object())
def test_build_normalizes_execution_options_and_registers_hook_lists() -> None:
async def before(_context) -> None:
return None
async def success(_context) -> None:
return None
async def error(_context) -> None:
return None
async def after(_context) -> None:
return None
async def teardown(_context) -> None:
return None
command = make_command(
execution_options=["summary", ExecutionOption.CONFIRM],
before_hooks=[before],
success_hooks=[success],
error_hooks=[error],
after_hooks=[after],
teardown_hooks=[teardown],
spinner=True,
)
assert ExecutionOption.SUMMARY in command.execution_options
assert ExecutionOption.CONFIRM in command.execution_options
assert before in command.hooks._hooks[HookType.BEFORE]
assert success in command.hooks._hooks[HookType.ON_SUCCESS]
assert error in command.hooks._hooks[HookType.ON_ERROR]
assert after in command.hooks._hooks[HookType.AFTER]
assert teardown in command.hooks._hooks[HookType.ON_TEARDOWN]
assert command.hooks._hooks[HookType.BEFORE]
assert command.hooks._hooks[HookType.ON_TEARDOWN]
def test_model_post_init_warns_for_retry_flags_on_plain_callable(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
make_command(retry=True, retry_all=True)
assert "Retry requested" in caplog.text
assert "Retry all requested" in caplog.text
def test_retry_all_for_base_action_enables_policy_recursively(
monkeypatch: pytest.MonkeyPatch,
) -> None:
action = FakeBaseAction()
calls: list[tuple[BaseAction, RetryPolicy]] = []
def fake_enable_retries_recursively(
base_action: BaseAction, policy: RetryPolicy
) -> None:
calls.append((base_action, policy))
monkeypatch.setattr(
command_module,
"enable_retries_recursively",
fake_enable_retries_recursively,
)
command = Command.build(
key="D",
description="Deploy command",
action=action,
retry_all=True,
auto_args=False,
)
assert command.retry_policy.enabled is True
assert calls == [(action, command.retry_policy)]
def test_logging_hooks_are_registered_on_base_action() -> None:
action = FakeBaseAction()
Command.build(
key="D",
description="Deploy command",
action=action,
logging_hooks=True,
auto_args=False,
)
assert any(action.hooks._hooks.values())
def test_ignore_in_history_is_copied_to_base_action() -> None:
action = FakeBaseAction()
Command.build(
key="D",
description="Deploy command",
action=action,
ignore_in_history=True,
auto_args=False,
)
assert action.ignore_in_history is True
def test_retry_flag_enables_retry_on_action_instance() -> None:
action = Action("DeployAction", lambda: "ok")
Command.build(
key="D",
description="Deploy command",
action=action,
retry=True,
auto_args=False,
)
assert action.retry_policy.enabled is True
def test_confirmation_prompt_uses_custom_message() -> None:
command = make_command(confirm_message="Ship it?")
assert list(command._confirmation_prompt) == [("class:confirm", "Ship it?")]
def test_confirmation_prompt_describes_default_callable_with_static_inputs() -> None:
def deploy() -> str:
return "ok"
command = Command.build(
key="D",
description="Deploy command",
action=deploy,
args=("api",),
kwargs={"region": "us-east"},
auto_args=False,
)
plain_text = formatted_plain_text(command._confirmation_prompt)
assert "Confirm execution of" in plain_text
assert "D" in plain_text
assert "Deploy command" in plain_text
assert "calls" in plain_text
assert "args=('api',)" in plain_text
assert "kwargs={'region': 'us-east'}" in plain_text
def test_confirmation_prompt_uses_base_action_name() -> None:
command = Command.build(
key="D",
description="Deploy command",
action=FakeBaseAction("DeployAction"),
auto_args=False,
)
assert "DeployAction" in formatted_plain_text(command._confirmation_prompt)
@pytest.mark.asyncio
async def test_confirmation_cancel_previews_then_raises_cancel_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
command = make_command(confirm=True, preview_before_confirm=True)
previewed: list[str] = []
confirmed_prompts: list[Any] = []
async def fake_preview(self: Command) -> None:
previewed.append(self.key)
async def fake_confirm(prompt) -> bool:
confirmed_prompts.append(prompt)
return False
monkeypatch.setattr(Command, "preview", fake_preview)
monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
with pytest.raises(CancelSignal, match="Cancelled by confirmation"):
await command()
assert previewed == ["D"]
assert confirmed_prompts
@pytest.mark.asyncio
async def test_confirmation_accepts_and_executes_action(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def fake_confirm(_prompt) -> bool:
return True
def action() -> str:
calls.append("ran")
return "done"
monkeypatch.setattr(command_module, "confirm_async", fake_confirm)
command = Command.build(
key="D",
description="Deploy command",
action=action,
confirm=True,
preview_before_confirm=False,
auto_args=False,
)
assert await command() == "done"
assert calls == ["ran"]
def test_get_option_returns_default_when_no_options_manager_is_available() -> None:
command = make_command()
command.options_manager = None
assert command.get_option("missing", "fallback") == "fallback"
def test_primary_alias_falls_back_to_command_key() -> None:
assert make_command(aliases=[]).primary_alias == "D"
def test_usage_reports_no_arguments_when_parser_is_absent() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
assert command.usage == "No arguments defined."
def test_usage_delegates_to_arg_parser_when_available() -> None:
command = make_command(aliases=["deploy"])
assert "D" in command.usage
assert "deploy" in command.usage
def test_help_signature_full_mode_includes_help_text_and_tags() -> None:
command = make_command(help_text="Detailed deploy help", tags=["deploy", "cloud"])
usage, description, tags = command.help_signature
assert "D" in usage
assert "Detailed deploy help" in description
assert "deploy, cloud" in tags
def test_help_signature_simple_mode_uses_key_and_aliases() -> None:
command = make_command(
aliases=["deploy"],
help_text="Detailed deploy help",
simple_help_signature=True,
)
usage, description, tags = command.help_signature
assert "D" in usage
assert "deploy" in usage
assert "Detailed deploy help" in description
assert tags == ""
def test_log_summary_delegates_to_existing_context() -> None:
command = make_command()
calls: list[str] = []
command._context = SimpleNamespace(log_summary=lambda: calls.append("logged"))
command.log_summary()
assert calls == ["logged"]
def test_render_usage_prefers_custom_usage(monkeypatch: pytest.MonkeyPatch) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(custom_usage=lambda: "custom usage")
command.render_usage()
assert captured.printed[0][0] == ("custom usage",)
def test_render_usage_falls_back_to_command_key_without_parser(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
command.render_usage()
assert captured.printed[0][0] == ("[bold]usage:[/] D",)
def test_render_help_and_tldr_custom_renderers_return_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
command = make_command(
custom_help=lambda: "custom help",
custom_tldr=lambda: "custom tldr",
)
assert command.render_help() is True
assert command.render_tldr() is True
assert [printed[0][0] for printed in captured.printed] == [
"custom help",
"custom tldr",
]
def test_render_help_and_tldr_return_false_without_parser_or_custom_renderer() -> None:
command = make_command(custom_parser=lambda tokens: ((), {}, {}))
assert command.render_help() is False
assert command.render_tldr() is False
@pytest.mark.asyncio
async def test_preview_renders_plain_callable_details(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
def deploy() -> str:
return "ok"
command = Command.build(
key="D",
description="Deploy command",
action=deploy,
args=("api",),
kwargs={"region": "us-east"},
help_text="Preview help",
auto_args=False,
)
await command.preview()
rendered = "\n".join(str(args[0]) for args, _ in captured.printed)
assert "Command:" in rendered
assert "Preview help" in rendered
assert "Would call:" in rendered
assert "args=('api',), kwargs={'region': 'us-east'}" in rendered
@pytest.mark.asyncio
async def test_preview_renders_base_action_tree(monkeypatch: pytest.MonkeyPatch) -> None:
captured = CaptureConsole()
monkeypatch.setattr(command_module, "console", captured)
action = FakeBaseAction("DeployAction")
command = Command.build(
key="D",
description="Deploy command",
action=action,
help_text="Preview help",
auto_args=False,
)
await command.preview()
assert action.preview_calls == 1
assert captured.printed
@pytest.mark.asyncio
async def test_call_merges_static_and_invocation_inputs_and_triggers_hooks() -> None:
events: list[tuple[str, Any]] = []
async def before(context) -> None:
events.append(("before", context.args))
async def success(context) -> None:
events.append(("success", context.result))
async def after(context) -> None:
events.append(("after", context.result))
async def teardown(context) -> None:
events.append(("teardown", context.result))
def action(*args: Any, **kwargs: Any) -> dict[str, Any]:
return {"args": args, "kwargs": kwargs}
command = Command.build(
key="D",
description="Deploy command",
action=action,
args=("static",),
kwargs={"region": "us-east"},
before_hooks=[before],
success_hooks=[success],
after_hooks=[after],
teardown_hooks=[teardown],
auto_args=False,
)
result = await command("runtime", region="us-west")
assert result == {
"args": ("runtime", "static"),
"kwargs": {"region": "us-west"},
}
assert command.result == result
assert events == [
("before", ("runtime", "static")),
("success", result),
("after", result),
("teardown", result),
]
@pytest.mark.asyncio
async def test_call_triggers_error_after_and_teardown_hooks_on_failure() -> None:
events: list[tuple[str, str | None]] = []
async def on_error(context) -> None:
events.append(("error", str(context.exception)))
async def after(context) -> None:
events.append(("after", str(context.exception)))
async def teardown(context) -> None:
events.append(("teardown", str(context.exception)))
def action() -> None:
raise RuntimeError("boom")
command = Command.build(
key="D",
description="Deploy command",
action=action,
error_hooks=[on_error],
after_hooks=[after],
teardown_hooks=[teardown],
auto_args=False,
)
with pytest.raises(RuntimeError, match="boom"):
await command()
assert events == [
("error", "boom"),
("after", "boom"),
("teardown", "boom"),
]
def test_str_includes_command_identity() -> None:
text = str(make_command())
assert "Command(key='D'" in text
assert "Deploy command" in text
def test_clone_with_overrides_clones_parser_hooks_and_base_action() -> None:
action = FakeBaseAction("DeployAction")
async def before(_context) -> None:
return None
command = Command.build(
key="D",
description="Deploy command",
action=action,
aliases=["deploy"],
before_hooks=[before],
auto_args=False,
)
clone = command.clone_with_overrides(
key="P",
description="Promote command",
aliases=["promote"],
)
assert clone.key == "P"
assert clone.description == "Promote command"
assert clone.aliases == ["promote"]
assert clone.action is not command.action
assert isinstance(clone.action, FakeBaseAction)
assert clone.hooks is not command.hooks
assert before in clone.hooks._hooks[HookType.BEFORE]
assert isinstance(clone.arg_parser, CommandArgumentParser)
assert clone.arg_parser.command_key == "P"
def test_clone_with_overrides_can_replace_action_and_execution_options() -> None:
command = make_command(execution_options=["summary"])
def replacement() -> str:
return "replacement"
clone = command.clone_with_overrides(
action=replacement,
execution_options=[ExecutionOption.CONFIRM],
simple_help_signature=True,
)
assert clone.action is not command.action
assert ExecutionOption.CONFIRM in clone.execution_options
assert ExecutionOption.SUMMARY not in clone.execution_options
assert clone.simple_help_signature is True

341
tests/test_context.py Normal file
View File

@@ -0,0 +1,341 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import pytest
from rich.console import Console
from falyx.context import ExecutionContext, InvocationContext, SharedContext
from falyx.mode import FalyxMode
class DummyAction:
def __init__(self, name: str = "DummyAction") -> None:
self.name = name
def __str__(self) -> str:
return self.name
def make_execution_context(**overrides: Any) -> ExecutionContext:
defaults: dict[str, Any] = {
"name": "Build",
"action": DummyAction("build"),
}
defaults.update(overrides)
return ExecutionContext(**defaults)
def make_shared_context(**overrides: Any) -> SharedContext:
defaults: dict[str, Any] = {
"name": "Workflow",
"action": DummyAction("workflow"),
}
defaults.update(overrides)
return SharedContext(**defaults)
def test_execution_context_get_shared_context_returns_existing_context() -> None:
shared = make_shared_context()
context = make_execution_context(shared_context=shared)
assert context.get_shared_context() is shared
def test_execution_context_get_shared_context_raises_when_missing() -> None:
context = make_execution_context()
with pytest.raises(ValueError, match="SharedContext is not set"):
context.get_shared_context()
def test_execution_context_duration_handles_not_started_running_and_stopped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = make_execution_context()
assert context.duration is None
context.start_time = 10.0
context.end_time = None
monkeypatch.setattr("falyx.context.time.perf_counter", lambda: 12.5)
assert context.duration == pytest.approx(2.5)
context.end_time = 14.0
assert context.duration == pytest.approx(4.0)
def test_execution_context_start_and_stop_timer_populate_timer_fields() -> None:
context = make_execution_context()
context.start_timer()
assert context.start_wall is not None
assert context.start_time is not None
context.stop_timer()
assert context.end_wall is not None
assert context.end_time is not None
assert context.duration is not None
assert context.duration >= 0
def test_execution_context_exception_setter_records_traceback_and_status() -> None:
context = make_execution_context(result="ignored after failure")
context.exception = RuntimeError("boom")
assert isinstance(context.exception, RuntimeError)
assert context.success is False
assert context.status == "ERROR"
assert context.traceback is not None
assert "RuntimeError: boom" in context.traceback
def test_execution_context_as_dict_includes_result_exception_traceback_duration_and_extra() -> (
None
):
context = make_execution_context(
result={"artifact": "dist/app.whl"},
start_time=2.0,
end_time=5.25,
extra={"attempt": 2},
)
context.exception = ValueError("invalid build")
summary = context.as_dict()
assert summary["name"] == "Build"
assert summary["result"] == {"artifact": "dist/app.whl"}
assert summary["exception"] == "ValueError('invalid build')"
assert "ValueError: invalid build" in summary["traceback"]
assert summary["duration"] == pytest.approx(3.25)
assert summary["extra"] == {"attempt": 2}
def test_execution_context_signature_formats_args_and_kwargs() -> None:
context = make_execution_context(args=("src", 3), kwargs={"verbose": True})
assert context.signature == "build ('src', 3, verbose=True)"
def test_execution_context_log_summary_prints_success_to_context_console() -> None:
recording_console = Console(record=True, width=160)
context = make_execution_context(
result="ok",
start_time=1.0,
end_time=2.5,
start_wall=datetime(2026, 6, 7, 11, 0, 0),
end_wall=datetime(2026, 6, 7, 11, 0, 2),
console=recording_console,
)
context.log_summary()
output = recording_console.export_text()
assert "[SUMMARY] Build" in output
assert "Start: 11:00:00" in output
assert "End: 11:00:02" in output
assert "Duration: 1.500s" in output
assert "Result: ok" in output
def test_execution_context_log_summary_uses_logger_and_includes_exception() -> None:
messages: list[str] = []
context = make_execution_context(
result="unused",
start_time=10.0,
end_time=11.0,
)
context.exception = OSError("disk full")
context.log_summary(logger=messages.append)
assert len(messages) == 1
assert "[SUMMARY] Build" in messages[0]
assert "Duration: 1.000s" in messages[0]
assert "Exception: OSError('disk full')" in messages[0]
def test_execution_context_to_log_line_renders_success_and_error_states() -> None:
success = make_execution_context(result="ok", start_time=1.0, end_time=1.5)
failure = make_execution_context(result=None, start_time=2.0, end_time=3.0)
failure.exception = LookupError("missing")
assert success.to_log_line() == (
"[Build] status=OK duration=0.500s result='ok' exception=None"
)
assert failure.to_log_line() == (
"[Build] status=ERROR duration=1.000s result=None "
"exception=LookupError: missing"
)
def test_execution_context_str_and_repr_render_success_with_no_duration() -> None:
context = make_execution_context(result=["ok"])
text = str(context)
debug = repr(context)
assert "<ExecutionContext 'Build' | OK | Duration: n/a" in text
assert "Result: ['ok']" in text
assert "ExecutionContext(name='Build', duration=n/a" in debug
assert "result=['ok']" in debug
def test_execution_context_str_and_repr_render_exception_with_duration() -> None:
context = make_execution_context(start_time=1.0, end_time=1.75)
context.exception = RuntimeError("failed")
text = str(context)
debug = repr(context)
assert "<ExecutionContext 'Build' | ERROR | Duration: 0.750s" in text
assert "Exception: failed" in text
assert "duration=0.750" in debug
assert "exception=RuntimeError('failed')" in debug
def test_shared_context_records_results_errors_and_share_values() -> None:
shared = make_shared_context()
error = RuntimeError("step failed")
shared.add_result("first")
shared.add_error(1, error)
shared.set("artifact", "dist/app.whl")
assert shared.results == ["first"]
assert shared.errors == [(1, error)]
assert shared.get("artifact") == "dist/app.whl"
assert shared.get("missing", "default") == "default"
assert shared.last_result() == "first"
def test_shared_context_last_result_returns_none_when_sequential_context_has_no_results() -> (
None
):
shared = make_shared_context()
assert shared.last_result() is None
def test_shared_context_set_shared_result_does_not_append_for_sequential_context() -> (
None
):
shared = make_shared_context(is_concurrent=False)
shared.set_shared_result("shared-value")
assert shared.shared_result == "shared-value"
assert shared.results == []
assert shared.last_result() is None
def test_shared_context_set_shared_result_appends_and_reads_from_concurrent_context() -> (
None
):
shared = make_shared_context(is_concurrent=True)
shared.set_shared_result("group-value")
assert shared.shared_result == "group-value"
assert shared.results == ["group-value"]
assert shared.last_result() == "group-value"
def test_shared_context_str_marks_sequential_and_concurrent_modes() -> None:
sequential = make_shared_context(results=["a"])
concurrent = make_shared_context(is_concurrent=True, results=["b"])
assert "<SequentialSharedContext 'Workflow'" in str(sequential)
assert "Results: ['a']" in str(sequential)
assert "<ConcurrentSharedContext 'Workflow'" in str(concurrent)
assert "Results: ['b']" in str(concurrent)
def test_invocation_context_menu_path_segment_operations_are_immutable() -> None:
root = InvocationContext(program="falyx", mode=FalyxMode.MENU)
one = root.with_path_segment("admin", style="cyan")
two = one.with_path_segment("deploy", style="green")
trimmed = two.without_last_path_segment()
assert root.typed_path == []
assert root.segments == []
assert one.typed_path == ["admin"]
assert one.segments[0].text == "admin"
assert str(one.segments[0].style) == "cyan"
assert two.typed_path == ["admin", "deploy"]
assert trimmed.typed_path == ["admin"]
assert trimmed.segments[0].text == "admin"
assert root.without_last_path_segment() is root
def test_invocation_context_plain_path_omits_program_in_menu_mode() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.MENU)
.with_path_segment("admin")
.with_path_segment("deploy")
)
assert context.is_cli_mode is False
assert context.plain_path == "admin deploy"
def test_invocation_context_plain_path_includes_program_in_cli_mode() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.COMMAND)
.with_path_segment("admin")
.with_path_segment("deploy")
)
assert context.is_cli_mode is True
assert context.plain_path == "falyx admin deploy"
def test_invocation_context_plain_path_handles_cli_context_without_program() -> None:
context = InvocationContext(mode=FalyxMode.COMMAND).with_path_segment("deploy")
assert context.plain_path == "deploy"
def test_invocation_context_markup_path_styles_program_and_segments_and_escapes_text() -> (
None
):
context = (
InvocationContext(
program="falyx[dev]",
program_style="bold blue",
mode=FalyxMode.COMMAND,
)
.with_path_segment("admin[ops]", style="cyan")
.with_path_segment("deploy", style="green")
)
assert context.markup_path == (
"[bold blue]falyx\\[dev][/bold blue] "
"[cyan]admin\\[ops][/cyan] "
"[green]deploy[/green]"
)
def test_invocation_context_markup_path_handles_unstyled_program_and_segments() -> None:
context = (
InvocationContext(program="falyx", mode=FalyxMode.COMMAND)
.with_path_segment("admin[ops]")
.with_path_segment("deploy")
)
assert context.markup_path == "falyx admin\\[ops] deploy"
def test_invocation_context_markup_path_omits_program_in_menu_mode() -> None:
context = (
InvocationContext(
program="falyx",
program_style="bold blue",
mode=FalyxMode.MENU,
)
.with_path_segment("admin", style="cyan")
.with_path_segment("deploy")
)
assert context.markup_path == "[cyan]admin[/cyan] deploy"

View File

@@ -0,0 +1,307 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterator
import pytest
from rich.console import Console
from rich.table import Table
from falyx.execution_registry import ExecutionRegistry
@dataclass
class DummyAction:
ignore_in_history: bool = False
class DummyContext:
def __init__(
self,
name: str,
*,
result: Any = None,
exception: Exception | None = None,
traceback: str = "",
signature: str | None = None,
start_time: float | None = 1_700_000_000.0,
end_time: float | None = 1_700_000_001.0,
duration: float | None = 1.25,
ignore_in_history: bool = False,
) -> None:
self.index = -1
self.name = name
self.result = result
self.exception = exception
self.traceback = traceback
self.signature = signature or f"{name}()"
self.start_time = start_time
self.end_time = end_time
self.duration = duration
self.action = DummyAction(ignore_in_history=ignore_in_history)
self.success = exception is None
def to_log_line(self) -> str:
return f"log:{self.name}:{self.index}"
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
def rendered_text(self) -> str:
output = Console(record=True, width=160)
for args, kwargs in self.printed:
output.print(*args, **kwargs)
return output.export_text()
@pytest.fixture(autouse=True)
def isolated_registry() -> Iterator[CaptureConsole]:
original_console = ExecutionRegistry._console
capture = CaptureConsole()
ExecutionRegistry._console = capture # type: ignore[assignment]
ExecutionRegistry._store_by_name.clear()
ExecutionRegistry._store_by_index.clear()
ExecutionRegistry._store_all.clear()
ExecutionRegistry._index = 0
yield capture
ExecutionRegistry._store_by_name.clear()
ExecutionRegistry._store_by_index.clear()
ExecutionRegistry._store_all.clear()
ExecutionRegistry._index = 0
ExecutionRegistry._console = original_console
def record_context(*args: Any, **kwargs: Any) -> DummyContext:
context = DummyContext(*args, **kwargs)
ExecutionRegistry.record(context) # type: ignore[arg-type]
return context
def latest_printed_table(console: CaptureConsole) -> Table:
assert console.printed
table = console.printed[-1][0][0]
assert isinstance(table, Table)
return table
def test_record_assigns_indexes_and_populates_all_lookup_stores() -> None:
first = record_context("Build", result="ok")
second = record_context("Build", result="again")
other = record_context("Deploy", result="done")
assert first.index == 0
assert second.index == 1
assert other.index == 2
assert ExecutionRegistry.get_all() == [first, second, other]
assert ExecutionRegistry.get_by_name("Build") == [first, second]
assert ExecutionRegistry.get_by_name("missing") == []
assert ExecutionRegistry._store_by_index == {0: first, 1: second, 2: other}
assert ExecutionRegistry.get_latest() is other
def test_clear_removes_all_recorded_contexts() -> None:
record_context("Build", result="ok")
ExecutionRegistry.clear()
assert ExecutionRegistry.get_all() == []
assert ExecutionRegistry.get_by_name("Build") == []
assert ExecutionRegistry._store_by_index == {}
def test_summary_clear_clears_registry_and_prints_confirmation(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
ExecutionRegistry.summary(clear=True)
assert ExecutionRegistry.get_all() == []
assert "Execution history cleared" in isolated_registry.rendered_text()
def test_summary_last_result_skips_ignored_contexts(
isolated_registry: CaptureConsole,
) -> None:
visible = record_context("Visible", result={"answer": 42})
record_context("Ignored", result="do not show", ignore_in_history=True)
ExecutionRegistry.summary(last_result=True)
assert isolated_registry.printed[0][0] == (f"{visible.signature}:",)
assert isolated_registry.printed[1][0] == (visible.result,)
def test_summary_last_result_prints_traceback_when_latest_visible_context_failed(
isolated_registry: CaptureConsole,
) -> None:
failed = record_context("Fail", exception=RuntimeError("boom"), traceback="TRACEBACK")
ExecutionRegistry.summary(last_result=True)
assert isolated_registry.printed[0][0] == (f"{failed.signature}:",)
assert isolated_registry.printed[1][0] == ("TRACEBACK",)
def test_summary_last_result_reports_when_all_contexts_are_ignored(
isolated_registry: CaptureConsole,
) -> None:
record_context("Ignored", result="hidden", ignore_in_history=True)
ExecutionRegistry.summary(last_result=True)
assert "No valid executions found" in isolated_registry.rendered_text()
def test_summary_result_index_prints_result_for_existing_context(
isolated_registry: CaptureConsole,
) -> None:
context = record_context("Build", result=["artifact.whl"])
ExecutionRegistry.summary(result_index=context.index)
assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
assert isolated_registry.printed[1][0] == (context.result,)
def test_summary_result_index_prints_traceback_for_failed_context(
isolated_registry: CaptureConsole,
) -> None:
context = record_context("Fail", exception=ValueError("bad"), traceback="STACK")
ExecutionRegistry.summary(result_index=context.index)
assert isolated_registry.printed[0][0] == (f"{context.signature}:",)
assert isolated_registry.printed[1][0] == ("STACK",)
def test_summary_result_index_reports_missing_index(
isolated_registry: CaptureConsole,
) -> None:
ExecutionRegistry.summary(result_index=99)
assert "No execution found for index 99" in isolated_registry.rendered_text()
def test_summary_name_filter_reports_missing_action(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
ExecutionRegistry.summary(name="Deploy")
assert "No executions found for action 'Deploy'" in isolated_registry.rendered_text()
def test_summary_name_filter_renders_only_matching_contexts(
isolated_registry: CaptureConsole,
) -> None:
record_context("Build", result="ok")
record_context("Deploy", result="done")
record_context("Build", result="again")
ExecutionRegistry.summary(name="Build")
table = latest_printed_table(isolated_registry)
assert table.title == "📊 Execution History for 'Build'"
assert len(table.rows) == 2
rendered = isolated_registry.rendered_text()
assert "Build" in rendered
assert "Deploy" not in rendered
def test_summary_index_filter_renders_existing_context(
isolated_registry: CaptureConsole,
capsys: pytest.CaptureFixture[str],
) -> None:
first = record_context("Build", result="ok")
second = record_context("Deploy", result="done")
ExecutionRegistry.summary(index=second.index)
table = latest_printed_table(isolated_registry)
assert table.title == f"📊 Execution History for Index {second.index}"
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Deploy" in rendered
assert "Build" not in rendered
# The implementation currently prints the filtered context list directly.
assert str([second]) in capsys.readouterr().out
assert first.index == 0
def test_summary_index_filter_reports_missing_index(
isolated_registry: CaptureConsole,
) -> None:
ExecutionRegistry.summary(index=12)
assert "No execution found for index 12" in isolated_registry.rendered_text()
def test_summary_status_success_filters_out_errors_and_truncates_long_results(
isolated_registry: CaptureConsole,
) -> None:
long_result = "x" * 80
record_context("Success", result=long_result)
record_context("Failure", exception=RuntimeError("boom"))
ExecutionRegistry.summary(status="success")
table = latest_printed_table(isolated_registry)
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Success" in rendered
assert "Failure" not in rendered
assert "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." in rendered
def test_summary_status_error_filters_out_successes(
isolated_registry: CaptureConsole,
) -> None:
record_context("Success", result="ok")
record_context("Failure", exception=RuntimeError("boom"))
ExecutionRegistry.summary(status="error")
table = latest_printed_table(isolated_registry)
assert len(table.rows) == 1
rendered = isolated_registry.rendered_text()
assert "Failure" in rendered
assert "RuntimeError" in rendered
assert "Success" not in rendered
def test_summary_uses_na_for_missing_timestamps_and_duration(
isolated_registry: CaptureConsole,
) -> None:
record_context("Pending", result=None, start_time=None, end_time=None, duration=None)
ExecutionRegistry.summary()
rendered = isolated_registry.rendered_text()
assert "Pending" in rendered
assert "n/a" in rendered
def test_summary_defaults_to_all_contexts(
isolated_registry: CaptureConsole,
) -> None:
record_context("One", result="ok")
record_context("Two", exception=RuntimeError("boom"))
ExecutionRegistry.summary()
table = latest_printed_table(isolated_registry)
assert table.title == "📊 Execution History"
assert len(table.rows) == 2
rendered = isolated_registry.rendered_text()
assert "One" in rendered
assert "Two" in rendered

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

View File

@@ -0,0 +1,900 @@
from __future__ import annotations
import pytest
from falyx import Falyx
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
from falyx.mode import FalyxMode
from falyx.parser.falyx_parser import FalyxParser
from falyx.parser.option import Option
from falyx.parser.parser_types import FalyxTLDRExample
@pytest.fixture
def flx() -> Falyx:
flx = Falyx()
flx.add_command(
"D",
description="Deploy command",
action=lambda: "deploy",
aliases=["deploy"],
)
return flx
@pytest.fixture
def parser(flx: Falyx) -> FalyxParser:
return FalyxParser(flx)
def test_init_registers_reserved_options_by_default(parser: FalyxParser) -> None:
flags = parser.get_flags()
assert "-h" in flags
assert "-v" in flags
assert "-d" in flags
assert "-n" in flags
assert parser.help_option is not None
assert parser.tldr_option is None
def test_init_respects_disabled_reserved_root_options() -> None:
parser = FalyxParser(
Falyx(
disable_verbose_option=True,
disable_debug_hooks_option=True,
disable_never_prompt_option=True,
)
)
assert parser.get_flags() == ["-h"]
with pytest.raises(FalyxOptionError, match="unknown option '-v'"):
parser.parse_args(["-v"])
def test_get_options_returns_registered_options(parser: FalyxParser) -> None:
parser.add_option("--region", "-r", default="us-east")
options = parser.get_options()
assert any(option.dest == "region" for option in options)
assert parser.get_flags()[-1] == "--region"
def test_add_option_registers_store_option_with_default_and_choices(
parser: FalyxParser,
) -> None:
parser.add_option(
"--region",
"-r",
default="us-east",
choices=["us-east", "us-west"],
)
result = parser.parse_args(["--region", "us-west", "deploy"])
assert result.namespace_options["region"] == "us-west"
assert result.namespace_defaults["region"] == "us-east"
assert result.remaining_argv == ["deploy"]
def test_add_option_infers_dest_from_long_flag(parser: FalyxParser) -> None:
parser.add_option("--dry-run-mode", default="safe")
result = parser.parse_args([])
assert result.namespace_defaults["dry_run_mode"] == "safe"
def test_add_option_uses_explicit_dest(parser: FalyxParser) -> None:
parser.add_option("--profile-name", dest="profile", default="dev")
result = parser.parse_args(["--profile-name", "prod"])
assert result.namespace_options["profile"] == "prod"
@pytest.mark.parametrize(
("flags", "match"),
[
((), "no flags provided"),
(("region",), "must start with '-'"),
(("--",), "long flags must have at least one character"),
(("-abc",), "short flags must be a single character"),
],
)
def test_add_option_rejects_invalid_flags(
parser: FalyxParser,
flags: tuple[str, ...],
match: str,
) -> None:
with pytest.raises(FalyxOptionError, match=match):
parser.add_option(*flags)
@pytest.mark.parametrize("dest", ["help", "tldr"])
def test_add_option_rejects_reserved_dests(
parser: FalyxParser,
dest: str,
) -> None:
with pytest.raises(FalyxOptionError, match="reserved"):
parser.add_option("--custom", dest=dest)
def test_add_option_rejects_duplicate_dest(parser: FalyxParser) -> None:
parser.add_option("--region")
with pytest.raises(FalyxOptionError, match="duplicate option dest 'region'"):
parser.add_option("--region-name", dest="region")
def test_add_option_rejects_duplicate_flag(parser: FalyxParser) -> None:
parser.add_option("--region")
with pytest.raises(FalyxOptionError, match="already used"):
parser.add_option("--region", dest="other_region")
@pytest.mark.parametrize(
("dest", "match"),
[
("bad-dest", "valid identifier"),
("1bad", "cannot start with a digit"),
],
)
def test_add_option_rejects_invalid_explicit_dest(
parser: FalyxParser,
dest: str,
match: str,
) -> None:
with pytest.raises(FalyxOptionError, match=match):
parser.add_option("--valid", dest=dest)
def test_add_option_rejects_invalid_action(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="invalid option action"):
parser.add_option("--region", action="not-real")
def test_add_option_rejects_invalid_store_true_default(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="must be False or None"):
parser.add_option("--foo", action="store_true", default=True)
def test_add_option_rejects_invalid_store_false_default(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="must be True or None"):
parser.add_option("--foo", action="store_false", default=False)
def test_add_option_rejects_invalid_store_bool_optional_default(
parser: FalyxParser,
) -> None:
with pytest.raises(
FalyxOptionError,
match="default value for 'store_bool_optional' action must be None",
):
parser.add_option("--foo", action="store_bool_optional", default="not-bool")
def test_add_option_rejects_default_for_help_or_tldr_option(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="default value cannot be set for action 'help'"
):
parser.add_option("--additional-help", action="help", default=True)
with pytest.raises(
FalyxOptionError, match="default value cannot be set for action 'tldr'"
):
parser.add_option("--more-tldr", action="tldr", default=True)
def test_add_option_rejects_choices_for_boolean_option(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="choices cannot be specified"):
parser.add_option("--foo", action="store_true", choices=["yes"])
def test_add_option_rejects_default_outside_choices(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="not in allowed choices"):
parser.add_option(
"--region",
default="eu-central",
choices=["us-east", "us-west"],
)
def test_add_option_rejects_invalid_default_type(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="cannot be coerced to int"):
parser.add_option("--retries", type=int, default="not-an-int")
def test_add_option_rejects_invalid_choice_type(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="invalid choice"):
parser.add_option("--retries", type=int, choices=["1", "bad"])
def test_add_option_rejects_non_list_suggestions(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="suggestions must be a list or None"):
parser.add_option("--profile", suggestions=("dev", "prod"))
def test_add_option_rejects_non_string_suggestions(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="suggestions must be a list of strings"):
parser.add_option("--profile", suggestions=["dev", 1])
def test_add_option_rejects_non_string_flags(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="invalid flag '123': must be a string"):
parser.add_option("--region", 123)
def test_add_option_rejects_flags_with_invalid_prefix(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="invalid flag 'region': must start with '-'"
):
parser.add_option("region")
def test_add_option_rejects_long_flag_with_insufficient_length(
parser: FalyxParser,
) -> None:
with pytest.raises(
FalyxOptionError, match="long flags must have at least one character after '--'"
):
parser.add_option("--", dest="invalid")
def test_add_option_rejects_speacial_characters_in_dest(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="invalid dest 'bad-dest': must be a valid identifier"
):
parser.add_option("--bad-dest", dest="bad-dest")
with pytest.raises(
FalyxOptionError, match=r"invalid dest 'bad\*dest': must be a valid identifier"
):
parser.add_option("--bad-dest", dest="bad*dest")
with pytest.raises(
FalyxOptionError, match="invalid dest '1bad-dest': must be a valid identifier"
):
parser.add_option("--bad-dest", dest="1bad-dest")
def test_add_option_rejects_dest_starting_with_digit(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="invalid dest '1bad': cannot start with a digit"
):
parser.add_option("--1bad", dest="1bad")
def test_add_option_rejects_special_characters_in_flags(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError,
match=r"invalid flag '--bad\*flag': must only contain letters, digits, underscores, or hyphens",
):
parser.add_option("--bad*flag", dest="bad_flag")
def test_add_option_rejects_short_flag_with_multiple_characters(
parser: FalyxParser,
) -> None:
with pytest.raises(
FalyxOptionError,
match="invalid flag '-ab': short flags must be a single character",
):
parser.add_option("-ab")
def test_add_option_rejects_bad_flags(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError,
match="--region1@': must only contain letters, digits, underscores, or hyphens",
):
parser.add_option("--region1@")
with pytest.raises(
FalyxOptionError, match="invalid dest '42region': cannot start with a digit"
):
parser.add_option("--42region")
def test_register_option_rejects_duplicate_flag(parser: FalyxParser) -> None:
parser.add_option("--region", "-r")
parser.add_option("--profile", "-p")
option1 = Option(flags=("--region", "-r"), dest="region")
option2 = Option(flags=("--profile", "-p"), dest="profile")
with pytest.raises(FalyxOptionError, match="already used"):
parser._register_option(option1)
with pytest.raises(FalyxOptionError, match="already used"):
parser._register_option(option2)
def test_parse_args_with_no_args_returns_defaults(parser: FalyxParser) -> None:
result = parser.parse_args([])
assert result.mode is FalyxMode.COMMAND
assert result.raw_argv == []
assert result.remaining_argv == []
assert result.current_head == ""
assert result.help is False
assert result.tldr is False
assert result.namespace_defaults["help"] is False
assert result.root_defaults["verbose"] is False
assert result.root_defaults["debug_hooks"] is False
assert result.root_defaults["never_prompt"] is False
def test_parse_args_splits_root_and_namespace_options(parser: FalyxParser) -> None:
parser.add_option("--profile", default="dev")
result = parser.parse_args(["--verbose", "--profile", "prod", "deploy"])
assert result.root_options == {"verbose": True}
assert result.namespace_options == {"profile": "prod"}
assert result.remaining_argv == ["deploy"]
assert result.current_head == "deploy"
@pytest.mark.parametrize("help_flag", ["-h", "--help"])
def test_parse_args_help_flag_sets_help_mode(
parser: FalyxParser,
help_flag: str,
) -> None:
result = parser.parse_args([help_flag])
assert result.mode is FalyxMode.HELP
assert result.help is True
assert result.namespace_options["help"] is True
assert result.remaining_argv == []
def test_parse_args_tldr_flag_sets_help_mode_after_tldr_registered(
parser: FalyxParser,
) -> None:
parser.add_tldr_example(
entry_key="D",
usage="--region us-east",
description="Deploy to us-east",
)
result = parser.parse_args(["--tldr"])
assert result.mode is FalyxMode.HELP
assert result.tldr is True
assert result.namespace_options["tldr"] is True
def test_parse_args_unknown_leading_option_raises(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="unknown option '--wat'"):
parser.parse_args(["--wat"])
def test_parse_args_stops_at_first_non_option_boundary(parser: FalyxParser) -> None:
result = parser.parse_args(["deploy", "--verbose", "--never-prompt"])
assert result.root_options == {}
assert result.namespace_options == {}
assert result.remaining_argv == ["deploy", "--verbose", "--never-prompt"]
assert result.current_head == "deploy"
def test_parse_args_allows_unknown_options_after_route_boundary(
parser: FalyxParser,
) -> None:
result = parser.parse_args(["deploy", "--command-local-option"])
assert result.remaining_argv == ["deploy", "--command-local-option"]
def test_parse_args_store_true_and_store_false(parser: FalyxParser) -> None:
parser.add_option("--json", action="store_true")
parser.add_option("--color", action="store_false")
result = parser.parse_args(["--json", "--color"])
assert result.namespace_defaults["json"] is False
assert result.namespace_defaults["color"] is True
assert result.namespace_options["json"] is True
assert result.namespace_options["color"] is False
def test_parse_args_count_option(parser: FalyxParser) -> None:
parser.add_option("-q", "--quiet", action="count")
result = parser.parse_args(["-q", "-q", "--quiet"])
assert result.namespace_defaults["quiet"] == 0
assert result.namespace_options["quiet"] == 3
def test_parse_args_posix_bundles_boolean_and_count_options(
parser: FalyxParser,
) -> None:
parser.add_option("-q", "--quiet", action="count")
result = parser.parse_args(["-vdnq", "deploy"])
assert result.root_options == {
"verbose": True,
"debug_hooks": True,
"never_prompt": True,
}
assert result.namespace_options == {"quiet": 1}
assert result.remaining_argv == ["deploy"]
def test_parse_args_posix_bundle_can_end_with_store_option(
parser: FalyxParser,
) -> None:
parser.add_option("-q", "--quiet", action="count")
parser.add_option("-r", "--region")
result = parser.parse_args(["-qr", "us-east", "deploy"])
assert result.namespace_options == {
"quiet": 1,
"region": "us-east",
}
assert result.remaining_argv == ["deploy"]
def test_parse_args_does_not_expand_invalid_posix_bundle(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="unknown option '-vz'"):
parser.parse_args(["-vz"])
def test_parse_args_store_option_requires_value(parser: FalyxParser) -> None:
parser.add_option("--region")
with pytest.raises(FalyxOptionError, match="expected a value"):
parser.parse_args(["--region"])
def test_parse_args_store_option_coerces_value(parser: FalyxParser) -> None:
parser.add_option("--retries", type=int)
result = parser.parse_args(["--retries", "3"])
assert result.namespace_options["retries"] == 3
def test_parse_args_store_option_rejects_invalid_value(parser: FalyxParser) -> None:
parser.add_option("--retries", type=int)
with pytest.raises(FalyxOptionError, match="invalid value for '--retries'"):
parser.parse_args(["--retries", "abc"])
def test_parse_args_store_option_rejects_value_outside_choices(
parser: FalyxParser,
) -> None:
parser.add_option("--region", choices=["us-east", "us-west"])
with pytest.raises(FalyxOptionError, match="expected one of"):
parser.parse_args(["--region", "eu-central"])
def test_suggest_next_returns_no_suggestions_for_empty_args(
parser: FalyxParser,
) -> None:
suggestions, expecting_value = parser.suggest_next([], cursor_at_end_of_token=False)
assert suggestions == []
assert expecting_value is False
def test_suggest_next_suggests_matching_option_flags(parser: FalyxParser) -> None:
parser.add_option("--region", "-r")
suggestions, expecting_value = parser.suggest_next(
["--r"],
cursor_at_end_of_token=False,
)
assert suggestions == ["--region"]
assert expecting_value is False
def test_suggest_next_suggests_all_remaining_flags_at_token_boundary(
parser: FalyxParser,
) -> None:
parser.add_option("--region", "-r")
suggestions, expecting_value = parser.suggest_next(
["--"],
cursor_at_end_of_token=False,
)
assert "--help" in suggestions
assert "--verbose" in suggestions
assert "--debug-hooks" in suggestions
assert "--never-prompt" in suggestions
assert "--region" in suggestions
assert expecting_value is False
def test_suggest_next_suggests_choice_values_after_store_option(
parser: FalyxParser,
) -> None:
parser.add_option("--region", choices=["us-east", "us-west"])
suggestions, expecting_value = parser.suggest_next(
["--region"],
cursor_at_end_of_token=True,
)
assert suggestions == ["us-east", "us-west"]
assert expecting_value is True
def test_suggest_next_filters_choice_values_by_prefix(parser: FalyxParser) -> None:
parser.add_option("--region", choices=["us-east", "us-west", "eu-central"])
suggestions, expecting_value = parser.suggest_next(
["--region", "us-e"],
cursor_at_end_of_token=False,
)
assert suggestions == ["us-east"]
assert expecting_value is True
def test_suggest_next_uses_custom_value_suggestions(parser: FalyxParser) -> None:
parser.add_option("--profile", suggestions=["dev", "prod", "staging"])
suggestions, expecting_value = parser.suggest_next(
["--profile", "pr"],
cursor_at_end_of_token=False,
)
assert suggestions == ["prod"]
assert expecting_value is True
def test_suggest_next_excludes_consumed_options(parser: FalyxParser) -> None:
parser.add_option("--region", choices=["us-east", "us-west"])
parser.parse_args(["--region", "us-east"])
suggestions, expecting_value = parser.suggest_next(
["-"],
cursor_at_end_of_token=False,
)
assert "--region" not in suggestions
assert "-r" not in suggestions
assert expecting_value is False
def test_add_tldr_example_registers_example_and_tldr_option(
parser: FalyxParser,
) -> None:
parser.add_tldr_example(
entry_key="D",
usage="--region us-east",
description="Deploy to us-east",
)
assert parser.tldr_option is not None
assert "--tldr" in parser._options_by_dest
assert parser._tldr_examples == [
FalyxTLDRExample(
entry_key="D",
usage="--region us-east",
description="Deploy to us-east",
)
]
def test_add_tldr_example_rejects_unknown_entry(parser: FalyxParser) -> None:
with pytest.raises(EntryNotFoundError) as error:
parser.add_tldr_example(
entry_key="depoy",
usage="",
description="Typo example",
)
assert error.value.unknown_name == "depoy"
assert error.value.suggestions == ["DEPLOY"]
def test_add_tldr_examples_accepts_dataclass_instances(
parser: FalyxParser,
) -> None:
example = FalyxTLDRExample(
entry_key="deploy",
usage="--region us-east",
description="Deploy to us-east",
)
parser.add_tldr_examples([example])
assert parser.tldr_option is not None
assert parser._tldr_examples == [example]
def test_add_tldr_examples_accepts_three_tuple_examples(
parser: FalyxParser,
) -> None:
parser.add_tldr_examples(
[
("deploy", "--region us-east", "Deploy to us-east"),
]
)
assert parser.tldr_option is not None
assert parser._tldr_examples == [
FalyxTLDRExample(
entry_key="deploy",
usage="--region us-east",
description="Deploy to us-east",
)
]
def test_add_tldr_examples_rejects_invalid_tuple_shape(
parser: FalyxParser,
) -> None:
with pytest.raises(FalyxOptionError, match="invalid TLDR example format"):
parser.add_tldr_examples([("deploy", "missing description")])
def test_add_tldr_examples_rejects_unknown_entry(parser: FalyxParser) -> None:
with pytest.raises(EntryNotFoundError) as error:
parser.add_tldr_examples(
[
("depoy", "--region us-east", "Typo example"),
]
)
with pytest.raises(EntryNotFoundError) as error:
parser.add_tldr_examples(
[
FalyxTLDRExample(
entry_key="depoy",
usage="--region us-east",
description="Typo example",
)
]
)
assert error.value.unknown_name == "depoy"
assert error.value.suggestions == ["DEPLOY"]
def test_store_bool_optional_registers_positive_and_negative_flags(
parser: FalyxParser,
) -> None:
parser.add_option("--cache", action="store_bool_optional")
assert "--cache" in parser._options_by_dest
assert "--no-cache" in parser._options_by_dest
result = parser.parse_args([])
assert result.namespace_defaults["cache"] is None
result = parser.parse_args(["--cache"])
assert result.namespace_options["cache"] is True
result = parser.parse_args(["--no-cache"])
assert result.namespace_options["cache"] is False
@pytest.mark.parametrize(
("flag", "expected"),
[
("--cache", True),
("--no-cache", False),
],
)
def test_parse_args_store_bool_optional_intended_behavior(
parser: FalyxParser,
flag: str,
expected: bool,
) -> None:
parser.add_option("--cache", action="store_bool_optional")
result = parser.parse_args([flag])
assert result.namespace_options["cache"] is expected
def test_parse_args_store_bool_optional_rejects_multiple_flags(
parser: FalyxParser,
) -> None:
with pytest.raises(
FalyxOptionError, match="store_bool_optional action can only have a single flag"
):
parser.add_option("--cache", "-c", action="store_bool_optional")
def test_parse_args_store_bool_optional_rejects_short_flags(parser: FalyxParser) -> None:
with pytest.raises(
FalyxOptionError, match="store_bool_optional action must use a long flag"
):
parser.add_option("-c", action="store_bool_optional")
def test_parse_args_long_root_flags(parser: FalyxParser) -> None:
result = parser.parse_args(["--verbose", "--debug-hooks", "--never-prompt", "deploy"])
assert result.root_options == {
"verbose": True,
"debug_hooks": True,
"never_prompt": True,
}
assert result.namespace_options == {}
assert result.remaining_argv == ["deploy"]
@pytest.mark.parametrize("help_flag", ["-h", "--help"])
def test_parse_args_forwards_help_after_route_boundary(
parser: FalyxParser,
help_flag: str,
) -> None:
result = parser.parse_args(["deploy", help_flag])
assert result.mode is FalyxMode.COMMAND
assert result.help is False
assert result.namespace_options == {}
assert result.remaining_argv == ["deploy", help_flag]
def test_parse_args_short_tldr_flag_sets_help_mode_after_tldr_registered(
parser: FalyxParser,
) -> None:
parser.add_tldr_example(
entry_key="D",
usage="--region us-east",
description="Deploy to us-east",
)
result = parser.parse_args(["-T"])
assert result.mode is FalyxMode.HELP
assert result.tldr is True
assert result.namespace_options["tldr"] is True
def test_add_tldr_examples_registers_tldr_option_only_once(
parser: FalyxParser,
) -> None:
parser.add_tldr_example(
entry_key="deploy",
usage="--region us-east",
description="Deploy to us-east",
)
parser.add_tldr_example(
entry_key="deploy",
usage="--region us-west",
description="Deploy to us-west",
)
tldr_options = [option for option in parser.get_options() if option.dest == "tldr"]
assert len(tldr_options) == 1
assert parser.get_flags().count("--tldr") == 1
assert len(parser._tldr_examples) == 2
def test_parse_args_resets_consumed_option_state_between_parses(
parser: FalyxParser,
) -> None:
parser.add_option("--region", "-r", choices=["us-east", "us-west"])
parser.parse_args(["--region", "us-east"])
suggestions, _ = parser.suggest_next(["-"], cursor_at_end_of_token=False)
assert "--region" not in suggestions
parser.parse_args([])
suggestions, expecting_value = parser.suggest_next(
["--r"],
cursor_at_end_of_token=False,
)
assert suggestions == ["--region"]
assert expecting_value is False
def test_disabled_reserved_root_options_are_omitted_from_defaults() -> None:
parser = FalyxParser(
Falyx(
disable_verbose_option=True,
disable_debug_hooks_option=True,
disable_never_prompt_option=True,
)
)
result = parser.parse_args([])
assert result.root_defaults == {}
assert result.namespace_defaults["help"] is False
def test_parse_args_typed_choices_are_compared_after_coercion(
parser: FalyxParser,
) -> None:
parser.add_option("--retries", type=int, choices=["1", "2"])
result = parser.parse_args(["--retries", "1"])
assert result.namespace_options["retries"] == 1
def test_add_option_rejects_dict_choices(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="choices cannot be a dict"):
parser.add_option("--region", choices={"east": "us-east"})
def test_add_option_rejects_non_iterable_choices(parser: FalyxParser) -> None:
with pytest.raises(FalyxOptionError, match="choices must be iterable"):
parser.add_option("--region", choices=1)
@pytest.mark.parametrize("flag", ["--verbose", "--debug-hooks", "--never-prompt"])
def test_suggest_next_does_not_expect_value_for_root_boolean_flags(
parser: FalyxParser,
flag: str,
) -> None:
suggestions, expecting_value = parser.suggest_next(
[flag],
cursor_at_end_of_token=True,
)
assert suggestions == []
assert expecting_value is False
def test_add_option_normalizes_typed_choices(parser: FalyxParser) -> None:
option = parser.add_option("--retries", type=int, choices=["1", "2"])
assert option.choices == [1, 2]
def test_parse_args_accepts_value_matching_normalized_typed_choice(
parser: FalyxParser,
) -> None:
parser.add_option("--retries", type=int, choices=["1", "2"])
result = parser.parse_args(["--retries", "1"])
assert result.namespace_options["retries"] == 1
def test_add_option_normalizes_typed_default_before_choice_check(
parser: FalyxParser,
) -> None:
parser.add_option("--retries", type=int, choices=["1", "2"], default="1")
result = parser.parse_args([])
assert result.namespace_defaults["retries"] == 1
def test_add_option_returns_registered_option(parser: FalyxParser) -> None:
option = parser.add_option(
"--retries",
type=int,
default="1",
choices=["1", "2"],
)
assert isinstance(option, Option)
assert option.dest == "retries"
assert option.default == 1
assert option.choices == [1, 2]
assert option in parser.get_options()
def test_add_option_store_bool_optional_returns_primary_option(
parser: FalyxParser,
) -> None:
option = parser.add_option("--cache", action="store_bool_optional")
assert option.dest == "cache"
assert option.flags == ("--cache",)
assert "--cache" in parser._options_by_dest
assert "--no-cache" in parser._options_by_dest

226
tests/test_hook_manager.py Normal file
View File

@@ -0,0 +1,226 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from falyx.hook_manager import HookManager, HookType
def make_context(*, name: str = "DemoAction", exception: Exception | None = None) -> Any:
return SimpleNamespace(name=name, exception=exception, events=[])
def test_hook_type_choices_aliases_and_string_representation() -> None:
assert HookType.choices() == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert HookType(" before ") is HookType.BEFORE
assert HookType("success") is HookType.ON_SUCCESS
assert HookType(" ERROR ") is HookType.ON_ERROR
assert HookType("teardown") is HookType.ON_TEARDOWN
assert str(HookType.AFTER) == "after"
@pytest.mark.parametrize("bad_value", [7, object()])
def test_hook_type_rejects_non_string_missing_values(bad_value: object) -> None:
with pytest.raises(ValueError, match="Invalid HookType"):
HookType(bad_value)
def test_hook_type_rejects_unknown_string_with_valid_choices() -> None:
with pytest.raises(ValueError) as exc_info:
HookType("not-a-hook")
message = str(exc_info.value)
assert "Invalid HookType: 'not-a-hook'" in message
assert "before" in message
assert "on_success" in message
assert "on_error" in message
assert "after" in message
assert "on_teardown" in message
def test_manager_initializes_all_hook_buckets_and_registers_aliases() -> None:
manager = HookManager()
assert set(manager._hooks) == set(HookType)
assert all(hooks == [] for hooks in manager._hooks.values())
def before_hook(context: Any) -> None:
context.events.append("before")
def success_hook(context: Any) -> None:
context.events.append("success")
manager.register(HookType.BEFORE, before_hook)
manager.register("success", success_hook)
assert manager._hooks[HookType.BEFORE] == [before_hook]
assert manager._hooks[HookType.ON_SUCCESS] == [success_hook]
def test_register_rejects_invalid_hook_type() -> None:
manager = HookManager()
def hook(context: Any) -> None:
context.events.append("never-called")
with pytest.raises(ValueError, match="Invalid HookType"):
manager.register("missing-phase", hook)
@pytest.mark.asyncio
async def test_trigger_runs_sync_and_async_hooks_in_registration_order() -> None:
manager = HookManager()
context = make_context()
def sync_first(ctx: Any) -> None:
ctx.events.append("sync-first")
async def async_second(ctx: Any) -> None:
ctx.events.append("async-second")
def sync_third(ctx: Any) -> None:
ctx.events.append("sync-third")
manager.register("before", sync_first)
manager.register(HookType.BEFORE, async_second)
manager.register("before", sync_third)
await manager.trigger(HookType.BEFORE, context)
assert context.events == ["sync-first", "async-second", "sync-third"]
@pytest.mark.asyncio
async def test_trigger_rejects_unsupported_runtime_hook_type() -> None:
manager = HookManager()
with pytest.raises(ValueError, match="Unsupported hook type"):
await manager.trigger("not-a-hook", make_context()) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_trigger_logs_and_continues_after_non_error_hook_failure() -> None:
manager = HookManager()
context = make_context()
def failing_hook(ctx: Any) -> None:
ctx.events.append("failing")
raise RuntimeError("hook exploded")
def surviving_hook(ctx: Any) -> None:
ctx.events.append("surviving")
manager.register(HookType.BEFORE, failing_hook)
manager.register(HookType.BEFORE, surviving_hook)
await manager.trigger(HookType.BEFORE, context)
assert context.events == ["failing", "surviving"]
@pytest.mark.asyncio
async def test_trigger_on_error_hook_failure_reraises_original_context_exception() -> (
None
):
manager = HookManager()
original_error = ValueError("original failure")
context = make_context(exception=original_error)
def failing_error_hook(ctx: Any) -> None:
ctx.events.append("error-hook")
raise RuntimeError("error hook failed")
manager.register("error", failing_error_hook)
with pytest.raises(ValueError) as exc_info:
await manager.trigger(HookType.ON_ERROR, context)
assert exc_info.value is original_error
assert isinstance(exc_info.value.__cause__, RuntimeError)
assert str(exc_info.value.__cause__) == "error hook failed"
assert context.events == ["error-hook"]
@pytest.mark.asyncio
async def test_trigger_on_error_requires_context_exception_when_hook_fails() -> None:
manager = HookManager()
context = make_context(exception=None)
def failing_error_hook(ctx: Any) -> None:
raise RuntimeError("error hook failed")
manager.register(HookType.ON_ERROR, failing_error_hook)
with pytest.raises(AssertionError, match="Context exception should be set"):
await manager.trigger(HookType.ON_ERROR, context)
def test_clear_removes_one_hook_bucket_or_all_buckets() -> None:
manager = HookManager()
def before_hook(context: Any) -> None:
context.events.append("before")
def after_hook(context: Any) -> None:
context.events.append("after")
manager.register("before", before_hook)
manager.register("after", after_hook)
manager.clear(HookType.BEFORE)
assert manager._hooks[HookType.BEFORE] == []
assert manager._hooks[HookType.AFTER] == [after_hook]
manager.clear()
assert all(hooks == [] for hooks in manager._hooks.values())
def test_string_representation_lists_registered_hook_names_and_empty_buckets() -> None:
manager = HookManager()
def before_hook(context: Any) -> None:
context.events.append("before")
manager.register("before", before_hook)
text = str(manager)
assert text.startswith("<HookManager>")
assert "before: before_hook" in text
assert "on_success: —" in text
assert "on_error: —" in text
assert "after: —" in text
assert "on_teardown: —" in text
def test_copy_copies_hook_lists_without_sharing_list_objects() -> None:
manager = HookManager()
def first_hook(context: Any) -> None:
context.events.append("first")
def second_hook(context: Any) -> None:
context.events.append("second")
manager.register("teardown", first_hook)
clone = manager.copy()
assert clone is not manager
assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook]
assert clone._hooks[HookType.ON_TEARDOWN] is not manager._hooks[HookType.ON_TEARDOWN]
clone.register("teardown", second_hook)
assert manager._hooks[HookType.ON_TEARDOWN] == [first_hook]
assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook, second_hook]

View File

@@ -5,7 +5,7 @@ from falyx.action import Action
from falyx.console import console as falyx_console
from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.options_manager import OptionsManager
from falyx.parser import ArgumentAction, CommandArgumentParser
from falyx.parser import Argument, ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal
@@ -1009,3 +1009,20 @@ def test_add_argument_invalid_lazy_resolver():
CommandArgumentError, match="lazy_resolver must be a boolean, got int"
):
parser.add_argument("--valid", lazy_resolver=123)
def test_add_argument_returns_registered_argument() -> None:
parser = CommandArgumentParser()
arg = parser.add_argument(
"--retries",
type=int,
default="1",
choices=["1", "2"],
)
assert isinstance(arg, Argument)
assert arg.dest == "retries"
assert arg.default == 1
assert arg.choices == [1, 2]
assert parser.get_argument("retries") is arg

View File

@@ -0,0 +1,373 @@
from falyx.console import console
from falyx.execution_option import ExecutionOption
from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser
from falyx.parser.parser_types import TLDRExample
def build_parser() -> CommandArgumentParser:
parser = CommandArgumentParser(
command_key="D",
command_description="Deploy",
help_text="Deploy something.",
help_epilog="More help text.",
aliases=["deploy"],
program="source",
options_manager=OptionsManager(),
)
parser.add_argument("--region", choices=["us-east", "us-west"], default="us-east")
parser.add_argument("target")
group = parser.add_argument_group("auth", description="Authentication options")
group.add_argument("--profile", suggestions=["dev", "prod"])
mutex = parser.add_mutually_exclusive_group(
"mode",
required=False,
description="Execution mode",
)
mutex.add_argument("--dry-run", action="store_true")
mutex.add_argument("--apply", action="store_true")
parser.add_tldr_examples(
[
("target-1 --region us-east", "Deploy target-1 to us-east."),
("target-2 --dry-run", "Preview target-2 without executing."),
]
)
parser.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
return parser
def build_parser_with_tldr_examples() -> CommandArgumentParser:
parser = build_parser()
parser.add_tldr_examples(
[
("target-3 --profile dev", "Deploy target-3 using dev profile."),
]
)
return parser
def build_parser_with_groups() -> CommandArgumentParser:
parser = build_parser()
group = parser.add_argument_group("output", description="Output options")
group.add_argument("--json", action="store_true")
return parser
def build_parser_with_execution_options() -> CommandArgumentParser:
parser = build_parser()
parser.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
return parser
def test_clone_with_overrides_preserves_core_metadata():
original = build_parser()
new_options = OptionsManager()
cloned = original.clone_with_overrides(
command_key="X",
command_description="Execute",
help_text="Execute something else.",
help_epilog="Different epilog.",
aliases=["execute"],
program="target",
options_manager=new_options,
)
assert cloned is not original
assert cloned.command_key == "X"
assert cloned.command_description == "Execute"
assert cloned.help_text == "Execute something else."
assert cloned.help_epilog == "Different epilog."
assert cloned.aliases == ["execute"]
assert cloned.program == "target"
assert cloned.options_manager is new_options
def test_clone_with_overrides_keeps_execution_options_enabled_without_double_registration():
original = build_parser()
cloned = original.clone_with_overrides()
summary = cloned.get_argument("summary")
retries = cloned.get_argument("retries")
retry_delay = cloned.get_argument("retry_delay")
retry_backoff = cloned.get_argument("retry_backoff")
force_confirm = cloned.get_argument("force_confirm")
skip_confirm = cloned.get_argument("skip_confirm")
assert summary is not None
assert retries is not None
assert retry_delay is not None
assert retry_backoff is not None
assert force_confirm is not None
assert skip_confirm is not None
# Re-enabling on the clone should be idempotent, not duplicate flags/dests.
cloned.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1
def test_clone_with_overrides_preserves_groups_and_mutex_groups():
original = build_parser()
cloned = original.clone_with_overrides()
assert "auth" in cloned._argument_groups
assert "mode" in cloned._mutex_groups
assert cloned._arg_group_by_dest["profile"] == "auth"
assert cloned._mutex_group_by_dest["dry_run"] == "mode"
assert cloned._mutex_group_by_dest["apply"] == "mode"
assert cloned.get_argument("profile") is not None
assert cloned.get_argument("dry_run") is not None
assert cloned.get_argument("apply") is not None
def test_clone_with_overrides_preserves_tldr_examples_and_help_flags():
original = build_parser()
cloned = original.clone_with_overrides()
assert cloned.help_text == original.help_text
assert cloned.help_epilog == original.help_epilog
assert cloned.get_argument("help") is not None
assert cloned.get_argument("tldr") is not None
assert cloned._tldr_examples == original._tldr_examples
assert cloned._tldr_examples is not original._tldr_examples
def test_clone_with_overrides_does_not_share_argument_registries_with_original():
original = build_parser()
cloned = original.clone_with_overrides()
assert cloned._arguments is not original._arguments
assert cloned._positional is not original._positional
assert cloned._keyword is not original._keyword
assert cloned._keyword_list is not original._keyword_list
assert cloned._flag_map is not original._flag_map
assert cloned._dest_set is not original._dest_set
assert cloned._execution_dests is not original._execution_dests
cloned.add_argument("--new-flag", default="x")
assert cloned.get_argument("new_flag") is not None
assert original.get_argument("new_flag") is None
def test_clone_with_overrides_does_not_share_group_registries_with_original():
original = build_parser()
cloned = original.clone_with_overrides()
assert cloned._argument_groups is not original._argument_groups
assert cloned._mutex_groups is not original._mutex_groups
assert cloned._arg_group_by_dest is not original._arg_group_by_dest
assert cloned._mutex_group_by_dest is not original._mutex_group_by_dest
cloned_group = cloned.add_argument_group("output", description="Output options")
cloned_group.add_argument("--json", action="store_true")
assert "output" in cloned._argument_groups
assert "output" not in original._argument_groups
assert cloned.get_argument("json") is not None
assert original.get_argument("json") is None
def test_clone_with_overrides_reuses_no_mutable_group_objects():
original = build_parser()
cloned = original.clone_with_overrides()
# These should ideally be distinct objects too, not just distinct dicts.
assert cloned._argument_groups["auth"] is not original._argument_groups["auth"]
assert cloned._mutex_groups["mode"] is not original._mutex_groups["mode"]
def test_clone_with_overrides_reuses_no_mutable_argument_objects():
original = build_parser()
cloned = original.clone_with_overrides()
# Strict contract: cloned parser should not share Argument instances either.
assert cloned.get_argument("region") is not original.get_argument("region")
assert cloned.get_argument("target") is not original.get_argument("target")
assert cloned.get_argument("profile") is not original.get_argument("profile")
def test_clone_with_overrides_uses_new_options_manager():
original = build_parser()
new_options = OptionsManager()
cloned = original.clone_with_overrides(options_manager=new_options)
assert cloned.options_manager is new_options
assert original.options_manager is not new_options
def test_clone_with_overrides_has_single_help_and_single_tldr_argument():
parser = build_parser_with_tldr_examples()
cloned = parser.clone_with_overrides()
assert len([arg for arg in cloned._arguments if arg.dest == "help"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "tldr"]) == 1
assert cloned.get_argument("help") is not None
assert cloned.get_argument("tldr") is not None
def test_clone_with_overrides_copies_tldr_examples():
parser = build_parser_with_tldr_examples()
cloned = parser.clone_with_overrides()
assert cloned._tldr_examples == parser._tldr_examples
assert cloned._tldr_examples is not parser._tldr_examples
assert all(c is not o for c, o in zip(cloned._tldr_examples, parser._tldr_examples))
def test_clone_with_overrides_copies_explicit_tldr_examples():
parser = build_parser()
examples = [TLDRExample("foo", "bar")]
cloned = parser.clone_with_overrides(tldr_examples=examples)
assert cloned._tldr_examples == examples
assert cloned._tldr_examples is not examples
def test_clone_with_overrides_does_not_share_aliases_list():
parser = build_parser()
cloned = parser.clone_with_overrides()
assert cloned.aliases == parser.aliases
assert cloned.aliases is not parser.aliases
cloned.aliases.append("new-alias")
assert "new-alias" not in parser.aliases
def test_clone_with_overrides_rebuilds_group_membership_without_duplicates():
parser = build_parser_with_groups()
cloned = parser.clone_with_overrides()
assert cloned._argument_groups["auth"].dests == {"profile"}
assert set(cloned._mutex_groups["mode"].dests) == {"dry_run", "apply"}
assert len(cloned._mutex_groups["mode"].dests) == 2
def test_clone_with_overrides_does_not_share_group_objects():
parser = build_parser_with_groups()
cloned = parser.clone_with_overrides()
assert cloned._argument_groups is not parser._argument_groups
assert cloned._mutex_groups is not parser._mutex_groups
assert cloned._argument_groups["auth"] is not parser._argument_groups["auth"]
assert cloned._mutex_groups["mode"] is not parser._mutex_groups["mode"]
def test_clone_with_overrides_does_not_share_argument_objects():
parser = build_parser()
cloned = parser.clone_with_overrides()
for original_arg in parser._arguments:
cloned_arg = cloned.get_argument(original_arg.dest)
console.print(original_arg)
console.print(cloned_arg)
assert cloned_arg is not None
assert cloned_arg is not original_arg
assert cloned_arg == original_arg
def test_clone_with_overrides_internal_registries_point_to_cloned_arguments():
parser = build_parser()
cloned = parser.clone_with_overrides()
for arg in cloned._arguments:
for flag in arg.flags:
assert cloned._flag_map[flag] is arg
if not arg.positional:
assert cloned._keyword[flag] is arg
if arg.positional:
assert cloned._positional[arg.dest] is arg
else:
assert arg in cloned._keyword_list
def test_clone_with_overrides_preserves_execution_option_state_without_duplication():
parser = build_parser_with_execution_options()
cloned = parser.clone_with_overrides()
assert cloned._summary_enabled is True
assert cloned._retries_enabled is True
assert cloned._confirm_enabled is True
assert cloned._execution_dests == parser._execution_dests
assert cloned._execution_dests is not parser._execution_dests
cloned.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
assert len([arg for arg in cloned._arguments if arg.dest == "summary"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retries"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retry_delay"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "retry_backoff"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "force_confirm"]) == 1
assert len([arg for arg in cloned._arguments if arg.dest == "skip_confirm"]) == 1
def test_clone_with_overrides_preserves_runner_and_help_mode_flags():
parser = build_parser()
parser.is_runner_mode = True
parser.mark_as_help_command()
cloned = parser.clone_with_overrides()
assert cloned.is_runner_mode is True
assert cloned._is_help_command is True
def test_clone_with_overrides_mutating_clone_does_not_mutate_original():
parser = build_parser()
cloned = parser.clone_with_overrides()
cloned.add_argument("--new-flag", default="x")
assert cloned.get_argument("new_flag") is not None
assert parser.get_argument("new_flag") is None

View File

@@ -0,0 +1,459 @@
from __future__ import annotations
from io import StringIO
from pathlib import Path
import pytest
from rich.console import Console
from falyx.action.action import Action
from falyx.exceptions import CommandArgumentError, InvalidValueError
from falyx.execution_option import ExecutionOption
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.signals import HelpSignal
@pytest.fixture
def parser() -> CommandArgumentParser:
return CommandArgumentParser(
command_key="D",
command_description="Deploy service",
help_text="Deploy a service.",
help_epilog="Deployment epilog.",
aliases=["deploy"],
program="flx",
)
def capture_console(parser: CommandArgumentParser) -> StringIO:
stream = StringIO()
parser.console = Console(
file=stream,
force_terminal=False,
color_system=None,
width=120,
)
return stream
def test_add_argument_rejects_suggestions_with_non_string_members(
parser: CommandArgumentParser,
) -> None:
with pytest.raises(
CommandArgumentError, match="suggestions must be a list of strings"
):
parser.add_argument("--region", suggestions=["dev", 1])
@pytest.mark.asyncio
async def test_parse_accepts_multi_value_choice_list_when_all_values_are_valid(
parser: CommandArgumentParser,
) -> None:
parser.add_argument(
"--ports",
type=int,
nargs="+",
choices=["80", 443],
default=[],
)
result = await parser.parse_args(["--ports", "80", "443"])
assert result["ports"] == [80, 443]
@pytest.mark.asyncio
async def test_positional_action_wraps_resolver_failure(
parser: CommandArgumentParser,
) -> None:
async def fail_resolver(value: str) -> str:
raise RuntimeError(f"cannot resolve {value}")
parser.add_argument(
"target",
action="action",
resolver=Action("Resolve target", fail_resolver),
lazy_resolver=False,
)
with pytest.raises(
CommandArgumentError, match=r"\[target\] action failed: cannot resolve web"
):
await parser.parse_args(["web"])
@pytest.mark.asyncio
async def test_dash_prefixed_numeric_token_can_be_a_positional_value(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("delta", type=int)
result = await parser.parse_args(["-3"])
assert result["delta"] == -3
@pytest.mark.asyncio
async def test_store_option_without_value_raises_type_specific_prompt(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--count", type=int, help="Number of instances.")
with pytest.raises(CommandArgumentError, match="enter a int value for 'count'"):
await parser.parse_args(["--count"])
@pytest.mark.asyncio
async def test_append_option_without_value_raises_type_specific_prompt(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--tag", action="append", help="Deployment tag.")
with pytest.raises(CommandArgumentError, match="enter a str value for 'tag'"):
await parser.parse_args(["--tag"])
@pytest.mark.asyncio
async def test_tldr_flag_on_help_command_is_parsed_as_a_normal_value(
parser: CommandArgumentParser,
) -> None:
parser.mark_as_help_command()
parser.add_tldr_example("D --region us-east", "Deploy to us-east")
result = await parser.parse_args(["--tldr"])
assert result["tldr"] is True
@pytest.mark.asyncio
async def test_tldr_flag_renders_examples_and_raises_help_signal(
parser: CommandArgumentParser,
) -> None:
stream = capture_console(parser)
parser.add_tldr_example("--region us-east", "Deploy to us-east")
with pytest.raises(HelpSignal):
await parser.parse_args(["--tldr"])
output = stream.getvalue()
assert "usage:" in output
assert "examples:" in output
assert "Deploy to us-east" in output
assert "--region us-east" in output
@pytest.mark.asyncio
async def test_required_mutex_group_requires_one_member(
parser: CommandArgumentParser,
) -> None:
mode = parser.add_mutually_exclusive_group("mode", required=True)
mode.add_argument("--dry-run", action="store_true")
mode.add_argument("--apply", action="store_true")
with pytest.raises(
CommandArgumentError, match="one of the following is required for group 'mode'"
):
await parser.parse_args([])
@pytest.mark.asyncio
async def test_mutex_group_rejects_multiple_present_members(
parser: CommandArgumentParser,
) -> None:
mode = parser.add_mutually_exclusive_group("mode")
mode.add_argument("--dry-run", action="store_true")
mode.add_argument("--apply", action="store_true")
with pytest.raises(
CommandArgumentError,
match="cannot be used together: (dry_run, apply|apply, dry_run)",
):
await parser.parse_args(["--dry-run", "--apply"])
@pytest.mark.parametrize(
("argument_kwargs", "argv"),
[
({"flags": ("--enabled",), "action": "store_true"}, ["--enabled", "--other"]),
({"flags": ("--disabled",), "action": "store_false"}, ["--disabled", "--other"]),
(
{"flags": ("--feature",), "action": "store_bool_optional"},
["--no-feature", "--other"],
),
({"flags": ("-v", "--verbose"), "action": "count"}, ["-v", "--other"]),
({"flags": ("--tag",), "action": "append"}, ["--tag", "beta", "--other"]),
(
{"flags": ("--item",), "action": "extend", "nargs": "+"},
["--item", "a", "--other"],
),
({"flags": ("--name",)}, ["--name", "web", "--other"]),
],
)
@pytest.mark.asyncio
async def test_mutex_presence_detection_handles_all_supported_action_shapes(
argument_kwargs: dict,
argv: list[str],
) -> None:
parser = CommandArgumentParser(command_key="D")
only_one = parser.add_mutually_exclusive_group("only-one")
kwargs = argument_kwargs.copy()
flags = kwargs.pop("flags")
only_one.add_argument(*flags, **kwargs)
only_one.add_argument("--other", action="store_true")
with pytest.raises(CommandArgumentError, match="cannot be used together"):
await parser.parse_args(argv)
@pytest.mark.asyncio
async def test_parse_args_split_separates_execution_options_from_command_inputs(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("service")
parser.add_argument("--region", default="us-east")
parser.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
args, kwargs, execution_args = await parser.parse_args_split(
[
"api",
"--region",
"us-west",
"--summary",
"--retries",
"3",
"--retry-delay",
"0.5",
"--retry-backoff",
"2.0",
"--skip-confirm",
]
)
assert args == ("api",)
assert kwargs == {"region": "us-west"}
assert execution_args == {
"summary": True,
"retries": 3,
"retry_delay": 0.5,
"retry_backoff": 2.0,
"force_confirm": False,
"skip_confirm": True,
}
@pytest.mark.asyncio
async def test_lazy_action_required_argument_is_deferred_during_validation(
parser: CommandArgumentParser,
) -> None:
calls: list[str] = []
async def resolve(value: str) -> str:
calls.append(value)
return value.upper()
parser.add_argument(
"--target",
action="action",
resolver=Action("Resolve target", resolve),
required=True,
)
result = await parser.parse_args(["--target", "web"], from_validate=True)
assert result["target"] is None
assert calls == []
@pytest.mark.asyncio
async def test_lazy_action_required_argument_still_errors_when_no_tokens_are_present(
parser: CommandArgumentParser,
) -> None:
async def resolve(value: str) -> str:
return value.upper()
parser.add_argument(
"--target",
action="action",
resolver=Action("Resolve target", resolve),
required=True,
)
with pytest.raises(CommandArgumentError, match="missing required argument 'target'"):
await parser.parse_args([], from_validate=True)
@pytest.mark.asyncio
async def test_default_list_with_wrong_fixed_nargs_arity_is_invalid(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--pair", nargs=2, default=["only-one"])
with pytest.raises(InvalidValueError) as exc_info:
await parser.parse_args([])
assert exc_info.value.dest == "pair"
assert "expected 2" in str(exc_info.value)
@pytest.mark.asyncio
async def test_required_plus_nargs_option_requires_at_least_one_value(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--item", nargs="+", required=True)
with pytest.raises(
CommandArgumentError, match="argument 'item' requires at least one value"
):
await parser.parse_args([])
@pytest.mark.asyncio
async def test_suggest_next_filters_mutex_siblings_after_one_member_is_consumed(
parser: CommandArgumentParser,
) -> None:
mode = parser.add_mutually_exclusive_group("mode")
mode.add_argument("--dry-run", action="store_true")
mode.add_argument("--apply", action="store_true")
parser.add_argument("--region", choices=["us-east", "us-west"])
await parser.parse_args(["--dry-run"])
suggestions = parser.suggest_next(["--dry-run"], cursor_at_end_of_token=True)
assert "--apply" not in suggestions
assert "--region" in suggestions
@pytest.mark.asyncio
async def test_optional_choice_argument_can_be_omitted(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--region", choices=["us-east", "us-west"])
result = await parser.parse_args(["--dry-run"])
assert result["dry_run"] is True
assert result["region"] is None
@pytest.mark.asyncio
async def test_suggest_next_returns_no_values_after_invalid_choice_is_committed(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--region", choices=["dev", "prod"])
with pytest.raises(InvalidValueError):
await parser.parse_args(["--region", "qa"])
assert parser.suggest_next(["--region", "qa"], cursor_at_end_of_token=True) == []
@pytest.mark.asyncio
async def test_suggest_next_suggests_value_for_keyword_when_stub_starts_with_dash(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("--profile", suggestions=["-prod", "-stage", "dev"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--profile"], from_validate=True)
assert parser.suggest_next(["--profile", "-"], cursor_at_end_of_token=False) == [
"-prod",
"-stage",
]
@pytest.mark.asyncio
async def test_suggest_next_returns_empty_for_missing_path_base(
parser: CommandArgumentParser,
tmp_path: Path,
) -> None:
missing = tmp_path / "missing-dir" / "config.toml"
parser.add_argument("--config", type=Path)
await parser.parse_args(["--config", str(missing)], from_validate=True)
assert (
parser.suggest_next(["--config", str(missing)], cursor_at_end_of_token=False)
== []
)
def test_get_options_text_repeats_fixed_width_positional_nargs(
parser: CommandArgumentParser,
) -> None:
parser.add_argument("coords", nargs=2)
assert "coords coords" in parser.get_options_text()
def test_get_usage_uses_program_only_when_parser_is_in_runner_mode(
parser: CommandArgumentParser,
) -> None:
parser.is_runner_mode = True
parser.program = "deploy-tool"
usage = parser.get_usage()
assert "deploy-tool" in usage
assert "[bold]D[/bold]" not in usage
assert "[bold]deploy[/bold]" not in usage
def test_render_help_includes_grouped_keywords_bool_optional_pair_and_epilog(
parser: CommandArgumentParser,
) -> None:
stream = capture_console(parser)
parser.add_argument("environment", help="Target environment.")
deploy = parser.add_argument_group("deploy", "Deployment options.")
deploy.add_argument("--region", help="Target region.")
mode = parser.add_mutually_exclusive_group("mode")
mode.add_argument("--dry-run", action="store_true", help="Preview only.")
parser.add_argument("--cache", action="store_bool_optional", help="Use cache.")
parser.render_help()
output = stream.getvalue()
assert "usage:" in output
assert "Deploy a service." in output
assert "positional:" in output
assert "environment" in output
assert "deploy:" in output
assert "Deployment options." in output
assert "--cache, --no-cache" in output
assert "Deployment epilog." in output
def test_render_tldr_without_examples_prints_empty_state_message(
parser: CommandArgumentParser,
) -> None:
stream = capture_console(parser)
parser.render_tldr()
assert "No TLDR examples available for D" in stream.getvalue()
def test_render_tldr_with_examples_prints_usage_help_and_example_panel(
parser: CommandArgumentParser,
) -> None:
stream = capture_console(parser)
parser.add_tldr_example("--region us-east", "Deploy east")
parser.render_tldr()
output = stream.getvalue()
assert "usage:" in output
assert "Deploy a service." in output
assert "examples:" in output
assert "Deploy east" in output
assert "--region us-east" in output

View File

@@ -34,10 +34,6 @@ def test_enable_execution_options_registers_retry_flags():
def test_enable_execution_options_invalid_double_registration_raises():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
with pytest.raises(
CommandArgumentError, match="destination 'summary' is already defined"
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
with pytest.raises(
CommandArgumentError,
@@ -68,9 +64,10 @@ def test_register_execution_dest_rejects_duplicates():
parser.add_argument("--summary", action="store_true")
with pytest.raises(
CommandArgumentError, match="destination 'summary' is already defined"
CommandArgumentError,
match="destination 'summary' is already registered as an execution argument",
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
parser._register_execution_dest("summary")
@pytest.mark.asyncio

View File

@@ -76,9 +76,10 @@ async def test_resolve_args_raises_on_conflicting_execution_option():
)
with pytest.raises(
CommandArgumentError, match="destination 'summary' is already defined"
CommandArgumentError,
match="destination 'summary' is already registered as an execution argument",
):
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
command.arg_parser._register_execution_dest("summary")
@pytest.mark.asyncio

View File

@@ -1,10 +1,12 @@
import asyncio
import logging
import sys
import pytest
from rich.console import Console
from rich.text import Text
from falyx import Falyx
from falyx.action import Action
from falyx.command import Command
from falyx.command_runner import CommandRunner
@@ -18,6 +20,7 @@ from falyx.exceptions import (
)
from falyx.hook_manager import HookManager, HookType
from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
@@ -133,15 +136,15 @@ async def test_command_runner_initialization(
assert runner.command == command_with_parser
assert runner.program == "test_program"
assert runner.command.arg_parser.program == "test_program"
assert isinstance(runner.options, OptionsManager)
assert isinstance(runner.options_manager, OptionsManager)
assert isinstance(runner.runner_hooks, HookManager)
assert runner.console == falyx_console
assert runner.command.options_manager == runner.options
assert runner.command.arg_parser.options_manager == runner.options
assert runner.command.options_manager == runner.options
assert runner.executor.options == runner.options
assert runner.command.options_manager == runner.options_manager
assert runner.command.arg_parser.options_manager == runner.options_manager
assert runner.command.options_manager == runner.options_manager
assert runner.executor.options_manager == runner.options_manager
assert runner.executor.hooks == runner.runner_hooks
assert runner.options.get("summary", namespace_name="execution") is None
assert runner.options_manager.get("summary", namespace_name="execution") is None
runner_no_parser = CommandRunner(command_with_no_parser)
assert runner_no_parser.command == command_with_no_parser
@@ -161,12 +164,12 @@ async def test_command_runner_initialization(
def test_command_runner_initialization_with_custom_options(command_with_parser):
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner(command_with_parser, options=custom_options)
assert runner.options == custom_options
assert runner.options.get("summary", namespace_name="default") is True
assert runner.command.options_manager == runner.options
assert runner.command.arg_parser.options_manager == runner.options
assert runner.command.options_manager == runner.options
runner = CommandRunner(command_with_parser, options_manager=custom_options)
assert runner.options_manager == custom_options
assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.command.options_manager == runner.options_manager
assert runner.command.arg_parser.options_manager == runner.options_manager
assert runner.command.options_manager == runner.options_manager
def test_command_runner_initialization_with_custom_console(command_with_parser):
@@ -190,11 +193,11 @@ def test_command_runner_initialization_with_all_bad_components(command_with_pars
custom_hooks = "Not a HookManager"
with pytest.raises(
NotAFalyxError, match="options must be an instance of OptionsManager"
NotAFalyxError, match="options_manager must be an instance of OptionsManager"
):
CommandRunner(
command_with_parser,
options=custom_options,
options_manager=custom_options,
)
with pytest.raises(
@@ -247,9 +250,10 @@ async def test_command_runner_run_with_failing_action(command_with_failing_actio
@pytest.mark.asyncio
async def test_command_runner_debug_statement(command_with_parser, caplog):
caplog.set_level("DEBUG")
logging.getLogger("falyx").setLevel(logging.DEBUG)
runner = CommandRunner(command_with_parser)
await runner.run("--foo 42")
print(command_with_parser.get_option("verbose", namespace_name="root"))
assert (
"Executing command 'Test Command' with args=(), kwargs={'foo': 42}" in caplog.text
)
@@ -539,3 +543,126 @@ async def test_command_runner_run_error(command_with_parser):
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=False)
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True)
await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False)
def test_command_runner_from_command_reuses_custom_options_manager_and_seeds_missing_namespaces():
flx = Falyx(program="source")
original = flx.add_command(
key="D",
description="Deploy",
action=lambda: "ok",
)
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner.from_command(
original,
options_manager=custom_options,
)
assert runner.command is not original
assert runner.options_manager is custom_options
assert runner.command.options_manager is custom_options
assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.options_manager.get_namespace("root") == {}
assert runner.options_manager.get_namespace("execution") == {}
assert original.options_manager is flx.options_manager
assert original.options_manager is not custom_options
@pytest.mark.asyncio
async def test_command_runner_root_options_affect_cloned_command_without_mutating_original(
monkeypatch,
):
flx = Falyx(program="source")
original = flx.add_command(
key="D",
description="Deploy",
action=Action("deploy-action", lambda: "ok"),
confirm=True,
)
original.options_manager.set("never_prompt", False, "root")
original.options_manager.set("verbose", False, "root")
runner_options = OptionsManager()
runner_options.from_mapping({}, "root")
runner_options.from_mapping({}, "execution")
runner_options.set("never_prompt", True, "root")
runner_options.set("verbose", True, "root")
runner = CommandRunner.from_command(
original,
options_manager=runner_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)
result = await runner.run([])
assert result == "ok"
assert calls["preview"] == 0
assert calls["confirm"] == 0
assert runner.command.get_option("never_prompt", namespace_name="root") is True
assert runner.command.get_option("verbose", namespace_name="root") is True
assert runner.command.action.get_option("verbose", namespace_name="root") is True
assert runner.command.action.never_prompt is True
assert original.get_option("never_prompt", namespace_name="root") is False
assert original.get_option("verbose", namespace_name="root") is False
assert original.options_manager is flx.options_manager
assert original.options_manager is not runner.options_manager
@pytest.mark.asyncio
async def test_command_runner_from_command_with_custom_options_preserves_parity_and_isolation():
falyx = Falyx("Custom Options Parity Test")
def add(x: int, y: int) -> int:
return x + y
command = falyx.add_command(
key="A",
description="Add",
action=add,
)
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner.from_command(
command,
options_manager=custom_options,
)
falyx_result = await falyx.execute_command("A 2 3")
runner_result = await runner.run(["2", "3"])
assert falyx_result == 5
assert runner_result == 5
assert falyx_result == runner_result
assert runner.options_manager is custom_options
assert runner.command.options_manager is custom_options
assert runner.options_manager.get("summary", namespace_name="default") is True
assert runner.options_manager.get_namespace("root") == {}
assert runner.options_manager.get_namespace("execution") == {}
assert runner.command is not command
assert command.options_manager is falyx.options_manager
assert command.options_manager is not runner.options_manager

489
tests/test_selection.py Normal file
View File

@@ -0,0 +1,489 @@
from __future__ import annotations
from typing import Any
import pytest
from rich import box
from rich.table import Table
import falyx.selection as selection_module
from falyx.selection import (
SelectionOption,
SelectionOptionMap,
get_selection_from_dict_menu,
prompt_for_index,
prompt_for_selection,
render_selection_dict_table,
render_selection_grid,
render_selection_indexed_table,
render_table_base,
select_key_from_dict,
select_value_from_dict,
select_value_from_list,
)
from falyx.themes import OneColors
class CaptureConsole:
def __init__(self) -> None:
self.printed: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.printed.append((args, kwargs))
class FakePromptSession:
def __init__(self, *responses: str) -> None:
self.responses = list(responses)
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
self.message: Any = "original-message"
self.validator: Any = "original-validator"
self.placeholder: Any = "original-placeholder"
async def prompt_async(self, *args: Any, **kwargs: Any) -> str:
self.calls.append((args, kwargs))
self.message = kwargs.get("message")
self.validator = kwargs.get("validator")
self.placeholder = kwargs.get("placeholder")
if not self.responses:
raise AssertionError("No fake prompt response configured")
return self.responses.pop(0)
@pytest.fixture
def sample_options() -> dict[str, SelectionOption]:
return {
"dev": SelectionOption("Development", "dev-value", style="green"),
"prod": SelectionOption("Production", "prod-value", style="red"),
"stage": SelectionOption("Staging", "stage-value", style="yellow"),
}
def test_selection_option_rejects_non_string_description() -> None:
with pytest.raises(TypeError, match="description must be a string"):
SelectionOption(123, "value") # type: ignore[arg-type]
def test_selection_option_render_escapes_key_and_applies_style() -> None:
option = SelectionOption("Deploy [prod]", "prod", style="red")
rendered = option.render("a[b]")
assert "a" in rendered
assert "Deploy [prod]" in rendered
assert f"[{OneColors.WHITE}]" in rendered
assert "[red]" in rendered
def test_selection_option_copy_returns_independent_equivalent_option() -> None:
option = SelectionOption("Development", {"env": "dev"}, style="green")
copied = option.copy()
assert copied == option
assert copied is not option
def test_selection_option_map_initializes_from_options_case_insensitively(
sample_options: dict[str, SelectionOption],
) -> None:
mapping = SelectionOptionMap({"dev": sample_options["dev"]})
assert mapping["DEV"] is sample_options["dev"]
assert mapping["dev"] is sample_options["dev"]
assert list(mapping.items()) == [("DEV", sample_options["dev"])]
def test_selection_option_map_rejects_non_selection_option_values() -> None:
mapping = SelectionOptionMap()
with pytest.raises(TypeError, match="must be a SelectionOption"):
mapping["bad"] = "not an option" # type: ignore[assignment]
with pytest.raises(TypeError, match="must be a SelectionOption"):
mapping.update({"bad": "not an option"})
with pytest.raises(TypeError, match="must be a SelectionOption"):
mapping.update(bad="not an option")
def test_selection_option_map_update_accepts_kwargs_and_copy_is_deep_for_options(
sample_options: dict[str, SelectionOption],
) -> None:
mapping = SelectionOptionMap()
mapping.update(dev=sample_options["dev"])
mapping.update({"prod": sample_options["prod"]})
copied = mapping.copy()
assert copied.allow_reserved is mapping.allow_reserved
assert copied["DEV"] == mapping["dev"]
assert copied["DEV"] is not mapping["dev"]
assert copied["PROD"].description == "Production"
def test_selection_option_map_reserved_key_protection_and_bypass(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"})
mapping = SelectionOptionMap()
reserved_option = SelectionOption("Quit", None)
with pytest.raises(ValueError, match="reserved"):
mapping["quit"] = reserved_option
mapping._add_reserved("quit", reserved_option)
assert mapping["QUIT"] is reserved_option
with pytest.raises(ValueError, match="Cannot delete reserved option"):
del mapping["quit"]
assert list(mapping.items(include_reserved=False)) == []
def test_selection_option_map_allows_reserved_key_when_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(SelectionOptionMap, "RESERVED_KEYS", {"QUIT"})
mapping = SelectionOptionMap(allow_reserved=True)
option = SelectionOption("Quit", None)
mapping["quit"] = option
assert mapping["QUIT"] is option
del mapping["quit"]
assert "QUIT" not in mapping
def test_render_table_base_uses_explicit_column_names_and_styles() -> None:
table = render_table_base(
"Environments",
caption="Choose carefully",
column_names=["Name", "Description"],
box_style=box.ROUNDED,
show_lines=True,
show_header=True,
show_footer=True,
style="blue",
header_style="bold",
footer_style="dim",
title_style="green",
caption_style="yellow",
highlight=False,
)
assert isinstance(table, Table)
assert table.title == "Environments"
assert table.caption == "Choose carefully"
assert len(table.columns) == 2
assert table.columns[0].header == "Name"
assert table.columns[1].header == "Description"
assert table.show_header is True
assert table.show_footer is True
assert table.highlight is False
def test_render_table_base_creates_blank_columns_when_no_names_are_given() -> None:
table = render_table_base("Choices", columns=3)
assert len(table.columns) == 3
def test_render_selection_grid_chunks_rows() -> None:
table = render_selection_grid("Choices", ["alpha", "beta", "gamma"], columns=2)
assert table.title == "Choices"
assert len(table.columns) == 2
assert len(table.rows) == 2
def test_render_selection_indexed_table_uses_default_and_custom_formatters() -> None:
default_table = render_selection_indexed_table(
"Indexed", ["alpha", "beta", "gamma"], columns=2
)
formatted_table = render_selection_indexed_table(
"Formatted",
["alpha", "beta"],
columns=2,
formatter=lambda index, value: f"{index}:{value.upper()}",
)
assert len(default_table.rows) == 2
assert len(formatted_table.rows) == 1
def test_render_selection_dict_table_renders_option_rows(
sample_options: dict[str, SelectionOption],
) -> None:
table = render_selection_dict_table(
"Environments",
sample_options,
columns=2,
caption="Pick one",
highlight=True,
)
assert table.title == "Environments"
assert table.caption == "Pick one"
assert len(table.columns) == 2
assert len(table.rows) == 2
assert table.highlight is True
@pytest.mark.asyncio
async def test_prompt_for_index_returns_single_index_and_restores_prompt_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_console = CaptureConsole()
monkeypatch.setattr(selection_module, "console", fake_console)
session = FakePromptSession(" 2 ")
table = render_table_base("Choices")
result = await prompt_for_index(
5,
table,
default_selection="1",
prompt_session=session, # type: ignore[arg-type]
prompt_message="[bold]Pick >[/] ",
show_table=True,
number_selections=1,
cancel_key="9",
)
assert result == 2
assert len(fake_console.printed) == 1
_, kwargs = session.calls[0]
assert kwargs["default"] == "1"
assert kwargs["placeholder"] == "Enter selection"
assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator"
assert session.message == "original-message"
assert session.validator == "original-validator"
assert session.placeholder == "original-placeholder"
@pytest.mark.asyncio
async def test_prompt_for_index_returns_cancel_key_as_int() -> None:
session = FakePromptSession(" 7 ")
table = render_table_base("Choices")
result = await prompt_for_index(
7,
table,
prompt_session=session, # type: ignore[arg-type]
show_table=False,
cancel_key="7",
)
assert result == 7
@pytest.mark.asyncio
async def test_prompt_for_index_returns_multiple_indexes_with_custom_separator() -> None:
session = FakePromptSession("0 ; 2 ; 4")
table = render_table_base("Choices")
result = await prompt_for_index(
5,
table,
prompt_session=session, # type: ignore[arg-type]
show_table=False,
number_selections=3,
separator=";",
allow_duplicates=True,
)
assert result == [0, 2, 4]
_, kwargs = session.calls[0]
assert kwargs["placeholder"] == "Enter 3 selections separated by ';'"
assert kwargs["validator"].__class__.__name__ == "MultiIndexValidator"
@pytest.mark.asyncio
async def test_prompt_for_selection_returns_single_key_and_prints_table(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_console = CaptureConsole()
monkeypatch.setattr(selection_module, "console", fake_console)
session = FakePromptSession(" DEV ")
table = render_table_base("Choices")
result = await prompt_for_selection(
["DEV", "PROD"],
table,
default_selection="PROD",
prompt_session=session, # type: ignore[arg-type]
prompt_message="Select > ",
show_table=True,
number_selections=1,
cancel_key="Q",
)
assert result == "DEV"
assert len(fake_console.printed) == 1
_, kwargs = session.calls[0]
assert kwargs["default"] == "PROD"
assert kwargs["placeholder"] == "Enter selection"
assert kwargs["validator"].__class__.__name__ == "MultiKeyValidator"
@pytest.mark.asyncio
async def test_prompt_for_selection_returns_cancel_key() -> None:
session = FakePromptSession(" q ")
table = render_table_base("Choices")
result = await prompt_for_selection(
["dev", "prod", "q"],
table,
prompt_session=session, # type: ignore[arg-type]
show_table=False,
cancel_key="q",
)
assert result == "q"
@pytest.mark.asyncio
async def test_prompt_for_selection_returns_multiple_keys_with_custom_separator() -> None:
session = FakePromptSession("dev | prod | stage")
table = render_table_base("Choices")
result = await prompt_for_selection(
["dev", "prod", "stage"],
table,
prompt_session=session, # type: ignore[arg-type]
show_table=False,
number_selections="*",
separator="|",
allow_duplicates=True,
)
assert result == ["dev", "prod", "stage"]
_, kwargs = session.calls[0]
assert kwargs["placeholder"] == "Enter selections separated by '|'"
@pytest.mark.asyncio
async def test_select_value_from_list_returns_single_and_multiple_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prompt_calls: list[dict[str, Any]] = []
async def fake_prompt_for_index(max_index: int, table: Table, **kwargs: Any) -> Any:
prompt_calls.append({"max_index": max_index, "table": table, **kwargs})
return [0, 2] if kwargs["number_selections"] != 1 else 1
monkeypatch.setattr(selection_module, "prompt_for_index", fake_prompt_for_index)
single = await select_value_from_list(
"Languages",
["python", "rust", "go"],
default_selection="1",
number_selections=1,
)
multiple = await select_value_from_list(
"Languages",
["python", "rust", "go"],
default_selection="0,2",
number_selections=2,
)
assert single == "rust"
assert multiple == ["python", "go"]
assert prompt_calls[0]["max_index"] == 2
assert prompt_calls[0]["default_selection"] == "1"
assert prompt_calls[1]["number_selections"] == 2
@pytest.mark.asyncio
async def test_select_key_from_dict_delegates_to_prompt_for_selection(
monkeypatch: pytest.MonkeyPatch,
sample_options: dict[str, SelectionOption],
) -> None:
fake_console = CaptureConsole()
monkeypatch.setattr(selection_module, "console", fake_console)
calls: list[dict[str, Any]] = []
async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> str:
calls.append({"keys": list(keys), "table": table, **kwargs})
return "prod"
monkeypatch.setattr(
selection_module, "prompt_for_selection", fake_prompt_for_selection
)
table = render_table_base("Environments")
result = await select_key_from_dict(
sample_options,
table,
default_selection="dev",
number_selections=1,
cancel_key="q",
)
assert result == "prod"
assert len(fake_console.printed) == 1
assert calls[0]["keys"] == ["dev", "prod", "stage"]
assert calls[0]["default_selection"] == "dev"
assert calls[0]["cancel_key"] == "q"
@pytest.mark.asyncio
async def test_select_value_from_dict_returns_single_and_multiple_values(
monkeypatch: pytest.MonkeyPatch,
sample_options: dict[str, SelectionOption],
) -> None:
fake_console = CaptureConsole()
monkeypatch.setattr(selection_module, "console", fake_console)
responses: list[Any] = ["prod", ["dev", "stage"]]
async def fake_prompt_for_selection(keys: Any, table: Table, **kwargs: Any) -> Any:
return responses.pop(0)
monkeypatch.setattr(
selection_module, "prompt_for_selection", fake_prompt_for_selection
)
table = render_table_base("Environments")
single = await select_value_from_dict(sample_options, table)
multiple = await select_value_from_dict(
sample_options,
table,
number_selections=2,
separator=",",
)
assert single == "prod-value"
assert multiple == ["dev-value", "stage-value"]
assert len(fake_console.printed) == 2
@pytest.mark.asyncio
async def test_get_selection_from_dict_menu_builds_table_and_returns_selected_value(
monkeypatch: pytest.MonkeyPatch,
sample_options: dict[str, SelectionOption],
) -> None:
calls: list[dict[str, Any]] = []
async def fake_select_value_from_dict(
*, selections: dict[str, SelectionOption], table: Table, **kwargs: Any
) -> str:
calls.append({"selections": selections, "table": table, **kwargs})
return "prod-value"
monkeypatch.setattr(
selection_module, "select_value_from_dict", fake_select_value_from_dict
)
result = await get_selection_from_dict_menu(
"Environments",
sample_options,
default_selection="prod",
number_selections=1,
cancel_key="q",
)
assert result == "prod-value"
assert calls[0]["selections"] is sample_options
assert calls[0]["table"].title == "Environments"
assert calls[0]["default_selection"] == "prod"
assert calls[0]["cancel_key"] == "q"