297 lines
9.8 KiB
Python
297 lines
9.8 KiB
Python
# 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, Callable
|
|
|
|
import toml
|
|
import yaml
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
from rich.console import Console
|
|
|
|
from falyx.action.action import Action
|
|
from falyx.action.base import BaseAction
|
|
from falyx.command import Command
|
|
from falyx.falyx import Falyx
|
|
from falyx.logger import logger
|
|
from falyx.parsers import CommandArgumentParser
|
|
from falyx.retry import RetryPolicy
|
|
from falyx.themes import OneColors
|
|
|
|
console = Console(color_system="auto")
|
|
|
|
|
|
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
|
|
if isinstance(obj, (BaseAction, Command)):
|
|
return obj
|
|
elif callable(obj):
|
|
return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj)
|
|
else:
|
|
raise TypeError(
|
|
f"Cannot wrap object of type '{type(obj).__name__}'. "
|
|
"Expected a function or BaseAction."
|
|
)
|
|
|
|
|
|
def import_action(dotted_path: str) -> Any:
|
|
"""Dynamically imports a callable from a dotted path like 'my.module.func'."""
|
|
module_path, _, attr = dotted_path.rpartition(".")
|
|
if not module_path:
|
|
console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
|
|
sys.exit(1)
|
|
try:
|
|
module = importlib.import_module(module_path)
|
|
except ModuleNotFoundError as error:
|
|
logger.error("Failed to import module '%s': %s", module_path, error)
|
|
console.print(
|
|
f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
|
|
f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable "
|
|
"via PYTHONPATH."
|
|
)
|
|
sys.exit(1)
|
|
try:
|
|
action = getattr(module, attr)
|
|
except AttributeError as error:
|
|
logger.error(
|
|
"Module '%s' does not have attribute '%s': %s", module_path, attr, error
|
|
)
|
|
console.print(
|
|
f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute "
|
|
f"'{attr}': {error}[/]"
|
|
)
|
|
sys.exit(1)
|
|
return action
|
|
|
|
|
|
class RawCommand(BaseModel):
|
|
"""Raw command model for Falyx CLI configuration."""
|
|
|
|
key: str
|
|
description: str
|
|
action: str
|
|
|
|
args: tuple[Any, ...] = Field(default_factory=tuple)
|
|
kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
aliases: list[str] = Field(default_factory=list)
|
|
tags: list[str] = Field(default_factory=list)
|
|
style: str = OneColors.WHITE
|
|
|
|
confirm: bool = False
|
|
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 = OneColors.CYAN
|
|
spinner_kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
before_hooks: list[Callable] = Field(default_factory=list)
|
|
success_hooks: list[Callable] = Field(default_factory=list)
|
|
error_hooks: list[Callable] = Field(default_factory=list)
|
|
after_hooks: list[Callable] = Field(default_factory=list)
|
|
teardown_hooks: list[Callable] = Field(default_factory=list)
|
|
|
|
logging_hooks: bool = False
|
|
retry: bool = False
|
|
retry_all: bool = False
|
|
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
|
hidden: bool = False
|
|
help_text: str = ""
|
|
help_epilogue: 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)
|
|
parser = CommandArgumentParser(
|
|
command_key=raw_command.key,
|
|
command_description=raw_command.description,
|
|
command_style=raw_command.style,
|
|
help_text=raw_command.help_text,
|
|
help_epilogue=raw_command.help_epilogue,
|
|
aliases=raw_command.aliases,
|
|
)
|
|
commands.append(
|
|
Command.model_validate(
|
|
{
|
|
**raw_command.model_dump(exclude={"action"}),
|
|
"action": wrap_if_needed(
|
|
import_action(raw_command.action), name=raw_command.description
|
|
),
|
|
"arg_parser": parser,
|
|
}
|
|
)
|
|
)
|
|
|
|
return commands
|
|
|
|
|
|
def convert_submenus(
|
|
raw_submenus: list[dict[str, Any]], *, parent_path: Path | None = None, depth: int = 0
|
|
) -> list[dict[str, Any]]:
|
|
submenus: list[dict[str, Any]] = []
|
|
for raw_submenu in raw_submenus:
|
|
if raw_submenu.get("config"):
|
|
config_path = Path(raw_submenu["config"])
|
|
if parent_path:
|
|
config_path = (parent_path.parent / config_path).resolve()
|
|
submenu = loader(config_path, _depth=depth + 1)
|
|
else:
|
|
submenu_module_path = raw_submenu.get("submenu")
|
|
if not isinstance(submenu_module_path, str):
|
|
console.print(
|
|
f"[{OneColors.DARK_RED}]❌ Invalid submenu path:[/] {submenu_module_path}"
|
|
)
|
|
sys.exit(1)
|
|
submenu = import_action(submenu_module_path)
|
|
if not isinstance(submenu, Falyx):
|
|
console.print(f"[{OneColors.DARK_RED}]❌ Invalid submenu:[/] {submenu}")
|
|
sys.exit(1)
|
|
|
|
key = raw_submenu.get("key")
|
|
if not isinstance(key, str):
|
|
console.print(f"[{OneColors.DARK_RED}]❌ Invalid submenu key:[/] {key}")
|
|
sys.exit(1)
|
|
|
|
description = raw_submenu.get("description")
|
|
if not isinstance(description, str):
|
|
console.print(
|
|
f"[{OneColors.DARK_RED}]❌ Invalid submenu description:[/] {description}"
|
|
)
|
|
sys.exit(1)
|
|
|
|
submenus.append(
|
|
Submenu(
|
|
key=key,
|
|
description=description,
|
|
submenu=submenu,
|
|
style=raw_submenu.get("style", OneColors.CYAN),
|
|
).model_dump()
|
|
)
|
|
return submenus
|
|
|
|
|
|
class Submenu(BaseModel):
|
|
"""Submenu model for Falyx CLI configuration."""
|
|
|
|
key: str
|
|
description: str
|
|
submenu: Any
|
|
style: str = OneColors.CYAN
|
|
|
|
|
|
class FalyxConfig(BaseModel):
|
|
"""Falyx CLI configuration model."""
|
|
|
|
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] = []
|
|
submenus: list[dict[str, Any]] = []
|
|
|
|
@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, # type: ignore[arg-type]
|
|
columns=self.columns,
|
|
welcome_message=self.welcome_message,
|
|
exit_message=self.exit_message,
|
|
)
|
|
flx.add_commands(self.commands)
|
|
for submenu in self.submenus:
|
|
flx.add_submenu(**submenu)
|
|
return flx
|
|
|
|
|
|
def loader(file_path: Path | str, _depth: int = 0) -> Falyx:
|
|
"""
|
|
Load Falyx CLI configuration from a YAML or TOML file.
|
|
|
|
The file should contain a dictionary with a list of commands.
|
|
|
|
Each command should be defined as a dictionary with at least:
|
|
- key: a unique single-character key
|
|
- description: short description
|
|
- action: dotted import path to the action function/class
|
|
|
|
Args:
|
|
file_path (str): Path to the config file (YAML or TOML).
|
|
|
|
Returns:
|
|
Falyx: An instance of the Falyx CLI with loaded commands.
|
|
|
|
Raises:
|
|
ValueError: If the file format is unsupported or file cannot be parsed.
|
|
"""
|
|
if _depth > 5:
|
|
raise ValueError("Maximum submenu depth exceeded (5 levels deep)")
|
|
|
|
if isinstance(file_path, (str, Path)):
|
|
path = Path(file_path)
|
|
else:
|
|
raise TypeError("file_path must be a string or Path object.")
|
|
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"No such config file: {file_path}")
|
|
|
|
suffix = path.suffix
|
|
with path.open("r", encoding="UTF-8") as config_file:
|
|
if suffix in (".yaml", ".yml"):
|
|
raw_config = yaml.safe_load(config_file)
|
|
elif suffix == ".toml":
|
|
raw_config = toml.load(config_file)
|
|
else:
|
|
raise ValueError(f"Unsupported config format: {suffix}")
|
|
|
|
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'"
|
|
)
|
|
|
|
commands = convert_commands(raw_config["commands"])
|
|
submenus = convert_submenus(raw_config.get("submenus", []))
|
|
return FalyxConfig(
|
|
title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"),
|
|
prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]),
|
|
columns=raw_config.get("columns", 4),
|
|
welcome_message=raw_config.get("welcome_message", ""),
|
|
exit_message=raw_config.get("exit_message", ""),
|
|
commands=commands,
|
|
submenus=submenus,
|
|
).to_falyx()
|