Add better starter examples init.py, change validation in config.py, add add_command_from_command
This commit is contained in:
parent
5c09f86b9b
commit
e999ad5e1c
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue