From 5d8f3aa603cf123eab606d32eb070599660af871 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Tue, 7 Apr 2026 18:58:24 -0400 Subject: [PATCH] feat(core): centralize command execution and add standalone command runner - add CommandExecutor to unify shared command execution lifecycle across Falyx and standalone command execution - add CommandRunner for running a single Command directly as a CLI or programmatic entrypoint - add Command.build() factory and rename parse_args() to resolve_args() to clarify the parsing-to-execution boundary - introduce ExecutionOption and wire execution-scoped flags into CommandArgumentParser and Command construction - refactor Falyx to use FalyxParser/ParseResult and CommandExecutor instead of the older argparse-based flow and run_key path - simplify __main__.py bootstrap by building a bootstrap Falyx instance directly and running flx.run() - improve completer support for preview commands and unique-prefix command resolution - default BottomBar toggle namespace to "default" - expand module/class docstrings to reflect the new execution architecture --- falyx/__main__.py | 87 +- falyx/bottom_bar.py | 2 +- falyx/command.py | 542 ++++++-- falyx/command_executor.py | 334 +++++ falyx/command_runner.py | 467 +++++++ falyx/completer.py | 97 +- falyx/execution_option.py | 21 + falyx/falyx.py | 1221 +++++++++-------- falyx/hooks.py | 32 +- falyx/menu.py | 6 +- falyx/mode.py | 8 +- falyx/options_manager.py | 91 +- falyx/parser/__init__.py | 9 +- falyx/parser/argument.py | 8 + falyx/parser/command_argument_parser.py | 616 +++++++-- falyx/parser/falyx_parser.py | 175 +++ falyx/parser/group.py | 19 + falyx/parser/parse_result.py | 24 + falyx/parser/parsers.py | 408 ------ falyx/prompt_utils.py | 28 +- falyx/protocols.py | 2 +- falyx/tagged_table.py | 10 +- falyx/validators.py | 4 +- falyx/version.py | 2 +- pyproject.toml | 2 +- tests/test_command_argument_parser.py | 3 +- tests/test_completer/test_completer.py | 74 +- ...est_run_key.py => test_execute_command.py} | 8 +- tests/test_falyx/test_help.py | 23 +- tests/test_falyx/test_run.py | 9 +- tests/test_falyx_parser/test_root_options.py | 47 + tests/test_main.py | 40 +- tests/test_parsers/test_action.py | 37 +- .../test_validators/test_command_validator.py | 6 +- 34 files changed, 3043 insertions(+), 1419 deletions(-) create mode 100644 falyx/command_executor.py create mode 100644 falyx/command_runner.py create mode 100644 falyx/execution_option.py create mode 100644 falyx/parser/falyx_parser.py create mode 100644 falyx/parser/group.py create mode 100644 falyx/parser/parse_result.py delete mode 100644 falyx/parser/parsers.py rename tests/{test_run_key.py => test_execute_command.py} (84%) create mode 100644 tests/test_falyx_parser/test_root_options.py diff --git a/falyx/__main__.py b/falyx/__main__.py index d3737a9..028e714 100644 --- a/falyx/__main__.py +++ b/falyx/__main__.py @@ -8,13 +8,12 @@ Licensed under the MIT License. See LICENSE file for details. import asyncio import os import sys -from argparse import ArgumentParser, Namespace, _SubParsersAction from pathlib import Path from typing import Any from falyx.config import loader from falyx.falyx import Falyx -from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers +from falyx.parser import CommandArgumentParser def find_falyx_config() -> Path | None: @@ -49,71 +48,39 @@ def init_config(parser: CommandArgumentParser) -> None: ) -def init_callback(args: Namespace) -> None: - """Callback for the init command.""" - if args.command == "init": - from falyx.init import init_project +def build_bootstrap_falyx() -> Falyx: + from falyx.init import init_global, init_project - init_project(args.name) - elif args.command == "init_global": - from falyx.init import init_global + flx = Falyx() - init_global() - - -def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]: - root_parser: ArgumentParser = get_root_parser() - subparsers = get_subparsers(root_parser) - init_parser = subparsers.add_parser( - "init", - help="Initialize a new Falyx project", - description="Create a new Falyx project with mock configuration files.", - epilog="If no name is provided, the current directory will be used.", + flx.add_command( + "I", + "Initialize a new Falyx project", + init_project, + aliases=["init"], + argument_config=init_config, + help_epilog="If no name is provided, the current directory will be used.", ) - init_parser.add_argument( - "name", - type=str, - help="Name of the new Falyx project", - default=".", - nargs="?", + flx.add_command( + "G", + "Initialize Falyx global configuration", + init_global, + aliases=["init-global"], + help_text="Create a global Falyx configuration at ~/.config/falyx/.", ) - subparsers.add_parser( - "init-global", - help="Initialize Falyx global configuration", - description="Create a global Falyx configuration at ~/.config/falyx/.", - ) - return root_parser, subparsers + return flx + + +def build_falyx() -> Falyx: + bootstrap_path = bootstrap() + if bootstrap_path: + return loader(bootstrap_path) + return build_bootstrap_falyx() def main() -> Any: - bootstrap_path = bootstrap() - if not bootstrap_path: - from falyx.init import init_global, init_project - - flx: Falyx = Falyx() - flx.add_command( - "I", - "Initialize a new Falyx project", - init_project, - aliases=["init"], - argument_config=init_config, - help_epilog="If no name is provided, the current directory will be used.", - ) - flx.add_command( - "G", - "Initialize Falyx global configuration", - init_global, - aliases=["init-global"], - help_text="Create a global Falyx configuration at ~/.config/falyx/.", - ) - else: - flx = loader(bootstrap_path) - - root_parser, subparsers = get_parsers() - - return asyncio.run( - flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback) - ) + flx = build_falyx() + return asyncio.run(flx.run()) if __name__ == "__main__": diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index 087b5ff..38f9127 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -202,7 +202,7 @@ class BottomBar: label: str, options: OptionsManager, option_name: str, - namespace_name: str = "cli_args", + namespace_name: str = "default", fg: str = OneColors.BLACK, bg_on: str = OneColors.GREEN, bg_off: str = OneColors.DARK_RED, diff --git a/falyx/command.py b/falyx/command.py index d697abb..8e6358d 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -1,19 +1,43 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -""" -Defines the Command class for Falyx CLI. +"""Command abstraction for the Falyx CLI framework. -Commands are callable units representing a menu option or CLI task, -wrapping either a BaseAction or a simple function. They provide: +This module defines the `Command` class, which represents a single executable +unit exposed to users via CLI or interactive menu interfaces. -- Hook lifecycle (before, on_success, on_error, after, on_teardown) +A `Command` acts as a bridge between: +- User input (parsed via CommandArgumentParser) +- Execution logic (encapsulated in Action / BaseAction) +- Runtime configuration (OptionsManager) +- Lifecycle hooks (HookManager) + +Core Responsibilities: +- Define command identity (key, aliases, description) +- Bind an executable action or workflow +- Configure argument parsing via CommandArgumentParser +- Separate execution arguments (e.g. retries, confirm) from action arguments +- Manage lifecycle hooks for command-level execution +- Provide help, usage, and preview interfaces - Execution timing and duration tracking -- Retry logic (single action or recursively through action trees) - Confirmation prompts and spinner integration -- Result capturing and summary logging -- Rich-based preview for CLI display -Every Command is self-contained, configurable, and plays a critical role -in building robust interactive menus. +Execution Model: +1. CLI input is routed via FalyxParser into a resolved Command +2. Arguments are parsed via CommandArgumentParser +3. Parsed values are split into: + - positional args + - keyword args + - execution args (e.g. retries, summary) +4. Execution occurs via the bound Action with lifecycle hooks applied +5. Results and context are tracked via ExecutionContext / ExecutionRegistry + +Key Concepts: +- Commands are *user-facing entrypoints*, not execution units themselves +- Execution is always delegated to an underlying Action or callable +- Argument parsing is declarative and optional +- Execution options are handled separately from business logic inputs + +This module defines the primary abstraction used by Falyx to expose structured, +composable workflows as CLI commands. """ from __future__ import annotations @@ -29,8 +53,11 @@ from falyx.action.base_action import BaseAction from falyx.console import console from falyx.context import ExecutionContext from falyx.debug import register_debug_hooks +from falyx.exceptions import NotAFalyxError +from falyx.execution_option import ExecutionOption from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType +from falyx.hooks import spinner_before_hook, spinner_teardown_hook from falyx.logger import logger from falyx.mode import FalyxMode from falyx.options_manager import OptionsManager @@ -46,67 +73,100 @@ from falyx.utils import ensure_async class Command(BaseModel): - """ - Represents a selectable command in a Falyx menu system. + """Represents a user-invokable command in Falyx. - A Command wraps an executable action (function, coroutine, or BaseAction) - and enhances it with: + A `Command` encapsulates all metadata, parsing logic, and execution behavior + required to expose a callable workflow through the Falyx CLI or interactive + menu system. - - Lifecycle hooks (before, success, error, after, teardown) - - Retry support (single action or recursive for chained/grouped actions) - - Confirmation prompts for safe execution - - Spinner visuals during execution - - Tagging for categorization and filtering - - Rich-based CLI previews + It is responsible for: + - Identifying the command via key and aliases + - Binding an executable Action or callable + - Parsing user-provided arguments + - Managing execution configuration (retries, confirmation, etc.) + - Integrating with lifecycle hooks and execution context + + Architecture: + - Parsing is delegated to CommandArgumentParser + - Execution is delegated to BaseAction / Action + - Runtime configuration is managed via OptionsManager + - Lifecycle hooks are managed via HookManager + + Argument Handling: + - Supports positional and keyword arguments via CommandArgumentParser + - Separates execution-specific options (e.g. retries, confirm flags) + from action arguments + - Returns structured `(args, kwargs, execution_args)` for execution + + Execution Behavior: + - Callable via `await command(*args, **kwargs)` + - Applies lifecycle hooks: + before → on_success/on_error → after → on_teardown + - Supports preview mode for dry-run introspection + - Supports retry policies and confirmation flows - Result tracking and summary reporting - Commands are built to be flexible yet robust, enabling dynamic CLI workflows - without sacrificing control or reliability. + Help & Introspection: + - Provides usage, help text, and TLDR examples + - Supports both CLI help and interactive menu rendering + - Can expose simplified or full help signatures - Attributes: - key (str): Primary trigger key for the command. + Args: + key (str): Primary identifier used to invoke the command. description (str): Short description for the menu display. - hidden (bool): Toggles visibility in the menu. - aliases (list[str]): Alternate keys or phrases. - action (BaseAction | Callable): The executable logic. - args (tuple): Static positional arguments. - kwargs (dict): Static keyword arguments. - help_text (str): Additional help or guidance text. - style (str): Rich style for description. - confirm (bool): Whether to require confirmation before executing. - confirm_message (str): Custom confirmation prompt. - preview_before_confirm (bool): Whether to preview before confirming. - spinner (bool): Whether to show a spinner during execution. - spinner_message (str): Spinner text message. - spinner_type (str): Spinner style (e.g., dots, line, etc.). - spinner_style (str): Color or style of the spinner. - spinner_speed (float): Speed of the spinner animation. - hooks (HookManager): Hook manager for lifecycle events. - retry (bool): Enable retry on failure. - retry_all (bool): Enable retry across chained or grouped actions. - retry_policy (RetryPolicy): Retry behavior configuration. - tags (list[str]): Organizational tags for the command. - logging_hooks (bool): Whether to attach logging hooks automatically. - options_manager (OptionsManager): Manages global command-line options. - arg_parser (CommandArgumentParser): Parses command arguments. - arguments (list[dict[str, Any]]): Argument definitions for the command. - argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments - for the command parser. - custom_parser (ArgParserProtocol | None): Custom argument parser. - custom_help (Callable[[], str | None] | None): Custom help message generator. - auto_args (bool): Automatically infer arguments from the action. - arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments, - such as help text or choices. - simple_help_signature (bool): Whether to use a simplified help signature. - ignore_in_history (bool): Whether to ignore this command in execution history last result. - program: (str | None): The parent program name. + action (BaseAction | Callable[..., Any]): + Execution logic for the command. + args (tuple, optional): Static positional arguments. + kwargs (dict[str, Any], optional): Static keyword arguments. + hidden (bool): Whether to hide the command from menus. + aliases (list[str], optional): Alternate names for invocation. + help_text (str): Help description shown in CLI/menu. + help_epilog (str): Additional help content. + style (str): Rich style used for rendering. + confirm (bool): Whether confirmation is required before execution. + confirm_message (str): Confirmation prompt text. + preview_before_confirm (bool): Whether to preview before confirmation. + spinner (bool): Enable spinner during execution. + spinner_message (str): Spinner message text. + spinner_type (str): Rich Spinner animation type (e.g., dots, line, etc.). + spinner_style (str): Rich style for the spinner. + spinner_speed (float): Spinner speed multiplier. + hooks (HookManager | None): Hook manager for lifecycle events. + tags (list[str], optional): Tags for grouping and filtering. + logging_hooks (bool): Enable debug logging hooks. + retry (bool): Enable retry behavior. + retry_all (bool): Apply retry to all nested actions. + retry_policy (RetryPolicy | None): Retry configuration. + arg_parser (CommandArgumentParser | None): + Custom argument parser instance. + execution_options (frozenset[ExecutionOption], optional): + Enabled execution-level options. + arguments (list[dict[str, Any]], optional): + Declarative argument definitions. + argument_config (Callable[[CommandArgumentParser], None] | None): + Callback to configure parser. + custom_parser (ArgParserProtocol | None): + Override parser logic entirely. + custom_help (Callable[[], str | None] | None): + Override help rendering. + auto_args (bool): Auto-generate arguments from action signature. + arg_metadata (dict[str, Any], optional): Metadata for arguments. + simple_help_signature (bool): Use simplified help formatting. + ignore_in_history (bool): + Ignore command for `last_result` in execution history. + options_manager (OptionsManager | None): + Shared options manager instance. + program (str | None): The parent program name. - Methods: - __call__(): Executes the command, respecting hooks and retries. - preview(): Rich tree preview of the command. - confirmation_prompt(): Formatted prompt for confirmation. - result: Property exposing the last result. - log_summary(): Summarizes execution details to the console. + Raises: + CommandArgumentError: If argument parsing fails. + InvalidActionError: If action is not callable or invalid. + FalyxError: If command configuration is invalid. + + Notes: + - Commands are lightweight wrappers; execution logic belongs in Actions + - Argument parsing and execution are intentionally decoupled + - Commands are case-insensitive and support alias resolution """ key: str @@ -135,6 +195,7 @@ class Command(BaseModel): logging_hooks: bool = False options_manager: OptionsManager = Field(default_factory=OptionsManager) arg_parser: CommandArgumentParser | None = None + execution_options: frozenset[ExecutionOption] = Field(default_factory=frozenset) arguments: list[dict[str, Any]] = Field(default_factory=list) argument_config: Callable[[CommandArgumentParser], None] | None = None custom_parser: ArgParserProtocol | None = None @@ -149,9 +210,53 @@ class Command(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - async def parse_args( + async def resolve_args( self, raw_args: list[str] | str, from_validate: bool = False - ) -> tuple[tuple, dict]: + ) -> tuple[tuple, dict, dict]: + """Parse CLI arguments into execution-ready components. + + This method delegates argument parsing to the configured + CommandArgumentParser (if present) and normalizes the result into three + distinct groups used during execution: + + - positional arguments (`args`) + - keyword arguments (`kwargs`) + - execution arguments (`execution_args`) + + Execution arguments represent runtime configuration (e.g. retries, + confirmation flags, summary output) and are handled separately from the + action's business logic inputs. + + Behavior: + - If an argument parser is defined, uses `CommandArgumentParser.parse_args_split()` + to resolve and type-coerce all inputs. + - If no parser is defined, returns empty args and kwargs. + - Supports validation mode (`from_validate=True`) for interactive input, + deferring certain errors and resolver execution where applicable. + - Handles help/preview signals raised during parsing. + + Args: + args (list[str] | None): CLI-style argument tokens. + from_validate (bool): Whether parsing is occurring in validation mode + (e.g. prompt_toolkit validator). When True, may suppress eager + resolution or defer certain errors. + + Returns: + tuple: + - tuple[Any, ...]: Positional arguments for execution. + - dict[str, Any]: Keyword arguments for execution. + - dict[str, Any]: Execution-specific arguments (e.g. retries, + confirm flags, summary). + + Raises: + CommandArgumentError: If argument parsing or validation fails. + HelpSignal: If help or TLDR output is triggered during parsing. + + Notes: + - Execution arguments are not passed to the underlying Action. + - This method is the canonical boundary between CLI parsing and + execution semantics. + """ if callable(self.custom_parser): if isinstance(raw_args, str): try: @@ -162,7 +267,7 @@ class Command(BaseModel): self.key, raw_args, ) - return ((), {}) + return ((), {}, {}) return self.custom_parser(raw_args) if isinstance(raw_args, str): @@ -174,13 +279,13 @@ class Command(BaseModel): self.key, raw_args, ) - return ((), {}) + return ((), {}, {}) if not isinstance(self.arg_parser, CommandArgumentParser): logger.warning( "[Command:%s] No argument parser configured, using default parsing.", self.key, ) - return ((), {}) + return ((), {}, {}) return await self.arg_parser.parse_args_split( raw_args, from_validate=from_validate ) @@ -249,6 +354,12 @@ class Command(BaseModel): for arg_def in self.get_argument_definitions(): self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) + if isinstance(self.arg_parser, CommandArgumentParser) and self.execution_options: + self.arg_parser.enable_execution_options(self.execution_options) + + if isinstance(self.arg_parser, CommandArgumentParser): + self.arg_parser.set_options_manager(self.options_manager) + if self.ignore_in_history and isinstance(self.action, BaseAction): self.action.ignore_in_history = True @@ -258,9 +369,41 @@ class Command(BaseModel): self.action.set_options_manager(self.options_manager) async def __call__(self, *args, **kwargs) -> Any: - """ - Run the action with full hook lifecycle, timing, error handling, - confirmation prompts, preview, and spinner integration. + """Execute the command's underlying action with lifecycle management. + + This method invokes the bound action (BaseAction or callable) using the + provided arguments while applying the full Falyx execution lifecycle. + + Execution Flow: + 1. Create an ExecutionContext for tracking inputs, results, and timing + 2. Trigger `before` hooks + 3. Execute the underlying action + 4. Trigger `on_success` or `on_error` hooks + 5. Trigger `after` and `on_teardown` hooks + 6. Record execution via ExecutionRegistry + + Behavior: + - Supports both synchronous and asynchronous actions + - Applies retry policies if configured + - Integrates with confirmation and execution options via OptionsManager + - Propagates exceptions unless recovered by hooks (e.g. retry handlers) + + Args: + *args (Any): Positional arguments passed to the action. + **kwargs (Any): Keyword arguments passed to the action. + + Returns: + Any: Result returned by the underlying action. + + Raises: + Exception: Propagates execution errors unless handled by hooks. + + Notes: + - This method does not perform argument parsing; inputs are assumed + to be pre-processed via `resolve_args`. + - Execution options (e.g. retries, confirm) are applied externally + via Falyx in OptionsManager before invocation. + - Lifecycle hooks are always executed, even in failure cases. """ self._inject_options_manager() combined_args = args + self.args @@ -341,15 +484,38 @@ class Command(BaseModel): @property def help_signature(self) -> tuple[str, str, str]: - """Generate a help signature for the command.""" - is_cli_mode = self.options_manager.get("mode") in { - FalyxMode.RUN, - FalyxMode.PREVIEW, - FalyxMode.RUN_ALL, - FalyxMode.HELP, - } + """Return a formatted help signature for display. - program = f"{self.program} run " if is_cli_mode else "" + This property provides the core information used to render command help + in both CLI and interactive menu modes. + + The signature consists of: + - usage: A formatted usage string (including arguments if defined) + - description: A short description of the command + - tag: Optional tag or category label (if applicable) + + Behavior: + - If a CommandArgumentParser is present, delegates usage generation to + the parser (`get_usage()`). + - Otherwise, constructs a minimal usage string from the command key. + - Honors `simple_help_signature` to produce a condensed representation + (e.g. omitting argument details). + - Applies styling appropriate for Rich rendering. + + Returns: + tuple: + - str: Usage string (e.g. "falyx D | deploy [--help] region") + - str: Command description + - str | None: Optional tag/category label + + Notes: + - This is the primary interface used by help menus, CLI help output, + and command listings. + - Formatting may vary depending on CLI vs menu mode. + """ + is_cli_mode = self.options_manager.get("mode") != FalyxMode.MENU + + program = f"{self.program} " if is_cli_mode else "" if self.arg_parser and not self.simple_help_signature: usage = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}" @@ -416,3 +582,219 @@ class Command(BaseModel): f"Command(key='{self.key}', description='{self.description}' " f"action='{self.action}')" ) + + @classmethod + def build( + cls, + key: str, + description: str, + action: BaseAction | Callable[..., Any], + *, + args: tuple = (), + kwargs: dict[str, Any] | None = None, + hidden: bool = False, + aliases: list[str] | None = None, + help_text: str = "", + help_epilog: str = "", + 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_speed: float = 1.0, + options_manager: OptionsManager | None = None, + hooks: HookManager | None = None, + before_hooks: list[Callable] | None = None, + success_hooks: list[Callable] | None = None, + error_hooks: list[Callable] | None = None, + after_hooks: list[Callable] | None = None, + teardown_hooks: list[Callable] | None = None, + tags: list[str] | None = None, + logging_hooks: bool = False, + retry: bool = False, + retry_all: bool = False, + retry_policy: RetryPolicy | None = None, + arg_parser: CommandArgumentParser | None = None, + arguments: list[dict[str, Any]] | None = None, + argument_config: Callable[[CommandArgumentParser], None] | None = None, + execution_options: list[ExecutionOption | str] | None = None, + custom_parser: ArgParserProtocol | None = None, + custom_help: Callable[[], str | None] | None = None, + auto_args: bool = True, + arg_metadata: dict[str, str | dict[str, Any]] | None = None, + simple_help_signature: bool = False, + ignore_in_history: bool = False, + program: str | None = None, + ) -> Command: + """Build and configure a `Command` instance from high-level constructor inputs. + + This factory centralizes command construction so callers such as `Falyx` and + `CommandRunner` can create fully configured commands through one consistent + path. It normalizes optional inputs, validates selected objects, converts + execution options into their canonical internal form, and registers any + requested command-level hooks. + + In addition to instantiating the `Command`, this method can: + - validate and attach an explicit `CommandArgumentParser` + - normalize execution options into a `frozenset[ExecutionOption]` + - ensure a shared `OptionsManager` is available + - attach a custom `HookManager` + - register lifecycle hooks for the command + - register spinner hooks when spinner support is enabled + + Args: + key (str): Primary identifier used to invoke the command. + description (str): Short description of the command. + action (BaseAction | Callable[..., Any]): Underlying execution logic for + the command. + args (tuple): Static positional arguments applied to every execution. + kwargs (dict[str, Any] | None): Static keyword arguments applied to every + execution. + hidden (bool): Whether the command should be hidden from menu displays. + aliases (list[str] | None): Optional alternate names for invocation. + help_text (str): Help text shown in command help output. + help_epilog (str): Additional help text shown after the main help body. + style (str): Rich style used when rendering the command. + confirm (bool): Whether confirmation is required before execution. + confirm_message (str): Confirmation prompt text. + preview_before_confirm (bool): Whether to preview before confirmation. + spinner (bool): Whether to enable spinner lifecycle hooks. + spinner_message (str): Spinner message text. + spinner_type (str): Spinner animation type. + spinner_style (str): Spinner style. + spinner_speed (float): Spinner speed multiplier. + options_manager (OptionsManager | None): Shared options manager for the + command and its parser. + hooks (HookManager | None): Optional hook manager to assign directly to the + command. + before_hooks (list[Callable] | None): Hooks registered for the `BEFORE` + lifecycle stage. + success_hooks (list[Callable] | None): Hooks registered for the + `ON_SUCCESS` lifecycle stage. + error_hooks (list[Callable] | None): Hooks registered for the `ON_ERROR` + lifecycle stage. + after_hooks (list[Callable] | None): Hooks registered for the `AFTER` + lifecycle stage. + teardown_hooks (list[Callable] | None): Hooks registered for the + `ON_TEARDOWN` lifecycle stage. + tags (list[str] | None): Optional tags used for grouping and filtering. + logging_hooks (bool): Whether to enable debug hook logging. + retry (bool): Whether retry behavior is enabled. + retry_all (bool): Whether retry behavior should be applied recursively. + retry_policy (RetryPolicy | None): Retry configuration for the command. + arg_parser (CommandArgumentParser | None): Optional explicit argument + parser instance. + arguments (list[dict[str, Any]] | None): Declarative argument + definitions for the command parser. + argument_config (Callable[[CommandArgumentParser], None] | None): Callback + used to configure the argument parser. + execution_options (list[ExecutionOption | str] | None): Execution-level + options to enable for the command. + custom_parser (ArgParserProtocol | None): Optional custom parser + implementation that overrides normal parser behavior. + custom_help (Callable[[], str | None] | None): Optional custom help + renderer. + auto_args (bool): Whether to infer arguments automatically from the action + signature when explicit definitions are not provided. + arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata + used during argument inference. + simple_help_signature (bool): Whether to use a simplified help signature. + ignore_in_history (bool): Whether to exclude the command from execution + history tracking. + program (str | None): Parent program name used in help rendering. + + Returns: + Command: A fully configured `Command` instance. + + Raises: + NotAFalyxError: If `arg_parser` is provided but is not a + `CommandArgumentParser` instance, or if `hooks` is provided but is not + a `HookManager` instance. + + Notes: + - Execution options supplied as strings are converted to + `ExecutionOption` enum values before the command is created. + - If no `options_manager` is provided, a new `OptionsManager` is created. + - Spinner hooks are registered at build time when `spinner=True`. + - This method is the canonical command-construction path used by higher- + level APIs such as `Falyx.add_command()` and `CommandRunner.build()`. + """ + if arg_parser: + if not isinstance(arg_parser, CommandArgumentParser): + raise NotAFalyxError( + "arg_parser must be an instance of CommandArgumentParser." + ) + arg_parser = arg_parser + + if execution_options: + parsed_execution_options = frozenset( + ExecutionOption(option) if isinstance(option, str) else option + for option in execution_options + ) + else: + parsed_execution_options = frozenset() + + options_manager = options_manager or OptionsManager() + + command = Command( + key=key, + description=description, + action=action, + args=args, + kwargs=kwargs if kwargs else {}, + hidden=hidden, + aliases=aliases if aliases else [], + help_text=help_text, + help_epilog=help_epilog, + style=style, + confirm=confirm, + confirm_message=confirm_message, + preview_before_confirm=preview_before_confirm, + spinner=spinner, + spinner_message=spinner_message, + spinner_type=spinner_type, + spinner_style=spinner_style, + spinner_speed=spinner_speed, + tags=tags if tags else [], + logging_hooks=logging_hooks, + retry=retry, + retry_all=retry_all, + retry_policy=retry_policy or RetryPolicy(), + options_manager=options_manager, + arg_parser=arg_parser, + execution_options=parsed_execution_options, + arguments=arguments or [], + argument_config=argument_config, + custom_parser=custom_parser, + custom_help=custom_help, + auto_args=auto_args, + arg_metadata=arg_metadata or {}, + simple_help_signature=simple_help_signature, + ignore_in_history=ignore_in_history, + program=program, + ) + + if hooks: + if not isinstance(hooks, HookManager): + raise NotAFalyxError("hooks must be an instance of HookManager.") + command.hooks = hooks + + for hook in before_hooks or []: + command.hooks.register(HookType.BEFORE, hook) + for hook in success_hooks or []: + command.hooks.register(HookType.ON_SUCCESS, hook) + for hook in error_hooks or []: + command.hooks.register(HookType.ON_ERROR, hook) + for hook in after_hooks or []: + command.hooks.register(HookType.AFTER, hook) + for hook in teardown_hooks or []: + command.hooks.register(HookType.ON_TEARDOWN, hook) + + if spinner: + command.hooks.register(HookType.BEFORE, spinner_before_hook) + command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook) + + return command diff --git a/falyx/command_executor.py b/falyx/command_executor.py new file mode 100644 index 0000000..ebb44c2 --- /dev/null +++ b/falyx/command_executor.py @@ -0,0 +1,334 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +"""Shared command execution engine for the Falyx CLI framework. + +This module defines `CommandExecutor`, a low-level execution service responsible +for running already-resolved `Command` objects with a consistent outer lifecycle. + +`CommandExecutor` sits between higher-level orchestration layers (such as +`Falyx.execute_command()` or `CommandRunner.run()`) and the command itself. +It does not perform command lookup or argument parsing. Instead, it accepts a +resolved `Command` plus prepared `args`, `kwargs`, and `execution_args`, then +applies executor-level behavior around the command invocation. + +Responsibilities: + - Apply execution-scoped runtime overrides such as confirmation flags + - Apply retry overrides from execution arguments + - Trigger executor-level lifecycle hooks + - Create and manage an outer `ExecutionContext` + - Delegate actual invocation to the resolved `Command` + - Handle interruption and failure policies + - Optionally print execution summaries via `ExecutionRegistry` + +Execution Model: + 1. A command is resolved and its arguments are prepared elsewhere. + 2. Retry and execution-option overrides are derived from `execution_args`. + 3. An outer `ExecutionContext` is created for executor-level tracking. + 4. Executor hooks are triggered around the command invocation. + 5. The command is executed inside an `OptionsManager.override_namespace()` + context for scoped runtime overrides. + 6. Errors are either surfaced, wrapped, or rendered depending on the + configured execution policy. + 7. Optional summary output is emitted after execution completes. + +Design Notes: + - `CommandExecutor` is intentionally narrower in scope than `Falyx`. + It does not resolve commands, parse raw CLI text, or manage menu state. + - `Command` still owns command-local behavior such as confirmation, + command hooks, and delegation to the underlying `Action`. + - This module exists to centralize shared execution behavior and reduce + duplication across Falyx runtime entrypoints. + +Typical Usage: + executor = CommandExecutor(options=options, hooks=hooks, console=console) + result = await executor.execute( + command=command, + args=args, + kwargs=kwargs, + execution_args=execution_args, + ) +""" +from __future__ import annotations + +from typing import Any + +from rich.console import Console + +from falyx.action import Action +from falyx.command import Command +from falyx.context import ExecutionContext +from falyx.exceptions import FalyxError +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookManager, HookType +from falyx.logger import logger +from falyx.options_manager import OptionsManager +from falyx.themes import OneColors + + +class CommandExecutor: + """Execute resolved Falyx commands with shared outer lifecycle handling. + + `CommandExecutor` provides a reusable execution service for running a + `Command` after command resolution and argument parsing have already been + completed. + + This class is intended to be shared by higher-level entrypoints such as + `Falyx` and `CommandRunner`. It centralizes the outer execution flow so + command execution semantics remain consistent across menu-driven and + programmatic use cases. + + Responsibilities: + - Apply retry overrides from execution arguments + - Apply scoped runtime overrides using `OptionsManager` + - Trigger executor-level hooks before and after command execution + - Create and manage an executor-level `ExecutionContext` + - Render execution errors to the configured console + - Control whether errors are raised, wrapped, or suppressed + - Emit optional execution summaries + + Attributes: + options (OptionsManager): Shared options manager used to apply scoped + execution overrides. + hooks (HookManager): Hook manager for executor-level lifecycle hooks. + console (Console): Rich console used for user-facing error output. + """ + + def __init__( + self, + *, + options: OptionsManager, + hooks: HookManager, + console: Console, + ) -> None: + self.options = options + self.hooks = hooks + self.console = console + + def _debug_hooks(self, command: Command) -> None: + """Log executor-level and command-level hook registrations for debugging. + + This helper is used to surface the currently registered hooks on both the + executor and the resolved command before execution begins. + + Args: + command (Command): The command about to be executed. + """ + logger.debug("Executor hooks:\n%s", str(self.hooks)) + logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks)) + + def _apply_retry_overrides( + self, + command: Command, + execution_args: dict[str, Any], + ) -> None: + """Apply retry-related execution overrides to the command. + + This method inspects execution-level retry options and updates the + command's retry policy in place when overrides are provided. If the + command's underlying action is an `Action`, the updated retry policy is + propagated to that action as well. + + Args: + command (Command): The command whose retry policy may be updated. + execution_args (dict[str, Any]): Execution-level arguments that may + contain retry overrides such as `retries`, `retry_delay`, and + `retry_backoff`. + + Notes: + - If no retry-related overrides are provided, this method does nothing. + - If the command action is not an `Action`, a warning is logged and the + command-level retry policy is updated without propagating it further. + """ + retries = execution_args.get("retries", 0) + retry_delay = execution_args.get("retry_delay", 0.0) + retry_backoff = execution_args.get("retry_backoff", 0.0) + + logger.debug( + "[_apply_retry_overrides]: retries=%s, retry_delay=%s, retry_backoff=%s", + retries, + retry_delay, + retry_backoff, + ) + if not retries and not retry_delay and not retry_backoff: + return + + command.retry_policy.enabled = True + if retries: + command.retry_policy.max_retries = retries + if retry_delay: + command.retry_policy.delay = retry_delay + if retry_backoff: + command.retry_policy.backoff = retry_backoff + + if isinstance(command.action, Action): + command.action.set_retry_policy(command.retry_policy) + else: + logger.warning( + "[%s] Retry requested, but action is not an Action instance.", + command.description, + ) + + def _execution_option_overrides( + self, + execution_args: dict[str, Any], + ) -> dict[str, Any]: + """Build scoped option overrides from execution arguments. + + This method extracts execution-only runtime flags that should be applied + through the `OptionsManager` during command execution. + + Args: + execution_args (dict[str, Any]): Execution-level arguments returned + from command argument resolution. + + Returns: + dict[str, Any]: Mapping of option names to temporary execution-scoped + override values. + """ + return { + "force_confirm": execution_args.get("force_confirm", False), + "skip_confirm": execution_args.get("skip_confirm", False), + } + + async def _handle_action_error( + self, selected_command: Command, error: Exception + ) -> None: + """Render and log a command execution error. + + This helper logs the full exception details for debugging and prints a + user-facing error message to the configured console. + + Args: + selected_command (Command): The command that failed. + error (Exception): The exception raised during command execution. + """ + logger.debug( + "[%s] '%s' failed with error: %s", + selected_command.key, + selected_command.description, + error, + exc_info=True, + ) + self.console.print( + f"[{OneColors.DARK_RED}]An error occurred while executing " + f"{selected_command.description}:[/] {error}" + ) + + async def execute( + self, + *, + command: Command, + args: tuple, + kwargs: dict[str, Any], + execution_args: dict[str, Any], + raise_on_error: bool = True, + wrap_errors: bool = False, + summary_last_result: bool = False, + ) -> Any: + """Execute a resolved command with executor-level lifecycle management. + + This method is the primary entrypoint of `CommandExecutor`. It accepts an + already-resolved `Command` and its prepared execution inputs, then applies + shared outer execution behavior around the command invocation. + + Execution Flow: + 1. Log currently registered hooks for debugging. + 2. Apply retry overrides from `execution_args`. + 3. Derive scoped runtime overrides for the execution namespace. + 4. Create and start an outer `ExecutionContext`. + 5. Trigger executor-level `BEFORE` hooks. + 6. Execute the command inside an execution-scoped options override + context. + 7. Trigger executor-level `SUCCESS` or `ERROR` hooks. + 8. Trigger `AFTER` and `ON_TEARDOWN` hooks. + 9. Optionally print an execution summary. + + Args: + command (Command): The resolved command to execute. + args (tuple): Positional arguments to pass to the command. + kwargs (dict[str, Any]): Keyword arguments to pass to the command. + execution_args (dict[str, Any]): Execution-only arguments that affect + runtime behavior, such as retry or confirmation overrides. + raise_on_error (bool): Whether execution errors should be re-raised + after handling. + wrap_errors (bool): Whether handled errors should be wrapped in a + `FalyxError` before being raised. + summary_last_result (bool): Whether summary output should only have the + last recorded result when summary reporting is enabled. + + Returns: + Any: The result returned by the command, or any recovered result + attached to the execution context. + + Raises: + KeyboardInterrupt: If execution is interrupted by the user and + `raise_on_error` is True and `wrap_errors` is False. + EOFError: If execution receives EOF interruption and `raise_on_error` + is True and `wrap_errors` is False. + FalyxError: If `wrap_errors` is True and execution is interrupted or + fails. + Exception: Re-raises the underlying execution error when + `raise_on_error` is True and `wrap_errors` is False. + + Notes: + - This method assumes the command has already been resolved and its + arguments have already been parsed. + - Command-local behavior, such as confirmation prompts and command hook + execution, remains the responsibility of `Command.__call__()`. + - Summary output is only emitted when the `summary` execution option is + present in `execution_args`. + """ + self._debug_hooks(command) + self._apply_retry_overrides(command, execution_args) + overrides = self._execution_option_overrides(execution_args) + + context = ExecutionContext( + name=command.description, + args=args, + kwargs=kwargs, + action=command, + ) + logger.info( + "[execute] Starting execution of '%s' with args: %s, kwargs: %s", + command.description, + args, + kwargs, + ) + context.start_timer() + + try: + await self.hooks.trigger(HookType.BEFORE, context) + with self.options.override_namespace( + overrides=overrides, + namespace_name="execution", + ): + result = await command(*args, **kwargs) + context.result = result + await self.hooks.trigger(HookType.ON_SUCCESS, context) + except (KeyboardInterrupt, EOFError) as error: + logger.info( + "[execute] '%s' interrupted by user.", + command.description, + ) + if wrap_errors: + raise FalyxError( + f"[execute] ⚠️ '{command.description}' interrupted by user." + ) from error + if raise_on_error: + raise error + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + await self._handle_action_error(command, error) + if wrap_errors: + raise FalyxError(f"[execute] '{command.description}' failed.") from error + if raise_on_error: + raise error + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + if execution_args.get("summary") and summary_last_result: + er.summary(last_result=True) + elif execution_args.get("summary"): + er.summary() + return context.result diff --git a/falyx/command_runner.py b/falyx/command_runner.py new file mode 100644 index 0000000..8f90215 --- /dev/null +++ b/falyx/command_runner.py @@ -0,0 +1,467 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +"""Standalone command runner for the Falyx CLI framework. + +This module defines `CommandRunner`, a developer-facing convenience wrapper for +executing a single `Command` outside the full `Falyx` runtime. + +`CommandRunner` is designed for programmatic and standalone command execution +where command lookup, menu interaction, and root CLI parsing are not needed. +It provides a small, focused API that: + +- owns a single `Command` +- ensures the command and parser share a consistent `OptionsManager` +- delegates shared execution behavior to `CommandExecutor` +- supports both wrapping an existing `Command` and building one from raw + constructor-style arguments + +Responsibilities: + - Hold a single resolved `Command` for repeated execution + - Normalize runtime dependencies such as `OptionsManager`, `HookManager`, + and `Console` + - Resolve command arguments from raw argv-style input + - Delegate execution to `CommandExecutor` for shared outer lifecycle + handling + +Design Notes: + - `CommandRunner` is intentionally narrower than `Falyx`. + It does not resolve commands by name, render menus, or manage built-ins. + - `CommandExecutor` remains the shared execution core. + `CommandRunner` exists as a convenience layer for developer-facing and + standalone use cases. + - `Command` still owns command-local behavior such as confirmation, + command hook execution, and delegation to the underlying `Action`. + +Typical Usage: + runner = CommandRunner.from_command(existing_command) + result = await runner.run(["--region", "us-east"]) + + #!/usr/bin/env python + import asyncio + runner = CommandRunner.build( + key="D", + description="Deploy", + action=deploy, + ) + result = asyncio.run(runner.cli()) + $ ./deploy.py --region us-east +""" +from __future__ import annotations + +import asyncio +import sys +from typing import Any, Callable + +from rich.console import Console + +from falyx.action import BaseAction +from falyx.command import Command +from falyx.command_executor import CommandExecutor +from falyx.console import console as falyx_console +from falyx.exceptions import CommandArgumentError, FalyxError, NotAFalyxError +from falyx.execution_option import ExecutionOption +from falyx.hook_manager import HookManager +from falyx.logger import logger +from falyx.options_manager import OptionsManager +from falyx.parser.command_argument_parser import CommandArgumentParser +from falyx.protocols import ArgParserProtocol +from falyx.retry import RetryPolicy +from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal +from falyx.themes import OneColors + + +class CommandRunner: + """Run a single Falyx command outside the full Falyx application runtime. + + `CommandRunner` is a lightweight wrapper around a single `Command` plus a + `CommandExecutor`. It is intended for standalone execution, testing, and + developer-facing programmatic usage where command resolution has already + happened or is unnecessary. + + This class is responsible for: + - storing the bound `Command` + - providing a shared `OptionsManager` to the command and its parser + - exposing a simple `run()` method that accepts argv-style input + - delegating shared execution behavior to `CommandExecutor` + + Attributes: + command (Command): The command executed by this runner. + options (OptionsManager): Shared options manager used by the command, + parser, and executor. + hooks (HookManager): Executor-level hooks used during execution. + console (Console): Rich console used for user-facing output. + executor (CommandExecutor): Shared execution engine used to run the + bound command. + """ + + def __init__( + self, + command: Command, + *, + options: OptionsManager | None = None, + hooks: HookManager | None = None, + console: Console | None = None, + ) -> None: + """Initialize a `CommandRunner` for a single command. + + The runner ensures that the bound command, its argument parser, and the + internal `CommandExecutor` all share the same `OptionsManager` and runtime + dependencies. + + Args: + command (Command): The command to execute. + options (OptionsManager | None): Optional shared options manager. If + omitted, a new `OptionsManager` is created. + hooks (HookManager | None): Optional executor-level hook manager. If + omitted, a new `HookManager` is created. + console (Console | None): Optional Rich console for output. If omitted, + the default Falyx console is used. + """ + self.command = command + self.options = options or OptionsManager() + self.hooks = hooks or HookManager() + self.console = console or falyx_console + self.command.options_manager = self.options + if isinstance(self.command.arg_parser, CommandArgumentParser): + self.command.arg_parser.set_options_manager(self.options) + self.executor = CommandExecutor( + options=self.options, + hooks=self.hooks, + console=self.console, + ) + self.options.from_mapping(values={}, namespace_name="execution") + + async def run( + self, + argv: list[str] | None = None, + raise_on_error: bool = True, + wrap_errors: bool = False, + summary_last_result: bool = False, + ) -> Any: + """Resolve arguments and execute the bound command. + + This method is the primary execution entrypoint for `CommandRunner`. It + accepts raw argv-style tokens, resolves them into positional arguments, + keyword arguments, and execution arguments via `Command.resolve_args()`, + then delegates execution to the internal `CommandExecutor`. + + Args: + argv (list[str] | None): Optional argv-style argument tokens. If + omitted, `sys.argv[1:]` is used. + + Returns: + Any: The result returned by the bound command. + + Raises: + Exception: Propagates any execution error surfaced by the underlying + `CommandExecutor` or command execution path. + """ + argv = sys.argv[1:] if argv is None else argv + args, kwargs, execution_args = await self.command.resolve_args(argv) + logger.debug( + "Executing command '%s' with args=%s, kwargs=%s, execution_args=%s", + self.command.description, + args, + kwargs, + execution_args, + ) + return await self.executor.execute( + command=self.command, + args=args, + kwargs=kwargs, + execution_args=execution_args, + raise_on_error=raise_on_error, + wrap_errors=wrap_errors, + summary_last_result=summary_last_result, + ) + + async def cli( + self, + argv: list[str] | None = None, + summary_last_result: bool = False, + ) -> Any: + """Run the bound command as a shell-oriented CLI entrypoint. + + This method wraps `run()` with command-line specific behavior. It executes the + bound command using raw argv-style input, then translates framework signals and + execution failures into user-facing console output and process exit codes. + + Unlike `run()`, this method is intended for direct CLI usage rather than + programmatic integration. It may terminate the current process via `sys.exit()`. + + Behavior: + - Delegates normal execution to `run()` + - Exits with status code `0` when help output is requested + - Exits with status code `2` for command argument or usage errors + - Exits with status code `1` for execution failures and non-success control + flow such as cancellation or back-navigation + - Exits with status code `130` for quit/interrupt-style termination + + Args: + argv (list[str] | None): Optional argv-style argument tokens. If omitted, + `sys.argv[1:]` is used by `run()`. + summary_last_result (bool): Whether summary output should include the last + recorded result when summary reporting is enabled. + + Returns: + Any: The result returned by the bound command when execution completes + successfully. + + Raises: + SystemExit: Always raised for handled CLI exit paths, including help, + argument errors, cancellations, and execution failures. + + Notes: + - This method is intentionally shell-facing and should be used in + script entrypoints such as `asyncio.run(runner.cli())`. + - For programmatic use, prefer `run()`, which preserves normal Python + exception behavior and does not call `sys.exit()`. + """ + try: + return await self.run( + argv=argv, + raise_on_error=False, + wrap_errors=True, + summary_last_result=summary_last_result, + ) + except HelpSignal: + sys.exit(0) + except CommandArgumentError as error: + self.command.render_help() + self.console.print(f"[{OneColors.DARK_RED}]❌ ['{self.command.key}'] {error}") + sys.exit(2) + except FalyxError as error: + self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") + sys.exit(1) + except QuitSignal: + logger.info("[QuitSignal]. <- Exiting run.") + sys.exit(130) + except BackSignal: + logger.info("[BackSignal]. <- Exiting run.") + sys.exit(1) + except CancelSignal: + logger.info("[CancelSignal]. <- Exiting run.") + sys.exit(1) + except asyncio.CancelledError: + logger.info("[asyncio.CancelledError]. <- Exiting run.") + sys.exit(1) + + @classmethod + def from_command( + cls, + command: Command, + *, + runner_hooks: HookManager | None = None, + options: OptionsManager | None = None, + console: Console | None = None, + ) -> CommandRunner: + """Create a `CommandRunner` from an existing `Command` instance. + + This factory is useful when a command has already been defined elsewhere + and should be exposed through the standalone runner interface without + rebuilding it. + + Args: + command (Command): Existing command instance to wrap. + runner_hooks (HookManager | None): Optional executor-level hook manager + for the runner. + options (OptionsManager | None): Optional shared options manager. + console (Console | None): Optional Rich console for output. + + Returns: + CommandRunner: A runner bound to the provided command. + + Raises: + NotAFalyxError: If `runner_hooks` is provided but is not a + `HookManager` instance. + """ + if runner_hooks and not isinstance(runner_hooks, HookManager): + raise NotAFalyxError("runner_hooks must be an instance of HookManager.") + return cls( + command=command, + options=options, + hooks=runner_hooks, + console=console, + ) + + @classmethod + def build( + cls, + key: str, + description: str, + action: BaseAction | Callable[..., Any], + *, + runner_hooks: HookManager | None = None, + args: tuple = (), + kwargs: dict[str, Any] | None = None, + hidden: bool = False, + aliases: list[str] | None = None, + help_text: str = "", + help_epilog: str = "", + 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_speed: float = 1.0, + options: OptionsManager | None = None, + command_hooks: HookManager | None = None, + before_hooks: list[Callable] | None = None, + success_hooks: list[Callable] | None = None, + error_hooks: list[Callable] | None = None, + after_hooks: list[Callable] | None = None, + teardown_hooks: list[Callable] | None = None, + tags: list[str] | None = None, + logging_hooks: bool = False, + retry: bool = False, + retry_all: bool = False, + retry_policy: RetryPolicy | None = None, + arg_parser: CommandArgumentParser | None = None, + arguments: list[dict[str, Any]] | None = None, + argument_config: Callable[[CommandArgumentParser], None] | None = None, + execution_options: list[ExecutionOption | str] | None = None, + custom_parser: ArgParserProtocol | None = None, + custom_help: Callable[[], str | None] | None = None, + auto_args: bool = True, + arg_metadata: dict[str, str | dict[str, Any]] | None = None, + simple_help_signature: bool = False, + ignore_in_history: bool = False, + console: Console | None = None, + ) -> CommandRunner: + """Build a `Command` and wrap it in a `CommandRunner`. + + This factory is a convenience constructor for standalone usage. It mirrors + the high-level command-building API by creating a configured `Command` + through `Command.build()` and then returning a `CommandRunner` bound to it. + + Args: + key (str): Primary key used to invoke the command. + description (str): Short description of the command. + action (BaseAction | Callable[..., Any]): Underlying execution logic for + the command. + runner_hooks (HookManager | None): Optional executor-level hooks for the + runner. + args (tuple): Static positional arguments applied to the command. + kwargs (dict[str, Any] | None): Static keyword arguments applied to the + command. + hidden (bool): Whether the command should be hidden from menu displays. + aliases (list[str] | None): Optional alternate invocation names. + help_text (str): Help text shown in command help output. + help_epilog (str): Additional help text shown after the main help body. + style (str): Rich style used for rendering the command. + confirm (bool): Whether confirmation is required before execution. + confirm_message (str): Confirmation prompt text. + preview_before_confirm (bool): Whether to preview before confirmation. + spinner (bool): Whether to enable spinner integration. + spinner_message (str): Spinner message text. + spinner_type (str): Spinner animation type. + spinner_style (str): Spinner style. + spinner_speed (float): Spinner speed multiplier. + options (OptionsManager | None): Shared options manager for the command + and runner. + command_hooks (HookManager | None): Optional hook manager for the built + command itself. + before_hooks (list[Callable] | None): Command hooks registered for the + `BEFORE` lifecycle stage. + success_hooks (list[Callable] | None): Command hooks registered for the + `ON_SUCCESS` lifecycle stage. + error_hooks (list[Callable] | None): Command hooks registered for the + `ON_ERROR` lifecycle stage. + after_hooks (list[Callable] | None): Command hooks registered for the + `AFTER` lifecycle stage. + teardown_hooks (list[Callable] | None): Command hooks registered for the + `ON_TEARDOWN` lifecycle stage. + tags (list[str] | None): Optional tags used for grouping and filtering. + logging_hooks (bool): Whether to enable debug hook logging. + retry (bool): Whether retry behavior is enabled. + retry_all (bool): Whether retry behavior should be applied recursively. + retry_policy (RetryPolicy | None): Retry configuration for the command. + arg_parser (CommandArgumentParser | None): Optional explicit argument + parser instance. + arguments (list[dict[str, Any]] | None): Declarative argument + definitions. + argument_config (Callable[[CommandArgumentParser], None] | None): + Callback used to configure the argument parser. + execution_options (list[ExecutionOption | str] | None): Execution-level + options to enable for the command. + custom_parser (ArgParserProtocol | None): Optional custom parser + implementation. + custom_help (Callable[[], str | None] | None): Optional custom help + renderer. + auto_args (bool): Whether to infer arguments automatically from the + action signature. + arg_metadata (dict[str, str | dict[str, Any]] | None): Optional + metadata used during argument inference. + simple_help_signature (bool): Whether to use a simplified help + signature. + ignore_in_history (bool): Whether to exclude the command from execution + history tracking. + console (Console | None): Optional Rich console for output. + + Returns: + CommandRunner: A runner wrapping the newly built command. + + Raises: + NotAFalyxError: If `runner_hooks` is provided but is not a + `HookManager` instance. + + Notes: + - This method is intended as a standalone convenience factory. + - Command construction is delegated to `Command.build()` so command + configuration remains centralized. + """ + options = options or OptionsManager() + command = Command.build( + key=key, + description=description, + action=action, + args=args, + kwargs=kwargs, + hidden=hidden, + aliases=aliases, + help_text=help_text, + help_epilog=help_epilog, + style=style, + confirm=confirm, + confirm_message=confirm_message, + preview_before_confirm=preview_before_confirm, + spinner=spinner, + spinner_message=spinner_message, + spinner_type=spinner_type, + spinner_style=spinner_style, + spinner_speed=spinner_speed, + tags=tags, + logging_hooks=logging_hooks, + retry=retry, + retry_all=retry_all, + retry_policy=retry_policy, + options_manager=options, + hooks=command_hooks, + before_hooks=before_hooks, + success_hooks=success_hooks, + error_hooks=error_hooks, + after_hooks=after_hooks, + teardown_hooks=teardown_hooks, + arg_parser=arg_parser, + execution_options=execution_options, + arguments=arguments, + argument_config=argument_config, + custom_parser=custom_parser, + custom_help=custom_help, + auto_args=auto_args, + arg_metadata=arg_metadata, + simple_help_signature=simple_help_signature, + ignore_in_history=ignore_in_history, + ) + + if runner_hooks and not isinstance(runner_hooks, HookManager): + raise NotAFalyxError("runner_hooks must be an instance of HookManager.") + + return cls( + command=command, + options=options, + hooks=runner_hooks, + console=console, + ) diff --git a/falyx/completer.py b/falyx/completer.py index 52f86e3..8236b6b 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -1,6 +1,5 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -""" -Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI +"""Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI menus using Prompt Toolkit. This completer supports: @@ -33,8 +32,7 @@ if TYPE_CHECKING: class FalyxCompleter(Completer): - """ - Prompt Toolkit completer for Falyx CLI command input. + """Prompt Toolkit completer for Falyx CLI command input. This completer provides real-time, context-aware suggestions for: - Command keys and aliases (resolved via Falyx._name_map) @@ -57,9 +55,58 @@ class FalyxCompleter(Completer): def __init__(self, falyx: "Falyx"): self.falyx = falyx + @property + def _command_names(self) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + + def add(name: str): + normalized = name.upper() + if normalized not in seen: + seen.add(normalized) + names.append(name) + + for command in self.falyx.commands.values(): + add(command.key) + for alias in command.aliases: + add(alias) + + for command in self.falyx.builtins.values(): + add(command.key) + for alias in command.aliases: + add(alias) + + if self.falyx.history_command: + add(self.falyx.history_command.key) + for alias in self.falyx.history_command.aliases: + add(alias) + + add(self.falyx.exit_command.key) + for alias in self.falyx.exit_command.aliases: + add(alias) + + return names + + def _resolve_command_for_completion(self, token: str): + normalized = token.upper().strip() + name_map = self.falyx._name_map + + if normalized in name_map: + return name_map[normalized] + + matches = [] + seen = set() + for key, command in name_map.items(): + if key.startswith(normalized) and id(command) not in seen: + matches.append(command) + seen.add(id(command)) + + if len(matches) == 1: + return matches[0] + return None + def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: - """ - Compute completions for the current user input. + """Compute completions for the current user input. Analyzes the input buffer, determines whether the user is typing: • A command key/alias @@ -82,6 +129,13 @@ class FalyxCompleter(Completer): except ValueError: return + if tokens and not cursor_at_end_of_token and tokens[0].startswith("?"): + stub = tokens[0][1:] + suggestions = [c.text for c in self._suggest_commands(stub)] + prefixed = [f"?{s}" for s in suggestions] + yield from self._yield_lcp_completions(prefixed, tokens[0]) + return + if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): # Suggest command keys and aliases stub = tokens[0] if tokens else "" @@ -91,7 +145,7 @@ class FalyxCompleter(Completer): # Identify command command_key = tokens[0].upper() - command = self.falyx._name_map.get(command_key) + command = self._resolve_command_for_completion(command_key) if not command or not command.arg_parser: return @@ -108,8 +162,7 @@ class FalyxCompleter(Completer): return def _suggest_commands(self, prefix: str) -> Iterable[Completion]: - """ - Suggest top-level command keys and aliases based on the given prefix. + """Suggest top-level command keys and aliases based on the given prefix. Filters all known commands (and `exit`, `help`, `history` built-ins) to only those starting with the given prefix. @@ -120,26 +173,13 @@ class FalyxCompleter(Completer): Yields: Completion: Matching keys or aliases from all registered commands. """ - keys = [self.falyx.exit_command.key] - keys.extend(self.falyx.exit_command.aliases) - if self.falyx.history_command: - keys.append(self.falyx.history_command.key) - keys.extend(self.falyx.history_command.aliases) - if self.falyx.help_command: - keys.append(self.falyx.help_command.key) - keys.extend(self.falyx.help_command.aliases) - for cmd in self.falyx.commands.values(): - keys.append(cmd.key) - keys.extend(cmd.aliases) - for key in keys: - if key.upper().startswith(prefix): - yield Completion(key.upper(), start_position=-len(prefix)) - elif key.lower().startswith(prefix): - yield Completion(key.lower(), start_position=-len(prefix)) + for name in self._command_names: + if name.upper().startswith(prefix.upper()): + text = name.lower() if prefix.islower() else name + yield Completion(text, start_position=-len(prefix), display=text) def _ensure_quote(self, text: str) -> str: - """ - Ensure that a suggestion is shell-safe by quoting if needed. + """Ensure that a suggestion is shell-safe by quoting if needed. Adds quotes around completions containing whitespace so they can be inserted into the CLI without breaking tokenization. @@ -155,8 +195,7 @@ class FalyxCompleter(Completer): return text def _yield_lcp_completions(self, suggestions, stub): - """ - Yield completions for the current stub using longest-common-prefix logic. + """Yield completions for the current stub using longest-common-prefix logic. Behavior: - If only one match → yield it fully. diff --git a/falyx/execution_option.py b/falyx/execution_option.py new file mode 100644 index 0000000..c11832c --- /dev/null +++ b/falyx/execution_option.py @@ -0,0 +1,21 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +from __future__ import annotations + +from enum import Enum + + +class ExecutionOption(Enum): + SUMMARY = "summary" + RETRY = "retry" + CONFIRM = "confirm" + + @classmethod + def _missing_(cls, value: object) -> ExecutionOption: + if not isinstance(value, str): + raise ValueError(f"Invalid {cls.__name__}: {value!r}") + normalized = value.strip().lower() + for member in cls: + if member.value == normalized: + return member + valid = ", ".join(member.value for member in cls) + raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") diff --git a/falyx/falyx.py b/falyx/falyx.py index 176b969..e285c05 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -1,22 +1,30 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -""" -Main class for constructing and running Falyx CLI menus. +"""Falyx CLI framework core module. -Falyx provides a structured, customizable interactive menu system -for running commands, actions, and workflows. It supports: +This module defines the `Falyx` class, the primary orchestration layer for +building and running structured CLI applications. It integrates command +registration, interactive menu handling, CLI parsing, execution lifecycle +management, and option scoping into a unified runtime. -- Hook lifecycle management (before/on_success/on_error/after/on_teardown) -- Dynamic command addition and alias resolution -- Rich-based menu display with multi-column layouts -- Interactive input validation and auto-completion -- History tracking and help menu generation -- Confirmation prompts and spinners -- Run key for automated script execution -- CLI argument parsing with argparse integration -- Retry policy configuration for actions +Core responsibilities: +- Manage command registration, alias resolution, and dispatch +- Coordinate interactive menu and non-interactive CLI execution modes +- Integrate with `FalyxParser` for root-level argument parsing and routing +- Apply execution-scoped overrides via `OptionsManager` +- Drive lifecycle hooks (`before`, `on_success`, `on_error`, `after`, `on_teardown`) +- Provide Rich-based rendering and Prompt Toolkit interaction +- Maintain execution history via `ExecutionRegistry` -Falyx enables building flexible, robust, and user-friendly -terminal applications with minimal boilerplate. +Execution Flow: +1. CLI arguments are parsed via `FalyxParser` +2. A `ParseResult` determines the execution mode (menu, command, help, etc.) +3. Execution options are applied through scoped namespaces +4. Commands are resolved and executed via `Command` and `Action` abstractions +5. Lifecycle hooks and context tracking are applied throughout execution + +This module serves as the entrypoint for most Falyx-based applications and +coordinates all major subsystems including parsing, execution, rendering, +and state management. """ from __future__ import annotations @@ -24,7 +32,6 @@ import asyncio import logging import shlex import sys -from argparse import ArgumentParser, Namespace, _SubParsersAction from difflib import get_close_matches from functools import cached_property from pathlib import Path @@ -42,17 +49,19 @@ from prompt_toolkit.validation import ValidationError from rich import box from rich.console import Console from rich.markdown import Markdown +from rich.markup import escape from rich.padding import Padding from rich.panel import Panel from rich.table import Table +from rich.text import Text from falyx.action.action import Action from falyx.action.base_action import BaseAction from falyx.bottom_bar import BottomBar from falyx.command import Command +from falyx.command_executor import CommandExecutor from falyx.completer import FalyxCompleter from falyx.console import console -from falyx.context import ExecutionContext from falyx.debug import log_after, log_before, log_error, log_success from falyx.exceptions import ( CommandAlreadyExistsError, @@ -61,13 +70,13 @@ from falyx.exceptions import ( InvalidActionError, NotAFalyxError, ) +from falyx.execution_option import ExecutionOption from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import Hook, HookManager, HookType -from falyx.hooks import spinner_before_hook, spinner_teardown_hook from falyx.logger import logger from falyx.mode import FalyxMode from falyx.options_manager import OptionsManager -from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers +from falyx.parser import CommandArgumentParser, FalyxParser, ParseResult from falyx.prompt_utils import rich_text_to_prompt_text from falyx.protocols import ArgParserProtocol from falyx.retry import RetryPolicy @@ -79,53 +88,77 @@ from falyx.version import __version__ class Falyx: - """ - Main menu controller for Falyx CLI applications. + """Primary controller for Falyx CLI applications. - Falyx orchestrates the full lifecycle of an interactive menu system, - handling user input, command execution, error recovery, and structured - CLI workflows. + `Falyx` coordinates command registration, input parsing, execution dispatch, + and lifecycle management across both interactive (menu) and non-interactive + (CLI) modes. - Key Features: - - Interactive menu with Rich rendering and Prompt Toolkit input handling - - Dynamic command management with alias and abbreviation matching - - Full lifecycle hooks (before, success, error, after, teardown) at both menu and - command levels - - Built-in retry support, spinner visuals, and confirmation prompts - - Submenu nesting and action chaining - - History tracking, help generation, and run key execution modes - - Seamless CLI argument parsing and integration via argparse - - Declarative option management with OptionsManager - - Command level argument parsing and validation - - Extensible with user-defined hooks, bottom bars, and custom layouts + It acts as the central integration point between: + - Command definitions (`Command`) + - Execution units (`Action`, `ChainedAction`, `ActionGroup`) + - CLI parsing (`FalyxParser`, `CommandArgumentParser`) + - Runtime configuration (`OptionsManager`) + - Lifecycle hooks (`HookManager`) + - UI layers (Rich + Prompt Toolkit) + + Key Responsibilities: + - Maintain a registry of commands, aliases, and builtins + - Resolve user input to commands via exact match, prefix match, or fuzzy match + - Dispatch execution with full lifecycle hook support + - Apply execution-scoped option overrides (e.g. confirm, retries) + - Support both CLI-driven execution and interactive menu loops + - Provide structured help, preview, and history functionality + + Execution Modes: + - MENU: Interactive prompt loop using Prompt Toolkit + - COMMAND: Direct CLI command execution + - HELP: Render help output + - ERROR: Render error and exit + + State Management: + - Uses `OptionsManager` with namespaced overrides (e.g. "execution") + - Tracks last executed command and execution context + - Integrates with `ExecutionRegistry` for history and summaries + + Design Notes: + - Commands are first-class and may encapsulate complex workflows + - Execution options are parsed separately from command arguments + - All execution passes through a unified hook lifecycle + - CLI and menu modes share the same execution semantics Args: title (str | Markdown): Title displayed for the menu. - prompt (AnyFormattedText): Prompt displayed when requesting user input. - columns (int): Number of columns to use when rendering menu commands. - bottom_bar (BottomBar | str | Callable | None): Bottom toolbar content or logic. - welcome_message (str | Markdown | dict): Welcome message shown at startup. - exit_message (str | Markdown | dict): Exit message shown on shutdown. + program (str | None): CLI program name used in help output. + usage (str | None): Optional usage string override. + description (str | None): Program description for CLI help. + epilog (str | None): Additional help text. + version (str): Program version string. + program_style (str): Rich style for program name in help. + usage_style (str): Rich style for usage string in help. + description_style (str): Rich style for description in help. + epilog_style (str): Rich style for epilog in help. + version_style (str): Rich style for version in help. + prompt (str | StyleAndTextTuples): Input prompt. + columns (int): Number of columns in menu display. + bottom_bar (BottomBar | str | Callable | None): Bottom bar renderer. + welcome_message (str | Markdown | dict): Message shown on startup. + exit_message (str | Markdown | dict): Message shown on exit. key_bindings (KeyBindings | None): Custom Prompt Toolkit key bindings. - include_history_command (bool): Whether to add a built-in history viewer command. - include_help_command (bool): Whether to add a built-in help viewer command. - never_prompt (bool): Seed default for `OptionsManager["never_prompt"]` - force_confirm (bool): Seed default for `OptionsManager["force_confirm"]` - cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. - options (OptionsManager | None): Declarative option mappings for global state. - custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table - generator. + include_history_command (bool): Whether to include history command. + never_prompt (bool): Default prompt suppression setting. + force_confirm (bool): Default confirmation behavior. + options (OptionsManager | None): Initial options manager. + render_menu (Callable | None): Custom menu renderer. + custom_table (Callable | Table | None): Custom table builder. + hide_menu_table (bool): Whether to hide menu table. + show_placeholder_menu (bool): Show placeholder suggestions. + prompt_history_base_dir (Path): Directory for prompt history. + enable_prompt_history (bool): Enable persistent history. + enable_help_tips (bool): Show random tips in help output. - Methods: - run(): Main entry point for CLI argument-based workflows. Suggested for - most use cases. - menu(): Run the interactive menu loop. - run_key(command_key, return_context): Run a command directly without the menu. - add_command(): Add a single command to the menu. - add_commands(): Add multiple commands at once. - register_all_hooks(): Register hooks across all commands and submenus. - debug_hooks(): Log hook registration for debugging. - build_default_table(): Construct the standard Rich table layout. + Raises: + FalyxError: If invalid configuration or command registration occurs. """ def __init__( @@ -137,6 +170,10 @@ class Falyx: description: str | None = "Falyx CLI - Run structured async command workflows.", epilog: str | None = None, version: str = __version__, + program_style: str = OneColors.BLUE_b, + usage_style: str = "white", + description_style: str = OneColors.BLUE, + epilog_style: str = "white", version_style: str = OneColors.BLUE_b, prompt: str | StyleAndTextTuples = "> ", columns: int = 3, @@ -145,10 +182,8 @@ class Falyx: exit_message: str | Markdown | dict[str, Any] = "", key_bindings: KeyBindings | None = None, include_history_command: bool = True, - include_help_command: bool = True, never_prompt: bool = False, force_confirm: bool = False, - cli_args: Namespace | None = None, options: OptionsManager | None = None, render_menu: Callable[[Falyx], None] | None = None, custom_table: Callable[[Falyx], Table] | Table | None = None, @@ -156,6 +191,7 @@ class Falyx: show_placeholder_menu: bool = False, prompt_history_base_dir: Path = Path.home(), enable_prompt_history: bool = False, + enable_help_tips: bool = True, ) -> None: """Initializes the Falyx object.""" self.title: str | Markdown = title @@ -164,10 +200,15 @@ class Falyx: self.description: str | None = description self.epilog: str | None = epilog self.version: str = version + self.program_style: str = program_style + self.usage_style: str = usage_style + self.description_style: str = description_style + self.epilog_style: str = epilog_style self.version_style: str = version_style self.prompt: str | StyleAndTextTuples = rich_text_to_prompt_text(prompt) self.columns: int = columns self.commands: dict[str, Command] = CaseInsensitiveDict() + self.builtins: dict[str, Command] = CaseInsensitiveDict() self.console: Console = console self.welcome_message: str | Markdown | dict[str, Any] = welcome_message self.exit_message: str | Markdown | dict[str, Any] = exit_message @@ -177,21 +218,18 @@ class Falyx: self.bottom_bar: BottomBar | str | Callable[[], None] | None = bottom_bar self._never_prompt: bool = never_prompt self._force_confirm: bool = force_confirm - self.cli_args: Namespace | None = cli_args self.render_menu: Callable[[Falyx], None] | None = render_menu self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table self._hide_menu_table: bool = hide_menu_table self.show_placeholder_menu: bool = show_placeholder_menu - self.validate_options(cli_args, options) + self._validate_options(options) self._prompt_session: PromptSession | None = None self.options.set("mode", FalyxMode.MENU) self.exit_command: Command = self._get_exit_command() self.history_command: Command | None = ( self._get_history_command() if include_history_command else None ) - self.help_command: Command | None = ( - self._get_help_command() if include_help_command else None - ) + self.help_command: Command = self._get_help_command() if enable_prompt_history: program = (self.program or "falyx").split(".")[0].replace(" ", "_") self.history_path: Path = ( @@ -200,76 +238,88 @@ class Falyx: self.history: FileHistory | None = FileHistory(self.history_path) else: self.history = None + self.enable_help_tips = enable_help_tips + self._register_default_builtins() + self._register_options() + self._executor = CommandExecutor( + options=self.options, + hooks=self.hooks, + console=self.console, + ) @property def is_cli_mode(self) -> bool: """Checks if the current mode is a CLI mode.""" - return self.options.get("mode") in { - FalyxMode.RUN, - FalyxMode.PREVIEW, - FalyxMode.RUN_ALL, - FalyxMode.HELP, - } + return self.options.get("mode") != FalyxMode.MENU - def validate_options( + def _validate_options( self, - cli_args: Namespace | None, options: OptionsManager | None = None, ) -> None: """Checks if the options are set correctly.""" self.options: OptionsManager = options or OptionsManager() - if not cli_args and not options: - return None - - if options and not cli_args: - raise FalyxError("Options are set, but CLI arguments are not.") - - assert isinstance( - cli_args, Namespace - ), "CLI arguments must be a Namespace object." - if not isinstance(self.options, OptionsManager): raise FalyxError("Options must be an instance of OptionsManager.") - if not isinstance(self.cli_args, Namespace): - raise FalyxError("CLI arguments must be a Namespace object.") + def _register_options(self) -> None: + """Registers default options if they are not already set.""" + self.options.from_mapping(values={}, namespace_name="execution") + + if not self.options.get("never_prompt"): + self.options.set("never_prompt", self._never_prompt) + + if not self.options.get("force_confirm"): + self.options.set("force_confirm", self._force_confirm) + + if not self.options.get("hide_menu_table"): + self.options.set("hide_menu_table", self._hide_menu_table) + + if not self.options.get("program"): + self.options.set("program", self.program) + + if not self.options.get("program_style"): + self.options.set("program_style", self.program_style) @property def _name_map(self) -> dict[str, Command]: - """ - Builds a mapping of all valid input names (keys, aliases, normalized names) to - Command objects. If a collision occurs, logs a warning and keeps the first + """Builds a mapping of all valid input names to Command objects. + + If a collision occurs, logs a warning and keeps the first registered command. """ mapping: dict[str, Command] = {} - def register(name: str, cmd: Command): + def register(name: str, command: Command): norm = name.upper().strip() if norm in mapping: existing = mapping[norm] - if existing is not cmd: - logger.warning( - "[alias conflict] '%s' already assigned to '%s'. " - "Skipping for '%s'.", - name, - existing.description, - cmd.description, + if existing is not command: + raise CommandAlreadyExistsError( + f"Identifier '{norm}' is already registered.\n" + f"Existing command: {mapping[norm].key}\n" + f"New command: {command.key}" ) else: - mapping[norm] = cmd + mapping[norm] = command - for special in [self.exit_command, self.history_command, self.help_command]: + for special in [self.exit_command, self.history_command]: if special: register(special.key, special) for alias in special.aliases: register(alias, special) register(special.description, special) - for cmd in self.commands.values(): - register(cmd.key, cmd) - for alias in cmd.aliases: - register(alias, cmd) - register(cmd.description, cmd) + for command in self.builtins.values(): + register(command.key, command) + for alias in command.aliases: + register(alias, command) + register(command.description, command) + + for command in self.commands.values(): + register(command.key, command) + for alias in command.aliases: + register(alias, command) + register(command.description, command) return mapping def get_title(self) -> str: @@ -369,7 +419,7 @@ class Falyx: def get_tip(self) -> str: """Returns a random tip for the user about using Falyx.""" - program = f"{self.program} run " if self.is_cli_mode else "" + program = f"{self.program} " if self.is_cli_mode else "" tips = [ f"Use '{program}?[COMMAND]' to preview a command.", "Every command supports aliases—try abbreviating the name!", @@ -379,17 +429,17 @@ class Falyx: f"'{self.program} --never-prompt' to disable all prompts for the [bold italic]entire menu session[/].", f"Use '{self.program} --verbose' to enable debug logging for a menu session.", f"'{self.program} --debug-hooks' will trace every before/after hook in action.", - f"Run commands directly from the CLI: '{self.program} run [COMMAND] [OPTIONS]'.", + f"Run commands directly from the CLI: '{self.program} [COMMAND] [OPTIONS]'.", "All [COMMAND] keys and aliases are case-insensitive.", ] if self.is_cli_mode: tips.extend( [ f"Use '{self.program} help' to list all commands at any time.", - f"Use '{self.program} --never-prompt run [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", - f"Use '{self.program} run --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", - f"Use '{self.program} run --summary [COMMAND] [OPTIONS]' to print a post-run summary.", - f"Use '{self.program} --verbose run [COMMAND] [OPTIONS]' to enable debug logging for any run.", + f"Use '{self.program} --never-prompt [COMMAND] [OPTIONS]' to disable all prompts for [bold italic]just this command[/].", + f"Use '{self.program} --skip-confirm [COMMAND] [OPTIONS]' to skip confirmations.", + f"Use '{self.program} --summary [COMMAND] [OPTIONS]' to print a post-run summary.", + f"Use '{self.program} --verbose [COMMAND] [OPTIONS]' to enable debug logging for any run.", "Use '--skip-confirm' for automation scripts where no prompts are wanted.", ] ) @@ -404,57 +454,68 @@ class Falyx: ) return choice(tips) - async def _render_help( - self, tag: str = "", key: str | None = None, tldr: bool = False - ) -> None: - """Renders the help menu with command details, usage examples, and tips.""" - if tldr and not key: - if self.help_command and self.help_command.arg_parser: - self.help_command.arg_parser.render_tldr() - self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") - return None - if key: - _, command, args, kwargs = await self.get_command(key, from_help=True) - if command and tldr and command.arg_parser: - command.arg_parser.render_tldr() - self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") - return None - elif command and tldr and not command.arg_parser: - self.console.print( - f"[bold]No TLDR examples available for '{command.description}'.[/bold]" - ) - elif command and command.arg_parser: - command.arg_parser.render_help() - self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") - return None - elif command and not command.arg_parser: - self.console.print( - f"[bold]No detailed help available for '{command.description}'.[/bold]" - ) - else: - self.console.print(f"[bold]No command found for '{key}'.[/bold]") - if tag: - tag_lower = tag.lower() - self.console.print(f"[bold]{tag_lower}:[/bold]") - commands = [ - command - for command in self.commands.values() - if any(tag_lower == tag.lower() for tag in command.tags) - ] - if not commands: - self.console.print(f"'{tag}'... Nothing to show here") - return None - for command in commands: - usage, description, _ = command.help_signature - self.console.print( - Padding( - Panel(usage, expand=False, title=description, title_align="left"), - (0, 2), - ) - ) - self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") + async def _render_command_tldr(self, key: str | None = None) -> None: + """Renders the TLDR examples for a command, if available.""" + if not key and self.help_command: + key = "H" + if not key: + self.console.print("[bold]No command specified for TLDR examples.[/bold]") return None + _, command, args, kwargs, execution_args = await self.get_command( + key, from_help=True + ) + if command and command.arg_parser: + command.arg_parser.render_tldr() + if self.enable_help_tips: + self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") + elif command and not command.arg_parser: + self.console.print( + f"[bold]No TLDR examples available for '{command.description}'.[/bold]" + ) + else: + self.console.print(f"[bold]No command found for '{key}'.[/bold]") + async def _render_command_help(self, key: str) -> None: + """Renders the detailed help for a command, if available.""" + _, command, args, kwargs, execution_args = await self.get_command( + key, from_help=True + ) + if command and command.arg_parser: + command.arg_parser.render_help() + if self.enable_help_tips: + self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}") + elif command and not command.arg_parser: + self.console.print( + f"[bold]No detailed help available for '{command.description}'.[/bold]" + ) + else: + self.console.print(f"[bold]No command found for '{key}'.[/bold]") + + async def _render_tag_help(self, tag: str) -> None: + """Renders a list of commands matching a specific tag.""" + tag_lower = tag.lower() + self.console.print(f"[bold]{tag_lower}:[/bold]") + commands = [ + command + for command in self.commands.values() + if any(tag_lower == tag.lower() for tag in command.tags) + ] + if not commands: + self.console.print(f"'{tag}'... Nothing to show here") + return None + for command in commands: + usage, description, _ = command.help_signature + self.console.print( + Padding( + Panel(usage, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) + if self.enable_help_tips: + self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") + + async def _render_menu_help(self) -> None: + """Renders the main menu help menu with all commands and menu builtins.""" self.console.print("[bold]help:[/bold]") for command in self.commands.values(): usage, description, tag = command.help_signature @@ -478,23 +539,94 @@ class Falyx: (0, 2), ) ) - if not self.is_cli_mode: - if self.history_command: - usage, description, _ = self.history_command.help_signature - self.console.print( - Padding( - Panel(usage, expand=False, title=description, title_align="left"), - (0, 2), - ) - ) - usage, description, _ = self.exit_command.help_signature + if self.history_command: + usage, description, _ = self.history_command.help_signature self.console.print( Padding( Panel(usage, expand=False, title=description, title_align="left"), (0, 2), ) ) - self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") + usage, description, _ = self.exit_command.help_signature + self.console.print( + Padding( + Panel(usage, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) + if self.enable_help_tips: + self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") + + async def _render_cli_help(self) -> None: + """Renders the CLI help menu with all available commands and options.""" + usage = self.usage or "[GLOBAL OPTIONS] [COMMAND] [OPTIONS]" + self.console.print( + f"[bold]usage:[/bold] [{self.program_style}]{self.program}[/{self.program_style}] [{self.usage_style}]{usage}[/{self.usage_style}]" + ) + if self.description: + self.console.print( + f"\n[{self.description_style}]{self.description}[/{self.description_style}]" + ) + self.console.print("\n[bold]global options:[/bold]") + self.console.print(f" {'-h, --help':<22}{'Show this help message and exit.'}") + self.console.print( + f" {'-v, --verbose':<22}{'Enable verbose debug logging for the session.'}" + ) + self.console.print( + f" {'--debug-hooks':<22}{'Log detailed information about hook execution for debugging.'}" + ) + self.console.print( + f" {'--never-prompt':<22}{'Disable all confirmation prompts for the entire session.'}" + ) + self.console.print("\n[bold]builtin commands:[/bold]") + for command in self.builtins.values(): + if command == self.help_command: + builtin_alias = Text("help", style=command.style) + else: + builtin_alias = Text(command.key, style=command.style) + + line = Text(" ") + line.append(builtin_alias) + line.pad_right(24 - len(line.plain)) + line.append(command.help_text) + + self.console.print(line) + if self.commands: + self.console.print("\n[bold]commands:[/bold]") + for command in self.commands.values(): + line = Text(" ") + line.append(command.key, style=command.style) + for alias in command.aliases: + line.append(" | ", style="dim") + line.append(alias, style=command.style) + line.pad_right(24 - len(line.plain)) + line.append(command.help_text or command.description) + self.console.print(line) + if self.epilog: + self.console.print(f"\n{self.epilog}", style=self.epilog_style) + if self.enable_help_tips: + self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}") + + async def _render_help( + self, + tag: str = "", + key: str | None = None, + tldr: bool = False, + ) -> None: + """Renders the help menu with command details, usage examples, and tips.""" + if tldr: + await self._render_command_tldr(key) + return None + if key: + await self._render_command_help(key) + return None + if tag: + await self._render_tag_help(tag) + return None + if self.options.get("mode") == FalyxMode.MENU: + await self._render_menu_help() + return None + await self._render_cli_help() def _get_help_command(self) -> Command: """Returns the help command for the menu.""" @@ -543,6 +675,86 @@ class Falyx: program=self.program, ) + async def _preview(self, command_key: str) -> None: + """Previews the execution of a command without actually running it.""" + _, command, args, kwargs, execution_args = await self.get_command( + command_key, from_help=True + ) + if not command: + self.console.print( + f"[{OneColors.DARK_RED}]❌ Command '{command_key}' not found." + ) + return None + self.console.print(f"Preview of command '{command.key}': {command.description}") + await command.preview() + + def _get_preview_command(self) -> Command: + """Returns the preview command for Falyx.""" + preview_parser = CommandArgumentParser( + command_key="preview", + command_description="Preview", + command_style=OneColors.GREEN, + program=self.program, + options_manager=self.options, + help_text="Preview the execution of a command without running it.", + ) + preview_parser.add_argument( + "command_key", + help="The key or alias of the command to preview.", + ) + preview_parser.add_tldr_examples( + [ + ("[COMMAND]", "Preview the execution of a specific command."), + ] + ) + preview_command = Command( + key="preview", + description="Preview", + action=Action("Preview", self._preview), + style=OneColors.GREEN, + simple_help_signature=True, + options_manager=self.options, + program=self.program, + help_text="Preview the execution of a command without running it.", + arg_parser=preview_parser, + ) + return preview_command + + async def _render_version(self) -> None: + """Renders the program version.""" + self.console.print(f"[{self.version_style}]{self.program} v{self.version}[/]") + + def _get_version_command(self) -> Command: + """Returns the version command for Falyx.""" + version_command = Command( + key="version", + description="Version", + action=Action("Version", self._render_version), + style=self.version_style, + simple_help_signature=True, + ignore_in_history=True, + options_manager=self.options, + program=self.program, + help_text=f"Show the {self.program} version.", + ) + if version_command.arg_parser: + version_command.arg_parser.add_tldr_examples( + [("", f"Show the {self.program} version.")] + ) + return version_command + + def _add_builtin(self, command: Command) -> None: + """Adds a built-in command to Falyx.""" + self._validate_command_aliases(command.key, command.aliases) + self.builtins[command.key.upper()] = command + _ = self._name_map + + def _register_default_builtins(self) -> None: + """Registers the default built-in commands for Falyx.""" + self._add_builtin(self.help_command) + self._add_builtin(self._get_preview_command()) + self._add_builtin(self._get_version_command()) + def _get_completer(self) -> FalyxCompleter: """Completer to provide auto-completion for the menu commands.""" return FalyxCompleter(self) @@ -554,13 +766,14 @@ class Falyx: if self.history_command: keys.add(self.history_command.key.upper()) keys.update({alias.upper() for alias in self.history_command.aliases}) - if self.help_command: - keys.add(self.help_command.key.upper()) - keys.update({alias.upper() for alias in self.help_command.aliases}) - for cmd in self.commands.values(): - keys.add(cmd.key.upper()) - keys.update({alias.upper() for alias in cmd.aliases}) + for command in self.builtins.values(): + keys.add(command.key.upper()) + keys.update({alias.upper() for alias in command.aliases}) + + for command in self.commands.values(): + keys.add(command.key.upper()) + keys.update({alias.upper() for alias in command.aliases}) commands_str = ", ".join(sorted(keys)) @@ -577,16 +790,6 @@ class Falyx: del self.prompt_session self._prompt_session = None - def add_help_command(self): - """Adds a help command to the menu if it doesn't already exist.""" - if not self.help_command: - self.help_command = self._get_help_command() - - def add_history_command(self): - """Adds a history command to the menu if it doesn't already exist.""" - if not self.history_command: - self.history_command = self._get_history_command() - @property def bottom_bar(self) -> BottomBar | str | Callable[[], Any] | None: """Returns the bottom bar for the menu.""" @@ -661,31 +864,43 @@ class Falyx: self.register_all_hooks(HookType.ON_SUCCESS, log_success) self.register_all_hooks(HookType.ON_ERROR, log_error) self.register_all_hooks(HookType.AFTER, log_after) + self.register_all_hooks(HookType.ON_TEARDOWN, log_after) - def debug_hooks(self) -> None: - """Logs the names of all hooks registered for the menu and its commands.""" - logger.debug("Menu-level hooks:\n%s", str(self.hooks)) - - for key, command in self.commands.items(): - logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks)) - - def _validate_command_key(self, key: str) -> None: - """Validates the command key to ensure it is unique.""" + def _validate_command_aliases(self, key: str, aliases: list[str] | None) -> None: + """Validates the command aliases to ensure they are unique.""" key = key.upper() - collisions = [] + aliases = [alias.upper() for alias in (aliases or [])] - if key in self.commands: - collisions.append("command") - if key == self.exit_command.key.upper(): - collisions.append("back command") - if self.history_command and key == self.history_command.key.upper(): - collisions.append("history command") - if self.help_command and key == self.help_command.key.upper(): - collisions.append("help command") + if len(set(aliases)) != len(aliases): + raise CommandAlreadyExistsError("Duplicate aliases provided.") + + if key in aliases: + raise CommandAlreadyExistsError("Command key cannot also be an alias.") + + existing_names = set() + + def collect_names(command: Command): + existing_names.add(command.key.upper()) + existing_names.update(alias.upper() for alias in command.aliases) + + for command in self.commands.values(): + collect_names(command) + + for command in self.builtins.values(): + collect_names(command) + + collect_names(self.exit_command) + + if self.history_command: + collect_names(self.history_command) + + new_names = {key, *aliases} + + collisions = new_names.intersection(existing_names) if collisions: raise CommandAlreadyExistsError( - f"Command key '{key}' conflicts with existing {', '.join(collisions)}." + f"Command identifiers {sorted(collisions)} already exist." ) def update_exit_command( @@ -700,7 +915,7 @@ class Falyx: help_text: str = "Exit the program.", ) -> None: """Updates the back command of the menu.""" - self._validate_command_key(key) + self._validate_command_aliases(key, aliases) action = action or Action(description, action=_noop) if not callable(action): raise InvalidActionError("Action must be a callable.") @@ -726,7 +941,7 @@ class Falyx: """Adds a submenu to the menu.""" if not isinstance(submenu, Falyx): raise NotAFalyxError("submenu must be an instance of Falyx.") - self._validate_command_key(key) + self._validate_command_aliases(key, []) self.add_command( key, description, submenu.menu, style=style, simple_help_signature=True ) @@ -754,8 +969,9 @@ class Falyx: """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._validate_command_aliases(command.key, command.aliases) self.commands[command.key] = command + _ = self._name_map def add_command( self, @@ -792,6 +1008,7 @@ class Falyx: arg_parser: CommandArgumentParser | None = None, arguments: list[dict[str, Any]] | None = None, argument_config: Callable[[CommandArgumentParser], None] | None = None, + execution_options: list[ExecutionOption | str] | None = None, custom_parser: ArgParserProtocol | None = None, custom_help: Callable[[], str | None] | None = None, auto_args: bool = True, @@ -800,23 +1017,16 @@ class Falyx: ignore_in_history: bool = False, ) -> Command: """Adds an command to the menu, preventing duplicates.""" - self._validate_command_key(key) + self._validate_command_aliases(key, aliases) - if arg_parser: - if not isinstance(arg_parser, CommandArgumentParser): - raise NotAFalyxError( - "arg_parser must be an instance of CommandArgumentParser." - ) - arg_parser = arg_parser - - command = Command( + command = Command.build( key=key, description=description, action=action, args=args, - kwargs=kwargs if kwargs else {}, + kwargs=kwargs, hidden=hidden, - aliases=aliases if aliases else [], + aliases=aliases, help_text=help_text, help_epilog=help_epilog, style=style, @@ -828,45 +1038,33 @@ class Falyx: spinner_type=spinner_type, spinner_style=spinner_style, spinner_speed=spinner_speed, - tags=tags if tags else [], + hooks=hooks, + before_hooks=before_hooks, + success_hooks=success_hooks, + error_hooks=error_hooks, + after_hooks=after_hooks, + teardown_hooks=teardown_hooks, + tags=tags, logging_hooks=logging_hooks, retry=retry, retry_all=retry_all, - retry_policy=retry_policy or RetryPolicy(), - options_manager=self.options, + retry_policy=retry_policy, arg_parser=arg_parser, - arguments=arguments or [], + arguments=arguments, argument_config=argument_config, custom_parser=custom_parser, custom_help=custom_help, + execution_options=execution_options, auto_args=auto_args, - arg_metadata=arg_metadata or {}, + arg_metadata=arg_metadata, simple_help_signature=simple_help_signature, + options_manager=self.options, ignore_in_history=ignore_in_history, program=self.program, ) - if hooks: - if not isinstance(hooks, HookManager): - raise NotAFalyxError("hooks must be an instance of HookManager.") - command.hooks = hooks - - for hook in before_hooks or []: - command.hooks.register(HookType.BEFORE, hook) - for hook in success_hooks or []: - command.hooks.register(HookType.ON_SUCCESS, hook) - for hook in error_hooks or []: - command.hooks.register(HookType.ON_ERROR, hook) - for hook in after_hooks or []: - command.hooks.register(HookType.AFTER, hook) - for hook in teardown_hooks or []: - command.hooks.register(HookType.ON_TEARDOWN, hook) - - if spinner: - command.hooks.register(HookType.BEFORE, spinner_before_hook) - command.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook) - self.commands[key] = command + _ = self._name_map return command def get_bottom_row(self) -> list[str]: @@ -889,16 +1087,17 @@ class Falyx: return bottom_row def build_default_table(self) -> Table: - """ - Build the standard table layout. Developers can subclass or call this - in custom tables. + """Build the standard table layout. + + Developers can subclass or call this in custom tables. """ table = Table(title=self.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type] visible_commands = [item for item in self.commands.items() if not item[1].hidden] for chunk in chunks(visible_commands, self.columns): row = [] for key, command in chunk: - row.append(f"[{key}] [{command.style}]{command.description}") + escaped_key = escape(f"[{key}]") + row.append(f"{escaped_key} [{command.style}]{command.description}") table.add_row(*row) bottom_row = self.get_bottom_row() for row in chunks(bottom_row, self.columns): @@ -906,9 +1105,7 @@ class Falyx: return table def build_placeholder_menu(self) -> StyleAndTextTuples: - """ - Builds a menu placeholder for show_placeholder_menu. - """ + """Builds a menu placeholder for show_placeholder_menu.""" visible_commands = [item for item in self.commands.items() if not item[1].hidden] if not visible_commands: return [("", "")] @@ -937,23 +1134,25 @@ class Falyx: return self.build_default_table() def parse_preview_command(self, input_str: str) -> tuple[bool, str]: + """Checks if the input is a preview command and returns the command key if so.""" if input_str.startswith("?"): return True, input_str[1:].strip() return False, input_str.strip() async def get_command( self, raw_choices: str, from_validate=False, from_help=False - ) -> tuple[bool, Command | None, tuple, dict[str, Any]]: - """ - Returns the selected command based on user input. + ) -> tuple[bool, Command | None, tuple, dict[str, Any], dict[str, Any]]: + """Returns the selected command based on user input. + Supports keys, aliases, and abbreviations. """ args = () kwargs: dict[str, Any] = {} + execution_args: dict[str, Any] = {} try: choice, *input_args = shlex.split(raw_choices) except ValueError: - return False, None, args, kwargs + return False, None, args, kwargs, execution_args is_preview, choice = self.parse_preview_command(choice) if is_preview and not choice and self.help_command: is_preview = False @@ -964,7 +1163,7 @@ class Falyx: self.console.print( f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode." ) - return is_preview, None, args, kwargs + return is_preview, None, args, kwargs, execution_args choice = choice.upper() name_map = self._name_map @@ -973,7 +1172,7 @@ class Falyx: run_command = name_map[choice] else: prefix_matches = [ - cmd for key, cmd in name_map.items() if key.startswith(choice) + command for key, command in name_map.items() if key.startswith(choice) ] if len(prefix_matches) == 1: run_command = prefix_matches[0] @@ -982,11 +1181,13 @@ class Falyx: if not from_validate: logger.info("Command '%s' selected.", run_command.key) if is_preview: - return True, run_command, args, kwargs + return True, run_command, args, kwargs, execution_args elif self.is_cli_mode or from_help: - return False, run_command, args, kwargs + return False, run_command, args, kwargs, execution_args try: - args, kwargs = await run_command.parse_args(input_args, from_validate) + args, kwargs, execution_args = await run_command.resolve_args( + input_args, from_validate + ) except (CommandArgumentError, Exception) as error: if not from_validate: run_command.render_help() @@ -997,10 +1198,10 @@ class Falyx: raise ValidationError( message=str(error), cursor_position=len(raw_choices) ) - return is_preview, None, args, kwargs + return is_preview, None, args, kwargs, execution_args except HelpSignal: - return True, None, args, kwargs - return is_preview, run_command, args, kwargs + return True, None, args, kwargs, execution_args + return is_preview, run_command, args, kwargs, execution_args fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7) if fuzzy_matches: @@ -1010,8 +1211,8 @@ class Falyx: "Did you mean:" ) for match in fuzzy_matches: - cmd = name_map[match] - self.console.print(f" • [bold]{match}[/] → {cmd.description}") + command = name_map[match] + self.console.print(f" • [bold]{match}[/] → {command.description}") else: raise ValidationError( message=f"Unknown command '{choice}'. Did you mean: " @@ -1028,156 +1229,119 @@ class Falyx: message=f"Unknown command '{choice}'.", cursor_position=len(raw_choices), ) - return is_preview, None, args, kwargs + return is_preview, None, args, kwargs, execution_args - def _create_context( - self, selected_command: Command, args: tuple, kwargs: dict[str, Any] - ) -> ExecutionContext: - """Creates an ExecutionContext object for the selected command.""" - return ExecutionContext( - name=selected_command.description, - args=args, - kwargs=kwargs, - action=selected_command, - ) + async def execute_command( + self, + raw_arguments: str, + *, + raise_on_error: bool = False, + wrap_errors: bool = True, + summary_last_result: bool = False, + ) -> Any | None: + """Execute a command from a raw CLI-style input string. + + This method resolves the requested command from `raw_arguments`, parses any + command-specific arguments, handles preview and exit behavior, and delegates + actual execution to the shared `CommandExecutor`. + + Behavior: + - Resolves the command and its parsed `args`, `kwargs`, and + `execution_args` via `get_command()`. + - Returns `None` when help output is triggered, argument parsing fails, + the command cannot be found, or preview mode is requested. + - Updates `last_run_command` when a valid command is resolved. + - Raises `QuitSignal` if the resolved command is the configured exit + command. + - For normal execution, forwards the resolved command and execution + options to `_executor.execute()`. + + Args: + raw_arguments (str): Raw command input string, including the command name + and any CLI-style arguments (for example, ``"deploy --region us-east"``). + raise_on_error (bool): Whether execution errors raised by the underlying + executor should be re-raised to the caller. + wrap_errors (bool): Whether execution errors should be wrapped in a + `FalyxError` by the underlying executor before being raised. + summary_last_result (bool): Whether summary output should include the last + result when execution summary reporting is requested. + + Returns: + Any | None: The command result returned by the underlying executor, or + `None` if execution does not occur because help was shown, preview mode + was used, parsing failed, or the command was not found. + + Raises: + QuitSignal: If the resolved command is the configured exit command. + + Notes: + - `HelpSignal` and `CommandArgumentError` are handled internally and do + not propagate to the caller. + - This method is the primary programmatic entrypoint for executing a + command from a raw input string outside the interactive menu loop. + """ + try: + is_preview, command, args, kwargs, execution_args = await self.get_command( + raw_arguments + ) + except HelpSignal: + return None + except CommandArgumentError as error: + logger.error( + "Argument parsing error for input '%s': %s", raw_arguments, error + ) + self.console.print(f"[{OneColors.DARK_RED}]❌ ['{raw_arguments}'] {error}[/]") + return None + + if not command: + logger.error("Command not found for input '%s'", raw_arguments) + self.console.print( + f"[{OneColors.DARK_RED}]❌ ['{raw_arguments}'] Command not found.[/]" + ) + return None + + self.last_run_command = command + + if is_preview: + logger.info("Preview command '%s' selected.", command.key) + await command.preview() + return None + + if command == self.exit_command: + logger.info("Back selected: exiting %s", self.get_title()) + raise QuitSignal() - async def _handle_action_error( - self, selected_command: Command, error: Exception - ) -> None: - """Handles errors that occur during the action of the selected command.""" logger.debug( - "[%s] '%s' failed with error: %s", - selected_command.key, - selected_command.description, - error, - exc_info=True, + "Executing command '%s' with args=%s, kwargs=%s, execution_args=%s", + command.description, + args, + kwargs, + execution_args, ) - self.console.print( - f"[{OneColors.DARK_RED}]An error occurred while executing " - f"{selected_command.description}:[/] {error}" + return await self._executor.execute( + command=command, + args=args, + kwargs=kwargs or {}, + execution_args=execution_args or {}, + raise_on_error=raise_on_error, + wrap_errors=wrap_errors, + summary_last_result=summary_last_result, ) - async def process_command(self) -> bool: + async def process_command(self) -> None: """Processes the action of the selected command.""" app = get_app() await asyncio.sleep(0.1) app.invalidate() with patch_stdout(raw=True): - choice = await self.prompt_session.prompt_async() - is_preview, selected_command, args, kwargs = await self.get_command(choice) - if not selected_command: - logger.info("Invalid command '%s'.", choice) - return True - - if is_preview: - logger.info("Preview command '%s' selected.", selected_command.key) - await selected_command.preview() - return True - - self.last_run_command = selected_command - - if selected_command == self.exit_command: - logger.info("Back selected: exiting %s", self.get_title()) - return False - - context = self._create_context(selected_command, args, kwargs) - context.start_timer() - try: - await self.hooks.trigger(HookType.BEFORE, context) - result = await selected_command(*args, **kwargs) - context.result = result - await self.hooks.trigger(HookType.ON_SUCCESS, context) - except Exception as error: - context.exception = error - await self.hooks.trigger(HookType.ON_ERROR, context) - await self._handle_action_error(selected_command, error) - finally: - context.stop_timer() - await self.hooks.trigger(HookType.AFTER, context) - await self.hooks.trigger(HookType.ON_TEARDOWN, context) - return True - - async def run_key( - self, - command_key: str, - return_context: bool = False, - args: tuple = (), - kwargs: dict[str, Any] | None = None, - ) -> Any: - """Run a command by key without displaying the menu (non-interactive mode).""" - self.debug_hooks() - is_preview, selected_command, _, __ = await self.get_command(command_key) - kwargs = kwargs or {} - - self.last_run_command = selected_command - - if not selected_command: - return None - - if is_preview: - logger.info("Preview command '%s' selected.", selected_command.key) - await selected_command.preview() - return None - - logger.info( - "[run_key] Executing: %s — %s", - selected_command.key, - selected_command.description, + raw_arguments = await self.prompt_session.prompt_async() + await self.execute_command( + raw_arguments, + raise_on_error=False, + wrap_errors=False, + summary_last_result=True, ) - context = self._create_context(selected_command, args, kwargs) - context.start_timer() - try: - await self.hooks.trigger(HookType.BEFORE, context) - result = await selected_command(*args, **kwargs) - context.result = result - - await self.hooks.trigger(HookType.ON_SUCCESS, context) - logger.info("[run_key] '%s' complete.", selected_command.description) - except (KeyboardInterrupt, EOFError) as error: - logger.warning( - "[run_key] Interrupted by user: %s", selected_command.description - ) - raise FalyxError( - f"[run_key] ⚠️ '{selected_command.description}' interrupted by user." - ) from error - except Exception as error: - context.exception = error - await self.hooks.trigger(HookType.ON_ERROR, context) - await self._handle_action_error(selected_command, error) - raise FalyxError( - f"[run_key] ❌ '{selected_command.description}' failed." - ) from error - finally: - context.stop_timer() - await self.hooks.trigger(HookType.AFTER, context) - await self.hooks.trigger(HookType.ON_TEARDOWN, context) - - return context if return_context else context.result - - def _set_retry_policy(self, selected_command: Command) -> None: - """Sets the retry policy for the command based on CLI arguments.""" - assert isinstance(self.cli_args, Namespace), "CLI arguments must be provided." - if ( - self.cli_args.retries - or self.cli_args.retry_delay - or self.cli_args.retry_backoff - ): - selected_command.retry_policy.enabled = True - if self.cli_args.retries: - selected_command.retry_policy.max_retries = self.cli_args.retries - if self.cli_args.retry_delay: - selected_command.retry_policy.delay = self.cli_args.retry_delay - if self.cli_args.retry_backoff: - selected_command.retry_policy.backoff = self.cli_args.retry_backoff - if isinstance(selected_command.action, Action): - selected_command.action.set_retry_policy(selected_command.retry_policy) - else: - logger.warning( - "[Command:%s] Retry requested, but action is not an Action instance.", - selected_command.key, - ) - def print_message(self, message: str | Markdown | dict[str, Any]) -> None: """Prints a message to the console.""" if isinstance(message, (str, Markdown)): @@ -1196,7 +1360,6 @@ class Falyx: """Runs the menu and handles user input.""" logger.info("Starting menu: %s", self.get_title()) self.options.set("mode", FalyxMode.MENU) - self.debug_hooks() if self.welcome_message: self.print_message(self.welcome_message) try: @@ -1207,9 +1370,7 @@ class Falyx: else: self.console.print(self.table, justify="center") try: - should_continue = await self.process_command() - if not should_continue: - break + await self.process_command() except (EOFError, KeyboardInterrupt): logger.info("EOF or KeyboardInterrupt. Exiting menu.") break @@ -1220,143 +1381,121 @@ class Falyx: logger.info("[BackSignal]. <- Returning to the menu.") except CancelSignal: logger.info("[CancelSignal]. <- Returning to the menu.") + except asyncio.CancelledError: + logger.info("[asyncio.CancelledError]. <- Returning to the menu.") finally: logger.info("Exiting menu: %s", self.get_title()) if self.exit_message: self.print_message(self.exit_message) + def _apply_parse_result(self, result: ParseResult) -> None: + """Applies the parsed CLI arguments to the menu options.""" + self.options.set("mode", result.mode) + + if result.verbose: + logging.getLogger("falyx").setLevel(logging.DEBUG) + self.options.set("verbose", True) + else: + self.options.set("verbose", False) + + if result.debug_hooks: + self.options.set("debug_hooks", True) + self.register_all_with_debug_hooks() + logger.debug("Enabling global debug hooks for all commands") + else: + self.options.set("debug_hooks", False) + + if result.never_prompt: + self.options.set("never_prompt", True) + async def run( self, - falyx_parsers: FalyxParsers | None = None, - root_parser: ArgumentParser | None = None, - subparsers: _SubParsersAction | None = None, callback: Callable[..., Any] | None = None, always_start_menu: bool = False, ) -> None: - """ - Entrypoint for executing a Falyx CLI application via structured subcommands. + """Execute the Falyx application using CLI-driven dispatch. - This method parses CLI arguments, configures the runtime environment, and dispatches - execution to the appropriate command mode: + This method is the primary entrypoint for Falyx applications. It parses + CLI arguments, configures runtime state, and dispatches execution based + on the resolved mode. - - help - Show help output, optionally filtered by tag. - - version - Print the program version and exit. - - preview - Display a preview of the specified command without executing it. - - run - Execute a single command with parsed arguments and lifecycle hooks. - - run-all - Run all commands matching a tag concurrently (with default args). - - (default) - Launch the interactive Falyx menu loop. + Execution Pipeline: + 1. Parse CLI input via `FalyxParser` into a `ParseResult` + 2. Optionally invoke a user-provided callback with the parse result + 3. Apply root-level options (e.g. verbose, debug hooks, prompt behavior) + 4. Dispatch based on `ParseResult.mode`: + - HELP: Render help output and exit + - COMMAND: Execute a resolved command + - MENU: Launch interactive menu loop + - ERROR: Render error and exit - It also applies CLI flags such as `--verbose`, `--debug-hooks`, and summary reporting, - and supports an optional callback for post-parse setup. + Command Execution: + - Arguments are parsed via `CommandArgumentParser` + - Execution options (e.g. retries, confirmation flags) are separated + - Execution-scoped overrides are applied using `OptionsManager` + - Commands are executed via `CommandExecutor.execute()` with full lifecycle hooks + + Callback Behavior: + - If provided, `callback` is executed after parsing but before dispatch + - Supports both sync and async callables + - Useful for logging setup, environment initialization, etc. Args: - falyx_parsers (FalyxParsers | None): - Preconfigured argument parser set. If not provided, a default parser - is created using the registered commands and passed-in `root_parser` - or `subparsers`. - root_parser (ArgumentParser | None): - Optional root parser to merge into the CLI (used if `falyx_parsers` - is not supplied). - subparsers (_SubParsersAction | None): - Optional subparser group to extend (used if `falyx_parsers` is not supplied). callback (Callable[..., Any] | None): - An optional function or coroutine to run after parsing CLI arguments, - typically for initializing logging, environment setup, or other - pre-execution configuration. + Optional function invoked after CLI parsing with the `ParseResult`. + always_start_menu (bool): + If True, launches the interactive menu after command execution + instead of exiting. Raises: FalyxError: - If invalid parser objects are supplied, or CLI arguments conflict - with the expected run mode. + If callback is invalid or command execution fails. SystemExit: - Exits with an appropriate exit code based on the selected command - or signal (e.g. Ctrl+C triggers exit code 130). + Terminates the process with an appropriate exit code based on mode. Notes: - - `run-all` executes all tagged commands **in parallel** and does not - supply arguments to individual commands; use `ChainedAction` or explicit - CLI calls for ordered or parameterized workflows. - - Most CLI commands exit the process via `sys.exit()` after completion. - - For interactive sessions, this method falls back to `menu()`. + - Most CLI execution paths terminate via `sys.exit()` + - Interactive mode continues via `menu()` + - Execution options are applied in a scoped "execution" namespace + - Preview mode (`?command`) bypasses execution and renders a preview Example: ``` + >>> import asyncio >>> flx = Falyx() - >>> await flx.run() # Parses CLI args and dispatches appropriately + >>> asyncio.run(flx.run()) ``` """ - if self.cli_args: - raise FalyxError( - "Run is incompatible with CLI arguments. Use 'run_key' instead." - ) - if falyx_parsers: - if not isinstance(falyx_parsers, FalyxParsers): - raise FalyxError("falyx_parsers must be an instance of FalyxParsers.") - else: - falyx_parsers = get_arg_parsers( - self.program, - self.usage, - self.description, - self.epilog, - commands=self.commands, - root_parser=root_parser, - subparsers=subparsers, - ) - self.cli_args = falyx_parsers.parse_args() - self.options.from_namespace(self.cli_args, "cli_args") + falyx_parser = FalyxParser(self) + parse_result = falyx_parser.parse(sys.argv[1:]) if callback: if not callable(callback): raise FalyxError("Callback must be a callable function.") async_callback = ensure_async(callback) - await async_callback(self.cli_args) + await async_callback(parse_result) - if not self.options.get("never_prompt"): - self.options.set("never_prompt", self._never_prompt) + self._apply_parse_result(parse_result) - if not self.options.get("force_confirm"): - self.options.set("force_confirm", self._force_confirm) + if parse_result.mode == FalyxMode.ERROR: + await self._render_help() + self.console.print(f"[{OneColors.DARK_RED}]Error: {parse_result.error}[/]") + sys.exit(1) - if not self.options.get("hide_menu_table"): - self.options.set("hide_menu_table", self._hide_menu_table) - - if self.cli_args.verbose: - logging.getLogger("falyx").setLevel(logging.DEBUG) - - if self.cli_args.debug_hooks: - logger.debug("Enabling global debug hooks for all commands") - self.register_all_with_debug_hooks() - - if self.cli_args.command == "help": - self.options.set("mode", FalyxMode.HELP) - await self._render_help( - tag=self.cli_args.tag, key=self.cli_args.key, tldr=self.cli_args.tldr - ) + if parse_result.mode == FalyxMode.HELP: + await self._render_help() sys.exit(0) - if self.cli_args.command == "version" or self.cli_args.version: - self.console.print(f"[{self.version_style}]{self.program} v{self.version}[/]") - sys.exit(0) - - if self.cli_args.command == "preview": - self.options.set("mode", FalyxMode.PREVIEW) - _, command, args, kwargs = await self.get_command(self.cli_args.name) - if not command: + if parse_result.mode == FalyxMode.COMMAND: + if not parse_result.command: self.console.print( - f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found." + f"[{OneColors.DARK_RED}]Error: No command specified for execution mode.[/]" ) sys.exit(1) - self.console.print( - f"Preview of command '{command.key}': {command.description}" - ) - await command.preview() - sys.exit(0) + command = parse_result.command - if self.cli_args.command == "run": - self.options.set("mode", FalyxMode.RUN) - is_preview, command, _, __ = await self.get_command(self.cli_args.name) - if is_preview: + if parse_result.is_preview: if command is None: sys.exit(1) logger.info("Preview command '%s' selected.", command.key) @@ -1364,17 +1503,32 @@ class Falyx: sys.exit(0) if not command: sys.exit(1) - self._set_retry_policy(command) try: - args, kwargs = await command.parse_args(self.cli_args.command_args) + args, kwargs, execution_args = await command.resolve_args( + parse_result.command_argv + ) except HelpSignal: sys.exit(0) except CommandArgumentError as error: - self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}") command.render_help() - sys.exit(1) + self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}") + sys.exit(2) try: - await self.run_key(self.cli_args.name, args=args, kwargs=kwargs) + logger.debug( + "Executing command '%s' with args=%s, kwargs=%s, execution_args=%s", + command.description, + args, + kwargs, + execution_args, + ) + await self._executor.execute( + command=command, + args=args, + kwargs=kwargs, + execution_args=execution_args, + raise_on_error=False, + wrap_errors=True, + ) except FalyxError as error: self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") sys.exit(1) @@ -1387,63 +1541,10 @@ class Falyx: except CancelSignal: logger.info("[CancelSignal]. <- Exiting run.") sys.exit(1) - - if self.cli_args.summary: - er.summary() - if not always_start_menu: - sys.exit(0) - - if self.cli_args.command == "run-all": - self.options.set("mode", FalyxMode.RUN_ALL) - matching = [ - cmd - for cmd in self.commands.values() - if self.cli_args.tag.lower() in (tag.lower() for tag in cmd.tags) - ] - if not matching: - self.console.print( - f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: " - f"'{self.cli_args.tag}'" - ) + except asyncio.CancelledError: + logger.info("[asyncio.CancelledError]. <- Exiting run.") sys.exit(1) - self.console.print( - f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] " - f"{self.cli_args.tag}" - ) - - tasks = [] - try: - for cmd in matching: - self._set_retry_policy(cmd) - tasks.append(self.run_key(cmd.key)) - except Exception as error: - self.console.print( - f"[{OneColors.DARK_RED}]❌ Unexpected error: {error}[/]" - ) - sys.exit(1) - - had_errors = False - results = await asyncio.gather(*tasks, return_exceptions=True) - for result in results: - if isinstance(result, QuitSignal): - logger.info("[QuitSignal]. <- Exiting run.") - sys.exit(130) - elif isinstance(result, CancelSignal): - logger.info("[CancelSignal]. <- Execution cancelled.") - sys.exit(1) - elif isinstance(result, BackSignal): - logger.info("[BackSignal]. <- Back signal received.") - sys.exit(1) - elif isinstance(result, FalyxError): - self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {result}[/]") - had_errors = True - - if had_errors: - sys.exit(1) - - if self.cli_args.summary: - er.summary() if not always_start_menu: sys.exit(0) diff --git a/falyx/hooks.py b/falyx/hooks.py index c26767d..dd191d4 100644 --- a/falyx/hooks.py +++ b/falyx/hooks.py @@ -38,34 +38,34 @@ from falyx.themes import OneColors async def spinner_before_hook(context: ExecutionContext): """Adds a spinner before the action starts.""" - cmd = context.action - if cmd.options_manager is None: + command = context.action + if command.options_manager is None: return sm = context.action.options_manager.spinners - if hasattr(cmd, "name"): - cmd_name = cmd.name + if hasattr(command, "name"): + command_name = command.name else: - cmd_name = cmd.key + command_name = command.key await sm.add( - cmd_name, - cmd.spinner_message, - cmd.spinner_type, - cmd.spinner_style, - cmd.spinner_speed, + command_name, + command.spinner_message, + command.spinner_type, + command.spinner_style, + command.spinner_speed, ) async def spinner_teardown_hook(context: ExecutionContext): """Removes the spinner after the action finishes (success or failure).""" - cmd = context.action - if cmd.options_manager is None: + command = context.action + if command.options_manager is None: return - if hasattr(cmd, "name"): - cmd_name = cmd.name + if hasattr(command, "name"): + command_name = command.name else: - cmd_name = cmd.key + command_name = command.key sm = context.action.options_manager.spinners - await sm.remove(cmd_name) + await sm.remove(command_name) class ResultReporter: diff --git a/falyx/menu.py b/falyx/menu.py index 6684866..134796c 100644 --- a/falyx/menu.py +++ b/falyx/menu.py @@ -101,12 +101,16 @@ class MenuOptionMap(CaseInsensitiveDict): self, options: dict[str, MenuOption] | None = None, allow_reserved: bool = False, + disable_reserved: bool = False, ): super().__init__() self.allow_reserved = allow_reserved if options: self.update(options) - self._inject_reserved_defaults() + if not disable_reserved: + self._inject_reserved_defaults() + else: + self.allow_reserved = True def _inject_reserved_defaults(self): from falyx.action import SignalAction diff --git a/falyx/mode.py b/falyx/mode.py index 977e755..703703f 100644 --- a/falyx/mode.py +++ b/falyx/mode.py @@ -1,13 +1,11 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -""" -Defines `FalyxMode`, an enum representing the different modes of operation for Falyx. -""" +"""Defines `FalyxMode`, an enum representing the different modes of operation for Falyx.""" from enum import Enum class FalyxMode(Enum): MENU = "menu" - RUN = "run" + COMMAND = "command" PREVIEW = "preview" - RUN_ALL = "run-all" HELP = "help" + ERROR = "error" diff --git a/falyx/options_manager.py b/falyx/options_manager.py index bd7e327..b2a5bd4 100644 --- a/falyx/options_manager.py +++ b/falyx/options_manager.py @@ -1,12 +1,11 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -""" -Manages global or scoped CLI options across namespaces for Falyx commands. +"""Manages global or scoped CLI options across namespaces for Falyx commands. The `OptionsManager` provides a centralized interface for retrieving, setting, toggling, and introspecting options defined in `argparse.Namespace` objects. It is used internally by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc. -Each option is stored under a namespace key (e.g., "cli_args", "user_config") to +Each option is stored under a namespace key (e.g., "default", "user_config") to support multiple sources of configuration. Key Features: @@ -17,7 +16,7 @@ Key Features: Typical Usage: options = OptionsManager() - options.from_namespace(args, namespace_name="cli_args") + options.from_namespace(args, namespace_name="default") if options.get("verbose"): ... options.toggle("force_confirm") @@ -29,51 +28,71 @@ Used by: - Bottom bar toggles - Dynamic flag injection into commands and actions """ - -from argparse import Namespace from collections import defaultdict -from typing import Any, Callable +from contextlib import contextmanager +from typing import Any, Callable, Iterator, Mapping from falyx.logger import logger from falyx.spinner_manager import SpinnerManager class OptionsManager: - """ - Manages CLI option state across multiple argparse namespaces. + """Manages CLI option state across multiple argparse namespaces. Allows dynamic retrieval, setting, toggling, and introspection of command-line - options. Supports named namespaces (e.g., "cli_args") and is used throughout + options. Supports named namespaces (e.g., "default") and is used throughout Falyx for runtime configuration and bottom bar toggle integration. """ - def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: - self.options: defaultdict = defaultdict(Namespace) + def __init__( + self, + namespaces: list[tuple[str, dict[str, Any]]] | None = None, + ) -> None: + self.options: defaultdict = defaultdict(dict) self.spinners = SpinnerManager() if namespaces: for namespace_name, namespace in namespaces: - self.from_namespace(namespace, namespace_name) + self.from_mapping(namespace, namespace_name) - def from_namespace( - self, namespace: Namespace, namespace_name: str = "cli_args" + def from_mapping( + self, + values: Mapping[str, Any], + namespace_name: str = "default", ) -> None: - self.options[namespace_name] = namespace + """Load options from a mapping, optionally with a prefix for namespacing.""" + self.options[namespace_name].update(dict(values)) def get( - self, option_name: str, default: Any = None, namespace_name: str = "cli_args" + self, + option_name: str, + default: Any = None, + namespace_name: str = "default", ) -> Any: """Get the value of an option.""" - return getattr(self.options[namespace_name], option_name, default) + return self.options[namespace_name].get(option_name, default) - def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None: + def set( + self, + option_name: str, + value: Any, + namespace_name: str = "default", + ) -> None: """Set the value of an option.""" - setattr(self.options[namespace_name], option_name, value) + self.options[namespace_name][option_name] = value - def has_option(self, option_name: str, namespace_name: str = "cli_args") -> bool: + def has_option( + self, + option_name: str, + namespace_name: str = "default", + ) -> bool: """Check if an option exists in the namespace.""" - return hasattr(self.options[namespace_name], option_name) + return option_name in self.options[namespace_name] - def toggle(self, option_name: str, namespace_name: str = "cli_args") -> None: + def toggle( + self, + option_name: str, + namespace_name: str = "default", + ) -> None: """Toggle a boolean option.""" current = self.get(option_name, namespace_name=namespace_name) if not isinstance(current, bool): @@ -86,7 +105,9 @@ class OptionsManager: ) def get_value_getter( - self, option_name: str, namespace_name: str = "cli_args" + self, + option_name: str, + namespace_name: str = "default", ) -> Callable[[], Any]: """Get the value of an option as a getter function.""" @@ -96,7 +117,9 @@ class OptionsManager: return _getter def get_toggle_function( - self, option_name: str, namespace_name: str = "cli_args" + self, + option_name: str, + namespace_name: str = "default", ) -> Callable[[], None]: """Get the toggle function for a boolean option.""" @@ -105,8 +128,22 @@ class OptionsManager: return _toggle - def get_namespace_dict(self, namespace_name: str) -> Namespace: + def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]: """Return all options in a namespace as a dictionary.""" if namespace_name not in self.options: raise ValueError(f"Namespace '{namespace_name}' not found.") - return vars(self.options[namespace_name]) + return dict(self.options[namespace_name]) + + @contextmanager + def override_namespace( + self, + overrides: Mapping[str, Any], + namespace_name: str = "execution", + ) -> Iterator[None]: + """Temporarily override options in a namespace within a context.""" + original = self.get_namespace_dict(namespace_name) + try: + self.from_mapping(values=overrides, namespace_name=namespace_name) + yield + finally: + self.options[namespace_name] = original diff --git a/falyx/parser/__init__.py b/falyx/parser/__init__.py index 10254ba..1f0957a 100644 --- a/falyx/parser/__init__.py +++ b/falyx/parser/__init__.py @@ -8,14 +8,13 @@ Licensed under the MIT License. See LICENSE file for details. from .argument import Argument from .argument_action import ArgumentAction from .command_argument_parser import CommandArgumentParser -from .parsers import FalyxParsers, get_arg_parsers, get_root_parser, get_subparsers +from .falyx_parser import FalyxParser +from .parse_result import ParseResult __all__ = [ "Argument", "ArgumentAction", "CommandArgumentParser", - "get_arg_parsers", - "get_root_parser", - "get_subparsers", - "FalyxParsers", + "FalyxParser", + "ParseResult", ] diff --git a/falyx/parser/argument.py b/falyx/parser/argument.py index 4ecc9f7..8a99aed 100644 --- a/falyx/parser/argument.py +++ b/falyx/parser/argument.py @@ -60,6 +60,8 @@ class Argument: An action object that resolves the argument, if applicable. lazy_resolver (bool): True if the resolver should be called lazily, False otherwise suggestions (list[str] | None): Optional completions for interactive shells + group (str | None): Optional name of the argument group this belongs to. + mutex_group (str | None): Optional name of the mutually exclusive group this belongs to. """ flags: tuple[str, ...] @@ -75,6 +77,8 @@ class Argument: resolver: BaseAction | None = None lazy_resolver: bool = False suggestions: list[str] | None = None + group: str | None = None + mutex_group: str | None = None def get_positional_text(self) -> str: """Get the positional text for the argument.""" @@ -132,6 +136,8 @@ class Argument: and self.positional == other.positional and self.default == other.default and self.help == other.help + and self.group == other.group + and self.mutex_group == other.mutex_group ) def __hash__(self) -> int: @@ -147,5 +153,7 @@ class Argument: self.positional, self.default, self.help, + self.group, + self.mutex_group, ) ) diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 85f8654..4ee4bc2 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -1,56 +1,55 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -""" -This module implements `CommandArgumentParser`, a flexible, rich-aware alternative to -argparse tailored specifically for Falyx CLI workflows. It provides structured parsing, -type coercion, flag support, and usage/help rendering for CLI-defined commands. +"""CommandArgumentParser implementation for the Falyx CLI framework. -Unlike argparse, this parser is lightweight, introspectable, and designed to integrate -deeply with Falyx's Action system, including support for lazy execution and resolver -binding via `BaseAction`. +This module provides a structured, extensible argument parsing system designed +specifically for Falyx commands. It replaces traditional argparse usage with a +parser that is deeply integrated with Falyx's execution model, including support +for Actions, execution options, and interactive completion. + +The parser is designed to: +- Define command arguments declaratively via `add_argument` +- Support both positional and keyword-style flags +- Perform type coercion and validation +- Separate execution-level options (e.g. retries, confirmation) from command inputs +- Integrate with Falyx lifecycle and Action-based execution +- Provide rich help rendering and interactive suggestions Key Features: -- Declarative argument registration via `add_argument()` -- Support for positional and keyword flags, type coercion, default values -- Enum- and action-driven argument semantics via `ArgumentAction` -- Lazy evaluation of arguments using Falyx `Action` resolvers -- Optional value completion via suggestions and choices -- Rich-powered help rendering with grouped display -- Optional boolean flags via `--flag` / `--no-flag` -- POSIX-style bundling for single-character flags (`-abc`) -- Partial parsing for completions and validation via `suggest_next()` +- Positional and flagged argument support +- Type coercion via configurable `type` handlers +- Enum-driven behavior via `ArgumentAction` +- Lazy and eager resolution using BaseAction resolvers +- Execution option support (e.g. retries, summary, confirm flags) +- Mutually exclusive and grouped argument definitions +- POSIX-style short flag bundling (e.g. `-abc`) +- Interactive suggestions via `suggest_next` +- Rich-based help and TLDR rendering -Public Interface: -- `add_argument(...)`: Register a new argument with type, flags, and behavior. -- `parse_args(...)`: Parse CLI-style argument list into a `dict[str, Any]`. -- `parse_args_split(...)`: Return `(*args, **kwargs)` for Action invocation. -- `render_help()`: Render a rich-styled help panel. -- `render_tldr()`: Render quick usage examples. -- `suggest_next(...)`: Return suggested flags or values for completion. +Core Parsing APIs: +- `parse_args(...)`: + Parse arguments into a resolved dictionary of values +- `parse_args_split(...)`: + Split parsed results into `(args, kwargs, execution_args)` for execution +- `add_argument(...)`: + Register argument definitions declaratively +- `suggest_next(...)`: + Provide completion suggestions for interactive input -Example Usage: - parser = CommandArgumentParser(command_key="D") - parser.add_argument("--env", choices=["prod", "dev"], required=True) - parser.add_argument("path", type=Path) +Design Principles: +- Minimal surface area compared to argparse +- Strong integration with Falyx execution model +- Predictable and explicit parsing behavior +- Separation of parsing, execution, and runtime configuration - args = await parser.parse_args(["--env", "prod", "./config.yml"]) - - # args == {'env': 'prod', 'path': Path('./config.yml')} - - parser.render_help() # Pretty Rich output - -Design Notes: -This parser intentionally omits argparse-style groups, metavar support, -and complex multi-level conflict handling. Instead, it favors: -- Simplicity -- Completeness -- Falyx-specific integration (hooks, lifecycle, and error surfaces) +This parser is intended for use exclusively within Falyx and is not a +general-purpose argparse replacement. """ from __future__ import annotations from collections import Counter, defaultdict from copy import deepcopy from pathlib import Path -from typing import Any, Iterable, Sequence +from typing import Any, Generator, Iterable, Sequence from rich.console import Console from rich.markup import escape @@ -60,15 +59,49 @@ from rich.panel import Panel from falyx.action.base_action import BaseAction from falyx.console import console from falyx.exceptions import CommandArgumentError +from falyx.execution_option import ExecutionOption from falyx.mode import FalyxMode from falyx.options_manager import OptionsManager from falyx.parser.argument import Argument from falyx.parser.argument_action import ArgumentAction +from falyx.parser.group import ArgumentGroup, MutuallyExclusiveGroup from falyx.parser.parser_types import ArgumentState, TLDRExample, false_none, true_none from falyx.parser.utils import coerce_value from falyx.signals import HelpSignal +class _GroupBuilder: + """Helper for assigning arguments to a named group or mutex group. + + This lightweight wrapper preserves the normal `add_argument()` API while + injecting `group` or `mutex_group` metadata into each registered argument. + + Args: + parser (CommandArgumentParser): Parser that owns the group definitions. + group_name (str | None): Name of the argument group to assign. + mutex_name (str | None): Name of the mutually exclusive group to assign. + """ + + def __init__( + self, + parser: CommandArgumentParser, + *, + group_name: str | None = None, + mutex_name: str | None = None, + ) -> None: + self.parser = parser + self.group_name = group_name + self.mutex_name = mutex_name + + def add_argument(self, *flags, **kwargs) -> None: + self.parser.add_argument( + *flags, + group=self.group_name, + mutex_group=self.mutex_name, + **kwargs, + ) + + class CommandArgumentParser: """ Custom argument parser for Falyx Commands. @@ -90,7 +123,7 @@ class CommandArgumentParser: - Render Help using Rich library. """ - RESERVED_DESTS = frozenset(("help", "tldr")) + RESERVED_DESTS = frozenset({"help", "tldr"}) def __init__( self, @@ -120,15 +153,89 @@ class CommandArgumentParser: self._keyword_list: list[Argument] = [] self._flag_map: dict[str, Argument] = {} self._dest_set: set[str] = set() + self._execution_dests: set[str] = set() self._add_help() self._last_positional_states: dict[str, ArgumentState] = {} self._last_keyword_states: dict[str, ArgumentState] = {} + self._argument_groups: dict[str, ArgumentGroup] = {} + self._mutex_groups: dict[str, MutuallyExclusiveGroup] = {} + self._arg_group_by_dest: dict[str, str] = {} + self._mutex_group_by_dest: dict[str, str] = {} self._tldr_examples: list[TLDRExample] = [] self._is_help_command: bool = _is_help_command if tldr_examples: self.add_tldr_examples(tldr_examples) self.options_manager: OptionsManager = options_manager or OptionsManager() + def set_options_manager(self, options_manager: OptionsManager) -> None: + """Set the options manager for the parser.""" + if not isinstance(options_manager, OptionsManager): + raise ValueError("options_manager must be an instance of OptionsManager") + self.options_manager = options_manager + + def enable_execution_options( + self, + execution_options: frozenset[ExecutionOption], + ) -> None: + """Enable support for execution options like retries, summary, etc.""" + if ExecutionOption.SUMMARY in execution_options: + self.add_argument( + "--summary", + action=ArgumentAction.STORE_TRUE, + help="Print an execution summary after command completes", + ) + self._register_execution_dest("summary") + + if ExecutionOption.RETRY in execution_options: + self.add_argument( + "--retries", + type=int, + help="Number of retries on failure", + default=0, + ) + self._register_execution_dest("retries") + self.add_argument( + "--retry-delay", + type=float, + default=0.0, + help="Initial delay between retries in seconds", + ) + self._register_execution_dest("retry_delay") + self.add_argument( + "--retry-backoff", + type=float, + default=0.0, + help="Backoff multiplier for retries (e.g. 2.0 doubles the delay each retry)", + ) + self._register_execution_dest("retry_backoff") + + if ExecutionOption.CONFIRM in execution_options: + self.add_argument( + "--confirm", + dest="force_confirm", + action=ArgumentAction.STORE_TRUE, + help="Force confirmation prompts", + ) + self._register_execution_dest("force_confirm") + self.add_argument( + "--skip-confirm", + action=ArgumentAction.STORE_TRUE, + help="Skip confirmation prompts", + ) + self._register_execution_dest("skip_confirm") + + def _register_execution_dest(self, dest: str) -> None: + """Register a destination as an execution argument.""" + if dest in self._execution_dests: + raise CommandArgumentError( + f"Destination '{dest}' is already registered as an execution argument" + ) + self._execution_dests.add(dest) + + def _is_execution_dest(self, dest: str) -> bool: + """Check if a destination is registered as an execution argument.""" + return dest in self._execution_dests + def _add_help(self): """Add help argument to the parser.""" help = Argument( @@ -165,6 +272,32 @@ class CommandArgumentParser: ) self._register_argument(tldr) + def add_argument_group( + self, + name: str, + description: str = "", + ) -> _GroupBuilder: + if name in self._argument_groups: + raise CommandArgumentError(f"Argument group '{name}' already exists") + self._argument_groups[name] = ArgumentGroup(name=name, description=description) + return _GroupBuilder(self, group_name=name) + + def add_mutually_exclusive_group( + self, + name: str, + *, + required: bool = False, + description: str = "", + ) -> _GroupBuilder: + if name in self._mutex_groups: + raise CommandArgumentError(f"Mutex group '{name}' already exists") + self._mutex_groups[name] = MutuallyExclusiveGroup( + name=name, + required=required, + description=description, + ) + return _GroupBuilder(self, mutex_name=name) + def _is_positional(self, flags: tuple[str, ...]) -> bool: """Check if the flags are positional.""" positional = False @@ -175,6 +308,34 @@ class CommandArgumentParser: raise CommandArgumentError("Positional arguments cannot have multiple flags") return positional + def _validate_groups( + self, + group: str | None, + mutex_group: str | None, + positional: bool = False, + required: bool = False, + ) -> None: + """Validate that the specified groups exist and are compatible.""" + if group is not None: + if group not in self._argument_groups: + raise CommandArgumentError(f"Argument group '{group}' does not exist") + + if mutex_group is not None: + if mutex_group not in self._mutex_groups: + raise CommandArgumentError( + f"Mutually exclusive group '{mutex_group}' does not exist" + ) + if positional and mutex_group is not None: + raise CommandArgumentError( + "Positional arguments cannot belong to a mutually exclusive group" + ) + + if required and mutex_group is not None: + raise CommandArgumentError( + "Arguments inside a mutually exclusive group should not be individually required; " + "make the group required instead." + ) + def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str: """Convert flags to a destination name.""" if dest: @@ -444,6 +605,8 @@ class CommandArgumentParser: flags: tuple[str, ...], dest: str, help: str, + group: str | None, + mutex_group: str | None, ) -> None: """Register a store_bool_optional action with the parser.""" if len(flags) != 1: @@ -464,6 +627,8 @@ class CommandArgumentParser: type=true_none, default=None, help=help, + group=group, + mutex_group=mutex_group, ) negated_argument = Argument( @@ -473,6 +638,8 @@ class CommandArgumentParser: type=false_none, default=None, help=help, + group=group, + mutex_group=mutex_group, ) self._register_argument(argument) @@ -503,6 +670,14 @@ class CommandArgumentParser: else: self._keyword_list.append(argument) + if argument.group: + self._arg_group_by_dest[argument.dest] = argument.group + self._argument_groups[argument.group].dests.append(argument.dest) + + if argument.mutex_group: + self._mutex_group_by_dest[argument.dest] = argument.mutex_group + self._mutex_groups[argument.mutex_group].dests.append(argument.dest) + def add_argument( self, *flags, @@ -517,6 +692,8 @@ class CommandArgumentParser: resolver: BaseAction | None = None, lazy_resolver: bool = True, suggestions: list[str] | None = None, + group: str | None = None, + mutex_group: str | None = None, ) -> None: """ Define a new argument for the parser. @@ -537,6 +714,8 @@ class CommandArgumentParser: resolver (BaseAction | None): If action="action", the BaseAction to call. lazy_resolver (bool): If True, resolver defers until action is triggered. suggestions (list[str] | None): Optional suggestions for interactive completion. + group (str | None): Optional argument group name for help organization. + mutex_group (str | None): Optional mutually exclusive group name. """ expected_type = type self._validate_flags(flags) @@ -552,6 +731,9 @@ class CommandArgumentParser: raise CommandArgumentError( f"Destination '{dest}' is reserved and cannot be used." ) + + self._validate_groups(group, mutex_group, positional, required) + action = self._validate_action(action, positional) resolver = self._validate_resolver(action, resolver) @@ -587,7 +769,7 @@ class CommandArgumentParser: f"lazy_resolver must be a boolean, got {type(lazy_resolver)}" ) if action == ArgumentAction.STORE_BOOL_OPTIONAL: - self._register_store_bool_optional(flags, dest, help) + self._register_store_bool_optional(flags, dest, help, group, mutex_group) else: argument = Argument( flags=flags, @@ -603,6 +785,8 @@ class CommandArgumentParser: resolver=resolver, lazy_resolver=lazy_resolver, suggestions=suggestions, + group=group, + mutex_group=mutex_group, ) self._register_argument(argument) @@ -641,6 +825,8 @@ class CommandArgumentParser: "positional": arg.positional, "default": arg.default, "help": arg.help, + "group": arg.group, + "mutex_group": arg.mutex_group, } ) return defs @@ -700,6 +886,10 @@ class CommandArgumentParser: ), f"Invalid nargs value: {spec.nargs}" values = [] if isinstance(spec.nargs, int): + if index + spec.nargs > len(args): + raise CommandArgumentError( + f"Expected {spec.nargs} value(s) for '{spec.dest}' but got {len(args) - index}" + ) values = args[index : index + spec.nargs] return values, index + spec.nargs elif spec.nargs == "+": @@ -744,7 +934,6 @@ class CommandArgumentParser: if spec_index not in consumed_positional_indicies ] index = 0 - for spec_index, spec in remaining_positional_args: # estimate how many args the remaining specs might need is_last = spec_index == len(positional_args) - 1 @@ -779,7 +968,6 @@ class CommandArgumentParser: ) values, new_index = self._consume_nargs(slice_args, 0, spec) index += new_index - try: typed = [coerce_value(value, spec.type) for value in values] except Exception as error: @@ -798,6 +986,14 @@ class CommandArgumentParser: assert isinstance( spec.resolver, BaseAction ), "resolver should be an instance of BaseAction" + if spec.nargs == "+" and len(typed) == 0: + raise CommandArgumentError( + f"Argument '{spec.dest}' requires at least one value" + ) + if isinstance(spec.nargs, int) and len(typed) != spec.nargs: + raise CommandArgumentError( + f"Argument '{spec.dest}' requires exactly {spec.nargs} value(s)" + ) if not spec.lazy_resolver or not from_validate: try: result[spec.dest] = await spec.resolver(*typed) @@ -831,7 +1027,6 @@ class CommandArgumentParser: if spec.nargs not in ("*", "+"): consumed_positional_indicies.add(spec_index) - if index < len(args): if len(args[index:]) == 1 and args[index].startswith("-"): token = args[index] @@ -1103,18 +1298,90 @@ class CommandArgumentParser: args[expand_index : expand_index + 1] = expand_token expand_index += len(expand_token) if isinstance(expand_token, list) else 1 + def _is_present(self, spec: Argument, value: Any) -> bool: + """ + Presence means 'user actually selected/provided this', not merely that + a default exists. + """ + if spec.action == ArgumentAction.STORE_TRUE: + return value is True + if spec.action == ArgumentAction.STORE_FALSE: + return value is False + if spec.action == ArgumentAction.STORE_BOOL_OPTIONAL: + return value is not None + if spec.action == ArgumentAction.COUNT: + return bool(value) + if spec.action in (ArgumentAction.APPEND, ArgumentAction.EXTEND): + return bool(value) + return value is not None + + def _validate_mutex_groups(self, result: dict[str, Any]) -> None: + for group in self._mutex_groups.values(): + present: list[str] = [] + + for dest in group.dests: + spec = self.get_argument(dest) + if spec is None: + continue + if self._is_present(spec, result.get(dest)): + present.append(dest) + + if len(present) > 1: + raise CommandArgumentError( + f"Arguments in mutually exclusive group '{group.name}' " + f"cannot be used together: {', '.join(present)}" + ) + + if group.required and not present: + members = [] + for dest in group.dests: + spec = self.get_argument(dest) + if spec: + members.append(spec.flags[0] if spec.flags else dest) + raise CommandArgumentError( + f"One of the following is required for group '{group.name}': " + f"{', '.join(members)}" + ) + async def parse_args( self, args: list[str] | None = None, from_validate: bool = False ) -> dict[str, Any]: - """ - Parse arguments into a dictionary of resolved values. + """Parse CLI arguments into a resolved mapping of values. + + This method parses the provided CLI-style tokens and returns a dictionary + mapping argument destinations to their resolved values. It performs full + validation, type coercion, default handling, and resolver execution. + + Unlike `parse_args_split`, this method returns a unified mapping of all + parsed arguments, including both command arguments and execution options. + + Behavior: + - Parses positional and keyword arguments based on registered definitions + - Applies type coercion via configured `type` handlers + - Resolves values using BaseAction resolvers (if defined) + - Validates required arguments, choices, and mutual exclusion constraints + - Applies default values for missing optional arguments + - Supports validation mode (`from_validate=True`) for interactive contexts Args: - args (list[str]): The CLI-style argument list. - from_validate (bool): If True, enables relaxed resolution for validation mode. + args (list[str]): CLI-style argument tokens to parse. + from_validate (bool): Whether parsing is occurring in validation mode + (e.g. prompt_toolkit validator). When True, may defer certain + resolution steps or suppress eager failures. Returns: - dict[str, Any]: Parsed argument result mapping. + dict[str, Any]: Mapping of argument destination names to resolved values. + + Raises: + CommandArgumentError: If parsing, validation, or coercion fails. + HelpSignal: If help or TLDR output is triggered during parsing. + + Notes: + - This method returns a flat mapping of all arguments. + - Use `parse_args_split` when separating execution options from + command arguments is required for execution. + - This is the primary parsing entrypoint used internally by + `parse_args_split`. """ if args is None: args = [] @@ -1151,6 +1418,27 @@ class CommandArgumentParser: from_validate=from_validate, ) + # Compare length of args with length of required positional arguments to catch missing required positionals + if len(args) < len( + [ + arg + for arg in self._arguments + if (arg.positional and arg.required and not arg.default) + ] + ): + missing_positionals = [ + arg.dest + for arg in self._arguments + if arg.positional + and arg.required + and arg.dest not in consumed_positional_indices + and not arg.default + ] + if missing_positionals: + raise CommandArgumentError( + f"Missing positional argument(s): {', '.join(missing_positionals)}" + ) + # Required validation for spec in self._arguments: if spec.dest == "help" or spec.dest == "tldr": @@ -1203,6 +1491,21 @@ class CommandArgumentParser: f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}" ) + if isinstance(spec.nargs, str) and spec.nargs == "+": + assert isinstance( + result.get(spec.dest), list + ), f"Invalid value for '{spec.dest}': expected a list" + if not result[spec.dest] and not spec.required: + continue + help_text = f" help: {spec.help}" if spec.help else "" + if not result[spec.dest]: + arg_states[spec.dest].reset() + raise CommandArgumentError( + f"Argument '{spec.dest}' requires at least one value{help_text}" + ) + + self._validate_mutex_groups(result) + result.pop("help", None) if not self._is_help_command: result.pop("tldr", None) @@ -1210,18 +1513,33 @@ class CommandArgumentParser: async def parse_args_split( self, args: list[str], from_validate: bool = False - ) -> tuple[tuple[Any, ...], dict[str, Any]]: - """ - Parse arguments and return both positional and keyword mappings. + ) -> tuple[tuple[Any, ...], dict[str, Any], dict[str, Any]]: + """Parse arguments and split them into execution-ready components. - Useful for function-style calling with `*args, **kwargs`. + This method parses the provided CLI-style tokens and separates the resolved + values into three categories: + + - positional arguments for `*args` + - keyword arguments for `**kwargs` + - execution arguments for Falyx runtime behavior + + Execution arguments are options such as retries, confirmation flags, or + summary output that should not be passed to the underlying action. + + Args: + args (list[str]): CLI-style argument tokens to parse. + from_validate (bool): Whether parsing is occurring in validation mode. Returns: - tuple: (args tuple, kwargs dict) + tuple: + - tuple[Any, ...]: Positional arguments for execution. + - dict[str, Any]: Keyword arguments for execution. + - dict[str, Any]: Execution-specific arguments handled by Falyx. """ parsed = await self.parse_args(args, from_validate) args_list = [] kwargs_dict = {} + execution_dict = {} for arg in self._arguments: if arg.dest == "help": continue @@ -1229,9 +1547,11 @@ class CommandArgumentParser: continue if arg.positional: args_list.append(parsed[arg.dest]) + elif self._is_execution_dest(arg.dest): + execution_dict[arg.dest] = parsed[arg.dest] else: kwargs_dict[arg.dest] = parsed[arg.dest] - return tuple(args_list), kwargs_dict + return tuple(args_list), kwargs_dict, execution_dict def _suggest_paths(self, stub: str) -> list[str]: """Return filesystem path suggestions based on a stub.""" @@ -1320,20 +1640,57 @@ class CommandArgumentParser: return self._suggest_paths(prefix if not cursor_at_end_of_token else ".") return [] + def _filter_mutex_flags( + self, + remaining_flags: list[str], + consumed_dests: list[str], + ) -> list[str]: + active_mutex_groups = { + self._mutex_group_by_dest[dest] + for dest in consumed_dests + if dest in self._mutex_group_by_dest + } + + if not active_mutex_groups: + return remaining_flags + + filtered: list[str] = [] + for flag in remaining_flags: + arg = self._keyword[flag] + mutex_name = self._mutex_group_by_dest.get(arg.dest) + if ( + mutex_name + and mutex_name in active_mutex_groups + and arg.dest not in consumed_dests + ): + continue + filtered.append(flag) + + return filtered + def suggest_next( self, args: list[str], cursor_at_end_of_token: bool = False ) -> list[str]: - """ - Suggest completions for the next argument based on current input. + """Suggest valid completions for the current argument state. - This is used for interactive shell completion or prompt_toolkit integration. + This method analyzes the partially entered argument list and returns + context-aware suggestions for the next token. Suggestions may include: + + - remaining flags + - valid choices for the current argument + - configured custom suggestions + - filesystem paths for `Path`-typed arguments + + It supports positional arguments, flagged arguments, multi-value arguments, + POSIX short-flag bundling, and mutually exclusive group filtering. Args: args (list[str]): Current partial argument tokens. - cursor_at_end_of_token (bool): True if space at end of args + cursor_at_end_of_token (bool): Whether the cursor is positioned after a + completed token (for example, after a trailing space). Returns: - list[str]: List of suggested completions. + list[str]: Sorted completion suggestions valid for the current parse state. """ self._resolve_posix_bundling(args) last = args[-1] if args else "" @@ -1406,6 +1763,7 @@ class CommandArgumentParser: remaining_flags = [ flag for flag, arg in self._keyword.items() if arg.dest not in consumed_dests ] + remaining_flags = self._filter_mutex_flags(remaining_flags, consumed_dests) last_keyword_state_in_args = None last_keyword = None @@ -1665,23 +2023,65 @@ class CommandArgumentParser: command_keys = self.get_command_keys_text(plain_text) options_text = self.get_options_text(plain_text) if options_text: - return f"{command_keys} {options_text}" + if self.options_manager.get("mode") == FalyxMode.MENU: + return f"{command_keys} {options_text}" + else: + program = self.program or "falyx" + program_style = ( + self.options_manager.get("program_style") or self.command_style + ) + return f"[{program_style}]{program}[/{program_style}] {command_keys} {options_text}" return command_keys - def render_help(self) -> None: + def _iter_keyword_help_sections( + self, + ) -> Generator[tuple[str, str, list[Argument]], None, None]: """ - Print formatted help text for this command using Rich output. + Yields (title, description, arguments) + """ + assigned = set() - Includes usage, description, argument groups, and optional epilog. + for group in self._argument_groups.values(): + args = [] + for dest in group.dests: + spec = self.get_argument(dest) + if spec and not spec.positional: + args.append(spec) + assigned.add(dest) + if args: + yield group.name, group.description, args + + ungrouped = [] + for arg in self._keyword_list: + if arg.dest not in assigned: + ungrouped.append(arg) + + if ungrouped: + yield "options", "", ungrouped + + def render_help(self) -> None: + """Render full help output for the command. + + This method displays a complete help view for the command, including + usage, description, argument definitions, execution options, and any + additional help text. + + The output is formatted using Rich and is intended for both CLI and + interactive menu contexts. + + Behavior: + - Renders a usage string derived from the parser configuration + - Displays command description, aliases, and optional epilog text + - Lists positional and keyword arguments with types, defaults, and help text + - Supports argument grouping and mutually exclusive groups + - Applies styling based on configured command style """ usage = self.get_usage() self.console.print(f"[bold]usage: {usage}[/bold]\n") - # Description if self.help_text: self.console.print(self.help_text + "\n") - # Arguments if self._arguments: if self._positional: self.console.print("[bold]positional:[/bold]") @@ -1692,62 +2092,70 @@ class CommandArgumentParser: if help_text and len(flags) > 30: help_text = f"\n{'':<33}{help_text}" self.console.print(f"{arg_line}{help_text}") - self.console.print("[bold]options:[/bold]") - arg_groups = defaultdict(list) - for arg in self._keyword_list: - arg_groups[arg.dest].append(arg) - for group in arg_groups.values(): - if len(group) == 2 and all( - arg.action == ArgumentAction.STORE_BOOL_OPTIONAL for arg in group - ): - # Merge --flag / --no-flag pair into single help line for STORE_BOOL_OPTIONAL - all_flags = tuple( - sorted( - (arg.flags[0] for arg in group), - key=lambda f: f.startswith("--no-"), + for title, description, args in self._iter_keyword_help_sections(): + self.console.print(f"\n[bold]{title}:[/bold]") + if description: + self.console.print(f" [dim]{description}[/dim]") + + arg_groups: defaultdict[str, list[Argument]] = defaultdict(list) + for arg in args: + arg_groups[arg.dest].append(arg) + + for group in arg_groups.values(): + if len(group) == 2 and all( + arg.action == ArgumentAction.STORE_BOOL_OPTIONAL for arg in group + ): + # Merge --flag / --no-flag pair into single help line for STORE_BOOL_OPTIONAL + all_flags = tuple( + sorted( + (arg.flags[0] for arg in group), + key=lambda f: f.startswith("--no-"), + ) ) - ) - else: - all_flags = group[0].flags + else: + all_flags = group[0].flags - flags = ", ".join(all_flags) - flags_choice = f"{flags} {group[0].get_choice_text()}" - arg_line = f" {flags_choice:<30} " - help_text = group[0].help or "" - if help_text and len(flags_choice) > 30: - help_text = f"\n{'':<33}{help_text}" - self.console.print(f"{arg_line}{help_text}") + suffix = "" + mutex_name = group[0].mutex_group + if mutex_name: + suffix = f" [dim]({mutex_name})[/dim]" + flags = ", ".join(all_flags) + flags_choice = f"{flags} {group[0].get_choice_text()}" + arg_line = f" {flags_choice:<30} " + help_text = f"{group[0].help or ''}{suffix}" + if help_text and len(flags_choice) > 30: + help_text = f"\n{'':<33}{help_text}" + self.console.print(f"{arg_line}{help_text}") - # Epilog if self.help_epilog: self.console.print("\n" + self.help_epilog, style="dim") def render_tldr(self) -> None: - """ - Print TLDR examples for this command using Rich output. + """Render concise example usage (TLDR) for the command. - Displays brief usage examples with descriptions. + This method displays a minimal, example-driven view of how to invoke + the command. It is intended as a quick-start reference rather than a + complete specification. + + Notes: + - TLDR output is designed for speed and clarity, not completeness. + - Typically invoked via `--tldr` or equivalent help flags. + - Complements `render_help`, which provides full documentation. """ if not self._tldr_examples: self.console.print( f"[bold]No TLDR examples available for {self.command_key}.[/bold]" ) return - is_cli_mode = self.options_manager.get("mode") in { - FalyxMode.RUN, - FalyxMode.PREVIEW, - FalyxMode.RUN_ALL, - FalyxMode.HELP, - } + is_cli_mode = self.options_manager.get("mode") != FalyxMode.MENU program = self.program or "falyx" + program_style = self.options_manager.get("program_style") or self.command_style command = self.aliases[0] if self.aliases else self.command_key if self._is_help_command and is_cli_mode: - command = f"[{self.command_style}]{program} help[/{self.command_style}]" + command = f"[{program_style}]{program}[/{program_style}] [{self.command_style}]help[/{self.command_style}]" elif is_cli_mode: - command = ( - f"[{self.command_style}]{program} run {command}[/{self.command_style}]" - ) + command = f"[{program_style}]{program}[/{program_style}] [{self.command_style}]{command}[/{self.command_style}]" else: command = f"[{self.command_style}]{command}[/{self.command_style}]" diff --git a/falyx/parser/falyx_parser.py b/falyx/parser/falyx_parser.py new file mode 100644 index 0000000..547d1a0 --- /dev/null +++ b/falyx/parser/falyx_parser.py @@ -0,0 +1,175 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +from __future__ import annotations + +from dataclasses import dataclass +from difflib import get_close_matches +from typing import TYPE_CHECKING + +from falyx.mode import FalyxMode +from falyx.parser.parse_result import ParseResult + +if TYPE_CHECKING: + from falyx.command import Command + from falyx.falyx import Falyx + + +@dataclass(slots=True) +class RootOptions: + verbose: bool = False + debug_hooks: bool = False + never_prompt: bool = False + version: bool = False + help: bool = False + + +class FalyxParser: + """Root parser and command router for Falyx. + + Responsibilities: + - parse global/root flags + - resolve built-ins vs registered commands + - normalize CLI input into ParseResult + - delegate command-specific parsing to CommandArgumentParser + """ + + ROOT_FLAG_ALIASES: dict[str, str] = { + "--never-prompt": "never_prompt", + "-v": "verbose", + "--verbose": "verbose", + "--debug-hooks": "debug_hooks", + "?": "help", + "-h": "help", + "--help": "help", + } + + def __init__(self, falyx: Falyx) -> None: + self.falyx = falyx + + def _parse_root_options( + self, + argv: list[str], + ) -> tuple[RootOptions, list[str]]: + """Parse only root/session flags from the start of argv. + + Parsing stops at the first token that is not a recognized root flag. + Remaining tokens are returned untouched for later routing. + + Examples: + ["--verbose", "deploy", "--env", "prod"] + -> (RootOptions(verbose=True), ["deploy", "--env", "prod"]) + + ["deploy", "--verbose"] + -> (RootOptions(), ["deploy", "--verbose"]) + """ + options = RootOptions() + remaining_start = 0 + + for index, token in enumerate(argv): + if token == "--": + remaining_start = index + 1 + break + + attr = self.ROOT_FLAG_ALIASES.get(token) + if attr is None: + remaining_start = index + break + + setattr(options, attr, True) + else: + remaining_start = len(argv) + + remaining = argv[remaining_start:] + return options, remaining + + def resolve_command(self, token: str) -> tuple[Command | None, list[str]]: + """Resolve a command by key, alias, or unique prefix. + + Returns: + (command, suggestions) + """ + normalized = token.upper().strip() + name_map = self.falyx._name_map + + if normalized in name_map: + return name_map[normalized], [] + + prefix_matches = [] + seen = set() + for key, command in name_map.items(): + if key.startswith(normalized) and id(command) not in seen: + prefix_matches.append(command) + seen.add(id(command)) + + if len(prefix_matches) == 1: + return prefix_matches[0], [] + + suggestions = get_close_matches( + normalized, list(name_map.keys()), n=3, cutoff=0.7 + ) + return None, suggestions + + def _parse_command( + self, + argv: list[str], + root: RootOptions, + remaining: list[str], + ) -> ParseResult: + raw_name = remaining[0] + is_preview = raw_name.startswith("?") + command_name = raw_name[1:] if is_preview else raw_name + + command, suggestions = self.resolve_command(command_name) + if not command: + sugguestions_text = ( + f" Did you mean: {', '.join(suggestions)}?" if suggestions else "" + ) + return ParseResult( + mode=FalyxMode.ERROR, + raw_argv=argv, + command_name=command_name, + command_argv=remaining[1:], + verbose=root.verbose, + debug_hooks=root.debug_hooks, + never_prompt=root.never_prompt, + error=f"Unknown command '{command_name}'.{sugguestions_text}", + ) + + command_argv = remaining[1:] + + return ParseResult( + mode=FalyxMode.COMMAND, + raw_argv=argv, + command_name=command_name, + command=command, + command_argv=command_argv, + is_preview=is_preview, + verbose=root.verbose, + debug_hooks=root.debug_hooks, + never_prompt=root.never_prompt, + ) + + def parse(self, argv: list[str] | None = None) -> ParseResult: + argv = argv or [] + root, remaining = self._parse_root_options(argv) + + if root.help: + return ParseResult( + mode=FalyxMode.HELP, + raw_argv=argv, + never_prompt=root.never_prompt, + verbose=root.verbose, + debug_hooks=root.debug_hooks, + ) + + if not remaining: + return ParseResult( + mode=FalyxMode.MENU, + raw_argv=argv, + verbose=root.verbose, + debug_hooks=root.debug_hooks, + never_prompt=root.never_prompt, + ) + + head, *tail = remaining + + return self._parse_command(argv, root, remaining) diff --git a/falyx/parser/group.py b/falyx/parser/group.py new file mode 100644 index 0000000..eb8971b --- /dev/null +++ b/falyx/parser/group.py @@ -0,0 +1,19 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class ArgumentGroup: + name: str + description: str = "" + dests: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class MutuallyExclusiveGroup: + name: str + required: bool = False + description: str = "" + dests: list[str] = field(default_factory=list) diff --git a/falyx/parser/parse_result.py b/falyx/parser/parse_result.py new file mode 100644 index 0000000..7e64eb0 --- /dev/null +++ b/falyx/parser/parse_result.py @@ -0,0 +1,24 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from falyx.mode import FalyxMode + +if TYPE_CHECKING: + from falyx.command import Command + + +@dataclass(slots=True) +class ParseResult: + mode: FalyxMode + raw_argv: list[str] = field(default_factory=list) + verbose: bool = False + debug_hooks: bool = False + never_prompt: bool = False + command_name: str = "" + command: Command | None = None + command_argv: list[str] = field(default_factory=list) + is_preview: bool = False + error: str | None = None diff --git a/falyx/parser/parsers.py b/falyx/parser/parsers.py deleted file mode 100644 index ce3e75f..0000000 --- a/falyx/parser/parsers.py +++ /dev/null @@ -1,408 +0,0 @@ -# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed -""" -Provides the argument parser infrastructure for the Falyx CLI. - -This module defines the `FalyxParsers` dataclass and related utilities for building -structured CLI interfaces with argparse. It supports top-level CLI commands like -`run`, `run-all`, `preview`, `help`, and `version`, and integrates seamlessly with -registered `Command` objects for dynamic help, usage generation, and argument handling. - -Key Components: -- `FalyxParsers`: Container for all CLI subparsers. -- `get_arg_parsers()`: Factory for generating full parser suite. -- `get_root_parser()`: Creates the root-level CLI parser with global options. -- `get_subparsers()`: Helper to attach subcommand parsers to the root parser. - -Used internally by the Falyx CLI `run()` entry point to parse arguments and route -execution across commands and workflows. -""" - -from argparse import ( - REMAINDER, - ArgumentParser, - Namespace, - RawDescriptionHelpFormatter, - _SubParsersAction, -) -from dataclasses import asdict, dataclass -from typing import Any, Sequence - -from falyx.command import Command - - -@dataclass -class FalyxParsers: - """Defines the argument parsers for the Falyx CLI.""" - - root: ArgumentParser - subparsers: _SubParsersAction - run: ArgumentParser - run_all: ArgumentParser - preview: ArgumentParser - help: ArgumentParser - version: ArgumentParser - - def parse_args(self, args: Sequence[str] | None = None) -> Namespace: - """Parse the command line arguments.""" - return self.root.parse_args(args) - - def as_dict(self) -> dict[str, ArgumentParser]: - """Convert the FalyxParsers instance to a dictionary.""" - return asdict(self) - - def get_parser(self, name: str) -> ArgumentParser | None: - """Get the parser by name.""" - return self.as_dict().get(name) - - -def get_root_parser( - prog: str | None = "falyx", - usage: str | None = None, - description: str | None = "Falyx CLI - Run structured async command workflows.", - epilog: str | None = "Tip: Use 'falyx help' to show available commands.", - parents: Sequence[ArgumentParser] | None = None, - prefix_chars: str = "-", - fromfile_prefix_chars: str | None = None, - argument_default: Any = None, - conflict_handler: str = "error", - add_help: bool = True, - allow_abbrev: bool = True, - exit_on_error: bool = True, -) -> ArgumentParser: - """ - Construct the root-level ArgumentParser for the Falyx CLI. - - This parser handles global arguments shared across subcommands and can serve - as the base parser for the Falyx CLI or standalone applications. It includes - options for verbosity, debug logging, and version output. - - Args: - prog (str | None): Name of the program (e.g., 'falyx'). - usage (str | None): Optional custom usage string. - description (str | None): Description shown in the CLI help. - epilog (str | None): Message displayed at the end of help output. - parents (Sequence[ArgumentParser] | None): Optional parent parsers. - prefix_chars (str): Characters to denote optional arguments (default: "-"). - fromfile_prefix_chars (str | None): Prefix to indicate argument file input. - argument_default (Any): Global default value for arguments. - conflict_handler (str): Strategy to resolve conflicting argument names. - add_help (bool): Whether to include help (`-h/--help`) in this parser. - allow_abbrev (bool): Allow abbreviated long options. - exit_on_error (bool): Exit immediately on error or raise an exception. - - Returns: - ArgumentParser: The root parser with global options attached. - - Notes: - ``` - Includes the following arguments: - --never-prompt : Run in non-interactive mode. - -v / --verbose : Enable debug logging. - --debug-hooks : Enable hook lifecycle debug logs. - --version : Print the Falyx version. - ``` - """ - parser = ArgumentParser( - prog=prog, - usage=usage, - description=description, - epilog=epilog, - parents=parents if parents else [], - prefix_chars=prefix_chars, - fromfile_prefix_chars=fromfile_prefix_chars, - argument_default=argument_default, - conflict_handler=conflict_handler, - add_help=add_help, - allow_abbrev=allow_abbrev, - exit_on_error=exit_on_error, - ) - parser.add_argument( - "--never-prompt", - action="store_true", - help="Run in non-interactive mode with all prompts bypassed.", - ) - parser.add_argument( - "-v", "--verbose", action="store_true", help=f"Enable debug logging for {prog}." - ) - parser.add_argument( - "--debug-hooks", - action="store_true", - help="Enable default lifecycle debug logging", - ) - parser.add_argument("--version", action="store_true", help=f"Show {prog} version") - return parser - - -def get_subparsers( - parser: ArgumentParser, - title: str = "Falyx Commands", - description: str | None = "Available commands for the Falyx CLI.", -) -> _SubParsersAction: - """ - Create and return a subparsers object for registering Falyx CLI subcommands. - - This function adds a `subparsers` block to the given root parser, enabling - structured subcommands such as `run`, `run-all`, `preview`, etc. - - Args: - parser (ArgumentParser): The root parser to attach the subparsers to. - title (str): Title used in help output to group subcommands. - description (str | None): Optional text describing the group of subcommands. - - Returns: - _SubParsersAction: The subparsers object that can be used to add new CLI subcommands. - - Raises: - TypeError: If `parser` is not an instance of `ArgumentParser`. - - Example: - ```python - >>> parser = get_root_parser() - >>> subparsers = get_subparsers(parser, title="Available Commands") - >>> subparsers.add_parser("run", help="Run a Falyx command") - ``` - """ - if not isinstance(parser, ArgumentParser): - raise TypeError("parser must be an instance of ArgumentParser") - subparsers = parser.add_subparsers( - title=title, - description=description, - dest="command", - ) - return subparsers - - -def get_arg_parsers( - prog: str | None = "falyx", - usage: str | None = None, - description: str | None = "Falyx CLI - Run structured async command workflows.", - epilog: ( - str | None - ) = "Tip: Use 'falyx preview [COMMAND]' to preview any command from the CLI.", - parents: Sequence[ArgumentParser] | None = None, - prefix_chars: str = "-", - fromfile_prefix_chars: str | None = None, - argument_default: Any = None, - conflict_handler: str = "error", - add_help: bool = True, - allow_abbrev: bool = True, - exit_on_error: bool = True, - commands: dict[str, Command] | None = None, - root_parser: ArgumentParser | None = None, - subparsers: _SubParsersAction | None = None, -) -> FalyxParsers: - """ - Create and return the full suite of argument parsers used by the Falyx CLI. - - This function builds the root parser and all subcommand parsers used for structured - CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`, - `preview`, `help`, and `version`, and integrates with registered `Command` objects - to populate dynamic help and usage documentation. - - Args: - prog (str | None): Program name to display in help and usage messages. - usage (str | None): Optional usage message to override the default. - description (str | None): Description for the CLI root parser. - epilog (str | None): Epilog message shown after the help text. - parents (Sequence[ArgumentParser] | None): Optional parent parsers. - prefix_chars (str): Characters that prefix optional arguments. - fromfile_prefix_chars (str | None): Prefix character for reading args from file. - argument_default (Any): Default value for arguments if not specified. - conflict_handler (str): Strategy for resolving conflicting arguments. - add_help (bool): Whether to add the `-h/--help` option to the root parser. - allow_abbrev (bool): Whether to allow abbreviated long options. - exit_on_error (bool): Whether the parser exits on error or raises. - commands (dict[str, Command] | None): Optional dictionary of registered commands - to populate help and subcommand descriptions dynamically. - root_parser (ArgumentParser | None): Custom root parser to use instead of building one. - subparsers (_SubParsersAction | None): Optional existing subparser object to extend. - - Returns: - FalyxParsers: A structured container of all parsers, including `run`, `run-all`, - `preview`, `help`, `version`, and the root parser. - - Raises: - TypeError: If `root_parser` is not an instance of ArgumentParser or - `subparsers` is not an instance of _SubParsersAction. - - Example: - ```python - >>> parsers = get_arg_parsers(commands=my_command_dict) - >>> args = parsers.root.parse_args() - ``` - - Notes: - - This function integrates dynamic command usage and descriptions if the - `commands` argument is provided. - - The `run` parser supports additional options for retry logic and confirmation - prompts. - - The `run-all` parser executes all commands matching a tag. - - Use `falyx run ?[COMMAND]` from the CLI to preview a command. - """ - if epilog is None: - epilog = f"Tip: Use '{prog} help' to show available commands." - if root_parser is None: - parser = get_root_parser( - prog=prog, - usage=usage, - description=description, - epilog=epilog, - parents=parents, - prefix_chars=prefix_chars, - fromfile_prefix_chars=fromfile_prefix_chars, - argument_default=argument_default, - conflict_handler=conflict_handler, - add_help=add_help, - allow_abbrev=allow_abbrev, - exit_on_error=exit_on_error, - ) - else: - if not isinstance(root_parser, ArgumentParser): - raise TypeError("root_parser must be an instance of ArgumentParser") - parser = root_parser - - if subparsers is None: - if prog == "falyx": - subparsers = get_subparsers( - parser, - title="Falyx Commands", - description="Available commands for the Falyx CLI.", - ) - else: - subparsers = get_subparsers(parser, title="subcommands", description=None) - if not isinstance(subparsers, _SubParsersAction): - raise TypeError("subparsers must be an instance of _SubParsersAction") - - run_description = ["Run a command by its key or alias.\n"] - run_description.append("commands:") - if isinstance(commands, dict): - for command in commands.values(): - run_description.append(command.usage) - command_description = command.help_text or command.description - run_description.append(f"{' '*24}{command_description}") - run_epilog = ( - f"Tip: Use '{prog} preview [COMMAND]' to preview commands by their key or alias." - ) - run_parser = subparsers.add_parser( - "run", - help="Run a specific command", - description="\n".join(run_description), - epilog=run_epilog, - formatter_class=RawDescriptionHelpFormatter, - ) - run_parser.add_argument( - "name", help="Run a command by its key or alias", metavar="COMMAND" - ) - run_parser.add_argument( - "--summary", - action="store_true", - help="Print an execution summary after command completes", - ) - run_parser.add_argument( - "--retries", type=int, help="Number of retries on failure", default=0 - ) - run_parser.add_argument( - "--retry-delay", - type=float, - help="Initial delay between retries in (seconds)", - default=0, - ) - run_parser.add_argument( - "--retry-backoff", type=float, help="Backoff factor for retries", default=0 - ) - run_group = run_parser.add_mutually_exclusive_group(required=False) - run_group.add_argument( - "-c", - "--confirm", - dest="force_confirm", - action="store_true", - help="Force confirmation prompts", - ) - run_group.add_argument( - "-s", - "--skip-confirm", - dest="skip_confirm", - action="store_true", - help="Skip confirmation prompts", - ) - - run_parser.add_argument( - "command_args", - nargs=REMAINDER, - help="Arguments to pass to the command (if applicable)", - metavar="ARGS", - ) - - run_all_parser = subparsers.add_parser( - "run-all", help="Run all commands with a given tag" - ) - run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match") - run_all_parser.add_argument( - "--summary", - action="store_true", - help="Print a summary after all tagged commands run", - ) - run_all_parser.add_argument( - "--retries", type=int, help="Number of retries on failure", default=0 - ) - run_all_parser.add_argument( - "--retry-delay", - type=float, - help="Initial delay between retries in (seconds)", - default=0, - ) - run_all_parser.add_argument( - "--retry-backoff", type=float, help="Backoff factor for retries", default=0 - ) - run_all_group = run_all_parser.add_mutually_exclusive_group(required=False) - run_all_group.add_argument( - "-c", - "--confirm", - dest="force_confirm", - action="store_true", - help="Force confirmation prompts", - ) - run_all_group.add_argument( - "-s", - "--skip-confirm", - dest="skip_confirm", - action="store_true", - help="Skip confirmation prompts", - ) - - preview_parser = subparsers.add_parser( - "preview", help="Preview a command without running it" - ) - preview_parser.add_argument("name", help="Key, alias, or description of the command") - - help_parser = subparsers.add_parser("help", help="List all available commands") - - help_parser.add_argument( - "-k", - "--key", - help="Show help for a specific command by its key or alias", - default=None, - ) - - help_parser.add_argument( - "-T", - "--tldr", - action="store_true", - help="Show a simplified TLDR examples of a command if available", - ) - - help_parser.add_argument( - "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None - ) - - version_parser = subparsers.add_parser("version", help=f"Show {prog} version") - - return FalyxParsers( - root=parser, - subparsers=subparsers, - run=run_parser, - run_all=run_all_parser, - preview=preview_parser, - help=help_parser, - version=version_parser, - ) diff --git a/falyx/prompt_utils.py b/falyx/prompt_utils.py index 1138d47..4b895d3 100644 --- a/falyx/prompt_utils.py +++ b/falyx/prompt_utils.py @@ -29,15 +29,27 @@ def should_prompt_user( *, confirm: bool, options: OptionsManager, - namespace: str = "cli_args", -): + namespace: str = "default", + override_namespace: str = "execution", +) -> bool: + """Determine whether to prompt the user for confirmation. + + Checks the `confirm` flag and consults the `OptionsManager` for any relevant + flags that may override the need for confirmation, such as `--never-prompt`, + `--force-confirm`, or `--skip-confirm`. The `override_namespace` is checked + first for any explicit overrides, followed by the main `namespace` for defaults. """ - Determine whether to prompt the user for confirmation based on command - and global options. - """ - never_prompt = options.get("never_prompt", False, namespace) - force_confirm = options.get("force_confirm", False, namespace) - skip_confirm = options.get("skip_confirm", False, namespace) + never_prompt = options.get("never_prompt", None, override_namespace) + if never_prompt is None: + never_prompt = options.get("never_prompt", False, namespace) + + force_confirm = options.get("force_confirm", None, override_namespace) + if force_confirm is None: + force_confirm = options.get("force_confirm", False, namespace) + + skip_confirm = options.get("skip_confirm", None, override_namespace) + if skip_confirm is None: + skip_confirm = options.get("skip_confirm", False, namespace) if never_prompt or skip_confirm: return False diff --git a/falyx/protocols.py b/falyx/protocols.py index d308555..bd59df2 100644 --- a/falyx/protocols.py +++ b/falyx/protocols.py @@ -29,4 +29,4 @@ class ActionFactoryProtocol(Protocol): @runtime_checkable class ArgParserProtocol(Protocol): - def __call__(self, args: list[str]) -> tuple[tuple, dict]: ... + def __call__(self, args: list[str]) -> tuple[tuple, dict, dict]: ... diff --git a/falyx/tagged_table.py b/falyx/tagged_table.py index 8026cae..fa2e094 100644 --- a/falyx/tagged_table.py +++ b/falyx/tagged_table.py @@ -25,15 +25,15 @@ def build_tagged_table(flx: Falyx) -> Table: # Group commands by first tag grouped: dict[str, list[Command]] = defaultdict(list) - for cmd in flx.commands.values(): - first_tag = cmd.tags[0] if cmd.tags else "Other" - grouped[first_tag.capitalize()].append(cmd) + for command in flx.commands.values(): + first_tag = command.tags[0] if command.tags else "Other" + grouped[first_tag.capitalize()].append(command) # Add grouped commands to table for group_name, commands in grouped.items(): table.add_row(f"[bold underline]{group_name} Commands[/]") - for cmd in commands: - table.add_row(f"[{cmd.key}] [{cmd.style}]{cmd.description}") + for command in commands: + table.add_row(f"[{command.key}] [{command.style}]{command.description}") table.add_row("") # Add bottom row diff --git a/falyx/validators.py b/falyx/validators.py index 096693e..28f03f5 100644 --- a/falyx/validators.py +++ b/falyx/validators.py @@ -48,7 +48,9 @@ class CommandValidator(Validator): message=self.error_message, cursor_position=len(text), ) - is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True) + is_preview, choice, _, __, ___ = await self.falyx.get_command( + text, from_validate=True + ) if is_preview: return None if not choice: diff --git a/falyx/version.py b/falyx/version.py index ea23fb5..d3ec452 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.87" +__version__ = "0.2.0" diff --git a/pyproject.toml b/pyproject.toml index e8c5504..efe5da9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.87" +version = "0.2.0" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_command_argument_parser.py b/tests/test_command_argument_parser.py index 9882aa7..080f3ca 100644 --- a/tests/test_command_argument_parser.py +++ b/tests/test_command_argument_parser.py @@ -431,7 +431,6 @@ async def test_parse_args_flagged_nargs_plus(): assert args["files"] == ["a", "b", "c"] args = await parser.parse_args(["--files", "a"]) - print(args) assert args["files"] == ["a"] args = await parser.parse_args([]) @@ -666,7 +665,7 @@ async def test_parse_args_split_order(): cap.add_argument("a") cap.add_argument("--x") cap.add_argument("b", nargs="*") - args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"]) + args, kwargs, _ = await cap.parse_args_split(["1", "--x", "100", "2"]) assert args == ("1", ["2"]) assert kwargs == {"x": "100"} diff --git a/tests/test_completer/test_completer.py b/tests/test_completer/test_completer.py index e5764ec..42d21bc 100644 --- a/tests/test_completer/test_completer.py +++ b/tests/test_completer/test_completer.py @@ -1,57 +1,65 @@ -from types import SimpleNamespace - import pytest from prompt_toolkit.completion import Completion from prompt_toolkit.document import Document +from falyx import Falyx from falyx.completer import FalyxCompleter +from falyx.parser import CommandArgumentParser @pytest.fixture -def fake_falyx(): - fake_arg_parser = SimpleNamespace( - suggest_next=lambda tokens, end: ["--tag", "--name", "value with space"] +def falyx(): + flx = Falyx() + parser = CommandArgumentParser( + command_key="R", + command_description="Run Command", ) - fake_command = SimpleNamespace(key="R", aliases=["RUN"], arg_parser=fake_arg_parser) - return SimpleNamespace( - exit_command=SimpleNamespace(key="X", aliases=["EXIT"]), - help_command=SimpleNamespace(key="H", aliases=["HELP"]), - history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]), - commands={"R": fake_command}, - _name_map={"R": fake_command, "RUN": fake_command, "X": fake_command}, + parser.add_argument( + "--tag", ) + parser.add_argument( + "--name", + ) + flx.add_command( + "R", + "Run Command", + lambda x: None, + aliases=["RUN"], + arg_parser=parser, + ) + return flx -def test_suggest_commands(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_suggest_commands(falyx): + completer = FalyxCompleter(falyx) completions = list(completer._suggest_commands("R")) assert any(c.text == "R" for c in completions) assert any(c.text == "RUN" for c in completions) -def test_suggest_commands_empty(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_suggest_commands_empty(falyx): + completer = FalyxCompleter(falyx) completions = list(completer._suggest_commands("")) assert any(c.text == "X" for c in completions) assert any(c.text == "H" for c in completions) -def test_suggest_commands_no_match(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_suggest_commands_no_match(falyx): + completer = FalyxCompleter(falyx) completions = list(completer._suggest_commands("Z")) assert not completions -def test_get_completions_no_input(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_get_completions_no_input(falyx): + completer = FalyxCompleter(falyx) doc = Document("") results = list(completer.get_completions(doc, None)) assert any(isinstance(c, Completion) for c in results) assert any(c.text == "X" for c in results) -def test_get_completions_no_match(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_get_completions_no_match(falyx): + completer = FalyxCompleter(falyx) doc = Document("Z") completions = list(completer.get_completions(doc, None)) assert not completions @@ -60,38 +68,38 @@ def test_get_completions_no_match(fake_falyx): assert not completions -def test_get_completions_partial_command(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_get_completions_partial_command(falyx): + completer = FalyxCompleter(falyx) doc = Document("R") results = list(completer.get_completions(doc, None)) assert any(c.text in ("R", "RUN") for c in results) -def test_get_completions_with_flag(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_get_completions_with_flag(falyx): + completer = FalyxCompleter(falyx) doc = Document("R ") results = list(completer.get_completions(doc, None)) assert "--tag" in [c.text for c in results] -def test_get_completions_partial_flag(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_get_completions_partial_flag(falyx): + completer = FalyxCompleter(falyx) doc = Document("R --t") results = list(completer.get_completions(doc, None)) assert all(c.start_position <= 0 for c in results) assert any(c.text.startswith("--t") or c.display == "--tag" for c in results) -def test_get_completions_bad_input(fake_falyx): - completer = FalyxCompleter(fake_falyx) +def test_get_completions_bad_input(falyx): + completer = FalyxCompleter(falyx) doc = Document('R "unclosed quote') results = list(completer.get_completions(doc, None)) assert results == [] -def test_get_completions_exception_handling(fake_falyx): - completer = FalyxCompleter(fake_falyx) - fake_falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0 +def test_get_completions_exception_handling(falyx): + completer = FalyxCompleter(falyx) + falyx.commands["R"].arg_parser.suggest_next = lambda *args: 1 / 0 doc = Document("R --tag") results = list(completer.get_completions(doc, None)) assert results == [] diff --git a/tests/test_run_key.py b/tests/test_execute_command.py similarity index 84% rename from tests/test_run_key.py rename to tests/test_execute_command.py index 79f6a1f..692a5fb 100644 --- a/tests/test_run_key.py +++ b/tests/test_execute_command.py @@ -5,7 +5,7 @@ from falyx.action import Action @pytest.mark.asyncio -async def test_run_key(): +async def test_execute_command(): """Test if Falyx can run in run key mode.""" falyx = Falyx("Run Key Test") @@ -17,12 +17,12 @@ async def test_run_key(): ) # Run the CLI - result = await falyx.run_key("T") + result = await falyx.execute_command("T") assert result == "Hello, World!" @pytest.mark.asyncio -async def test_run_key_recover(): +async def test_execute_command_recover(): """Test if Falyx can recover from a failure in run key mode.""" falyx = Falyx("Run Key Recovery Test") @@ -42,5 +42,5 @@ async def test_run_key_recover(): retry=True, ) - result = await falyx.run_key("E") + result = await falyx.execute_command("E") assert result == "ok" diff --git a/tests/test_falyx/test_help.py b/tests/test_falyx/test_help.py index 9431e37..1aa399f 100644 --- a/tests/test_falyx/test_help.py +++ b/tests/test_falyx/test_help.py @@ -1,6 +1,8 @@ import pytest +from rich.text import Text from falyx import Falyx +from falyx.console import console @pytest.mark.asyncio @@ -8,7 +10,7 @@ async def test_help_command(capsys): flx = Falyx() assert flx.help_command.arg_parser.aliases[0] == "HELP" assert flx.help_command.arg_parser.command_key == "H" - await flx.run_key("H") + await flx.execute_command("H") captured = capsys.readouterr() assert "Show this help menu" in captured.out @@ -28,7 +30,7 @@ async def test_help_command_with_new_command(capsys): aliases=["TEST"], help_text="This is a new command.", ) - await flx.run_key("H") + await flx.execute_command("H") captured = capsys.readouterr() assert "This is a new command." in captured.out @@ -70,12 +72,14 @@ async def test_help_command_by_tag(capsys): tags=["tag1"], help_text="This command is tagged.", ) - await flx.run_key("H", args=("tag1",)) + await flx.execute_command("H -t tag1") captured = capsys.readouterr() - assert "tag1" in captured.out - assert "This command is tagged." in captured.out - assert "HELP" not in captured.out + print(captured.out) + text = Text.from_ansi(captured.out) + assert "tag1" in text.plain + assert "This command is tagged." in text.plain + assert "HELP" not in text.plain @pytest.mark.asyncio @@ -88,9 +92,8 @@ async def test_help_command_empty_tags(capsys): flx.add_command( "U", "Untagged Command", untagged_command, help_text="This command has no tags." ) - await flx.run_key("H", args=("nonexistent_tag",)) + await flx.execute_command("H nonexistent_tag") captured = capsys.readouterr() - print(captured.out) - assert "nonexistent_tag" in captured.out - assert "Nothing to show here" in captured.out + text = Text.from_ansi(captured.out) + assert "Unexpected positional argument: nonexistent_tag" in text.plain diff --git a/tests/test_falyx/test_run.py b/tests/test_falyx/test_run.py index ac83d30..a289860 100644 --- a/tests/test_falyx/test_run.py +++ b/tests/test_falyx/test_run.py @@ -3,17 +3,14 @@ import sys import pytest from falyx import Falyx -from falyx.parser import get_arg_parsers @pytest.mark.asyncio async def test_run_basic(capsys): - sys.argv = ["falyx", "run", "-h"] - falyx_parsers = get_arg_parsers() - assert falyx_parsers is not None, "Falyx parsers should be initialized" + sys.argv = ["falyx", "-h"] flx = Falyx() with pytest.raises(SystemExit): - await flx.run(falyx_parsers) + await flx.run() captured = capsys.readouterr() - assert "Run a command by its key or alias." in captured.out + assert "Show this help menu." in captured.out diff --git a/tests/test_falyx_parser/test_root_options.py b/tests/test_falyx_parser/test_root_options.py new file mode 100644 index 0000000..3a0354f --- /dev/null +++ b/tests/test_falyx_parser/test_root_options.py @@ -0,0 +1,47 @@ +from falyx import Falyx +from falyx.parser.falyx_parser import FalyxParser, RootOptions + + +def get_falyx_parser(): + falyx = Falyx() + return FalyxParser(falyx=falyx) + + +def test_parse_root_options_empty(): + parser = get_falyx_parser() + opts, remaining = parser._parse_root_options([]) + assert opts == RootOptions() + assert remaining == [] + + +def test_parse_root_options_consumes_known_leading_flags(): + parser = get_falyx_parser() + opts, remaining = parser._parse_root_options( + ["--verbose", "--never-prompt", "deploy", "--env", "prod"] + ) + assert opts.verbose is True + assert opts.never_prompt is True + assert remaining == ["deploy", "--env", "prod"] + + +def test_parse_root_options_stops_at_first_non_root_token(): + parser = get_falyx_parser() + opts, remaining = parser._parse_root_options(["deploy", "--verbose"]) + assert opts == RootOptions() + assert remaining == ["deploy", "--verbose"] + + +def test_parse_root_options_supports_help(): + parser = get_falyx_parser() + opts, remaining = parser._parse_root_options(["--help"]) + assert opts.help is True + assert remaining == [] + + +def test_parse_root_options_supports_double_dash_separator(): + parser = get_falyx_parser() + opts, remaining = parser._parse_root_options( + ["--verbose", "--", "deploy", "--verbose"] + ) + assert opts.verbose is True + assert remaining == ["deploy", "--verbose"] diff --git a/tests/test_main.py b/tests/test_main.py index 58add25..6d9b2eb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,19 +1,11 @@ import shutil import sys import tempfile -from argparse import ArgumentParser, Namespace, _SubParsersAction from pathlib import Path import pytest -from falyx.__main__ import ( - bootstrap, - find_falyx_config, - get_parsers, - init_callback, - init_config, - main, -) +from falyx.__main__ import bootstrap, find_falyx_config, init_config, main from falyx.parser import CommandArgumentParser @@ -94,38 +86,10 @@ async def test_init_config(): assert args["name"] == "." -def test_init_callback(tmp_path): - """Test if the init_callback function works correctly.""" - # Test project initialization - args = Namespace(command="init", name=str(tmp_path)) - init_callback(args) - assert (tmp_path / "falyx.yaml").exists() - - -def test_init_global_callback(): - # Test global initialization - args = Namespace(command="init_global") - init_callback(args) - assert (Path.home() / ".config" / "falyx" / "tasks.py").exists() - assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists() - - -def test_get_parsers(): - """Test if the get_parsers function returns the correct parsers.""" - root_parser, subparsers = get_parsers() - assert isinstance(root_parser, ArgumentParser) - assert isinstance(subparsers, _SubParsersAction) - - # Check if the 'init' command is available - init_parser = subparsers.choices.get("init") - assert init_parser is not None - assert "name" == init_parser._get_positional_actions()[0].dest - - def test_main(): """Test if the main function runs with the correct arguments.""" - sys.argv = ["falyx", "run", "?"] + sys.argv = ["falyx", "?"] with pytest.raises(SystemExit) as exc_info: main() diff --git a/tests/test_parsers/test_action.py b/tests/test_parsers/test_action.py index 6598b5c..d3064a2 100644 --- a/tests/test_parsers/test_action.py +++ b/tests/test_parsers/test_action.py @@ -71,22 +71,28 @@ async def test_action_with_nargs_positional(): return int(a) * int(b) action = Action("multiply", multiply) - parser.add_argument("mul", action=ArgumentAction.ACTION, resolver=action, nargs=2) + parser.add_argument( + "mul", + action=ArgumentAction.ACTION, + resolver=action, + nargs=2, + type=int, + ) args = await parser.parse_args(["3", "4"]) assert args["mul"] == 12 with pytest.raises(CommandArgumentError): await parser.parse_args(["3"]) - with pytest.raises(CommandArgumentError): - await parser.parse_args([]) - with pytest.raises(CommandArgumentError): await parser.parse_args(["3", "4", "5"]) with pytest.raises(CommandArgumentError): await parser.parse_args(["--mul", "3", "4"]) + with pytest.raises(CommandArgumentError): + await parser.parse_args([]) + @pytest.mark.asyncio async def test_action_with_nargs_positional_int(): @@ -102,6 +108,9 @@ async def test_action_with_nargs_positional_int(): args = await parser.parse_args(["3", "4"]) assert args["mul"] == 12 + with pytest.raises(CommandArgumentError): + await parser.parse_args([]) + with pytest.raises(CommandArgumentError): await parser.parse_args(["3"]) @@ -209,11 +218,19 @@ async def test_action_with_default_and_value_not(): @pytest.mark.asyncio async def test_action_with_default_and_value_positional(): parser = CommandArgumentParser() - action = Action("default", lambda: "default_value") - parser.add_argument("default", action=ArgumentAction.ACTION, resolver=action) + action = Action("action", lambda x: x) + parser.add_argument( + "default", + action=ArgumentAction.ACTION, + resolver=action, + default="default_value", + ) + + args = await parser.parse_args([]) + assert args["default"] == "default_value" + + args = await parser.parse_args(["be"]) + assert args["default"] == "be" with pytest.raises(CommandArgumentError): - await parser.parse_args([]) - - with pytest.raises(CommandArgumentError): - await parser.parse_args(["be"]) + await parser.parse_args(["one", "new_value"]) diff --git a/tests/test_validators/test_command_validator.py b/tests/test_validators/test_command_validator.py index 0fd2d5e..2d7abbf 100644 --- a/tests/test_validators/test_command_validator.py +++ b/tests/test_validators/test_command_validator.py @@ -10,7 +10,7 @@ from falyx.validators import CommandValidator @pytest.mark.asyncio async def test_command_validator_validates_command(): fake_falyx = AsyncMock() - fake_falyx.get_command.return_value = (False, object(), (), {}) + fake_falyx.get_command.return_value = (False, object(), (), {}, {}) validator = CommandValidator(fake_falyx, "Invalid!") await validator.validate_async(Document("valid")) @@ -20,7 +20,7 @@ async def test_command_validator_validates_command(): @pytest.mark.asyncio async def test_command_validator_rejects_invalid_command(): fake_falyx = AsyncMock() - fake_falyx.get_command.return_value = (False, None, (), {}) + fake_falyx.get_command.return_value = (False, None, (), {}, {}) validator = CommandValidator(fake_falyx, "Invalid!") with pytest.raises(ValidationError): @@ -33,7 +33,7 @@ async def test_command_validator_rejects_invalid_command(): @pytest.mark.asyncio async def test_command_validator_is_preview(): fake_falyx = AsyncMock() - fake_falyx.get_command.return_value = (True, None, (), {}) + fake_falyx.get_command.return_value = (True, None, (), {}, {}) validator = CommandValidator(fake_falyx, "Invalid!") await validator.validate_async(Document("?preview_command"))