Add better starter examples init.py, change validation in config.py, add add_command_from_command
This commit is contained in:
		| @@ -6,6 +6,7 @@ Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| import asyncio | ||||
| import os | ||||
| import sys | ||||
| from argparse import Namespace | ||||
| from pathlib import Path | ||||
| @@ -14,7 +15,6 @@ from typing import Any | ||||
| from falyx.config import loader | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.parsers import FalyxParsers, get_arg_parsers | ||||
| from falyx.themes.colors import OneColors | ||||
|  | ||||
|  | ||||
| def find_falyx_config() -> Path | None: | ||||
| @@ -23,6 +23,7 @@ def find_falyx_config() -> Path | None: | ||||
|         Path.cwd() / "falyx.toml", | ||||
|         Path.cwd() / ".falyx.yaml", | ||||
|         Path.cwd() / ".falyx.toml", | ||||
|         Path(os.environ.get("FALYX_CONFIG", "falyx.yaml")), | ||||
|         Path.home() / ".config" / "falyx" / "falyx.yaml", | ||||
|         Path.home() / ".config" / "falyx" / "falyx.toml", | ||||
|         Path.home() / ".falyx.yaml", | ||||
| @@ -68,13 +69,7 @@ def run(args: Namespace) -> Any: | ||||
|         print("No Falyx config file found. Exiting.") | ||||
|         return None | ||||
|  | ||||
|     flx = Falyx( | ||||
|         title="🛠️ Config-Driven CLI", | ||||
|         cli_args=args, | ||||
|         columns=4, | ||||
|         prompt=[(OneColors.BLUE_b, "FALYX > ")], | ||||
|     ) | ||||
|     flx.add_commands(loader(bootstrap_path)) | ||||
|     flx: Falyx = loader(bootstrap_path) | ||||
|     return asyncio.run(flx.run()) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| from typing import Any | ||||
|  | ||||
| from rich.tree import Tree | ||||
|   | ||||
							
								
								
									
										174
									
								
								falyx/config.py
									
									
									
									
									
								
							
							
						
						
									
										174
									
								
								falyx/config.py
									
									
									
									
									
								
							| @@ -1,18 +1,21 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """config.py | ||||
| Configuration loader for Falyx CLI commands.""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| import importlib | ||||
| import sys | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
| from typing import Any, Callable | ||||
|  | ||||
| import toml | ||||
| import yaml | ||||
| from pydantic import BaseModel, Field, field_validator, model_validator | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.action import Action, BaseAction | ||||
| from falyx.command import Command | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.retry import RetryPolicy | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
| @@ -27,8 +30,8 @@ def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | ||||
|         return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj) | ||||
|     else: | ||||
|         raise TypeError( | ||||
|             f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. " | ||||
|             "It must be a callable or an instance of BaseAction." | ||||
|             f"Cannot wrap object of type '{type(obj).__name__}'. " | ||||
|             "Expected a function or BaseAction." | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @@ -60,7 +63,101 @@ def import_action(dotted_path: str) -> Any: | ||||
|     return action | ||||
|  | ||||
|  | ||||
| def loader(file_path: Path | str) -> list[dict[str, Any]]: | ||||
| class RawCommand(BaseModel): | ||||
|     key: str | ||||
|     description: str | ||||
|     action: str | ||||
|  | ||||
|     args: tuple[Any, ...] = () | ||||
|     kwargs: dict[str, Any] = {} | ||||
|     aliases: list[str] = [] | ||||
|     tags: list[str] = [] | ||||
|     style: str = "white" | ||||
|  | ||||
|     confirm: bool = False | ||||
|     confirm_message: str = "Are you sure?" | ||||
|     preview_before_confirm: bool = True | ||||
|  | ||||
|     spinner: bool = False | ||||
|     spinner_message: str = "Processing..." | ||||
|     spinner_type: str = "dots" | ||||
|     spinner_style: str = "cyan" | ||||
|     spinner_kwargs: dict[str, Any] = {} | ||||
|  | ||||
|     before_hooks: list[Callable] = [] | ||||
|     success_hooks: list[Callable] = [] | ||||
|     error_hooks: list[Callable] = [] | ||||
|     after_hooks: list[Callable] = [] | ||||
|     teardown_hooks: list[Callable] = [] | ||||
|  | ||||
|     logging_hooks: bool = False | ||||
|     retry: bool = False | ||||
|     retry_all: bool = False | ||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||
|     requires_input: bool | None = None | ||||
|     hidden: bool = False | ||||
|     help_text: str = "" | ||||
|  | ||||
|     @field_validator("retry_policy") | ||||
|     @classmethod | ||||
|     def validate_retry_policy(cls, value: dict | RetryPolicy) -> RetryPolicy: | ||||
|         if isinstance(value, RetryPolicy): | ||||
|             return value | ||||
|         if not isinstance(value, dict): | ||||
|             raise ValueError("retry_policy must be a dictionary.") | ||||
|         return RetryPolicy(**value) | ||||
|  | ||||
|  | ||||
| def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]: | ||||
|     commands = [] | ||||
|     for entry in raw_commands: | ||||
|         raw_command = RawCommand(**entry) | ||||
|         commands.append( | ||||
|             Command.model_validate( | ||||
|                 { | ||||
|                     **raw_command.model_dump(exclude={"action"}), | ||||
|                     "action": wrap_if_needed( | ||||
|                         import_action(raw_command.action), name=raw_command.description | ||||
|                     ), | ||||
|                 } | ||||
|             ) | ||||
|         ) | ||||
|     return commands | ||||
|  | ||||
|  | ||||
| class FalyxConfig(BaseModel): | ||||
|     title: str = "Falyx CLI" | ||||
|     prompt: str | list[tuple[str, str]] | list[list[str]] = [ | ||||
|         (OneColors.BLUE_b, "FALYX > ") | ||||
|     ] | ||||
|     columns: int = 4 | ||||
|     welcome_message: str = "" | ||||
|     exit_message: str = "" | ||||
|     commands: list[Command] | list[dict] = [] | ||||
|  | ||||
|     @model_validator(mode="after") | ||||
|     def validate_prompt_format(self) -> FalyxConfig: | ||||
|         if isinstance(self.prompt, list): | ||||
|             for pair in self.prompt: | ||||
|                 if not isinstance(pair, (list, tuple)) or len(pair) != 2: | ||||
|                     raise ValueError( | ||||
|                         "Prompt list must contain 2-element (style, text) pairs" | ||||
|                     ) | ||||
|         return self | ||||
|  | ||||
|     def to_falyx(self) -> Falyx: | ||||
|         flx = Falyx( | ||||
|             title=self.title, | ||||
|             prompt=self.prompt, | ||||
|             columns=self.columns, | ||||
|             welcome_message=self.welcome_message, | ||||
|             exit_message=self.exit_message, | ||||
|         ) | ||||
|         flx.add_commands(self.commands) | ||||
|         return flx | ||||
|  | ||||
|  | ||||
| def loader(file_path: Path | str) -> Falyx: | ||||
|     """ | ||||
|     Load command definitions from a YAML or TOML file. | ||||
|  | ||||
| @@ -73,15 +170,13 @@ def loader(file_path: Path | str) -> list[dict[str, Any]]: | ||||
|         file_path (str): Path to the config file (YAML or TOML). | ||||
|  | ||||
|     Returns: | ||||
|         list[dict[str, Any]]: A list of command configuration dictionaries. | ||||
|         Falyx: An instance of the Falyx CLI with loaded commands. | ||||
|  | ||||
|     Raises: | ||||
|         ValueError: If the file format is unsupported or file cannot be parsed. | ||||
|     """ | ||||
|     if isinstance(file_path, str): | ||||
|     if isinstance(file_path, (str, Path)): | ||||
|         path = Path(file_path) | ||||
|     elif isinstance(file_path, Path): | ||||
|         path = file_path | ||||
|     else: | ||||
|         raise TypeError("file_path must be a string or Path object.") | ||||
|  | ||||
| @@ -97,48 +192,23 @@ def loader(file_path: Path | str) -> list[dict[str, Any]]: | ||||
|         else: | ||||
|             raise ValueError(f"Unsupported config format: {suffix}") | ||||
|  | ||||
|     if not isinstance(raw_config, list): | ||||
|         raise ValueError("Configuration file must contain a list of command definitions.") | ||||
|     if not isinstance(raw_config, dict): | ||||
|         raise ValueError( | ||||
|             "Configuration file must contain a dictionary with a list of commands.\n" | ||||
|             "Example:\n" | ||||
|             "title: 'My CLI'\n" | ||||
|             "commands:\n" | ||||
|             "  - key: 'a'\n" | ||||
|             "    description: 'Example command'\n" | ||||
|             "    action: 'my_module.my_function'" | ||||
|         ) | ||||
|  | ||||
|     required = ["key", "description", "action"] | ||||
|     commands = [] | ||||
|     for entry in raw_config: | ||||
|         for field in required: | ||||
|             if field not in entry: | ||||
|                 raise ValueError(f"Missing '{field}' in command entry: {entry}") | ||||
|  | ||||
|         command_dict = { | ||||
|             "key": entry["key"], | ||||
|             "description": entry["description"], | ||||
|             "action": wrap_if_needed( | ||||
|                 import_action(entry["action"]), name=entry["description"] | ||||
|             ), | ||||
|             "args": tuple(entry.get("args", ())), | ||||
|             "kwargs": entry.get("kwargs", {}), | ||||
|             "hidden": entry.get("hidden", False), | ||||
|             "aliases": entry.get("aliases", []), | ||||
|             "help_text": entry.get("help_text", ""), | ||||
|             "style": entry.get("style", "white"), | ||||
|             "confirm": entry.get("confirm", False), | ||||
|             "confirm_message": entry.get("confirm_message", "Are you sure?"), | ||||
|             "preview_before_confirm": entry.get("preview_before_confirm", True), | ||||
|             "spinner": entry.get("spinner", False), | ||||
|             "spinner_message": entry.get("spinner_message", "Processing..."), | ||||
|             "spinner_type": entry.get("spinner_type", "dots"), | ||||
|             "spinner_style": entry.get("spinner_style", "cyan"), | ||||
|             "spinner_kwargs": entry.get("spinner_kwargs", {}), | ||||
|             "before_hooks": entry.get("before_hooks", []), | ||||
|             "success_hooks": entry.get("success_hooks", []), | ||||
|             "error_hooks": entry.get("error_hooks", []), | ||||
|             "after_hooks": entry.get("after_hooks", []), | ||||
|             "teardown_hooks": entry.get("teardown_hooks", []), | ||||
|             "retry": entry.get("retry", False), | ||||
|             "retry_all": entry.get("retry_all", False), | ||||
|             "retry_policy": RetryPolicy(**entry.get("retry_policy", {})), | ||||
|             "tags": entry.get("tags", []), | ||||
|             "logging_hooks": entry.get("logging_hooks", False), | ||||
|             "requires_input": entry.get("requires_input", None), | ||||
|         } | ||||
|         commands.append(command_dict) | ||||
|  | ||||
|     return commands | ||||
|     commands = convert_commands(raw_config["commands"]) | ||||
|     return FalyxConfig( | ||||
|         title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"), | ||||
|         prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]), | ||||
|         columns=raw_config.get("columns", 4), | ||||
|         welcome_message=raw_config.get("welcome_message", ""), | ||||
|         exit_message=raw_config.get("exit_message", ""), | ||||
|         commands=commands, | ||||
|     ).to_falyx() | ||||
|   | ||||
| @@ -9,6 +9,7 @@ from rich.console import Console | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
|  | ||||
|  | ||||
| @@ -66,10 +67,10 @@ class ExecutionRegistry: | ||||
|             duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" | ||||
|  | ||||
|             if ctx.exception: | ||||
|                 status = "[bold red]❌ Error" | ||||
|                 status = f"[{OneColors.DARK_RED}]❌ Error" | ||||
|                 result = repr(ctx.exception) | ||||
|             else: | ||||
|                 status = "[green]✅ Success" | ||||
|                 status = f"[{OneColors.GREEN}]✅ Success" | ||||
|                 result = repr(ctx.result) | ||||
|                 if len(result) > 1000: | ||||
|                     result = f"{result[:1000]}..." | ||||
|   | ||||
| @@ -291,7 +291,7 @@ class Falyx: | ||||
|         """Returns the help command for the menu.""" | ||||
|         return Command( | ||||
|             key="H", | ||||
|             aliases=["HELP"], | ||||
|             aliases=["HELP", "?"], | ||||
|             description="Help", | ||||
|             action=self._show_help, | ||||
|             style=OneColors.LIGHT_YELLOW, | ||||
| @@ -560,10 +560,24 @@ class Falyx: | ||||
|         self.add_command(key, description, submenu.menu, style=style) | ||||
|         submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) | ||||
|  | ||||
|     def add_commands(self, commands: list[dict]) -> None: | ||||
|         """Adds multiple commands to the menu.""" | ||||
|     def add_commands(self, commands: list[Command] | list[dict]) -> None: | ||||
|         """Adds a list of Command instances or config dicts.""" | ||||
|         for command in commands: | ||||
|             self.add_command(**command) | ||||
|             if isinstance(command, dict): | ||||
|                 self.add_command(**command) | ||||
|             elif isinstance(command, Command): | ||||
|                 self.add_command_from_command(command) | ||||
|             else: | ||||
|                 raise FalyxError( | ||||
|                     "Command must be a dictionary or an instance of Command." | ||||
|                 ) | ||||
|  | ||||
|     def add_command_from_command(self, command: Command) -> None: | ||||
|         """Adds a command to the menu from an existing Command object.""" | ||||
|         if not isinstance(command, Command): | ||||
|             raise FalyxError("command must be an instance of Command.") | ||||
|         self._validate_command_key(command.key) | ||||
|         self.commands[command.key] = command | ||||
|  | ||||
|     def add_command( | ||||
|         self, | ||||
| @@ -696,7 +710,10 @@ class Falyx: | ||||
|     ) -> tuple[bool, Command | None]: | ||||
|         """Returns the selected command based on user input. Supports keys, aliases, and abbreviations.""" | ||||
|         is_preview, choice = self.parse_preview_command(choice) | ||||
|         if is_preview and not choice: | ||||
|         if is_preview and not choice and self.help_command: | ||||
|             is_preview = False | ||||
|             choice = "?" | ||||
|         elif is_preview and not choice: | ||||
|             if not from_validate: | ||||
|                 self.console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]" | ||||
| @@ -891,28 +908,29 @@ class Falyx: | ||||
|         self.debug_hooks() | ||||
|         if self.welcome_message: | ||||
|             self.print_message(self.welcome_message) | ||||
|         while True: | ||||
|             if callable(self.render_menu): | ||||
|                 self.render_menu(self) | ||||
|             else: | ||||
|                 self.console.print(self.table, justify="center") | ||||
|             try: | ||||
|                 task = asyncio.create_task(self.process_command()) | ||||
|                 should_continue = await task | ||||
|                 if not should_continue: | ||||
|         try: | ||||
|             while True: | ||||
|                 if callable(self.render_menu): | ||||
|                     self.render_menu(self) | ||||
|                 else: | ||||
|                     self.console.print(self.table, justify="center") | ||||
|                 try: | ||||
|                     task = asyncio.create_task(self.process_command()) | ||||
|                     should_continue = await task | ||||
|                     if not should_continue: | ||||
|                         break | ||||
|                 except (EOFError, KeyboardInterrupt): | ||||
|                     logger.info("EOF or KeyboardInterrupt. Exiting menu.") | ||||
|                     break | ||||
|             except (EOFError, KeyboardInterrupt): | ||||
|                 logger.info("EOF or KeyboardInterrupt. Exiting menu.") | ||||
|                 break | ||||
|             except QuitSignal: | ||||
|                 logger.info("QuitSignal received. Exiting menu.") | ||||
|                 break | ||||
|             except BackSignal: | ||||
|                 logger.info("BackSignal received.") | ||||
|             finally: | ||||
|                 logger.info(f"Exiting menu: {self.get_title()}") | ||||
|                 if self.exit_message: | ||||
|                     self.print_message(self.exit_message) | ||||
|                 except QuitSignal: | ||||
|                     logger.info("QuitSignal received. Exiting menu.") | ||||
|                     break | ||||
|                 except BackSignal: | ||||
|                     logger.info("BackSignal received.") | ||||
|         finally: | ||||
|             logger.info(f"Exiting menu: {self.get_title()}") | ||||
|             if self.exit_message: | ||||
|                 self.print_message(self.exit_message) | ||||
|  | ||||
|     async def run(self) -> None: | ||||
|         """Run Falyx CLI with structured subcommands.""" | ||||
|   | ||||
							
								
								
									
										102
									
								
								falyx/init.py
									
									
									
									
									
								
							
							
						
						
									
										102
									
								
								falyx/init.py
									
									
									
									
									
								
							| @@ -4,27 +4,85 @@ from pathlib import Path | ||||
| from rich.console import Console | ||||
|  | ||||
| TEMPLATE_TASKS = """\ | ||||
| async def build(): | ||||
|     print("🔨 Building project...") | ||||
|     return "Build complete!" | ||||
| # This file is used by falyx.yaml to define CLI actions. | ||||
| # You can run: falyx run [key] or falyx list to see available commands. | ||||
|  | ||||
| async def test(): | ||||
|     print("🧪 Running tests...") | ||||
|     return "Tests complete!" | ||||
| import asyncio | ||||
| import json | ||||
|  | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.io_action import ShellAction | ||||
| from falyx.selection_action import SelectionAction | ||||
|  | ||||
|  | ||||
| post_ids = ["1", "2", "3", "4", "5"] | ||||
|  | ||||
| pick_post = SelectionAction( | ||||
|     name="Pick Post ID", | ||||
|     selections=post_ids, | ||||
|     title="Choose a Post ID", | ||||
|     prompt_message="Select a post > ", | ||||
| ) | ||||
|  | ||||
| fetch_post = ShellAction( | ||||
|     name="Fetch Post via curl", | ||||
|     command_template="curl https://jsonplaceholder.typicode.com/posts/{}", | ||||
| ) | ||||
|  | ||||
| async def get_post_title(last_result): | ||||
|     return json.loads(last_result).get("title", "No title found") | ||||
|  | ||||
| post_flow = ChainedAction( | ||||
|     name="Fetch and Parse Post", | ||||
|     actions=[pick_post, fetch_post, get_post_title], | ||||
|     auto_inject=True, | ||||
| ) | ||||
|  | ||||
| async def hello(): | ||||
|     print("👋 Hello from Falyx!") | ||||
|     return "Hello Complete!" | ||||
|  | ||||
| async def some_work(): | ||||
|     await asyncio.sleep(2) | ||||
|     print("Work Finished!") | ||||
|     return "Work Complete!" | ||||
|  | ||||
| work_action = Action( | ||||
|     name="Work Action", | ||||
|     action=some_work, | ||||
| ) | ||||
| """ | ||||
|  | ||||
| TEMPLATE_CONFIG = """\ | ||||
| - key: B | ||||
|   description: Build the project | ||||
|   action: tasks.build | ||||
|   aliases: [build] | ||||
|   spinner: true | ||||
| # falyx.yaml — Config-driven CLI definition | ||||
| # Define your commands here and point to Python callables in tasks.py | ||||
| title: Sample CLI Project | ||||
| prompt: | ||||
|   - ["#61AFEF bold", "FALYX > "] | ||||
| columns: 3 | ||||
| welcome_message: "🚀 Welcome to your new CLI project!" | ||||
| exit_message: "👋 See you next time!" | ||||
| commands: | ||||
|   - key: S | ||||
|     description: Say Hello | ||||
|     action: tasks.hello | ||||
|     aliases: [hi, hello] | ||||
|     tags: [example] | ||||
|  | ||||
| - key: T | ||||
|   description: Run tests | ||||
|   action: tasks.test | ||||
|   aliases: [test] | ||||
|   spinner: true | ||||
|   - key: P | ||||
|     description: Get Post Title | ||||
|     action: tasks.post_flow | ||||
|     aliases: [submit] | ||||
|     preview_before_confirm: true | ||||
|     confirm: true | ||||
|     tags: [demo, network] | ||||
|  | ||||
|   - key: G | ||||
|     description: Do Some Work | ||||
|     action: tasks.work_action | ||||
|     aliases: [work] | ||||
|     spinner: true | ||||
|     spinner_message: "Working..." | ||||
| """ | ||||
|  | ||||
| GLOBAL_TEMPLATE_TASKS = """\ | ||||
| @@ -33,10 +91,12 @@ async def cleanup(): | ||||
| """ | ||||
|  | ||||
| GLOBAL_CONFIG = """\ | ||||
| - key: C | ||||
|   description: Cleanup temp files | ||||
|   action: tasks.cleanup | ||||
|   aliases: [clean, cleanup] | ||||
| title: Global Falyx Config | ||||
| commands: | ||||
|   - key: C | ||||
|     description: Cleanup temp files | ||||
|     action: tasks.cleanup | ||||
|     aliases: [clean, cleanup] | ||||
| """ | ||||
|  | ||||
| console = Console(color_system="auto") | ||||
| @@ -56,7 +116,7 @@ def init_project(name: str = ".") -> None: | ||||
|     tasks_path.write_text(TEMPLATE_TASKS) | ||||
|     config_path.write_text(TEMPLATE_CONFIG) | ||||
|  | ||||
|     print(f"✅ Initialized Falyx project in {target}") | ||||
|     console.print(f"✅ Initialized Falyx project in {target}") | ||||
|  | ||||
|  | ||||
| def init_global() -> None: | ||||
|   | ||||
| @@ -16,6 +16,7 @@ Common usage includes shell-like filters, input transformers, or any tool that | ||||
| needs to consume input from another process or pipeline. | ||||
| """ | ||||
| import asyncio | ||||
| import shlex | ||||
| import subprocess | ||||
| import sys | ||||
| from typing import Any | ||||
| @@ -183,13 +184,13 @@ class ShellAction(BaseIOAction): | ||||
|  | ||||
|     Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. | ||||
|  | ||||
|     ⚠️ Warning: | ||||
|     Be cautious when using ShellAction with untrusted user input. Since it uses | ||||
|     `shell=True`, unsanitized input can lead to command injection vulnerabilities. | ||||
|     Avoid passing raw user input directly unless the template or use case is secure. | ||||
|     ⚠️ Security Warning: | ||||
|     By default, ShellAction uses `shell=True`, which can be dangerous with unsanitized input. | ||||
|     To mitigate this, set `safe_mode=True` to use `shell=False` with `shlex.split()`. | ||||
|  | ||||
|     Features: | ||||
|     - Automatically handles input parsing (str/bytes) | ||||
|     - `safe_mode=True` disables shell interpretation and runs with `shell=False` | ||||
|     - Captures stdout and stderr from shell execution | ||||
|     - Raises on non-zero exit codes with stderr as the error | ||||
|     - Result is returned as trimmed stdout string | ||||
| @@ -199,11 +200,15 @@ class ShellAction(BaseIOAction): | ||||
|         name (str): Name of the action. | ||||
|         command_template (str): Shell command to execute. Must include `{}` to include input. | ||||
|                                 If no placeholder is present, the input is not included. | ||||
|         safe_mode (bool): If True, runs with `shell=False` using shlex parsing (default: False). | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, name: str, command_template: str, **kwargs): | ||||
|     def __init__( | ||||
|         self, name: str, command_template: str, safe_mode: bool = False, **kwargs | ||||
|     ): | ||||
|         super().__init__(name=name, **kwargs) | ||||
|         self.command_template = command_template | ||||
|         self.safe_mode = safe_mode | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> str: | ||||
|         if not isinstance(raw, (str, bytes)): | ||||
| @@ -215,7 +220,11 @@ class ShellAction(BaseIOAction): | ||||
|     async def _run(self, parsed_input: str) -> str: | ||||
|         # Replace placeholder in template, or use raw input as full command | ||||
|         command = self.command_template.format(parsed_input) | ||||
|         result = subprocess.run(command, shell=True, text=True, capture_output=True) | ||||
|         if self.safe_mode: | ||||
|             args = shlex.split(command) | ||||
|             result = subprocess.run(args, capture_output=True, text=True) | ||||
|         else: | ||||
|             result = subprocess.run(command, shell=True, text=True, capture_output=True) | ||||
|         if result.returncode != 0: | ||||
|             raise RuntimeError(result.stderr.strip()) | ||||
|         return result.stdout.strip() | ||||
| @@ -225,14 +234,18 @@ class ShellAction(BaseIOAction): | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"] | ||||
|         label.append(f"\n[dim]Template:[/] {self.command_template}") | ||||
|         label.append( | ||||
|             f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}" | ||||
|         ) | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||
|         if parent: | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             self.console.print(Tree("".join(label))) | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ShellAction(name={self.name!r}, command_template={self.command_template!r})" | ||||
|             f"ShellAction(name={self.name!r}, command_template={self.command_template!r}, " | ||||
|             f"safe_mode={self.safe_mode})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| from falyx.options_manager import OptionsManager | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Protocol | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| from __future__ import annotations | ||||
|  | ||||
| import csv | ||||
| @@ -33,8 +34,30 @@ class FileReturnType(Enum): | ||||
|     TOML = "toml" | ||||
|     YAML = "yaml" | ||||
|     CSV = "csv" | ||||
|     TSV = "tsv" | ||||
|     XML = "xml" | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "yml": "yaml", | ||||
|             "txt": "text", | ||||
|             "file": "path", | ||||
|             "filepath": "path", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> FileReturnType: | ||||
|         if isinstance(value, str): | ||||
|             normalized = value.lower() | ||||
|             alias = cls._get_alias(normalized) | ||||
|             for member in cls: | ||||
|                 if member.value == alias: | ||||
|                     return member | ||||
|         valid = ", ".join(member.value for member in cls) | ||||
|         raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|  | ||||
| class SelectFileAction(BaseAction): | ||||
|     """ | ||||
| @@ -42,7 +65,7 @@ class SelectFileAction(BaseAction): | ||||
|     - file content (as text, JSON, CSV, etc.) | ||||
|     - or the file path itself. | ||||
|  | ||||
|     Supported formats: text, json, yaml, toml, csv, xml. | ||||
|     Supported formats: text, json, yaml, toml, csv, tsv, xml. | ||||
|  | ||||
|     Useful for: | ||||
|     - dynamically loading config files | ||||
| @@ -72,7 +95,7 @@ class SelectFileAction(BaseAction): | ||||
|         prompt_message: str = "Choose > ", | ||||
|         style: str = OneColors.WHITE, | ||||
|         suffix_filter: str | None = None, | ||||
|         return_type: FileReturnType = FileReturnType.PATH, | ||||
|         return_type: FileReturnType | str = FileReturnType.PATH, | ||||
|         console: Console | None = None, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|     ): | ||||
| @@ -83,9 +106,14 @@ class SelectFileAction(BaseAction): | ||||
|         self.prompt_message = prompt_message | ||||
|         self.suffix_filter = suffix_filter | ||||
|         self.style = style | ||||
|         self.return_type = return_type | ||||
|         self.console = console or Console(color_system="auto") | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.return_type = self._coerce_return_type(return_type) | ||||
|  | ||||
|     def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType: | ||||
|         if isinstance(return_type, FileReturnType): | ||||
|             return return_type | ||||
|         return FileReturnType(return_type) | ||||
|  | ||||
|     def get_options(self, files: list[Path]) -> dict[str, SelectionOption]: | ||||
|         value: Any | ||||
| @@ -106,6 +134,10 @@ class SelectFileAction(BaseAction): | ||||
|                     with open(file, newline="", encoding="UTF-8") as csvfile: | ||||
|                         reader = csv.reader(csvfile) | ||||
|                         value = list(reader) | ||||
|                 elif self.return_type == FileReturnType.TSV: | ||||
|                     with open(file, newline="", encoding="UTF-8") as tsvfile: | ||||
|                         reader = csv.reader(tsvfile, delimiter="\t") | ||||
|                         value = list(reader) | ||||
|                 elif self.return_type == FileReturnType.XML: | ||||
|                     tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8")) | ||||
|                     root = tree.getroot() | ||||
| @@ -183,7 +215,7 @@ class SelectFileAction(BaseAction): | ||||
|             if len(files) > 10: | ||||
|                 file_list.add(f"[dim]... ({len(files) - 10} more)[/]") | ||||
|         except Exception as error: | ||||
|             tree.add(f"[bold red]⚠️ Error scanning directory: {error}[/]") | ||||
|             tree.add(f"[{OneColors.DARK_RED_b}]⚠️ Error scanning directory: {error}[/]") | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|   | ||||
| @@ -216,7 +216,7 @@ async def prompt_for_index( | ||||
|     console = console or Console(color_system="auto") | ||||
|  | ||||
|     if show_table: | ||||
|         console.print(table) | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selection = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
| @@ -318,7 +318,7 @@ async def select_key_from_dict( | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|  | ||||
|     console.print(table) | ||||
|     console.print(table, justify="center") | ||||
|  | ||||
|     return await prompt_for_selection( | ||||
|         selections.keys(), | ||||
| @@ -343,7 +343,7 @@ async def select_value_from_dict( | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|  | ||||
|     console.print(table) | ||||
|     console.print(table, justify="center") | ||||
|  | ||||
|     selection_key = await prompt_for_selection( | ||||
|         selections.keys(), | ||||
|   | ||||
| @@ -28,7 +28,7 @@ class SelectionAction(BaseAction): | ||||
|         selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption], | ||||
|         *, | ||||
|         title: str = "Select an option", | ||||
|         columns: int = 2, | ||||
|         columns: int = 5, | ||||
|         prompt_message: str = "Select > ", | ||||
|         default_selection: str = "", | ||||
|         inject_last_result: bool = False, | ||||
| @@ -186,7 +186,7 @@ class SelectionAction(BaseAction): | ||||
|             if len(self.selections) > 10: | ||||
|                 sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]") | ||||
|         else: | ||||
|             tree.add("[bold red]Invalid selections type[/]") | ||||
|             tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]") | ||||
|             return | ||||
|  | ||||
|         tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'") | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| from falyx.action import Action | ||||
| from falyx.signals import FlowSignal | ||||
|  | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.22" | ||||
| __version__ = "0.1.23" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.22" | ||||
| version = "0.1.23" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user