Add better starter examples init.py, change validation in config.py, add add_command_from_command

This commit is contained in:
Roland Thomas Jr 2025-05-11 14:32:12 -04:00
parent 5c09f86b9b
commit e999ad5e1c
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
15 changed files with 324 additions and 131 deletions

View File

@ -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())

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from typing import Any
from rich.tree import Tree

View File

@ -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()

View File

@ -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]}..."

View File

@ -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:
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,6 +908,7 @@ class Falyx:
self.debug_hooks()
if self.welcome_message:
self.print_message(self.welcome_message)
try:
while True:
if callable(self.render_menu):
self.render_menu(self)

View File

@ -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]
- 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,6 +91,8 @@ async def cleanup():
"""
GLOBAL_CONFIG = """\
title: Global Falyx Config
commands:
- key: C
description: Cleanup temp files
action: tasks.cleanup
@ -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:

View File

@ -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,6 +220,10 @@ 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)
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())
@ -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})"
)

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from falyx.options_manager import OptionsManager

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from __future__ import annotations
from typing import Any, Protocol

View File

@ -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)

View File

@ -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(),

View File

@ -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}'")

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from falyx.action import Action
from falyx.signals import FlowSignal

View File

@ -1 +1 @@
__version__ = "0.1.22"
__version__ = "0.1.23"

View File

@ -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"