diff --git a/examples/falyx.yaml b/examples/falyx.yaml index 579056e..85727f0 100644 --- a/examples/falyx.yaml +++ b/examples/falyx.yaml @@ -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 diff --git a/examples/http.yaml b/examples/http.yaml new file mode 100644 index 0000000..8e0f0d4 --- /dev/null +++ b/examples/http.yaml @@ -0,0 +1,6 @@ +commands: + - key: T + description: HTTP Test + action: single_http.http_action + tags: [http, demo] + help_text: Run HTTP test. diff --git a/examples/process.yaml b/examples/process.yaml new file mode 100644 index 0000000..2fe230f --- /dev/null +++ b/examples/process.yaml @@ -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 diff --git a/examples/run_key.py b/examples/run_key.py new file mode 100644 index 0000000..dc48c04 --- /dev/null +++ b/examples/run_key.py @@ -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()) diff --git a/examples/single_http.py b/examples/single_http.py new file mode 100644 index 0000000..29b74df --- /dev/null +++ b/examples/single_http.py @@ -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()) diff --git a/falyx/config.py b/falyx/config.py index 48bc775..ee9d34b 100644 --- a/falyx/config.py +++ b/falyx/config.py @@ -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() diff --git a/falyx/falyx.py b/falyx/falyx.py index 44fe3d4..caf38e0 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -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.""" diff --git a/falyx/version.py b/falyx/version.py index 43a0e4e..c8ec146 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.25" +__version__ = "0.1.26" diff --git a/pyproject.toml b/pyproject.toml index 904291f..457df1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "MIT"