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
490 lines
15 KiB
Python
490 lines
15 KiB
Python
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"
|