Add loading submenus from config or Falyx object, more examples

This commit is contained in:
Roland Thomas Jr 2025-05-13 23:19:29 -04:00
parent 2bdca72e04
commit bba473047c
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
9 changed files with 151 additions and 15 deletions

View File

@ -20,3 +20,12 @@ commands:
action: menu_demo.menu
tags: [menu, demo]
help_text: Run a menu demo with multiple options.
submenus:
- key: C
description: Process Menu (From Config)
config: process.yaml
- key: U
description: Submenu From Python
submenu: submenu.submenu

6
examples/http.yaml Normal file
View File

@ -0,0 +1,6 @@
commands:
- key: T
description: HTTP Test
action: single_http.http_action
tags: [http, demo]
help_text: Run HTTP test.

11
examples/process.yaml Normal file
View File

@ -0,0 +1,11 @@
commands:
- key: P
description: Pipeline Demo
action: pipeline_demo.pipeline
tags: [pipeline, demo]
help_text: Run Demployment Pipeline with retries.
submenus:
- key: C
description: HTTP Test (Nested From Config)
config: http.yaml

31
examples/run_key.py Normal file
View File

@ -0,0 +1,31 @@
import asyncio
from falyx import Action, Falyx
async def main():
state = {"count": 0}
async def flaky():
if not state["count"]:
state["count"] += 1
print("Flaky step failed, retrying...")
raise RuntimeError("Random failure!")
return "ok"
# Add a command that raises an exception
falyx.add_command(
key="E",
description="Error Command",
action=Action("flaky", flaky),
retry=True,
)
result = await falyx.run_key("E")
print(result)
assert result == "ok"
if __name__ == "__main__":
falyx = Falyx("Headless Recovery Test")
asyncio.run(main())

14
examples/single_http.py Normal file
View File

@ -0,0 +1,14 @@
import asyncio
from falyx.action import HTTPAction
http_action = HTTPAction(
name="Get Example",
method="GET",
url="https://jsonplaceholder.typicode.com/posts/1",
headers={"Accept": "application/json"},
retry=True,
)
if __name__ == "__main__":
asyncio.run(http_action())

View File

@ -72,10 +72,10 @@ class RawCommand(BaseModel):
description: str
action: str
args: tuple[Any, ...] = ()
kwargs: dict[str, Any] = {}
aliases: list[str] = []
tags: list[str] = []
args: tuple[Any, ...] = Field(default_factory=tuple)
kwargs: dict[str, Any] = Field(default_factory=dict)
aliases: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
style: str = OneColors.WHITE
confirm: bool = False
@ -86,13 +86,13 @@ class RawCommand(BaseModel):
spinner_message: str = "Processing..."
spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = {}
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
before_hooks: list[Callable] = []
success_hooks: list[Callable] = []
error_hooks: list[Callable] = []
after_hooks: list[Callable] = []
teardown_hooks: list[Callable] = []
before_hooks: list[Callable] = Field(default_factory=list)
success_hooks: list[Callable] = Field(default_factory=list)
error_hooks: list[Callable] = Field(default_factory=list)
after_hooks: list[Callable] = Field(default_factory=list)
teardown_hooks: list[Callable] = Field(default_factory=list)
logging_hooks: bool = False
retry: bool = False
@ -129,6 +129,60 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
return commands
def convert_submenus(
raw_submenus: list[dict[str, Any]], *, parent_path: Path | None = None, depth: int = 0
) -> list[dict[str, Any]]:
submenus: list[dict[str, Any]] = []
for raw_submenu in raw_submenus:
if raw_submenu.get("config"):
config_path = Path(raw_submenu["config"])
if parent_path:
config_path = (parent_path.parent / config_path).resolve()
submenu = loader(config_path, _depth=depth + 1)
else:
submenu_module_path = raw_submenu.get("submenu")
if not isinstance(submenu_module_path, str):
console.print(
f"[{OneColors.DARK_RED}]❌ Invalid submenu path:[/] {submenu_module_path}"
)
sys.exit(1)
submenu = import_action(submenu_module_path)
if not isinstance(submenu, Falyx):
console.print(f"[{OneColors.DARK_RED}]❌ Invalid submenu:[/] {submenu}")
sys.exit(1)
key = raw_submenu.get("key")
if not isinstance(key, str):
console.print(f"[{OneColors.DARK_RED}]❌ Invalid submenu key:[/] {key}")
sys.exit(1)
description = raw_submenu.get("description")
if not isinstance(description, str):
console.print(
f"[{OneColors.DARK_RED}]❌ Invalid submenu description:[/] {description}"
)
sys.exit(1)
submenus.append(
Submenu(
key=key,
description=description,
submenu=submenu,
style=raw_submenu.get("style", OneColors.CYAN),
).model_dump()
)
return submenus
class Submenu(BaseModel):
"""Submenu model for Falyx CLI configuration."""
key: str
description: str
submenu: Any
style: str = OneColors.CYAN
class FalyxConfig(BaseModel):
"""Falyx CLI configuration model."""
@ -140,6 +194,7 @@ class FalyxConfig(BaseModel):
welcome_message: str = ""
exit_message: str = ""
commands: list[Command] | list[dict] = []
submenus: list[dict[str, Any]] = []
@model_validator(mode="after")
def validate_prompt_format(self) -> FalyxConfig:
@ -160,10 +215,12 @@ class FalyxConfig(BaseModel):
exit_message=self.exit_message,
)
flx.add_commands(self.commands)
for submenu in self.submenus:
flx.add_submenu(**submenu)
return flx
def loader(file_path: Path | str) -> Falyx:
def loader(file_path: Path | str, _depth: int = 0) -> Falyx:
"""
Load Falyx CLI configuration from a YAML or TOML file.
@ -183,6 +240,9 @@ def loader(file_path: Path | str) -> Falyx:
Raises:
ValueError: If the file format is unsupported or file cannot be parsed.
"""
if _depth > 5:
raise ValueError("Maximum submenu depth exceeded (5 levels deep)")
if isinstance(file_path, (str, Path)):
path = Path(file_path)
else:
@ -212,6 +272,7 @@ def loader(file_path: Path | str) -> Falyx:
)
commands = convert_commands(raw_config["commands"])
submenus = convert_submenus(raw_config.get("submenus", []))
return FalyxConfig(
title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"),
prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]),
@ -219,4 +280,5 @@ def loader(file_path: Path | str) -> Falyx:
welcome_message=raw_config.get("welcome_message", ""),
exit_message=raw_config.get("exit_message", ""),
commands=commands,
submenus=submenus,
).to_falyx()

View File

@ -19,6 +19,8 @@ for running commands, actions, and workflows. It supports:
Falyx enables building flexible, robust, and user-friendly
terminal applications with minimal boilerplate.
"""
from __future__ import annotations
import asyncio
import logging
import sys
@ -528,14 +530,15 @@ class Falyx:
)
def add_submenu(
self, key: str, description: str, submenu: "Falyx", *, style: str = OneColors.CYAN
self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN
) -> None:
"""Adds a submenu to the menu."""
if not isinstance(submenu, Falyx):
raise NotAFalyxError("submenu must be an instance of Falyx.")
self._validate_command_key(key)
self.add_command(key, description, submenu.menu, style=style)
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
if submenu.exit_command.key == "Q":
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
def add_commands(self, commands: list[Command] | list[dict]) -> None:
"""Adds a list of Command instances or config dicts."""

View File

@ -1 +1 @@
__version__ = "0.1.25"
__version__ = "0.1.26"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
version = "0.1.25"
version = "0.1.26"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"