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 asyncio
import os
import sys import sys
from argparse import Namespace from argparse import Namespace
from pathlib import Path from pathlib import Path
@ -14,7 +15,6 @@ from typing import Any
from falyx.config import loader from falyx.config import loader
from falyx.falyx import Falyx from falyx.falyx import Falyx
from falyx.parsers import FalyxParsers, get_arg_parsers from falyx.parsers import FalyxParsers, get_arg_parsers
from falyx.themes.colors import OneColors
def find_falyx_config() -> Path | None: def find_falyx_config() -> Path | None:
@ -23,6 +23,7 @@ def find_falyx_config() -> Path | None:
Path.cwd() / "falyx.toml", Path.cwd() / "falyx.toml",
Path.cwd() / ".falyx.yaml", Path.cwd() / ".falyx.yaml",
Path.cwd() / ".falyx.toml", Path.cwd() / ".falyx.toml",
Path(os.environ.get("FALYX_CONFIG", "falyx.yaml")),
Path.home() / ".config" / "falyx" / "falyx.yaml", Path.home() / ".config" / "falyx" / "falyx.yaml",
Path.home() / ".config" / "falyx" / "falyx.toml", Path.home() / ".config" / "falyx" / "falyx.toml",
Path.home() / ".falyx.yaml", Path.home() / ".falyx.yaml",
@ -68,13 +69,7 @@ def run(args: Namespace) -> Any:
print("No Falyx config file found. Exiting.") print("No Falyx config file found. Exiting.")
return None return None
flx = Falyx( flx: Falyx = loader(bootstrap_path)
title="🛠️ Config-Driven CLI",
cli_args=args,
columns=4,
prompt=[(OneColors.BLUE_b, "FALYX > ")],
)
flx.add_commands(loader(bootstrap_path))
return asyncio.run(flx.run()) 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 typing import Any
from rich.tree import Tree from rich.tree import Tree

View File

@ -1,18 +1,21 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""config.py """config.py
Configuration loader for Falyx CLI commands.""" Configuration loader for Falyx CLI commands."""
from __future__ import annotations
import importlib import importlib
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
import toml import toml
import yaml import yaml
from pydantic import BaseModel, Field, field_validator, model_validator
from rich.console import Console from rich.console import Console
from falyx.action import Action, BaseAction from falyx.action import Action, BaseAction
from falyx.command import Command from falyx.command import Command
from falyx.falyx import Falyx
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import logger 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) return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj)
else: else:
raise TypeError( raise TypeError(
f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. " f"Cannot wrap object of type '{type(obj).__name__}'. "
"It must be a callable or an instance of BaseAction." "Expected a function or BaseAction."
) )
@ -60,7 +63,101 @@ def import_action(dotted_path: str) -> Any:
return action 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. 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). file_path (str): Path to the config file (YAML or TOML).
Returns: Returns:
list[dict[str, Any]]: A list of command configuration dictionaries. Falyx: An instance of the Falyx CLI with loaded commands.
Raises: Raises:
ValueError: If the file format is unsupported or file cannot be parsed. 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) path = Path(file_path)
elif isinstance(file_path, Path):
path = file_path
else: else:
raise TypeError("file_path must be a string or Path object.") 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: else:
raise ValueError(f"Unsupported config format: {suffix}") raise ValueError(f"Unsupported config format: {suffix}")
if not isinstance(raw_config, list): if not isinstance(raw_config, dict):
raise ValueError("Configuration file must contain a list of command definitions.") 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 = convert_commands(raw_config["commands"])
commands = [] return FalyxConfig(
for entry in raw_config: title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"),
for field in required: prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]),
if field not in entry: columns=raw_config.get("columns", 4),
raise ValueError(f"Missing '{field}' in command entry: {entry}") welcome_message=raw_config.get("welcome_message", ""),
exit_message=raw_config.get("exit_message", ""),
command_dict = { commands=commands,
"key": entry["key"], ).to_falyx()
"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

View File

@ -9,6 +9,7 @@ from rich.console import Console
from rich.table import Table from rich.table import Table
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.themes.colors import OneColors
from falyx.utils import logger from falyx.utils import logger
@ -66,10 +67,10 @@ class ExecutionRegistry:
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
if ctx.exception: if ctx.exception:
status = "[bold red]❌ Error" status = f"[{OneColors.DARK_RED}]❌ Error"
result = repr(ctx.exception) result = repr(ctx.exception)
else: else:
status = "[green]✅ Success" status = f"[{OneColors.GREEN}]✅ Success"
result = repr(ctx.result) result = repr(ctx.result)
if len(result) > 1000: if len(result) > 1000:
result = f"{result[:1000]}..." result = f"{result[:1000]}..."

View File

@ -291,7 +291,7 @@ class Falyx:
"""Returns the help command for the menu.""" """Returns the help command for the menu."""
return Command( return Command(
key="H", key="H",
aliases=["HELP"], aliases=["HELP", "?"],
description="Help", description="Help",
action=self._show_help, action=self._show_help,
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
@ -560,10 +560,24 @@ class Falyx:
self.add_command(key, description, submenu.menu, style=style) self.add_command(key, description, submenu.menu, style=style)
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
def add_commands(self, commands: list[dict]) -> None: def add_commands(self, commands: list[Command] | list[dict]) -> None:
"""Adds multiple commands to the menu.""" """Adds a list of Command instances or config dicts."""
for command in commands: 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( def add_command(
self, self,
@ -696,7 +710,10 @@ class Falyx:
) -> tuple[bool, Command | None]: ) -> tuple[bool, Command | None]:
"""Returns the selected command based on user input. Supports keys, aliases, and abbreviations.""" """Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
is_preview, choice = self.parse_preview_command(choice) 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: if not from_validate:
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]" f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]"
@ -891,28 +908,29 @@ class Falyx:
self.debug_hooks() self.debug_hooks()
if self.welcome_message: if self.welcome_message:
self.print_message(self.welcome_message) self.print_message(self.welcome_message)
while True: try:
if callable(self.render_menu): while True:
self.render_menu(self) if callable(self.render_menu):
else: self.render_menu(self)
self.console.print(self.table, justify="center") else:
try: self.console.print(self.table, justify="center")
task = asyncio.create_task(self.process_command()) try:
should_continue = await task task = asyncio.create_task(self.process_command())
if not should_continue: should_continue = await task
if not should_continue:
break
except (EOFError, KeyboardInterrupt):
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
break break
except (EOFError, KeyboardInterrupt): except QuitSignal:
logger.info("EOF or KeyboardInterrupt. Exiting menu.") logger.info("QuitSignal received. Exiting menu.")
break break
except QuitSignal: except BackSignal:
logger.info("QuitSignal received. Exiting menu.") logger.info("BackSignal received.")
break finally:
except BackSignal: logger.info(f"Exiting menu: {self.get_title()}")
logger.info("BackSignal received.") if self.exit_message:
finally: self.print_message(self.exit_message)
logger.info(f"Exiting menu: {self.get_title()}")
if self.exit_message:
self.print_message(self.exit_message)
async def run(self) -> None: async def run(self) -> None:
"""Run Falyx CLI with structured subcommands.""" """Run Falyx CLI with structured subcommands."""

View File

@ -4,27 +4,85 @@ from pathlib import Path
from rich.console import Console from rich.console import Console
TEMPLATE_TASKS = """\ TEMPLATE_TASKS = """\
async def build(): # This file is used by falyx.yaml to define CLI actions.
print("🔨 Building project...") # You can run: falyx run [key] or falyx list to see available commands.
return "Build complete!"
async def test(): import asyncio
print("🧪 Running tests...") import json
return "Tests complete!"
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 = """\ TEMPLATE_CONFIG = """\
- key: B # falyx.yaml — Config-driven CLI definition
description: Build the project # Define your commands here and point to Python callables in tasks.py
action: tasks.build title: Sample CLI Project
aliases: [build] prompt:
spinner: true - ["#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 - key: P
description: Run tests description: Get Post Title
action: tasks.test action: tasks.post_flow
aliases: [test] aliases: [submit]
spinner: true 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 = """\ GLOBAL_TEMPLATE_TASKS = """\
@ -33,10 +91,12 @@ async def cleanup():
""" """
GLOBAL_CONFIG = """\ GLOBAL_CONFIG = """\
- key: C title: Global Falyx Config
description: Cleanup temp files commands:
action: tasks.cleanup - key: C
aliases: [clean, cleanup] description: Cleanup temp files
action: tasks.cleanup
aliases: [clean, cleanup]
""" """
console = Console(color_system="auto") console = Console(color_system="auto")
@ -56,7 +116,7 @@ def init_project(name: str = ".") -> None:
tasks_path.write_text(TEMPLATE_TASKS) tasks_path.write_text(TEMPLATE_TASKS)
config_path.write_text(TEMPLATE_CONFIG) 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: 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. needs to consume input from another process or pipeline.
""" """
import asyncio import asyncio
import shlex
import subprocess import subprocess
import sys import sys
from typing import Any from typing import Any
@ -183,13 +184,13 @@ class ShellAction(BaseIOAction):
Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
Warning: Security Warning:
Be cautious when using ShellAction with untrusted user input. Since it uses By default, ShellAction uses `shell=True`, which can be dangerous with unsanitized input.
`shell=True`, unsanitized input can lead to command injection vulnerabilities. To mitigate this, set `safe_mode=True` to use `shell=False` with `shlex.split()`.
Avoid passing raw user input directly unless the template or use case is secure.
Features: Features:
- Automatically handles input parsing (str/bytes) - Automatically handles input parsing (str/bytes)
- `safe_mode=True` disables shell interpretation and runs with `shell=False`
- Captures stdout and stderr from shell execution - Captures stdout and stderr from shell execution
- Raises on non-zero exit codes with stderr as the error - Raises on non-zero exit codes with stderr as the error
- Result is returned as trimmed stdout string - Result is returned as trimmed stdout string
@ -199,11 +200,15 @@ class ShellAction(BaseIOAction):
name (str): Name of the action. name (str): Name of the action.
command_template (str): Shell command to execute. Must include `{}` to include input. command_template (str): Shell command to execute. Must include `{}` to include input.
If no placeholder is present, the input is not included. 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) super().__init__(name=name, **kwargs)
self.command_template = command_template self.command_template = command_template
self.safe_mode = safe_mode
def from_input(self, raw: str | bytes) -> str: def from_input(self, raw: str | bytes) -> str:
if not isinstance(raw, (str, bytes)): if not isinstance(raw, (str, bytes)):
@ -215,7 +220,11 @@ class ShellAction(BaseIOAction):
async def _run(self, parsed_input: str) -> str: async def _run(self, parsed_input: str) -> str:
# Replace placeholder in template, or use raw input as full command # Replace placeholder in template, or use raw input as full command
command = self.command_template.format(parsed_input) 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: if result.returncode != 0:
raise RuntimeError(result.stderr.strip()) raise RuntimeError(result.stderr.strip())
return result.stdout.strip() return result.stdout.strip()
@ -225,14 +234,18 @@ class ShellAction(BaseIOAction):
async def preview(self, parent: Tree | None = None): async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"] 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: if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]") label.append(f" [dim](injects '{self.inject_into}')[/dim]")
if parent: tree = parent.add("".join(label)) if parent else Tree("".join(label))
parent.add("".join(label)) if not parent:
else: self.console.print(tree)
self.console.print(Tree("".join(label)))
def __str__(self): def __str__(self):
return ( 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 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 __future__ import annotations
from typing import Any, Protocol 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 from __future__ import annotations
import csv import csv
@ -33,8 +34,30 @@ class FileReturnType(Enum):
TOML = "toml" TOML = "toml"
YAML = "yaml" YAML = "yaml"
CSV = "csv" CSV = "csv"
TSV = "tsv"
XML = "xml" 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): class SelectFileAction(BaseAction):
""" """
@ -42,7 +65,7 @@ class SelectFileAction(BaseAction):
- file content (as text, JSON, CSV, etc.) - file content (as text, JSON, CSV, etc.)
- or the file path itself. - or the file path itself.
Supported formats: text, json, yaml, toml, csv, xml. Supported formats: text, json, yaml, toml, csv, tsv, xml.
Useful for: Useful for:
- dynamically loading config files - dynamically loading config files
@ -72,7 +95,7 @@ class SelectFileAction(BaseAction):
prompt_message: str = "Choose > ", prompt_message: str = "Choose > ",
style: str = OneColors.WHITE, style: str = OneColors.WHITE,
suffix_filter: str | None = None, suffix_filter: str | None = None,
return_type: FileReturnType = FileReturnType.PATH, return_type: FileReturnType | str = FileReturnType.PATH,
console: Console | None = None, console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
): ):
@ -83,9 +106,14 @@ class SelectFileAction(BaseAction):
self.prompt_message = prompt_message self.prompt_message = prompt_message
self.suffix_filter = suffix_filter self.suffix_filter = suffix_filter
self.style = style self.style = style
self.return_type = return_type
self.console = console or Console(color_system="auto") self.console = console or Console(color_system="auto")
self.prompt_session = prompt_session or PromptSession() 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]: def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
value: Any value: Any
@ -106,6 +134,10 @@ class SelectFileAction(BaseAction):
with open(file, newline="", encoding="UTF-8") as csvfile: with open(file, newline="", encoding="UTF-8") as csvfile:
reader = csv.reader(csvfile) reader = csv.reader(csvfile)
value = list(reader) 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: elif self.return_type == FileReturnType.XML:
tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8")) tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8"))
root = tree.getroot() root = tree.getroot()
@ -183,7 +215,7 @@ class SelectFileAction(BaseAction):
if len(files) > 10: if len(files) > 10:
file_list.add(f"[dim]... ({len(files) - 10} more)[/]") file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
except Exception as error: 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: if not parent:
self.console.print(tree) self.console.print(tree)

View File

@ -216,7 +216,7 @@ async def prompt_for_index(
console = console or Console(color_system="auto") console = console or Console(color_system="auto")
if show_table: if show_table:
console.print(table) console.print(table, justify="center")
selection = await prompt_session.prompt_async( selection = await prompt_session.prompt_async(
message=prompt_message, message=prompt_message,
@ -318,7 +318,7 @@ async def select_key_from_dict(
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto") console = console or Console(color_system="auto")
console.print(table) console.print(table, justify="center")
return await prompt_for_selection( return await prompt_for_selection(
selections.keys(), selections.keys(),
@ -343,7 +343,7 @@ async def select_value_from_dict(
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto") console = console or Console(color_system="auto")
console.print(table) console.print(table, justify="center")
selection_key = await prompt_for_selection( selection_key = await prompt_for_selection(
selections.keys(), selections.keys(),

View File

@ -28,7 +28,7 @@ class SelectionAction(BaseAction):
selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption], selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption],
*, *,
title: str = "Select an option", title: str = "Select an option",
columns: int = 2, columns: int = 5,
prompt_message: str = "Select > ", prompt_message: str = "Select > ",
default_selection: str = "", default_selection: str = "",
inject_last_result: bool = False, inject_last_result: bool = False,
@ -186,7 +186,7 @@ class SelectionAction(BaseAction):
if len(self.selections) > 10: if len(self.selections) > 10:
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]") sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
else: else:
tree.add("[bold red]Invalid selections type[/]") tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]")
return return
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'") 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.action import Action
from falyx.signals import FlowSignal 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] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.22" version = "0.1.23"
description = "Reliable and introspectable async CLI action framework." description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"] authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT" license = "MIT"