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