diff --git a/falyx/action/action_mixins.py b/falyx/action/action_mixins.py index 6784265..51d5a8a 100644 --- a/falyx/action/action_mixins.py +++ b/falyx/action/action_mixins.py @@ -7,7 +7,7 @@ maintaining a mutable list of named actions—such as adding, removing, or retri actions by name—without duplicating logic across composite action types. """ -from typing import Sequence +from typing import Any, Sequence from falyx.action.base_action import BaseAction diff --git a/falyx/action/chained_action.py b/falyx/action/chained_action.py index 8c31039..751a0b7 100644 --- a/falyx/action/chained_action.py +++ b/falyx/action/chained_action.py @@ -115,6 +115,7 @@ class ChainedAction(BaseAction, ActionListMixin): name: str, actions: ( Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]] + | Any | None ) = None, *, diff --git a/falyx/action/load_file_action.py b/falyx/action/load_file_action.py index 0265bb9..7a5a770 100644 --- a/falyx/action/load_file_action.py +++ b/falyx/action/load_file_action.py @@ -185,6 +185,7 @@ class LoadFileAction(BaseAction): except Exception as error: logger.error("Failed to parse %s: %s", self.file_path.name, error) + raise return value async def _run(self, *args, **kwargs) -> Any: @@ -241,7 +242,7 @@ class LoadFileAction(BaseAction): for line in preview_lines: content_tree.add(f"[dim]{line}[/]") elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}: - raw = self.load_file() + raw = await self.load_file() if raw is not None: preview_str = ( json.dumps(raw, indent=2) diff --git a/falyx/action/selection_action.py b/falyx/action/selection_action.py index eb29856..b7e06dc 100644 --- a/falyx/action/selection_action.py +++ b/falyx/action/selection_action.py @@ -88,7 +88,12 @@ class SelectionAction(BaseAction): allow_duplicates (bool): Whether duplicate selections are allowed. inject_last_result (bool): If True, attempts to inject the last result as default. inject_into (str): The keyword name for injected value (default: "last_result"). - return_type (SelectionReturnType | str): The type of result to return. + return_type (SelectionReturnType | str): The type of result to return. Options: + - KEY: Return the selected key(s) only. + - VALUE: Return the value(s) associated with the selected key(s). + - DESCRIPTION: Return the description(s) of the selected item(s). + - DESCRIPTION_VALUE: Return a dict of {description: value} pairs. + - ITEMS: Return full `SelectionOption` objects as a dict {key: SelectionOption}. prompt_session (PromptSession | None): Reused or customized prompt_toolkit session. never_prompt (bool): If True, skips prompting and uses default_selection or last_result. show_table (bool): Whether to render the selection table before prompting. diff --git a/falyx/command.py b/falyx/command.py index a432732..ef57ce7 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -46,6 +46,7 @@ from typing import Any, Awaitable, Callable from prompt_toolkit.formatted_text import FormattedText from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator +from rich.style import Style from rich.tree import Tree from falyx.action.action import Action @@ -53,7 +54,7 @@ from falyx.action.base_action import BaseAction from falyx.console import console from falyx.context import ExecutionContext, InvocationContext from falyx.debug import register_debug_hooks -from falyx.exceptions import CommandArgumentError, NotAFalyxError +from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError from falyx.execution_option import ExecutionOption from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType @@ -121,14 +122,14 @@ class Command(BaseModel): 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. + style (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_style (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. @@ -150,6 +151,8 @@ class Command(BaseModel): Override help rendering. custom_tldr (Callable[[], str | None] | None): Override TLDR rendering. + custom_usage (Callable[[], str | None] | None): + Override usage 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. @@ -179,14 +182,14 @@ class Command(BaseModel): aliases: list[str] = Field(default_factory=list) help_text: str = "" help_epilog: str = "" - style: str = OneColors.WHITE + style: 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_style: Style | str = OneColors.CYAN spinner_speed: float = 1.0 hooks: "HookManager" = Field(default_factory=HookManager) retry: bool = False @@ -200,8 +203,9 @@ class Command(BaseModel): arguments: list[dict[str, Any]] = Field(default_factory=list) argument_config: Callable[[CommandArgumentParser], None] | None = None custom_parser: ArgParserProtocol | None = None - custom_help: Callable[[], None] | None = None - custom_tldr: Callable[[], None] | None = None + custom_help: Callable[[], str | None] | None = None + custom_tldr: Callable[[], str | None] | None = None + custom_usage: Callable[[], str | None] | None = None auto_args: bool = True arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) simple_help_signature: bool = False @@ -482,6 +486,13 @@ class Command(BaseModel): return FormattedText(prompt) + @property + def primary_alias(self) -> str: + """Get the primary alias for the command, used in help displays.""" + if self.aliases: + return self.aliases[0].lower() + return self.key + @property def usage(self) -> str: """Generate a help string for the command arguments.""" @@ -527,7 +538,7 @@ class Command(BaseModel): - Formatting may vary depending on CLI vs menu mode. """ if self.arg_parser and not self.simple_help_signature: - usage = self.arg_parser.get_usage(invocation_context=invocation_context) + usage = self.arg_parser.get_usage(invocation_context) description = f"[dim]{self.help_text or self.description}[/dim]" if self.tags: tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]" @@ -549,6 +560,18 @@ class Command(BaseModel): if self._context: self._context.log_summary() + def render_usage(self, invocation_context: InvocationContext | None = None) -> None: + """Render the usage information for the command.""" + if callable(self.custom_usage): + output = self.custom_usage() + if output: + console.print(output) + return + if isinstance(self.arg_parser, CommandArgumentParser): + self.arg_parser.render_usage(invocation_context) + else: + console.print(f"[bold]usage:[/] {self.key}") + def render_help(self, invocation_context: InvocationContext | None = None) -> bool: """Display the help message for the command.""" if callable(self.custom_help): @@ -557,7 +580,7 @@ class Command(BaseModel): console.print(output) return True if isinstance(self.arg_parser, CommandArgumentParser): - self.arg_parser.render_help(invocation_context=invocation_context) + self.arg_parser.render_help(invocation_context) return True return False @@ -569,7 +592,7 @@ class Command(BaseModel): console.print(output) return True if isinstance(self.arg_parser, CommandArgumentParser): - self.arg_parser.render_tldr(invocation_context=invocation_context) + self.arg_parser.render_tldr(invocation_context) return True return False @@ -617,14 +640,14 @@ class Command(BaseModel): aliases: list[str] | None = None, help_text: str = "", help_epilog: str = "", - style: str = OneColors.WHITE, + style: 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_style: Style | str = OneColors.CYAN, spinner_speed: float = 1.0, options_manager: OptionsManager | None = None, hooks: HookManager | None = None, @@ -645,6 +668,7 @@ class Command(BaseModel): custom_parser: ArgParserProtocol | None = None, custom_help: Callable[[], str | None] | None = None, custom_tldr: Callable[[], str | None] | None = None, + custom_usage: Callable[[], str | None] | None = None, auto_args: bool = True, arg_metadata: dict[str, str | dict[str, Any]] | None = None, simple_help_signature: bool = False, @@ -679,14 +703,14 @@ class Command(BaseModel): 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. + style (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_style (Style | str): Spinner style. spinner_speed (float): Spinner speed multiplier. options_manager (OptionsManager | None): Shared options manager for the command and its parser. @@ -721,6 +745,8 @@ class Command(BaseModel): renderer. custom_tldr (Callable[[], str | None] | None): Optional custom TLDR renderer. + custom_usage (Callable[[], str | None] | None): Optional custom usage + 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 @@ -735,8 +761,8 @@ class Command(BaseModel): Raises: NotAFalyxError: If `arg_parser` is provided but is not a - `CommandArgumentParser` instance, or if `hooks` is provided but is not - a `HookManager` instance. + `CommandArgumentParser` instance. + InvalidHookError: If `hooks` is provided but is not a `HookManager` instance. Notes: - Execution options supplied as strings are converted to @@ -757,7 +783,7 @@ class Command(BaseModel): options_manager = options_manager or OptionsManager() if hooks and not isinstance(hooks, HookManager): - raise NotAFalyxError("hooks must be an instance of HookManager.") + raise InvalidHookError("hooks must be an instance of HookManager.") hooks = hooks or HookManager() if retry_policy and not isinstance(retry_policy, RetryPolicy): @@ -805,6 +831,7 @@ class Command(BaseModel): custom_parser=custom_parser, custom_help=custom_help, custom_tldr=custom_tldr, + custom_usage=custom_usage, auto_args=auto_args, arg_metadata=arg_metadata or {}, simple_help_signature=simple_help_signature, diff --git a/falyx/command_executor.py b/falyx/command_executor.py index 5077bea..b512d24 100644 --- a/falyx/command_executor.py +++ b/falyx/command_executor.py @@ -39,7 +39,7 @@ Design Notes: duplication across Falyx runtime entrypoints. Typical Usage: - executor = CommandExecutor(options=options, hooks=hooks, console=console) + executor = CommandExecutor(options=options, hooks=hooks) result = await executor.execute( command=command, args=args, @@ -51,8 +51,6 @@ 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 @@ -61,7 +59,6 @@ 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: @@ -81,15 +78,13 @@ class CommandExecutor: - 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 + - Control whether errors are raised or wrapped - 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__( @@ -97,11 +92,9 @@ class CommandExecutor: *, 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. @@ -112,7 +105,7 @@ class CommandExecutor: Args: command (Command): The command about to be executed. """ - logger.debug("Executor hooks:\n%s", str(self.hooks)) + logger.debug("executor hooks:\n%s", str(self.hooks)) logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks)) def _apply_retry_overrides( @@ -164,7 +157,7 @@ class CommandExecutor: else: logger.warning( "[%s] Retry requested, but action is not an Action instance.", - command.description, + command.key, ) def _execution_option_overrides( @@ -189,30 +182,6 @@ class CommandExecutor: "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, *, @@ -277,6 +246,11 @@ class CommandExecutor: - Summary output is only emitted when the `summary` execution option is present in `execution_args`. """ + if not (raise_on_error or wrap_errors): + raise FalyxError( + "CommandExecutor.execute() requires either raise_on_error=True " + "or wrap_errors=True." + ) self._debug_hooks(command) self._apply_retry_overrides(command, execution_args) overrides = self._execution_option_overrides(execution_args) @@ -307,24 +281,25 @@ class CommandExecutor: except (KeyboardInterrupt, EOFError) as error: logger.info( "[execute] '%s' interrupted by user.", - command.description, + command.key, ) if wrap_errors: raise FalyxError( - f"[execute] ⚠️ '{command.description}' interrupted by user." + f"[execute] '{command.key}' interrupted by user." ) from error - if raise_on_error: - raise error + raise error except Exception as error: + logger.debug( + "[execute] '%s' failed: %s", + command.key, + error, + exc_info=True, + ) 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: {error}" - ) from error - if raise_on_error: - raise error + raise FalyxError(f"[execute] '{command.key}' failed: {error}") from error + raise error finally: context.stop_timer() await self.hooks.trigger(HookType.AFTER, context) diff --git a/falyx/command_runner.py b/falyx/command_runner.py index 25cc42e..2e60f0a 100644 --- a/falyx/command_runner.py +++ b/falyx/command_runner.py @@ -57,7 +57,13 @@ 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.console import error_console, print_error +from falyx.exceptions import ( + CommandArgumentError, + FalyxError, + InvalidHookError, + NotAFalyxError, +) from falyx.execution_option import ExecutionOption from falyx.hook_manager import HookManager from falyx.logger import logger @@ -85,6 +91,7 @@ class CommandRunner: Attributes: command (Command): The command executed by this runner. + program (str): Program name used in CLI usage text and help output. options (OptionsManager): Shared options manager used by the command, parser, and executor. runner_hooks (HookManager): Executor-level hooks used during execution. @@ -97,6 +104,7 @@ class CommandRunner: self, command: Command, *, + program: str | None = None, options: OptionsManager | None = None, runner_hooks: HookManager | None = None, console: Console | None = None, @@ -109,6 +117,9 @@ class CommandRunner: Args: command (Command): The command to execute. + program (str | None): Program name used in CLI usage text, invocation-path + rendering, and built-in help output. If `None`, an empty program name is + used. options (OptionsManager | None): Optional shared options manager. If omitted, a new `OptionsManager` is created. runner_hooks (HookManager | None): Optional executor-level hook manager. If @@ -117,16 +128,22 @@ class CommandRunner: the default Falyx console is used. """ self.command = command + self.program = program or "" self.options = self._get_options(options) self.runner_hooks = self._get_hooks(runner_hooks) self.console = self._get_console(console) + self.error_console = error_console self.command.options_manager = self.options + if program: + self.command.program = program if isinstance(self.command.arg_parser, CommandArgumentParser): self.command.arg_parser.set_options_manager(self.options) + self.command.arg_parser.is_runner_mode = True + if program: + self.command.arg_parser.program = program self.executor = CommandExecutor( options=self.options, hooks=self.runner_hooks, - console=self.console, ) self.options.from_mapping(values={}, namespace_name="execution") @@ -152,7 +169,7 @@ class CommandRunner: elif isinstance(hooks, HookManager): return hooks else: - raise NotAFalyxError("hooks must be an instance of HookManager or None.") + raise InvalidHookError("hooks must be an instance of HookManager or None.") async def run( self, @@ -253,10 +270,10 @@ class CommandRunner: sys.exit(0) except CommandArgumentError as error: self.command.render_help() - self.console.print(f"[{OneColors.DARK_RED}]❌ ['{self.command.key}'] {error}") + print_error(message=error) sys.exit(2) except FalyxError as error: - self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") + print_error(message=error) sys.exit(1) except QuitSignal: logger.info("[QuitSignal]. <- Exiting run.") @@ -276,6 +293,7 @@ class CommandRunner: cls, command: Command, *, + program: str | None = None, runner_hooks: HookManager | None = None, options: OptionsManager | None = None, console: Console | None = None, @@ -288,6 +306,9 @@ class CommandRunner: Args: command (Command): Existing command instance to wrap. + program (str | None): Program name used in CLI usage text, invocation-path + rendering, and built-in help output. If `None`, an empty program name is + used. runner_hooks (HookManager | None): Optional executor-level hook manager for the runner. options (OptionsManager | None): Optional shared options manager. @@ -303,9 +324,10 @@ class CommandRunner: if not isinstance(command, Command): raise NotAFalyxError("command must be an instance of Command.") if runner_hooks and not isinstance(runner_hooks, HookManager): - raise NotAFalyxError("runner_hooks must be an instance of HookManager.") + raise InvalidHookError("runner_hooks must be an instance of HookManager.") return cls( command=command, + program=program, options=options, runner_hooks=runner_hooks, console=console, @@ -318,6 +340,7 @@ class CommandRunner: description: str, action: BaseAction | Callable[..., Any], *, + program: str | None = None, runner_hooks: HookManager | None = None, args: tuple = (), kwargs: dict[str, Any] | None = None, @@ -352,6 +375,8 @@ class CommandRunner: execution_options: list[ExecutionOption | str] | None = None, custom_parser: ArgParserProtocol | None = None, custom_help: Callable[[], str | None] | None = None, + custom_tldr: Callable[[], str | None] | None = None, + custom_usage: Callable[[], str | None] | None = None, auto_args: bool = True, arg_metadata: dict[str, str | dict[str, Any]] | None = None, simple_help_signature: bool = False, @@ -369,6 +394,9 @@ class CommandRunner: description (str): Short description of the command. action (BaseAction | Callable[..., Any]): Underlying execution logic for the command. + program (str | None): Program name used in CLI usage text, invocation-path + rendering, and built-in help output. If `None`, an empty program name is + used. runner_hooks (HookManager | None): Optional executor-level hooks for the runner. args (tuple): Static positional arguments applied to the command. @@ -418,6 +446,10 @@ class CommandRunner: implementation. custom_help (Callable[[], str | None] | None): Optional custom help renderer. + custom_tldr (Callable[[], str | None] | None): Optional custom TLDR + renderer. + custom_usage (Callable[[], str | None] | None): Optional custom usage + renderer. auto_args (bool): Whether to infer arguments automatically from the action signature. arg_metadata (dict[str, str | dict[str, Any]] | None): Optional @@ -432,8 +464,9 @@ class CommandRunner: CommandRunner: A runner wrapping the newly built command. Raises: - NotAFalyxError: If `runner_hooks` is provided but is not a - `HookManager` instance. + NotAFalyxError: If `arg_parser` is provided but is not a + `CommandArgumentParser` instance. + InvalidHookError: If `runner_hooks` is provided but is not a `HookManager` Notes: - This method is intended as a standalone convenience factory. @@ -445,6 +478,7 @@ class CommandRunner: key=key, description=description, action=action, + program=program, args=args, kwargs=kwargs, hidden=hidden, @@ -478,6 +512,8 @@ class CommandRunner: argument_config=argument_config, custom_parser=custom_parser, custom_help=custom_help, + custom_tldr=custom_tldr, + custom_usage=custom_usage, auto_args=auto_args, arg_metadata=arg_metadata, simple_help_signature=simple_help_signature, @@ -485,7 +521,7 @@ class CommandRunner: ) if runner_hooks and not isinstance(runner_hooks, HookManager): - raise NotAFalyxError("runner_hooks must be an instance of HookManager.") + raise InvalidHookError("runner_hooks must be an instance of HookManager.") return cls( command=command, diff --git a/falyx/completer.py b/falyx/completer.py index 07868b0..7852513 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -140,12 +140,11 @@ class FalyxCompleter(Completer): suggestions = self._suggest_namespace_entries(route.namespace, route.stub) # Only here should namespace-level help/TLDR be suggested. - if not route.command and (not route.stub or route.stub.startswith("-")): - suggestions.extend( - flag - for flag in ("-h", "--help", "-T", "--tldr") - if flag.startswith(route.stub) - ) + # TODO: better completer in FalyxParser + if not route.command: # and (not route.stub or route.stub.startswith("-")): + for flag in route.namespace.parser._options_by_dest: + if flag.startswith(route.stub): + suggestions.append(flag) if route.is_preview: suggestions = [f"?{s}" for s in suggestions] diff --git a/falyx/console.py b/falyx/console.py index 429f476..ff66566 100644 --- a/falyx/console.py +++ b/falyx/console.py @@ -2,6 +2,17 @@ """Global console instance for Falyx CLI applications.""" from rich.console import Console -from falyx.themes import get_nord_theme +from falyx.themes import OneColors, get_nord_theme console = Console(color_system="truecolor", theme=get_nord_theme()) +error_console = Console(color_system="truecolor", theme=get_nord_theme(), stderr=True) + + +def print_error( + message: str | Exception, + *, + hint: str | None = None, +) -> None: + error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}") + if hint: + error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}") diff --git a/falyx/context.py b/falyx/context.py index 2d92783..745d57c 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -10,8 +10,6 @@ It provides: - `SharedContext` for transient shared state across grouped or chained actions, including propagated results, indexed errors, and arbitrary shared data. - - `InvocationSegment` for representing a single styled token within a - rendered invocation path. - `InvocationContext` for capturing the current routed command path as an immutable value object that supports both plain-text and Rich-markup rendering. @@ -30,8 +28,10 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field from rich.console import Console from rich.markup import escape +from rich.style import Style from falyx.console import console +from falyx.display_types import StyledSegment from falyx.mode import FalyxMode @@ -292,24 +292,6 @@ class SharedContext(BaseModel): ) -class InvocationSegment(BaseModel): - """Styled path segment used to build an invocation display path. - - `InvocationSegment` represents a single token within an `InvocationContext`, - such as a namespace key, command key, or alias. It stores the raw display - text and an optional Rich style so invocation paths can be rendered either - as plain text or styled markup. - - Attributes: - text (str): Display text for this path segment. - style (str | None): Optional Rich style applied when rendering this - segment in markup output. - """ - - text: str - style: str | None = None - - class InvocationContext(BaseModel): """Immutable invocation-path context for routed Falyx help and execution. @@ -324,11 +306,11 @@ class InvocationContext(BaseModel): Attributes: program (str): Root program name used in CLI-mode help and usage output. - program_style (str): Rich style applied to the program name when rendering + program_style (Style | str): Rich style applied to the program name when rendering `markup_path`. typed_path (list[str]): Raw invocation tokens collected during routing, excluding the root program name. - segments (list[InvocationSegment]): Styled path segments used to render the + segments (list[StyledSegment]): Styled path segments used to render the invocation path with Rich markup. mode (FalyxMode): Active Falyx mode for this invocation context. This is used to determine whether the path should include the program name. @@ -337,12 +319,14 @@ class InvocationContext(BaseModel): """ program: str = "" - program_style: str = "" + program_style: Style | str = "" typed_path: list[str] = Field(default_factory=list) - segments: list[InvocationSegment] = Field(default_factory=list) + segments: list[StyledSegment] = Field(default_factory=list) mode: FalyxMode = FalyxMode.MENU is_preview: bool = False + model_config = ConfigDict(arbitrary_types_allowed=True) + @property def is_cli_mode(self) -> bool: """Whether this context should render using CLI path semantics. @@ -357,7 +341,7 @@ class InvocationContext(BaseModel): self, token: str, *, - style: str | None = None, + style: Style | str | None = None, ) -> InvocationContext: """Return a new context with one additional path segment appended. @@ -377,7 +361,7 @@ class InvocationContext(BaseModel): program=self.program, program_style=self.program_style, typed_path=[*self.typed_path, token], - segments=[*self.segments, InvocationSegment(text=token, style=style)], + segments=[*self.segments, StyledSegment(text=token, style=style)], mode=self.mode, is_preview=self.is_preview, ) @@ -427,7 +411,7 @@ class InvocationContext(BaseModel): In CLI mode, the root program name is included and styled with `program_style` when provided. Each path segment is escaped and styled - using its associated `InvocationSegment.style` value when present. + using its associated `StyledSegment.style` value when present. Returns: str: Rich-markup invocation path suitable for help and usage rendering. diff --git a/falyx/display_types.py b/falyx/display_types.py new file mode 100644 index 0000000..d8d64b6 --- /dev/null +++ b/falyx/display_types.py @@ -0,0 +1,33 @@ +# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed +"""Display types for Falyx. + +This module defines data models used for representing styled display elements in +Falyx's CLI output, such as command paths, namespaces, and TLDR examples. These +models are designed to be simple containers for the raw text and styling +information needed to render consistent and visually appealing CLI interfaces using +the Rich library. + +It provides: + - `StyledSegment` for representing a single styled token. +""" +from pydantic import BaseModel, ConfigDict +from rich.style import Style + + +class StyledSegment(BaseModel): + """Styled path segment used to build Rich styled markup. + + `StyledSegment` represents a single token. It stores the raw display + text and an optional Rich style so text can be rendered either + as plain text or styled markup. + + Attributes: + text (str): Display text for this path segment. + style (str | None): Optional Rich style applied when rendering this + segment in markup output. + """ + + text: str + style: Style | str | None = None + + model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/falyx/exceptions.py b/falyx/exceptions.py index 9652d63..2ff1a8d 100644 --- a/falyx/exceptions.py +++ b/falyx/exceptions.py @@ -17,7 +17,8 @@ Exception Hierarchy: ├── EmptyChainError ├── EmptyGroupError ├── EmptyPoolError - └── CommandArgumentError + ├── CommandArgumentError + └── EntryNotFoundError These are raised internally throughout the Falyx system to signal user-facing or developer-facing problems that should be caught and reported. @@ -25,7 +26,16 @@ developer-facing problems that should be caught and reported. class FalyxError(Exception): - """Custom exception for the Falyx class.""" + """Base exception class for all Falyx CLI framework errors.""" + + def __init__( + self, + message: str | None = None, + hint: str | None = None, + ): + if message: + super().__init__(message) + self.hint = hint class CommandAlreadyExistsError(FalyxError): @@ -60,5 +70,152 @@ class EmptyPoolError(FalyxError): """Exception raised when the pool is empty.""" -class CommandArgumentError(FalyxError): +class UsageError(FalyxError): + """Exception raised when there is an error in the command usage.""" + + def __init__( + self, + message: str | None = None, + hint: str | None = None, + show_short_usage: bool = True, + ): + super().__init__(message, hint) + self.show_short_usage = show_short_usage + + +class FalyxOptionError(UsageError): + """Exception raised when there is an error in the Falyx option parser.""" + + +class CommandArgumentError(UsageError): """Exception raised when there is an error in the command argument parser.""" + + +class ArgumentGroupError(CommandArgumentError): + """Exception raised when there is an error in the argument group.""" + + +class ArgumentParsingError(CommandArgumentError): + """Exception raised when there is an error during argument parsing.""" + + def __init__( + self, + message: str | None = None, + hint: str | None = None, + show_short_usage: bool = True, + command_key: str | None = None, + dest: str | None = None, + token: str | None = None, + ): + self.command_key = command_key + self.dest = dest + self.token = token + super().__init__(message, hint, show_short_usage) + + +class EntryNotFoundError(UsageError): + """Exception raised when a routing entry is not found.""" + + def __init__( + self, + unknown_name: str, + suggestions: list[str] | None = None, + message_context: str = "", + show_short_usage: bool = True, + ): + self.unknown_name = unknown_name + self.suggestions = suggestions + self.message_context = message_context + super().__init__( + self.build_message(), + self.build_hint(), + show_short_usage, + ) + + def build_message(self) -> str: + prefix = f"{self.message_context}: " if self.message_context else "" + return f"{prefix}unknown command or namespace '{self.unknown_name}'." + + def build_hint(self) -> str | None: + if self.suggestions: + return f"did you mean: {', '.join(self.suggestions[:10])}?" + else: + return None + + +class UnrecognizedOptionError(ArgumentParsingError): + def __init__( + self, + token: str, + remaining_flags: list[str] | None = None, + show_short_usage: bool = True, + ): + self.remaining_flags = remaining_flags + self.token = token + super().__init__( + self.build_message(), + self.build_hint(), + show_short_usage=show_short_usage, + token=token, + ) + + def build_message(self) -> str: + return f"unrecognized option '{self.token}'" + + def build_hint(self) -> str: + if self.remaining_flags: + return f"did you mean one of: {', '.join(self.remaining_flags)}?" + return "use --help to see available options" + + +class InvalidValueError(ArgumentParsingError): + def __init__( + self, + dest: str | None = None, + choices: list[str] | None = None, + expected: str | None = None, + error: Exception | str | None = None, + show_short_usage: bool = True, + ): + self.choices = choices + self.expected = expected + self.error = error + self.dest = dest + super().__init__( + self.build_message(), + self.build_hint(), + show_short_usage=show_short_usage, + dest=dest, + ) + + def build_message(self) -> str: + if self.dest and self.choices: + return f"invalid value for '{self.dest}'" + elif self.dest and self.error: + return f"invalid value for '{self.dest}': {self.error}" + elif self.dest and self.expected: + return f"invalid value for '{self.dest}': expected {self.expected}" + else: + return "invalid command argument value." + + def build_hint(self) -> str | None: + if self.dest and self.choices: + return f"the value for '{self.dest}' must be one of {{{', '.join(self.choices)}}}." + else: + return None + + +class MissingValueError(ArgumentParsingError): + def __init__( + self, + dest: str, + expected_count: int | None = None, + actual_count: int | None = None, + ): + self.expected_count = expected_count + self.actual_count = actual_count + self.dest = dest + + +class TokenizationError(UsageError): + raw_input: str | None = None diff --git a/falyx/falyx.py b/falyx/falyx.py index 50d0ff7..7c70791 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -80,10 +80,10 @@ from prompt_toolkit.shortcuts import CompleteStyle 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.style import StyleType from rich.table import Table from rich.text import Text @@ -95,15 +95,18 @@ from falyx.command import Command from falyx.command_executor import CommandExecutor from falyx.completer import FalyxCompleter from falyx.completer_types import CompletionRoute -from falyx.console import console +from falyx.console import console, error_console, print_error from falyx.context import InvocationContext from falyx.debug import log_after, log_before, log_error, log_success from falyx.exceptions import ( CommandAlreadyExistsError, CommandArgumentError, + EntryNotFoundError, FalyxError, InvalidActionError, + InvalidHookError, NotAFalyxError, + UsageError, ) from falyx.execution_option import ExecutionOption from falyx.execution_registry import ExecutionRegistry as er @@ -112,19 +115,20 @@ from falyx.logger import logger from falyx.mode import FalyxMode from falyx.namespace import FalyxNamespace from falyx.options_manager import OptionsManager -from falyx.parser import CommandArgumentParser, FalyxParser, RootParseResult -from falyx.parser.parser_types import FalyxTLDRExample, FalyxTLDRInput +from falyx.parser import CommandArgumentParser, FalyxParser, ParseResult +from falyx.parser.parser_types import FalyxTLDRInput from falyx.prompt_utils import rich_text_to_prompt_text from falyx.protocols import ArgParserProtocol from falyx.retry import RetryPolicy from falyx.routing import RouteKind, RouteResult -from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal +from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal from falyx.themes import OneColors -from falyx.utils import CaseInsensitiveDict, chunks, ensure_async +from falyx.utils import CaseInsensitiveDict, chunks from falyx.validators import CommandValidator from falyx.version import __version__ +# TODO: better OptionsManager determination (assert same instance across a namespace) class Falyx: """Primary controller for Falyx CLI applications. @@ -224,27 +228,32 @@ class Falyx: def __init__( self, - title: str | Markdown = "Menu", + title: str = "Menu", *, program: str | None = "falyx", usage: str | None = None, description: str | None = "Falyx CLI - Run structured async command workflows.", epilog: str | None = None, + caption: 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, + title_style: StyleType = "white bold", + program_style: StyleType = OneColors.BLUE_b, + usage_style: StyleType = "white", + description_style: StyleType = OneColors.BLUE, + epilog_style: StyleType = "white", + caption_style: StyleType = "white", + version_style: StyleType = OneColors.BLUE_b, prompt: str | StyleAndTextTuples = "> ", columns: int = 3, bottom_bar: BottomBar | str | Callable[[], Any] | None = None, - welcome_message: str | Markdown | dict[str, Any] = "", - exit_message: str | Markdown | dict[str, Any] = "", + welcome_message: str = "", + exit_message: str = "", key_bindings: KeyBindings | None = None, include_history_command: bool = True, never_prompt: bool = False, force_confirm: bool = False, + verbose: bool = False, + debug_hooks: bool = False, options: OptionsManager | None = None, render_menu: Callable[[Falyx], None] | None = None, custom_table: Callable[[Falyx], Table] | Table | None = None, @@ -253,6 +262,11 @@ class Falyx: prompt_history_base_dir: Path = Path.home(), enable_prompt_history: bool = False, enable_help_tips: bool = True, + default_to_menu: bool = True, + simple_usage: bool = False, + disable_verbose_option: bool = False, + disable_debug_hooks_option: bool = False, + disable_never_prompt_option: bool = False, ) -> None: """Initialize a Falyx application runtime. @@ -275,7 +289,7 @@ class Falyx: being executed in CLI or menu mode. Args: - title (str | Markdown): Title displayed for the interactive menu or top-level + title (str): Title displayed for the interactive menu or top-level application view. program (str | None): Program name used in CLI usage text, invocation-path rendering, and built-in help output. If `None`, an empty program name is @@ -287,11 +301,11 @@ class Falyx: epilog (str | None): Optional trailing help text rendered after the main help sections. version (str): Application version string used by the built-in version command. - program_style (str): Rich style used when rendering the program name. - usage_style (str): Rich style used for rendered usage text. - description_style (str): Rich style used for the program description. - epilog_style (str): Rich style used for the help epilog. - version_style (str): Rich style used for version output and version-related + program_style (StyleType): Rich style used when rendering the program name. + usage_style (StyleType): Rich style used for rendered usage text. + description_style (StyleType): Rich style used for the program description. + epilog_style (StyleType): Rich style used for the help epilog. + version_style (StyleType): Rich style used for version output and version-related rendering. prompt (str | StyleAndTextTuples): Prompt text or Prompt Toolkit formatted text shown in menu mode. @@ -300,9 +314,9 @@ class Falyx: bottom_bar (BottomBar | str | Callable[[], Any] | None): Bottom toolbar configuration for menu mode. May be a `BottomBar` instance, a static string, a callable renderer, or `None` to use the default bottom bar. - welcome_message (str | Markdown | dict[str, Any]): Optional welcome content + welcome_message (str): Optional welcome content rendered when entering the interactive menu. - exit_message (str | Markdown | dict[str, Any]): Optional exit content rendered + exit_message (str): Optional exit content rendered when leaving the interactive menu. key_bindings (KeyBindings | None): Optional Prompt Toolkit key bindings for menu interaction. If omitted, a default `KeyBindings` object is created. @@ -312,6 +326,8 @@ class Falyx: runtime option. force_confirm (bool): Default session-level value for the `force_confirm` runtime option. + verbose (bool): Default session-level value for the `verbose` runtime option. + debug_hooks (bool): Default session-level value for the `debug_hooks` runtime option. options (OptionsManager | None): Shared options manager for the application. If omitted, a new `OptionsManager` instance is created. render_menu (Callable[[Falyx], None] | None): Optional custom menu renderer @@ -327,6 +343,16 @@ class Falyx: to disk. enable_help_tips (bool): Whether to show contextual usage tips in rendered help output. + default_to_menu (bool): Whether to enter menu mode if no CLI arguments are + provided on startup. If `False`, the application will print help and + exit when no arguments are provided. + simple_usage (bool): Whether to use a simplified usage format in help output. + disable_verbose_option (bool): Whether to omit the built-in `--verbose` option + from the root parser. + disable_debug_hooks_option (bool): Whether to omit the built-in `--debug-hooks` + option from the root parser. + disable_never_prompt_option (bool): Whether to omit the built-in `--never-prompt` + option from the root parser. Raises: FalyxError: If the provided options object is invalid or other core runtime @@ -339,90 +365,69 @@ class Falyx: - The prompt session itself is created lazily, allowing UI-related state such as bottom bars and key bindings to be finalized before first use. """ - self.title: str | Markdown = title + self.title: str = title self.program: str = program or "" self.usage: str | None = usage self.description: str | None = description self.epilog: str | None = epilog + self.caption: str | None = caption 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.title_style: StyleType = title_style + self.program_style: StyleType = program_style + self.usage_style: StyleType = usage_style + self.description_style: StyleType = description_style + self.epilog_style: StyleType = epilog_style + self.caption_style: StyleType = caption_style + self.version_style: StyleType = 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.namespaces: dict[str, FalyxNamespace] = 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 + self.error_console: Console = error_console + self.welcome_message: str = welcome_message + self.exit_message: str = exit_message self.hooks: HookManager = HookManager() self.key_bindings: KeyBindings = key_bindings or KeyBindings() self.bottom_bar: BottomBar | str | Callable[[], None] | None = bottom_bar self._never_prompt: bool = never_prompt self._force_confirm: bool = force_confirm + self._verbose: bool = verbose + self._debug_hooks: bool = debug_hooks 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(options) self._prompt_session: PromptSession | None = None - self.options.set("mode", FalyxMode.MENU) + self.options.set("mode", FalyxMode.COMMAND) 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 = self._get_help_command() if enable_prompt_history: - program = (self.program or "falyx").split(".")[0].replace(" ", "_") + program = (program or "falyx").split(".")[0].replace(" ", "_") self.history_path: Path = ( Path(prompt_history_base_dir) / f".{program}_history" ) self.history: FileHistory | None = FileHistory(self.history_path) else: self.history = None - self.enable_help_tips = enable_help_tips - self._tldr_examples: list[FalyxTLDRExample] = [] + self.enable_help_tips: bool = enable_help_tips + self.default_to_menu: bool = default_to_menu + self.simple_usage: bool = simple_usage self._register_default_builtins() self._register_options() self._executor = CommandExecutor( options=self.options, hooks=self.hooks, - console=self.console, - ) - - def _print_suggestions_message( - self, - key: str, - suggestions: list[str], - message_context: str = "", - ) -> None: - """Render an unknown-entry message with optional suggestions. - - This helper standardizes the user-facing output shown when a command or - namespace token cannot be resolved. When suggestions are available, it - renders a "did you mean" style message; otherwise it prints a direct - not-found error. - - Args: - key (str): Raw token the user attempted to invoke. - suggestions (list[str]): Candidate entry names returned by resolution. - message_context (str): Optional label describing the lookup context, such as - "TLDR example". - """ - if message_context: - message_context = f"'{message_context}' " - if not suggestions: - self.console.print( - f"[{OneColors.DARK_RED}]❌ {message_context}No command or namespace found for '{key}'.[/]" - ) - return None - self.console.print( - f"[{OneColors.LIGHT_YELLOW}]⚠️ {message_context}Unknown command or namespace '{key}'.\nDid you mean: [/]" - f"{', '.join(suggestions)[:10]}" ) + self.disable_verbose_option: bool = disable_verbose_option + self.disable_debug_hooks_option: bool = disable_debug_hooks_option + self.disable_never_prompt_option: bool = disable_never_prompt_option + self.parser: FalyxParser = FalyxParser(self) def add_tldr_example( self, @@ -441,15 +446,15 @@ class Falyx: entry_key (str): Command or namespace key the example is associated with. usage (str): Example usage fragment shown after the resolved invocation path. description (str): Short explanation displayed alongside the example. + + Raises: + EntryNotFoundError: If `entry_key` cannot be resolved to a known command or + namespace in this `Falyx` instance. """ - entry, suggestions = self.resolve_entry(entry_key) - if not entry: - self._print_suggestions_message( - entry_key, suggestions, message_context="TLDR example" - ) - return None - self._tldr_examples.append( - FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description) + self.parser.add_tldr_example( + entry_key=entry_key, + usage=usage, + description=description, ) def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None: @@ -463,29 +468,10 @@ class Falyx: Raises: FalyxError: If an example has an unsupported shape. + EntryNotFoundError: If `entry_key` cannot be resolved to a known command or + namespace in this `Falyx` instance. """ - for example in examples: - if isinstance(example, FalyxTLDRExample): - entry, suggestions = self.resolve_entry(example.entry_key) - if not entry: - self._print_suggestions_message( - example.entry_key, suggestions, message_context="TLDR example" - ) - continue - self._tldr_examples.append(example) - elif len(example) == 3: - entry_key, usage, description = example - self.add_tldr_example( - entry_key=entry_key, - usage=usage, - description=description, - ) - else: - raise FalyxError( - f"Invalid TLDR example format: {example}. " - "Examples must be either FalyxTLDRExample instances " - "or tuples of (entry_key, usage, description).", - ) + self.parser.add_tldr_examples(examples) def get_current_invocation_context(self) -> InvocationContext: """Build the default invocation context for this namespace. @@ -526,11 +512,11 @@ class Falyx: options (OptionsManager | None): Optional options manager to reuse. Raises: - FalyxError: If `options` is provided but is not an `OptionsManager`. + NotAFalyxError: If `options` is provided but is not an `OptionsManager`. """ self.options: OptionsManager = options or OptionsManager() if not isinstance(self.options, OptionsManager): - raise FalyxError("Options must be an instance of OptionsManager.") + raise NotAFalyxError("options must be an instance of OptionsManager.") def _register_options(self) -> None: """Seed default application options and execution namespace values. @@ -547,6 +533,12 @@ class Falyx: if not self.options.get("force_confirm"): self.options.set("force_confirm", self._force_confirm) + if not self.options.get("verbose"): + self.options.set("verbose", self._verbose) + + if not self.options.get("debug_hooks"): + self.options.set("debug_hooks", self._debug_hooks) + if not self.options.get("hide_menu_table"): self.options.set("hide_menu_table", self._hide_menu_table) @@ -628,9 +620,9 @@ class Falyx: existing = mapping[norm] if existing is not entry: raise CommandAlreadyExistsError( - f"Identifier '{norm}' is already registered.\n" - f"Existing entry: {mapping[norm].key}\n" - f"New entry: {entry.key}" + f"identifier '{norm}' is already registered.\n" + f"existing entry: {mapping[norm].key}\n" + f"new entry: {entry.key}" ) else: mapping[norm] = entry @@ -695,59 +687,53 @@ class Falyx: Returns: Command: Configured history command instance. """ - parser = CommandArgumentParser( - command_key="Y", - command_description="History", - command_style=OneColors.DARK_YELLOW, - aliases=["HISTORY"], - program=self.program, - options_manager=self.options, - ) - parser.add_argument( - "-n", - "--name", - help="Filter by execution name.", - ) - parser.add_argument( - "-i", - "--index", - type=int, - help="Filter by execution index (0-based).", - ) - parser.add_argument( - "-s", - "--status", - choices=["all", "success", "error"], - default="all", - help="Filter by execution status (default: all).", - ) - parser.add_argument( - "-c", - "--clear", - action="store_true", - help="Clear the Execution History.", - ) - parser.add_argument( - "-r", - "--result-index", - type=int, - help="Get the result by index", - ) - parser.add_argument( - "-l", "--last-result", action="store_true", help="Get the last result" - ) - parser.add_tldr_examples( - [ - ("", "Show the full execution history."), - ("-n build", "Show history entries for the 'build' command."), - ("-s success", "Show only successful executions."), - ("-s error", "Show only failed executions."), - ("-i 3", "Show the history entry at index 3."), - ("-r 0", "Show the result or traceback for entry index 0."), - ("-l", "Show the last execution result."), - ("-c", "Clear the execution history."), - ] - ) + + def add_history_arguments(parser: CommandArgumentParser) -> None: + parser.add_argument( + "-n", + "--name", + help="Filter by execution name.", + ) + parser.add_argument( + "-i", + "--index", + type=int, + help="Filter by execution index (0-based).", + ) + parser.add_argument( + "-s", + "--status", + choices=["all", "success", "error"], + default="all", + help="Filter by execution status (default: all).", + ) + parser.add_argument( + "-c", + "--clear", + action="store_true", + help="Clear the Execution History.", + ) + parser.add_argument( + "-r", + "--result-index", + type=int, + help="Get the result by index", + ) + parser.add_argument( + "-l", "--last-result", action="store_true", help="Get the last result" + ) + parser.add_tldr_examples( + [ + ("", "Show the full execution history."), + ("-n build", "Show history entries for the 'build' command."), + ("-s success", "Show only successful executions."), + ("-s error", "Show only failed executions."), + ("-i 3", "Show the history entry at index 3."), + ("-r 0", "Show the result or traceback for entry index 0."), + ("-l", "Show the last execution result."), + ("-c", "Clear the execution history."), + ] + ) return Command( key="Y", @@ -755,7 +741,7 @@ class Falyx: aliases=["HISTORY"], action=Action(name="View Execution History", action=er.summary), style=OneColors.DARK_YELLOW, - arg_parser=parser, + argument_config=add_history_arguments, help_text="View the execution history of commands.", ignore_in_history=True, options_manager=self.options, @@ -806,6 +792,106 @@ class Falyx: ) return choice(tips) + def _get_command_keys_usage_string(self) -> str: + """Build a usage string fragment representing the available command keys. + + This method gathers all visible command and builtin keys, and formats them in a + '|' separated string suitable for inclusion in usage text. + + Returns: + str: Formatted usage fragment containing available command keys. + """ + keys = [ + f"[{command.style}]{command.key}[/{command.style}]" + for command in self.commands.values() + if not command.hidden + ] + keys.extend( + [ + f"[{namespace.style}]{namespace.key}[/{namespace.style}]" + for namespace in self.namespaces.values() + if not namespace.hidden + ] + ) + keys.extend( + [ + f"[{command.style}]{command.key}[/{command.style}]" + for command in self.builtins.values() + if not command.hidden + ] + ) + if not self._is_cli_mode: + if self.history_command and not self.history_command.hidden: + keys.append( + f"[{self.history_command.style}]{self.history_command.key}[/{self.history_command.style}]" + ) + keys.append( + f"[{self.exit_command.style}]{self.exit_command.key}[/{self.exit_command.style}]" + ) + return "|".join(keys) + + def _get_usage_fragment(self, invocation_context: InvocationContext) -> str: + """Build the default namespace usage fragment for the given context. + + Usage text will contain all commands and namespaces if `simple_usage` is + disabled, or a generic placeholder if `simple_usage` is enabled. + If `simple_usage` is enabled, the usage fragment is simplified to a generic + placeholder format. + + Args: + invocation_context (InvocationContext): Routed invocation context for + the current help target. + + Returns: + str: Escaped usage fragment suitable for Rich output. + """ + has_namespaces = any(not ns.hidden for ns in self.namespaces.values()) + + root_flags = " ".join( + f"{escape(f"[{flag}]")}" for flag in self.parser.get_flags() + ) + + if self.simple_usage: + target = "command or namespace" if has_namespaces else "command" + else: + target = self._get_command_keys_usage_string() + return f"{root_flags} <{target}> {escape('[args...]')}" + + def _get_usage( + self, + invocation_context: InvocationContext | None = None, + ) -> str: + """Build usage information for the current namespace. + + This method builds a usage string based on the current invocation context + and renders it to the console with appropriate styling. + + Args: + invocation_context (InvocationContext | None): Routed invocation context for + the current help target. + """ + invocation_context = invocation_context or self.get_current_invocation_context() + usage = self.usage or self._get_usage_fragment(invocation_context) + if self._is_cli_mode: + return f"[bold]usage:[/bold] {invocation_context.markup_path} [{self.usage_style}]{usage}[/{self.usage_style}]" + return f"[bold]usage:[/bold] [{self.usage_style}]{usage}[/{self.usage_style}]" + + def render_usage( + self, + invocation_context: InvocationContext | None = None, + ) -> None: + """Public method to render usage information for the current namespace. + + This method is a public wrapper around `_get_usage` that can be called + from commands or hooks to display usage information in the current context. + + Args: + invocation_context (InvocationContext | None): Routed invocation context for + the current help target. + """ + usage = self._get_usage(invocation_context) + console.print(usage) + async def _render_command_tldr( self, command: Command, @@ -818,21 +904,14 @@ class Falyx: Args: command (Command): Command whose TLDR output should be shown. - invocation_context (InvocationContext | None): Optional routed invocation context used to scope the - rendered usage path. + invocation_context (InvocationContext | None): Optional routed invocation + context used to scope the rendered usage path. """ - if not isinstance(command, Command): - self.console.print( - f"Entry '{command.key}' is not a command.", style=OneColors.DARK_RED - ) - return None - if command.render_tldr(invocation_context=invocation_context): + if command.render_tldr(invocation_context): if self.enable_help_tips: self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") else: - self.console.print( - f"[bold]No TLDR examples available for '{command.description}'.[/bold]" - ) + print_error(f"No TLDR examples available for '{command.description}'.") async def _render_command_help( self, @@ -845,25 +924,16 @@ class Falyx: Args: command (Command): Target command to render. tldr (bool): When `True`, render TLDR output instead of full help. - invocation_context (InvocationContext | None): Optional routed invocation context used to scope the - rendered usage path. + invocation_context (InvocationContext | None): Optional routed invocation + context used to scope the rendered usage path. """ - if not isinstance(command, Command): - self.console.print( - f"Entry '{command.key}' is not a command.", style=OneColors.DARK_RED - ) - return None if tldr: - await self._render_command_tldr( - command, invocation_context=invocation_context - ) - elif command.render_help(invocation_context=invocation_context): + await self._render_command_tldr(command, invocation_context) + elif command.render_help(invocation_context): if self.enable_help_tips: self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}") else: - self.console.print( - f"[bold]No detailed help available for '{command.description}'.[/bold]" - ) + print_error(f"No detailed help available for '{command.description}'.") async def _render_tag_help(self, tag: str) -> None: """Render all visible commands associated with a tag. @@ -895,29 +965,26 @@ class Falyx: if self.enable_help_tips: self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") - async def _render_menu_help(self) -> None: + async def _render_menu_help(self, invocation_context: InvocationContext) -> None: """Render the interactive menu-style help view for this namespace. The menu help view displays user commands plus the special help, history, and exit entries using panel-based Rich rendering. """ - self.console.print("[bold]help:[/bold]") - for command in self.commands.values(): - usage, description, tag = command.help_signature + self.render_usage(invocation_context) + if self.description: self.console.print( - Padding( - Panel( - usage, - expand=False, - title=description, - title_align="left", - subtitle=tag, - ), - (0, 2), - ) + f"\n[{self.description_style}]{self.description}[/{self.description_style}]" ) - if self.help_command: - usage, description, _ = self.help_command.help_signature + + # TODO: implement self.parser.render_options_help() and include it here if options are registered at the namespace level + self.console.print("\n[bold]global options:[/bold]") + for option in self.parser.get_options(): + self.console.print(f" {option.format_for_help():<22}{option.help}") + + self.console.print("\n[bold]builtin commands:[/bold]") + for command in self.builtins.values(): + usage, description, _ = command.help_signature self.console.print( Padding( Panel(usage, expand=False, title=description, title_align="left"), @@ -939,29 +1006,38 @@ class Falyx: (0, 2), ) ) + if self.namespaces: + self.console.print("\n[bold]namespaces:[/bold]") + for namespace in self.namespaces.values(): + usage, description, _ = namespace.get_help_signature(invocation_context) + self.console.print( + Padding( + Panel(usage, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) + + if self.commands: + self.console.print("\n[bold]commands:[/bold]") + for command in self.commands.values(): + usage, description, tag = command.help_signature + self.console.print( + Padding( + Panel( + usage, + expand=False, + title=description, + title_align="left", + subtitle=tag, + ), + (0, 2), + ) + ) + + if self.epilog: + self.console.print(f"\n{self.epilog}", style=self.epilog_style) if self.enable_help_tips: - self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") - - def _get_usage(self, invocation_context: InvocationContext) -> str: - """Build the default namespace usage fragment for the given context. - - Usage text is aware of whether the current namespace exposes nested - namespaces and whether rendering is happening in CLI or menu mode. - - Args: - invocation_context (InvocationContext): Routed invocation context for the current help - target. - - Returns: - str: Escaped usage fragment suitable for Rich output. - """ - has_namespaces = any(not ns.hidden for ns in self.namespaces.values()) - target = "command" if not has_namespaces else "command or namespace" - if not invocation_context.typed_path and invocation_context.is_cli_mode: - return escape(f"[-h] [-T] [-v] [-d] [-n] <{target}> [args...]") - elif not invocation_context.typed_path: - return escape(f"[-h] [-T] <{target}> [args...]") - return escape(f"<{target}> [args...]") + self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}") async def _render_namespace_tldr_help( self, invocation_context: InvocationContext @@ -972,42 +1048,39 @@ class Falyx: examples using the routed invocation path supplied by the context. Args: - invocation_context (InvocationContext): Routed invocation context for the namespace being - rendered. + invocation_context (InvocationContext): Routed invocation context for the + namespace being rendered. """ - if not self._tldr_examples: + if not self.parser.tldr_option: self.console.print( - f"[bold]No TLDR examples available for '{self._get_title()}'.[/bold]" + f"[bold]No TLDR examples available for '{self.title}'.[/bold]" ) return None - usage = self.usage or self._get_usage(invocation_context) + self.render_usage(invocation_context) prefix = invocation_context.markup_path - self.console.print( - f"[bold]usage:[/bold] {prefix} [{self.usage_style}]{usage}[/{self.usage_style}]" - ) if self.description: self.console.print( f"\n[{self.description_style}]{self.description}[/{self.description_style}]" ) - if self._tldr_examples: - self.console.print("\n[bold]examples:[/bold]") - for example in self._tldr_examples: - entry, suggestions = self.resolve_entry(example.entry_key) - if not entry: - self._print_suggestions_message( - example.entry_key, suggestions, message_context="TLDR example" - ) - continue - command = f"[{entry.style}]{example.entry_key}[/{entry.style}]" - usage = f"{prefix} {command} {example.usage.strip()}" - description = example.description.strip() - block = f"[bold]{usage}[/bold]" - self.console.print( - Padding( - Panel(block, expand=False, title=description, title_align="left"), - (0, 2), - ) + self.console.print("\n[bold]examples:[/bold]") + for example in self.parser._tldr_examples: + entry, suggestions = self.resolve_entry(example.entry_key) + if not entry: + raise EntryNotFoundError( + unknown_name=example.entry_key, + suggestions=suggestions, + message_context="TLDR example", ) + command = f"[{entry.style}]{example.entry_key}[/{entry.style}]" + usage = f"{prefix} {command} {example.usage.strip()}" + description = example.description.strip() + block = f"[bold]{usage}[/bold]" + self.console.print( + Padding( + Panel(block, expand=False, title=description, title_align="left"), + (0, 2), + ) + ) async def render_namespace_help( self, @@ -1020,15 +1093,15 @@ class Falyx: menu-style help, or CLI-style help rendering. Args: - invocation_context (InvocationContext | None): Optional routed invocation context. When omitted, a - fresh root context is created. + invocation_context (InvocationContext | None): Optional routed invocation + context. When omitted, a fresh root context is created. tldr (bool): Whether to render namespace TLDR output instead of standard help. """ invocation_context = invocation_context or self.get_current_invocation_context() if tldr: await self._render_namespace_tldr_help(invocation_context) elif invocation_context.mode is FalyxMode.MENU: - await self._render_menu_help() + await self._render_menu_help(invocation_context) else: await self._render_cli_help(invocation_context) @@ -1039,35 +1112,22 @@ class Falyx: user commands, and optional epilog content. Args: - invocation_context (InvocationContext): Routed invocation context used to render the current - invocation path. + invocation_context (InvocationContext): Routed invocation context used to + render the current invocation path. """ - usage = self.usage or self._get_usage(invocation_context) - self.console.print( - f"[bold]usage:[/bold] {invocation_context.markup_path} [{self.usage_style}]{usage}[/{self.usage_style}]" - ) + self.render_usage(invocation_context) if self.description: self.console.print( f"\n[{self.description_style}]{self.description}[/{self.description_style}]" ) + # TODO: implement self.parser.render_options_help() and include it here if options are registered at the namespace level 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" {'-T, --tldr':<22}{'Show quick usage examples 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.'}" - ) + for option in self.parser.get_options(): + self.console.print(f" {option.format_for_help():<22}{option.help}") + 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) + builtin_alias = Text(command.primary_alias, style=command.style) line = Text(" ") line.append(builtin_alias) @@ -1075,6 +1135,17 @@ class Falyx: line.append(command.help_text) self.console.print(line) + if self.namespaces: + self.console.print("\n[bold]namespaces:[/bold]") + for namespace in self.namespaces.values(): + line = Text(" ") + line.append(namespace.key, style=namespace.style) + for alias in namespace.aliases: + line.append(" | ", style="dim") + line.append(alias, style=namespace.style) + line.pad_right(24 - len(line.plain)) + line.append(namespace.description or "") + self.console.print(line) if self.commands: self.console.print("\n[bold]commands:[/bold]") for command in self.commands.values(): @@ -1141,6 +1212,10 @@ class Falyx: tldr (bool): Whether targeted command help should use TLDR output. namespace_tldr (bool): Whether top-level namespace help should use TLDR output. invocation_context (InvocationContext | None): Optional routed invocation context. + + Raises: + EntryNotFoundError: If `key` is provided but cannot be resolved to a known command + or namespace in this scope. """ context = invocation_context or self.get_current_invocation_context() if key: @@ -1164,7 +1239,10 @@ class Falyx: ) else: await self.render_namespace_help(base_context) - self._print_suggestions_message(key, suggestions) + raise EntryNotFoundError( + unknown_name=key, + suggestions=suggestions, + ) return None elif tldr: await self._render_command_help( @@ -1186,38 +1264,46 @@ class Falyx: Returns: Command: Configured help command instance. """ - parser = CommandArgumentParser( - command_key="H", - command_description="Help", - command_style=OneColors.LIGHT_YELLOW, - aliases=["HELP", "?"], - program=self.program, - options_manager=self.options, - ) - parser.mark_as_help_command() - parser.add_argument( - "-t", - "--tag", - nargs="?", - default="", - help="Optional tag to filter commands by.", - ) - parser.add_argument( - "-k", - "--key", - nargs="?", - default=None, - help="Optional command key or alias to get detailed help for.", - ) - parser.add_tldr_examples( - [ - ("", "Show all commands."), - ("-k [COMMAND]", "Show detailed help for a specific command."), - ("-Tk [COMMAND]", "Show quick usage examples for a specific command."), - ("-T", "Show these quick usage examples."), - ("-t [TAG]", "Show commands with the specified tag."), - ] - ) + + def add_help_arguments(parser: CommandArgumentParser): + parser.mark_as_help_command() + parser.add_argument( + "--namespace-tldr", + "-N", + action="store_true", + help="Show TLDR examples for the namespace instead of full help.", + ) + parser.add_argument( + "-t", + "--tag", + nargs="?", + default="", + help="Optional tag to filter commands by.", + ) + parser.add_argument( + "-k", + "--key", + nargs="?", + default=None, + help="Optional command key or alias to get detailed help for.", + ) + parser.add_tldr_examples( + [ + ("", "Show all commands."), + ("-k [COMMAND]", "Show detailed help for a specific command."), + ( + "-Tk [COMMAND]", + "Show quick usage examples for a specific command.", + ), + ("-T", "Show these quick usage examples."), + ("-t [TAG]", "Show commands with the specified tag."), + ("-N", "Show TLDR examples for the current namespace."), + ] + ) + tldr_argument = parser.get_argument("tldr") + if tldr_argument: + tldr_argument.help = "Show TLDR examples instead of full help." + return Command( key="H", aliases=["HELP", "?"], @@ -1225,7 +1311,7 @@ class Falyx: help_text="Show this help menu.", action=Action("Help", self.render_help), style=OneColors.LIGHT_YELLOW, - arg_parser=parser, + argument_config=add_help_arguments, ignore_in_history=True, options_manager=self.options, program=self.program, @@ -1242,15 +1328,14 @@ class Falyx: """ entry, suggestions = self.resolve_entry(key) if isinstance(entry, FalyxNamespace): - self.console.print( - f"❌ Entry '{key}' is a namespace. Please specify a command to preview.", - style=OneColors.DARK_RED, - ) + raise FalyxError("preview mode is only supported for commands.") elif isinstance(entry, Command): - self.console.print(f"Preview of command '{entry.key}': {entry.description}") await entry.preview() else: - self._print_suggestions_message(key, suggestions) + raise EntryNotFoundError( + unknown_name=key, + suggestions=suggestions, + ) def _get_preview_command(self) -> Command: """Create the built-in preview command. @@ -1261,33 +1346,28 @@ class Falyx: Returns: Command: Configured preview command instance. """ - 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( - "key", - help="The key or alias of the command to preview.", - ) - preview_parser.add_tldr_examples( - [ - ("[COMMAND]", "Preview the execution of a specific command."), - ] - ) + + def add_preview_argument(parser: CommandArgumentParser): + parser.add_argument( + "key", + help="The key or alias of the command to preview.", + ) + parser.add_tldr_examples( + [ + ("", "Preview the execution of a specific command."), + ] + ) + preview_command = Command( - key="preview", + key="PVW", description="Preview", + aliases=["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, + argument_config=add_preview_argument, ) return preview_command @@ -1302,11 +1382,11 @@ class Falyx: Command: Configured version command instance. """ version_command = Command( - key="version", + key="VER", description="Version", + aliases=["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, @@ -1413,7 +1493,7 @@ class Falyx: self._bottom_bar = bottom_bar else: raise FalyxError( - "Bottom bar must be a string, callable, None, or BottomBar instance." + "bottom_bar must be a string, callable, None, or BottomBar instance." ) self._invalidate_prompt_session_cache() @@ -1473,12 +1553,12 @@ class Falyx: hooks (Hook | list[Hook]): Single hook or list of hooks to apply recursively. Raises: - InvalidActionError: If any supplied hook is not callable. + InvalidHookError: If any supplied hook is not callable. """ hook_list = hooks if isinstance(hooks, list) else [hooks] for hook in hook_list: if not callable(hook): - raise InvalidActionError("Hook must be a callable.") + raise InvalidHookError("hooks must be a callable.") self.hooks.register(hook_type, hook) for command in self.commands.values(): command.hooks.register(hook_type, hook) @@ -1511,10 +1591,10 @@ class Falyx: aliases = [alias.upper() for alias in (aliases or [])] if len(set(aliases)) != len(aliases): - raise CommandAlreadyExistsError("Duplicate aliases provided.") + raise CommandAlreadyExistsError("duplicate aliases provided.") if key in aliases: - raise CommandAlreadyExistsError("Command key cannot also be an alias.") + raise CommandAlreadyExistsError("command key cannot also be an alias.") existing_names = set() @@ -1539,7 +1619,7 @@ class Falyx: if collisions: raise CommandAlreadyExistsError( - f"Command identifiers {sorted(collisions)} already exist." + f"command identifiers {sorted(collisions)} already exist." ) def update_exit_command( @@ -1574,7 +1654,7 @@ class Falyx: self._validate_command_aliases(key, aliases) action = action or SignalAction(description, QuitSignal()) if not callable(action): - raise InvalidActionError("Action must be a callable.") + raise InvalidActionError("action must be a callable.") self.exit_command = Command( key=key, description=description, @@ -1600,6 +1680,7 @@ class Falyx: style: str | None = None, aliases: list[str] | None = None, help_text: str = "", + hidden: bool = False, ) -> None: """Register a nested `Falyx` instance as a namespace entry. @@ -1611,9 +1692,11 @@ class Falyx: key (str): Namespace key used to enter the submenu. description (str): User-facing namespace description. submenu (Falyx): Nested `Falyx` instance to register. - style (str | None): Optional style override for the namespace entry. + style (StyleType | None): Optional style override for the namespace entry. aliases (list[str] | None): Optional aliases for the namespace. help_text (str): Optional help text for namespace listings. + hidden (bool): Where the namespace should be omitted from visible menus and + help listings. Raises: NotAFalyxError: If `submenu` is not a `Falyx` instance. @@ -1630,6 +1713,7 @@ class Falyx: aliases=aliases or [], help_text=help_text or f"Open the {description} namespace.", style=style or submenu.program_style, + hidden=hidden, ) self.namespaces[key] = entry @@ -1646,8 +1730,8 @@ class Falyx: """Register multiple commands from instances or config dictionaries. Args: - commands (list[Command] | list[dict]): Sequence of `Command` objects or `add_command()` keyword - dictionaries. + commands (list[Command] | list[dict]): Sequence of `Command` objects or + `add_command()` keyword dictionaries. Raises: FalyxError: If an element is neither a `Command` nor a configuration @@ -1660,7 +1744,7 @@ class Falyx: self.add_command_from_command(command) else: raise FalyxError( - "Command must be a dictionary or an instance of Command." + "command must be a dictionary or an instance of Command." ) def add_command_from_command(self, command: Command) -> None: @@ -1716,6 +1800,8 @@ class Falyx: execution_options: list[ExecutionOption | str] | None = None, custom_parser: ArgParserProtocol | None = None, custom_help: Callable[[], str | None] | None = None, + custom_tldr: Callable[[], str | None] | None = None, + custom_usage: Callable[[], str | None] | None = None, auto_args: bool = True, arg_metadata: dict[str, str | dict[str, Any]] | None = None, simple_help_signature: bool = False, @@ -1763,6 +1849,8 @@ class Falyx: execution_options (list[ExecutionOption | str] | None): Optional execution-level options to enable. custom_parser (ArgParserProtocol | None): Optional parser override for full custom argument parsing. custom_help (Callable[[], str | None] | None): Optional custom help renderer. + custom_tldr (Callable[[], str | None] | None): Optional custom TLDR renderer. + custom_usage (Callable[[], str | None] | None): Optional custom usage renderer. auto_args (bool): Whether argument inference should run automatically. arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata used during argument inference. simple_help_signature (bool): Whether command listings should use compact help. @@ -1809,6 +1897,8 @@ class Falyx: argument_config=argument_config, custom_parser=custom_parser, custom_help=custom_help, + custom_tldr=custom_tldr, + custom_usage=custom_usage, execution_options=execution_options, auto_args=auto_args, arg_metadata=arg_metadata, @@ -1883,7 +1973,14 @@ class Falyx: Returns: Table: Default menu table for the current namespace. """ - table = Table(title=self.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type] + table = Table( + title=self.title, + show_header=False, + box=box.SIMPLE, + title_style=self.title_style, + caption=self.caption, + caption_style=self.caption_style, + ) visible = self._iter_visible_entries() for chunk in chunks(visible, self.columns): row = [] @@ -1991,7 +2088,7 @@ class Falyx: *, mode: FalyxMode | None = None, from_validate: bool = False, - ) -> tuple[RouteResult | None, tuple, dict[str, Any], dict[str, Any]]: + ) -> tuple[RouteResult, tuple, dict[str, Any], dict[str, Any]]: """Tokenize input, resolve a route, and parse leaf-command arguments. This is the main preparation boundary between raw user input and executable @@ -2010,8 +2107,12 @@ class Falyx: errors instead of normal runtime output. Returns: - tuple[RouteResult | None, tuple, dict[str, Any], dict[str, Any]]: + tuple[RouteResult, tuple, dict[str, Any], dict[str, Any]]: Resolved route, positional args, keyword args, and execution args. + + Raises: + ValidationError: If `from_validate` is `True` and tokenization or argument parsing fails. + CommandArgumentError: If `from_validate` is `False` and argument parsing fails """ args: tuple = () kwargs: dict[str, Any] = {} @@ -2022,25 +2123,22 @@ class Falyx: except ValueError as error: if from_validate: raise ValidationError( - cursor_position=len(raw_arguments), message=str(error) + cursor_position=len(raw_arguments), message=f"{error}" ) from error - self.console.print( - f"Parse error: {error}", - style=OneColors.DARK_RED, - ) - return None, args, kwargs, execution_args + raise UsageError(str(error)) from error elif isinstance(raw_arguments, list): tokens = raw_arguments else: if from_validate: - raise ValidationError( - cursor_position=len(raw_arguments), - message="TypeError", - ) - return None, args, kwargs, execution_args + assert ( + False + ), "Validator can only pass a string or list of strings as raw_arguments." + raise UsageError( + "raw_arguments must be a string or list of strings." + ) from TypeError("invalid type for raw_arguments") is_preview = False - if tokens and tokens[0].startswith("?"): + if tokens and tokens[0].startswith("?") and len(tokens[0]) > 1: is_preview = True tokens[0] = tokens[0][1:] @@ -2052,10 +2150,21 @@ class Falyx: is_preview=is_preview, ) - route = await self.resolve_route(tokens, invocation_context=context) + try: + route = await self.resolve_route( + tokens, + invocation_context=context, + is_preview=is_preview, + ) + except FalyxError as error: + if from_validate: + hint = f" hint: {error.hint}" if error.hint else "" + raise ValidationError( + cursor_position=len(raw_arguments), message=f"{error}{hint}" + ) from error + raise - if is_preview: - route.is_preview = True + if route.is_preview: return route, args, kwargs, execution_args if route.kind is RouteKind.COMMAND: @@ -2068,14 +2177,11 @@ class Falyx: ) except CommandArgumentError as error: if from_validate: + hint = f" hint: {error.hint}" if error.hint else "" raise ValidationError( - cursor_position=len(raw_arguments), message=str(error) + cursor_position=len(raw_arguments), message=f"{error}{hint}" ) from error else: - route.command.render_help(invocation_context=route.context) - self.console.print( - f"[{OneColors.DARK_RED}]❌ [{route.command.key}]: {error}" - ) raise error except HelpSignal: if not from_validate: @@ -2084,17 +2190,26 @@ class Falyx: return route, args, kwargs, execution_args - async def _render_unknown_route(self, route: RouteResult) -> None: + async def _render_unknown_route( + self, + route: RouteResult, + ) -> None: """Render help plus suggestions for an unresolved route. Args: route (RouteResult): Unknown route returned by namespace resolution. + + Raises: + FalyxError: If the route is a preview route, which cannot be rendered. + EntryNotFoundError: If the route is unknown and cannot be resolved. """ - context = route.context - typed_key = context.typed_path[0].upper() - await route.namespace.render_namespace_help(context) - self._print_suggestions_message(typed_key, route.suggestions) - return None + if route.kind is RouteKind.NAMESPACE_MENU: + raise FalyxError("preview mode is only supported for commands.") + else: + raise EntryNotFoundError( + unknown_name=route.current_head, + suggestions=route.suggestions, + ) async def _dispatch_route( self, @@ -2117,8 +2232,8 @@ class Falyx: route (RouteResult): Prepared route to dispatch. args (tuple): Positional arguments prepared for a leaf command. kwargs (dict[str, Any] | None): Keyword arguments prepared for a leaf command. - execution_args (dict[str, Any] | None): Execution-only arguments such as confirmation or retry - overrides. + execution_args (dict[str, Any] | None): Execution-only arguments such as + confirmation or retry overrides. raise_on_error (bool): Whether executor errors should be re-raised. wrap_errors (bool): Whether executor errors should be wrapped as `FalyxError`. summary_last_result (bool): Whether summary output should only have the last @@ -2126,7 +2241,27 @@ class Falyx: Returns: Any | None: Command result for executed leaf commands, otherwise `None`. + + Raises: + FalyxError: If the route is invalid for preview or if execution fails and + `wrap_errors` is `True`. + Exception: If execution fails and `raise_on_error` is `True` and + `wrap_errors` is `False`. + EntryNotFoundError: If the route is unknown and cannot be resolved. + KeyboardInterrupt: If execution is interrupted by the user and `wrap_errors` + is `False`. + EOFError: If execution receives an unexpected end of input and `wrap_errors` + is `False`. """ + if route.is_preview: + if route.kind is RouteKind.COMMAND and route.command: + logger.info("preview command '%s' selected.", route.command.key) + await route.command.preview() + else: + logger.info("preview route selected with no command.") + await self._render_unknown_route(route) + return None + if route.kind is RouteKind.NAMESPACE_MENU: await route.namespace.menu() return None @@ -2145,20 +2280,10 @@ class Falyx: if route.kind is RouteKind.COMMAND: if not route.command: - self.console.print( - f"[{OneColors.DARK_RED}]Error: No command specified for execution mode.[/]" - ) - if wrap_errors or raise_on_error: - raise FalyxError - return None + raise FalyxError("invalid route: command expected but not found.") command = route.command - if route.is_preview: - logger.info("Preview command '%s' selected.", command.key) - await command.preview() - return None - if command is route.namespace.help_command: kwargs = kwargs or {} kwargs["invocation_context"] = route.context @@ -2221,24 +2346,31 @@ class Falyx: Raises: QuitSignal: If the resolved command is the configured exit command. + FalyxError: If the route is invalid for preview or if execution fails and + `wrap_errors` is `True`. + Exception: If execution fails and `raise_on_error` is `True` and + `wrap_errors` is `False`. + KeyboardInterrupt: If execution is interrupted by the user and + `wrap_errors` is `False`. + EOFError: If execution receives an unexpected end of input and + `wrap_errors` is `False`. 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. + command from a raw input string outside the interactive menu loop. + - One of the flags `raise_on_error` or `wrap_errors` must be `True` to + ensure that errors are properly handled. """ - try: - route, args, kwargs, execution_args = await self.prepare_route( - raw_arguments, mode=mode + if not (raise_on_error or wrap_errors): + raise FalyxError( + "Falyx.execute_command() requires either raise_on_error=True " + "or wrap_errors=True." ) - except (CommandArgumentError, Exception): - return None - except HelpSignal: - return None + route, args, kwargs, execution_args = await self.prepare_route( + raw_arguments, mode=mode + ) - if route is None: - return None + assert route is not None, "prepare_route should never return None." return await self._dispatch_route( route=route, @@ -2285,7 +2417,7 @@ class Falyx: if entry is None: # Still routing namespace entries; could not resolve this token. - # Let the completer suggest entries or namespace-level help flags. + # Let the completer suggest entries or namespace-level flags. return CompletionRoute( namespace=namespace, context=route_context, @@ -2332,6 +2464,7 @@ class Falyx: tokens: list[str], *, invocation_context: InvocationContext, + is_preview: bool = False, ) -> RouteResult: """Resolve an invocation path across namespaces until a leaf boundary. @@ -2346,60 +2479,73 @@ class Falyx: Args: tokens (list[str]): Remaining tokens to route. invocation_context (InvocationContext): Routed context accumulated so far. - + is_preview (bool): Whether the input is preview-prefixed. Returns: RouteResult: Final routed result for the supplied token path. """ - # 1. No more tokens -> this namespace itself was targeted + # 1. Namespace-level parsing for help/tldr flags and root/session options + parse_result = self.parser.parse_args(tokens) + self.parser.apply_to_options(parse_result, self.options) + tokens = parse_result.remaining_argv + + # 2. Help or TLDR requested for this namespace + if parse_result.help: + return RouteResult( + kind=RouteKind.NAMESPACE_HELP, + namespace=self, + context=invocation_context, + current_head=parse_result.current_head, + is_preview=is_preview, + ) + if parse_result.tldr: + return RouteResult( + kind=RouteKind.NAMESPACE_TLDR, + namespace=self, + context=invocation_context, + current_head=parse_result.current_head, + is_preview=is_preview, + ) + + # 3. No more tokens -> this namespace itself was targeted if not tokens: return RouteResult( kind=RouteKind.NAMESPACE_MENU, namespace=self, context=invocation_context, + is_preview=is_preview, ) head, *tail = tokens - # 2. Namespace-level help/tldr belongs to the current namespace - if head in {"-h", "--help"}: - return RouteResult( - kind=RouteKind.NAMESPACE_HELP, - namespace=self, - context=invocation_context, - ) - - if head in {"-T", "--tldr"}: - return RouteResult( - kind=RouteKind.NAMESPACE_TLDR, - namespace=self, - context=invocation_context, - ) - - # 3. Resolve the next entry in this namespace + # 4. Resolve the next entry in this namespace entry, suggestions = self.resolve_entry(head) if entry is None: return RouteResult( kind=RouteKind.UNKNOWN, namespace=self, context=invocation_context, + current_head=head, suggestions=suggestions, + is_preview=is_preview, ) route_context = invocation_context.with_path_segment(head, style=entry.style) - # 4. Namespace entry -> recurse with remaining tokens + # 5. Namespace entry -> recurse with remaining tokens if isinstance(entry, FalyxNamespace): return await entry.namespace.resolve_route( - tail, invocation_context=route_context + tail, invocation_context=route_context, is_preview=is_preview ) - # 5. Leaf command -> stop routing; leave tail untouched for leaf parser + # 6. Leaf command -> stop routing; leave tail untouched for leaf parser return RouteResult( kind=RouteKind.COMMAND, namespace=self, context=route_context, command=entry, leaf_argv=tail, + current_head=head, + is_preview=is_preview, ) async def _process_command(self) -> None: @@ -2413,49 +2559,15 @@ class Falyx: app.invalidate() with patch_stdout(raw=True): 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, - ) - - def _print_message(self, message: str | Markdown | dict[str, Any]) -> None: - """Print a startup or exit message using the configured console. - - Args: - message (str | Markdown | dict[str, Any]): Plain string, `Markdown`, - or a Rich-print argument dictionary. - - Raises: - TypeError: If the message is not a supported type. - """ - if isinstance(message, (str, Markdown)): - self.console.print(message) - elif isinstance(message, dict): - self.console.print( - *message.get("args", tuple()), - **message.get("kwargs", {}), + try: + await self.execute_command( + raw_arguments, + raise_on_error=False, + wrap_errors=True, + summary_last_result=True, ) - else: - raise TypeError( - "Message must be a string, Markdown, or dictionary with args and kwargs." - ) - - def _get_title(self) -> str: - """Return the menu title as plain text. - - This normalizes string and `Markdown` title inputs into a single text value - for logging and display helpers. - - Returns: - str: Plain-text title for the current namespace. - """ - if isinstance(self.title, str): - return self.title - elif isinstance(self.title, Markdown): - return self.title.markup - return self.title + except FalyxError as error: + print_error(message=error) async def menu(self) -> None: """Run the interactive menu loop for this namespace. @@ -2464,10 +2576,10 @@ class Falyx: session, handles navigation and cancellation signals, and prints optional welcome and exit messages. """ - logger.info("Starting menu: %s", self._get_title()) + logger.info("Starting menu: %s", self.title) self.options.set("mode", FalyxMode.MENU) if self.welcome_message: - self._print_message(self.welcome_message) + self.console.print(self.welcome_message) try: while True: if not self.options.get("hide_menu_table", self._hide_menu_table): @@ -2480,6 +2592,8 @@ class Falyx: except (EOFError, KeyboardInterrupt): logger.info("EOF or KeyboardInterrupt. Exiting menu.") break + except HelpSignal: + logger.info("[HelpSignal]. <- Returning to the menu.") except QuitSignal: logger.info("[QuitSignal]. <- Exiting menu.") break @@ -2490,18 +2604,18 @@ class Falyx: except asyncio.CancelledError: logger.info("[asyncio.CancelledError]. <- Returning to the menu.") finally: - logger.info("Exiting menu: %s", self._get_title()) + logger.info("Exiting menu: %s", self.title) if self.exit_message: - self._print_message(self.exit_message) + self.console.print(self.exit_message) - def _apply_parse_result(self, result: RootParseResult) -> None: + def _apply_parse_result(self, result: ParseResult) -> None: """Apply parsed root/session options to runtime state. This updates the active mode, logging verbosity, debug-hook registration, and prompt behavior based on the root parse result. Args: - result (RootParseResult): Parsed root CLI result to apply. + result (ParseResult): Parsed root CLI result to apply. """ self.options.set("mode", result.mode) @@ -2521,11 +2635,7 @@ class Falyx: if result.never_prompt: self.options.set("never_prompt", True) - async def run( - self, - callback: Callable[..., Any] | None = None, - always_start_menu: bool = False, - ) -> None: + async def run(self, always_start_menu: bool = False) -> None: """Execute the Falyx application using CLI-driven dispatch. This method is the primary entrypoint for Falyx applications. @@ -2538,21 +2648,14 @@ class Falyx: - exits with CLI-appropriate status codes - optionally falls through to interactive menu mode - 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: - callback (Callable[..., Any] | None): - Optional function invoked after CLI parsing with the `ParseResult`. always_start_menu (bool): Whether to enter menu mode after a successful command dispatch when the route itself does not already target help or a namespace menu. Raises: FalyxError: - If callback is invalid or command execution fails. + If command execution fails. SystemExit: Terminates the process with an appropriate exit code based on mode. @@ -2568,35 +2671,23 @@ class Falyx: >>> asyncio.run(flx.run()) ``` """ - parse_result = FalyxParser.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(parse_result) - - self._apply_parse_result(parse_result) - - if parse_result.mode == FalyxMode.HELP: - await self.render_help(namespace_tldr=parse_result.tldr_requested) + if not sys.argv[1:] and not self.default_to_menu and not always_start_menu: + await self.render_help() sys.exit(0) try: route, args, kwargs, execution_args = await self.prepare_route( - raw_arguments=parse_result.remaining_argv, + raw_arguments=sys.argv[1:], ) - except CommandArgumentError: + except UsageError as error: + if error.show_short_usage: + self.render_usage() + print_error(message=error) sys.exit(2) except HelpSignal: sys.exit(0) - if not route: - await self.render_help() - self.console.print( - f"[{OneColors.DARK_RED}]❌ Error unable to parse: {parse_result.raw_argv}" - ) - sys.exit(2) + assert route is not None, "prepare_route should never return None." try: await self._dispatch_route( @@ -2607,10 +2698,14 @@ class Falyx: raise_on_error=False, wrap_errors=True, ) - except FalyxError as error: - self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") - sys.exit(1) - except Exception: + except EntryNotFoundError as error: + await self.render_help() + print_error(message=error) + sys.exit(2) + except (FalyxError, Exception) as error: + print_error(message=error) + if self.options.get("verbose"): + logger.error("Error: %s", error, exc_info=True) sys.exit(1) except QuitSignal: logger.info("[QuitSignal]. <- Exiting run.") @@ -2621,6 +2716,9 @@ class Falyx: except CancelSignal: logger.info("[CancelSignal]. <- Exiting run.") sys.exit(1) + except FlowSignal: + logger.info("[FlowSignal]. <- Exiting run.") + sys.exit(1) except asyncio.CancelledError: logger.info("[asyncio.CancelledError]. <- Exiting run.") sys.exit(1) diff --git a/falyx/namespace.py b/falyx/namespace.py index d817bed..235b73a 100644 --- a/falyx/namespace.py +++ b/falyx/namespace.py @@ -19,6 +19,9 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import TYPE_CHECKING +from rich.style import StyleType + +from falyx.context import InvocationContext from falyx.themes import OneColors if TYPE_CHECKING: @@ -35,15 +38,15 @@ class FalyxNamespace: resolution, completion, help output, and menu rendering. Attributes: - key: Primary identifier used to enter the namespace. - description: User-facing description of the namespace. - namespace: Nested `Falyx` instance activated when this namespace is + key (str): Primary identifier used to enter the namespace. + description (str): User-facing namespace description. + namespace (Falyx): Nested `Falyx` instance activated when this namespace is selected. - aliases: Optional alternate names that may also resolve to the same + aliases (list[str]): Optional alternate names that may also resolve to the same namespace. - help_text: Optional short help text used in listings or help output. - style: Rich style used when rendering the namespace key or aliases. - hidden: Whether the namespace should be omitted from visible menus and + help_text (str): Optional short help text used in listings or help output. + style (StyleType): Rich style used when rendering the namespace key or aliases. + hidden (bool): Whether the namespace should be omitted from visible menus and help listings. """ @@ -52,5 +55,14 @@ class FalyxNamespace: namespace: Falyx aliases: list[str] = field(default_factory=list) help_text: str = "" - style: str = OneColors.CYAN + style: StyleType = OneColors.CYAN hidden: bool = False + + def get_help_signature( + self, invocation_context: InvocationContext + ) -> tuple[str, str, str | None]: + """Returns the usage signature for this namespace, used in help rendering.""" + usage = f"{self.key} {self.namespace._get_usage_fragment(invocation_context)}" + if self.aliases: + usage += f" (aliases: {', '.join(self.aliases)})" + return usage, self.description, self.help_text diff --git a/falyx/options_manager.py b/falyx/options_manager.py index 6728bf6..e8cf85a 100644 --- a/falyx/options_manager.py +++ b/falyx/options_manager.py @@ -1,32 +1,39 @@ # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed -"""Manages global or scoped CLI options across namespaces for Falyx commands. +"""Option state management for Falyx CLI runtimes. -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. +This module defines `OptionsManager`, a small utility responsible for +storing, retrieving, and temporarily overriding runtime option values across +named namespaces. -Each option is stored under a namespace key (e.g., "default", "user_config") to -support multiple sources of configuration. +Falyx uses this manager to hold global session- and execution-scoped flags such +as verbosity, prompt suppression, confirmation behavior, and other mutable +runtime settings. Options are stored in isolated namespace dictionaries so +different layers of the runtime can share one manager without clobbering each +other's state. -Key Features: -- Safe getter/setter for typed option resolution -- Toggle support for boolean options (used by bottom bar toggles, etc.) -- Callable getter/toggler wrappers for dynamic UI bindings -- Namespace merging via `from_namespace` +In addition to basic get/set operations, the manager provides helpers for: -Typical Usage: +- toggling boolean flags +- exposing option access as zero-argument callables for UI bindings +- temporarily overriding a namespace within a context manager +- holding a shared `SpinnerManager` for spinner lifecycle integration + +Typical usage: + ``` options = OptionsManager() - options.from_namespace(args, namespace_name="default") + options.from_mapping({"verbose": True}) if options.get("verbose"): ... - options.toggle("force_confirm") - value_fn = options.get_value_getter("dry_run") - toggle_fn = options.get_toggle_function("debug") -Used by: -- Falyx CLI runtime configuration -- Bottom bar toggles -- Dynamic flag injection into commands and actions + with options.override_namespace({"skip_confirm": True}, "execution"): + ... + ``` + +Attributes: + options (defaultdict[str, dict[str, Any]]): Mapping of namespace names to + option dictionaries. + spinners (SpinnerManager): Shared spinner manager available to runtime + components that need coordinated spinner rendering. """ from collections import defaultdict from contextlib import contextmanager @@ -37,17 +44,40 @@ from falyx.spinner_manager import SpinnerManager class OptionsManager: - """Manages CLI option state across multiple argparse namespaces. + """Manage mutable option values across named runtime namespaces. - Allows dynamic retrieval, setting, toggling, and introspection of command-line - options. Supports named namespaces (e.g., "default") and is used throughout - Falyx for runtime configuration and bottom bar toggle integration. + `OptionsManager` is the central store for Falyx runtime flags. Each option + is stored under a namespace name such as `"default"` or `"execution"`, + allowing global settings and temporary execution-scoped overrides to + coexist in one shared object. + + The manager supports direct reads and writes, boolean toggling, namespace + snapshots, and temporary override contexts. It also exposes small callable + wrappers that are useful when integrating option reads or toggles into UI + components such as bottom-bar controls or key bindings. + + Args: + namespaces (list[tuple[str, dict[str, Any]]] | None): Optional initial + namespace/value pairs to preload into the manager. + + Attributes: + options (defaultdict[str, dict[str, Any]]): Internal namespace-to-option + mapping. + spinners (SpinnerManager): Shared spinner manager used by other Falyx + runtime components. """ def __init__( self, namespaces: list[tuple[str, dict[str, Any]]] | None = None, ) -> None: + """Initialize the option manager. + + Args: + namespaces (list[tuple[str, dict[str, Any]]] | None): Optional list + of `(namespace_name, values)` pairs to load during + initialization. + """ self.options: defaultdict = defaultdict(dict) self.spinners = SpinnerManager() if namespaces: @@ -59,7 +89,16 @@ class OptionsManager: values: Mapping[str, Any], namespace_name: str = "default", ) -> None: - """Load options from a mapping, optionally with a prefix for namespacing.""" + """Merge option values into a namespace. + + Existing keys in the target namespace are updated in place. Missing + namespaces are created automatically. + + Args: + values (Mapping[str, Any]): Mapping of option names to values. + namespace_name (str): Target namespace to update. Defaults to + `"default"`. + """ self.options[namespace_name].update(dict(values)) def get( @@ -68,7 +107,18 @@ class OptionsManager: default: Any = None, namespace_name: str = "default", ) -> Any: - """Get the value of an option.""" + """Return an option value from a namespace. + + Args: + option_name (str): Name of the option to retrieve. + default (Any): Value to return when the option is not present. + Defaults to `None`. + namespace_name (str): Namespace to read from. Defaults to + `"default"`. + + Returns: + Any: The stored option value if present, otherwise `default`. + """ return self.options[namespace_name].get(option_name, default) def set( @@ -77,7 +127,13 @@ class OptionsManager: value: Any, namespace_name: str = "default", ) -> None: - """Set the value of an option.""" + """Store an option value in a namespace. + + Args: + option_name (str): Name of the option to set. + value (Any): Value to store. + namespace_name (str): Namespace to update. Defaults to `"default"`. + """ self.options[namespace_name][option_name] = value def has_option( @@ -85,7 +141,16 @@ class OptionsManager: option_name: str, namespace_name: str = "default", ) -> bool: - """Check if an option exists in the namespace.""" + """Return whether an option exists in a namespace. + + Args: + option_name (str): Name of the option to check. + namespace_name (str): Namespace to inspect. Defaults to `"default"`. + + Returns: + bool: `True` if the option exists in the namespace, otherwise + `False`. + """ return option_name in self.options[namespace_name] def toggle( @@ -93,7 +158,16 @@ class OptionsManager: option_name: str, namespace_name: str = "default", ) -> None: - """Toggle a boolean option.""" + """Invert a boolean option in place. + + Args: + option_name (str): Name of the option to toggle. + namespace_name (str): Namespace containing the option. Defaults to + `"default"`. + + Raises: + TypeError: If the target option is missing or is not a boolean. + """ current = self.get(option_name, namespace_name=namespace_name) if not isinstance(current, bool): raise TypeError( @@ -109,7 +183,20 @@ class OptionsManager: option_name: str, namespace_name: str = "default", ) -> Callable[[], Any]: - """Get the value of an option as a getter function.""" + """Return a zero-argument callable that reads an option value. + + This is useful for UI integrations that expect a callback instead of an + eagerly evaluated value. + + Args: + option_name (str): Name of the option to read. + namespace_name (str): Namespace to read from. Defaults to + `"default"`. + + Returns: + Callable[[], Any]: Function that returns the current option value + when called. + """ def _getter() -> Any: return self.get(option_name, namespace_name=namespace_name) @@ -121,7 +208,19 @@ class OptionsManager: option_name: str, namespace_name: str = "default", ) -> Callable[[], None]: - """Get the toggle function for a boolean option.""" + """Return a zero-argument callable that toggles a boolean option. + + This is useful for key bindings, bottom-bar toggles, or other UI hooks + that need a callable action. + + Args: + option_name (str): Name of the boolean option to toggle. + namespace_name (str): Namespace containing the option. Defaults to + `"default"`. + + Returns: + Callable[[], None]: Function that toggles the option when called. + """ def _toggle() -> None: self.toggle(option_name, namespace_name=namespace_name) @@ -129,7 +228,17 @@ class OptionsManager: return _toggle def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]: - """Return all options in a namespace as a dictionary.""" + """Return a shallow copy of one namespace's option dictionary. + + Args: + namespace_name (str): Namespace to snapshot. + + Returns: + dict[str, Any]: Copy of the namespace's stored options. + + Raises: + ValueError: If the requested namespace does not exist. + """ if namespace_name not in self.options: raise ValueError(f"Namespace '{namespace_name}' not found.") return dict(self.options[namespace_name]) @@ -140,7 +249,24 @@ class OptionsManager: overrides: Mapping[str, Any], namespace_name: str = "execution", ) -> Iterator[None]: - """Temporarily override options in a namespace within a context.""" + """Temporarily apply option overrides within a namespace. + + The current namespace contents are copied before the overrides are + applied. When the context exits, the original namespace state is + restored, even if an exception is raised inside the context block. + + Args: + overrides (Mapping[str, Any]): Temporary option values to merge into + the namespace. + namespace_name (str): Namespace to override. Defaults to + `"execution"`. + + Yields: + None: Control is yielded to the wrapped context block. + + Raises: + ValueError: If the namespace does not already exist. + """ original = self.get_namespace_dict(namespace_name) try: self.from_mapping(values=overrides, namespace_name=namespace_name) diff --git a/falyx/parser/__init__.py b/falyx/parser/__init__.py index 7d19f46..7d29c06 100644 --- a/falyx/parser/__init__.py +++ b/falyx/parser/__init__.py @@ -8,12 +8,12 @@ from .argument import Argument from .argument_action import ArgumentAction from .command_argument_parser import CommandArgumentParser from .falyx_parser import FalyxParser -from .parse_result import RootParseResult +from .parse_result import ParseResult __all__ = [ "Argument", "ArgumentAction", "CommandArgumentParser", "FalyxParser", - "RootParseResult", + "ParseResult", ] diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 94ce6e0..77c8292 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -1,5 +1,5 @@ # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed -"""CommandArgumentParser implementation for the Falyx CLI framework. +"""CommandArgumentParser for the Falyx CLI framework. This module provides a structured, extensible argument parsing system designed specifically for Falyx commands. It replaces traditional argparse usage with a @@ -49,17 +49,26 @@ from __future__ import annotations from collections import Counter, defaultdict from copy import deepcopy from pathlib import Path -from typing import Any, Generator, Iterable, Sequence +from typing import Any, Generator, Iterable from rich.console import Console from rich.markup import escape from rich.padding import Padding from rich.panel import Panel +from rich.style import StyleType from falyx.action.base_action import BaseAction from falyx.console import console from falyx.context import InvocationContext -from falyx.exceptions import CommandArgumentError, NotAFalyxError +from falyx.exceptions import ( + ArgumentGroupError, + ArgumentParsingError, + CommandArgumentError, + InvalidValueError, + MissingValueError, + NotAFalyxError, + UnrecognizedOptionError, +) from falyx.execution_option import ExecutionOption from falyx.mode import FalyxMode from falyx.options_manager import OptionsManager @@ -73,9 +82,11 @@ from falyx.parser.parser_types import ( false_none, true_none, ) -from falyx.parser.utils import coerce_value +from falyx.parser.utils import coerce_value, get_type_name from falyx.signals import HelpSignal +builtin_type = type + class _GroupBuilder: """Helper for assigning arguments to a named group or mutex group. @@ -99,15 +110,52 @@ class _GroupBuilder: self.parser = parser self.group_name = group_name self.mutex_name = mutex_name + if group_name and mutex_name: + raise ArgumentGroupError("cannot specify both group_name and mutex_name") + if not group_name and not mutex_name: + raise ArgumentGroupError("must specify either group_name or mutex_name") - def add_argument(self, *flags, **kwargs) -> None: + def add_argument( + self, + *flags, + action: str | ArgumentAction = "store", + nargs: int | str | None = None, + default: Any = None, + type: Any = str, + choices: Iterable | None = None, + required: bool = False, + help: str = "", + dest: str | None = None, + resolver: BaseAction | None = None, + lazy_resolver: bool = True, + suggestions: list[str] | None = None, + ) -> None: self.parser.add_argument( *flags, + action=action, + nargs=nargs, + default=default, + type=type, + choices=choices, + required=required, + help=help, + dest=dest, + resolver=resolver, + lazy_resolver=lazy_resolver, + suggestions=suggestions, group=self.group_name, mutex_group=self.mutex_name, - **kwargs, ) + def __str__(self) -> str: + if self.group_name: + return f"GroupBuilder(group='{self.group_name}')" + elif self.mutex_name: + return f"GroupBuilder(mutex_group='{self.mutex_name}')" + assert ( + False + ), "Invalid GroupBuilder state: neither group_name nor mutex_name is set" + class CommandArgumentParser: """ @@ -136,7 +184,7 @@ class CommandArgumentParser: self, command_key: str = "", command_description: str = "", - command_style: str = "bold", + command_style: StyleType = "bold", help_text: str = "", help_epilog: str = "", aliases: list[str] | None = None, @@ -148,7 +196,7 @@ class CommandArgumentParser: self.console: Console = console self.command_key: str = command_key self.command_description: str = command_description - self.command_style: str = command_style + self.command_style: StyleType = command_style self.help_text: str = help_text self.help_epilog: str = help_epilog self.aliases: list[str] = aliases or [] @@ -172,11 +220,22 @@ class CommandArgumentParser: if tldr_examples: self.add_tldr_examples(tldr_examples) self.options_manager: OptionsManager = options_manager or OptionsManager() + self._is_runner_mode: bool = False def mark_as_help_command(self) -> None: """Mark this parser as the help command parser.""" self._is_help_command = True + @property + def is_runner_mode(self) -> bool: + """Check if the parser is being used in a CommandRunner context.""" + return self._is_runner_mode + + @is_runner_mode.setter + def is_runner_mode(self, is_runner_mode: bool) -> None: + """Set whether the parser is being used in a CommandRunner context.""" + self._is_runner_mode = is_runner_mode + def set_options_manager(self, options_manager: OptionsManager) -> None: """Set the options manager for the parser.""" if not isinstance(options_manager, OptionsManager): @@ -238,7 +297,7 @@ class CommandArgumentParser: """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" + f"destination '{dest}' is already registered as an execution argument" ) self._execution_dests.add(dest) @@ -288,9 +347,9 @@ class CommandArgumentParser: ) else: raise CommandArgumentError( - f"Invalid TLDR example format: {example}. " - "Examples must be either TLDRExample instances " - "or tuples of (usage, description)." + f"invalid TLDR example format: {example}.", + hint="examples must be either TLDRExample instances " + "or tuples of (usage, description).", ) if "tldr" not in self._dest_set: @@ -302,7 +361,7 @@ class CommandArgumentParser: description: str = "", ) -> _GroupBuilder: if name in self._argument_groups: - raise CommandArgumentError(f"Argument group '{name}' already exists") + raise ArgumentGroupError(f"argument group '{name}' already exists") self._argument_groups[name] = ArgumentGroup(name=name, description=description) return _GroupBuilder(self, group_name=name) @@ -314,7 +373,7 @@ class CommandArgumentParser: description: str = "", ) -> _GroupBuilder: if name in self._mutex_groups: - raise CommandArgumentError(f"Mutex group '{name}' already exists") + raise ArgumentGroupError(f"mutex group '{name}' already exists") self._mutex_groups[name] = MutuallyExclusiveGroup( name=name, required=required, @@ -329,7 +388,7 @@ class CommandArgumentParser: positional = True if positional and len(flags) > 1: - raise CommandArgumentError("Positional arguments cannot have multiple flags") + raise CommandArgumentError("positional arguments cannot have multiple flags") return positional def _validate_groups( @@ -342,22 +401,23 @@ class CommandArgumentParser: """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") + raise ArgumentGroupError(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" + raise ArgumentGroupError( + 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" + raise ArgumentGroupError( + "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." + raise ArgumentGroupError( + "arguments inside a mutually exclusive group cannot be individually required", + hint="make the group required instead", ) def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str: @@ -365,27 +425,31 @@ class CommandArgumentParser: if dest: if not dest.replace("_", "").isalnum(): raise CommandArgumentError( - "dest must be a valid identifier (letters, digits, and underscores only)" + f"invalid dest '{dest}' must be a valid identifier (letters, digits, and underscores only)" ) if dest[0].isdigit(): - raise CommandArgumentError("dest must not start with a digit") + raise CommandArgumentError( + f"invalid dest '{dest}': cannot start with a digit" + ) return dest dest = None for flag in flags: if flag.startswith("--"): - dest = flag.lstrip("-").replace("-", "_").lower() + dest = flag.lstrip("-").replace("-", "_") break elif flag.startswith("-"): - dest = flag.lstrip("-").replace("-", "_").lower() + dest = flag.lstrip("-").replace("-", "_") else: - dest = flag.replace("-", "_").lower() + dest = flag.replace("-", "_") assert dest is not None, "dest should not be None" if not dest.replace("_", "").isalnum(): raise CommandArgumentError( - "dest must be a valid identifier (letters, digits, and underscores only)" + f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)" ) if dest[0].isdigit(): - raise CommandArgumentError("dest must not start with a digit") + raise CommandArgumentError( + f"invalid dest '{dest}': cannot start with a digit" + ) return dest def _determine_required( @@ -405,7 +469,7 @@ class CommandArgumentParser: ArgumentAction.TLDR, ): raise CommandArgumentError( - f"Argument with action {action} cannot be required" + f"argument with action '{action}' cannot be required" ) return True if positional: @@ -441,7 +505,7 @@ class CommandArgumentParser: ): if nargs is not None: raise CommandArgumentError( - f"nargs cannot be specified for {action} actions" + f"nargs cannot be specified for '{action}' actions" ) return None if nargs is None: @@ -452,7 +516,7 @@ class CommandArgumentParser: raise CommandArgumentError("nargs must be a positive integer") elif isinstance(nargs, str): if nargs not in allowed_nargs: - raise CommandArgumentError(f"Invalid nargs value: {nargs}") + raise CommandArgumentError(f"invalid nargs value: {nargs}") else: raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}") return nargs @@ -461,14 +525,16 @@ class CommandArgumentParser: self, choices: Iterable | None, expected_type: Any, action: ArgumentAction ) -> list[Any]: """Normalize and validate choices for the argument.""" - if choices is not None: + if choices is None: + choices = [] + else: if action in ( ArgumentAction.STORE_TRUE, ArgumentAction.STORE_FALSE, ArgumentAction.STORE_BOOL_OPTIONAL, ): raise CommandArgumentError( - f"choices cannot be specified for {action} actions" + f"choices cannot be specified for '{action}' actions" ) if isinstance(choices, dict): raise CommandArgumentError("choices cannot be a dict") @@ -478,14 +544,13 @@ class CommandArgumentParser: raise CommandArgumentError( "choices must be iterable (like list, tuple, or set)" ) from error - else: - choices = [] for choice in choices: try: coerce_value(choice, expected_type) except Exception as error: + type_name = get_type_name(expected_type) raise CommandArgumentError( - f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}" + f"invalid choice {choice!r}: cannot be coerced to {type_name} error: {error}" ) from error return choices @@ -493,26 +558,30 @@ class CommandArgumentParser: self, default: Any, expected_type: type, dest: str ) -> None: """Validate the default value type.""" - if default is not None: - try: - coerce_value(default, expected_type) - except Exception as error: - raise CommandArgumentError( - f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}" - ) from error + if default is None: + return None + try: + coerce_value(default, expected_type) + except Exception as error: + type_name = get_type_name(expected_type) + raise CommandArgumentError( + f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}" + ) from error def _validate_default_list_type( self, default: list[Any], expected_type: type, dest: str ) -> None: """Validate the default value type for a list.""" - if isinstance(default, list): - for item in default: - try: - coerce_value(item, expected_type) - except Exception as error: - raise CommandArgumentError( - f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}" - ) from error + if not isinstance(default, list): + return None + for item in default: + try: + coerce_value(item, expected_type) + except Exception as error: + type_name = get_type_name(expected_type) + raise CommandArgumentError( + f"invalid default list value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}" + ) from error def _validate_resolver( self, action: ArgumentAction, resolver: BaseAction | None @@ -524,7 +593,7 @@ class CommandArgumentParser: raise CommandArgumentError("resolver must be provided for ACTION action") elif action != ArgumentAction.ACTION and resolver is not None: raise CommandArgumentError( - f"resolver should not be provided for action {action}" + f"resolver should not be provided for action '{action}'" ) if not isinstance(resolver, BaseAction): @@ -540,7 +609,8 @@ class CommandArgumentParser: action = ArgumentAction(action) except ValueError as error: raise CommandArgumentError( - f"Invalid action '{action}' is not a valid ArgumentAction" + f"invalid action '{action}' is not a valid ArgumentAction", + hint=f"valid actions are: {', '.join([a.value for a in ArgumentAction])}", ) from error if action in ( ArgumentAction.STORE_TRUE, @@ -552,7 +622,7 @@ class CommandArgumentParser: ): if positional: raise CommandArgumentError( - f"Action '{action}' cannot be used with positional arguments" + f"action '{action}' cannot be used with positional arguments" ) return action @@ -579,49 +649,55 @@ class CommandArgumentParser: return [] else: return None - elif action in ( - ArgumentAction.STORE_TRUE, - ArgumentAction.STORE_FALSE, - ArgumentAction.STORE_BOOL_OPTIONAL, - ): + elif action is ArgumentAction.STORE_TRUE and default is not False: raise CommandArgumentError( - f"Default value cannot be set for action {action}. It is a boolean flag." + f"default value for '{action}' action must be False or None, got {default!r}" + ) + elif action is ArgumentAction.STORE_FALSE and default is not True: + raise CommandArgumentError( + f"default value for '{action}' action must be True or None, got {default!r}" + ) + elif action is ArgumentAction.STORE_BOOL_OPTIONAL: + raise CommandArgumentError( + f"default value for '{action}' action must be None, got {default!r}" ) elif action in (ArgumentAction.HELP, ArgumentAction.TLDR, ArgumentAction.COUNT): raise CommandArgumentError( - f"Default value cannot be set for action {action}." + f"default value cannot be set for action '{action}'." ) if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance( default, list ): + type_name = get_type_name(default) raise CommandArgumentError( - f"Default value for action {action} must be a list, got {type(default).__name__}" + f"default value for action '{action}' must be a list, got {type_name}" ) if isinstance(nargs, int) and nargs == 1: if not isinstance(default, list): default = [default] if isinstance(nargs, int) or nargs in ("*", "+"): if not isinstance(default, list): + type_name = get_type_name(default) raise CommandArgumentError( - f"Default value for action {action} with nargs {nargs} must be a list, got {type(default).__name__}" + f"default value for action '{action}' with nargs {nargs} must be a list, got {type_name}" ) return default def _validate_flags(self, flags: tuple[str, ...]) -> None: """Validate the flags provided for the argument.""" if not flags: - raise CommandArgumentError("No flags provided") + raise CommandArgumentError("no flags provided for argument") for flag in flags: if not isinstance(flag, str): - raise CommandArgumentError(f"Flag '{flag}' must be a string") + raise CommandArgumentError(f"invalid flag '{flag}' must be a string") if flag.startswith("--") and len(flag) < 3: raise CommandArgumentError( - f"Flag '{flag}' must be at least 3 characters long" + f"invalid flag '{flag}': long flags must have at least one character after '--'" ) if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2: raise CommandArgumentError( - f"Flag '{flag}' must be a single character or start with '--'" + f"invalid flag '{flag}': short flags must be a single character" ) def _register_store_bool_optional( @@ -654,7 +730,6 @@ class CommandArgumentParser: group=group, mutex_group=mutex_group, ) - negated_argument = Argument( flags=(negated_flag,), dest=dest, @@ -677,7 +752,7 @@ class CommandArgumentParser: if flag in self._flag_map and not bypass_validation: existing = self._flag_map[flag] raise CommandArgumentError( - f"Flag '{flag}' is already used by argument '{existing.dest}'" + f"flag '{flag}' is already used by argument '{existing.dest}'" ) for flag in argument.flags: @@ -719,8 +794,7 @@ class CommandArgumentParser: group: str | None = None, mutex_group: str | None = None, ) -> None: - """ - Define a new argument for the parser. + """Define a new argument for the parser. Supports positional and flagged arguments, type coercion, default values, validation rules, and optional resolution via `BaseAction`. @@ -741,19 +815,18 @@ class CommandArgumentParser: 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) positional = self._is_positional(flags) dest = self._get_dest_from_flags(flags, dest) - if dest in self._dest_set: - raise CommandArgumentError( - f"Destination '{dest}' is already defined.\n" - "Merging multiple arguments into the same dest (e.g. positional + flagged) " - "is not supported. Define a unique 'dest' for each argument." - ) if dest in self.RESERVED_DESTS: raise CommandArgumentError( - f"Destination '{dest}' is reserved and cannot be used." + f"invalid dest '{dest}': '{dest}' is reserved and cannot be used." + ) + if dest in self._dest_set: + raise CommandArgumentError( + f"destination '{dest}' is already defined.", + hint="merging multiple arguments into the same dest (e.g. positional + flagged) " + "is not supported. Define a unique 'dest' for each argument.", ) self._validate_groups(group, mutex_group, positional, required) @@ -768,51 +841,58 @@ class CommandArgumentParser: and default is not None ): if isinstance(default, list): - self._validate_default_list_type(default, expected_type, dest) + self._validate_default_list_type(default, type, dest) else: - self._validate_default_type(default, expected_type, dest) - choices = self._normalize_choices(choices, expected_type, action) + self._validate_default_type(default, type, dest) + choices = self._normalize_choices(choices, type, action) if default is not None and choices: + choices_str = ", ".join((str(choice) for choice in choices)) if isinstance(default, list): if not all(choice in choices for choice in default): raise CommandArgumentError( - f"Default list value {default!r} for '{dest}' must be a subset of choices: {choices}" + f"default list value {default!r} for '{dest}' must be a subset of choices: {choices_str}" ) elif default not in choices: # If default is not in choices, raise an error raise CommandArgumentError( - f"Default value '{default}' not in allowed choices: {choices}" + f"default value '{default}' not in allowed choices: {choices_str}" ) required = self._determine_required(required, positional, nargs, action) - if not isinstance(suggestions, Sequence) and suggestions is not None: + if suggestions is not None and not isinstance(suggestions, list): + type_name = get_type_name(suggestions) raise CommandArgumentError( - f"suggestions must be a list or None, got {type(suggestions)}" + f"suggestions must be a list or None, got {type_name}" ) + if isinstance(suggestions, list) and not all( + isinstance(suggestion, str) for suggestion in suggestions + ): + raise CommandArgumentError("suggestions must be a list of strings") if not isinstance(lazy_resolver, bool): + type_name = get_type_name(lazy_resolver) raise CommandArgumentError( - f"lazy_resolver must be a boolean, got {type(lazy_resolver)}" + f"lazy_resolver must be a boolean, got {type_name}" ) if action == ArgumentAction.STORE_BOOL_OPTIONAL: self._register_store_bool_optional(flags, dest, help, group, mutex_group) - else: - argument = Argument( - flags=flags, - dest=dest, - action=action, - type=expected_type, - default=default, - choices=choices, - required=required, - help=help, - nargs=nargs, - positional=positional, - resolver=resolver, - lazy_resolver=lazy_resolver, - suggestions=suggestions, - group=group, - mutex_group=mutex_group, - ) - self._register_argument(argument) + return None + argument = Argument( + flags=flags, + dest=dest, + action=action, + type=type, + default=default, + choices=choices, + required=required, + help=help, + nargs=nargs, + positional=positional, + resolver=resolver, + lazy_resolver=lazy_resolver, + suggestions=suggestions, + group=group, + mutex_group=mutex_group, + ) + self._register_argument(argument) def get_argument(self, dest: str) -> Argument | None: """Return the Argument object for a given destination name. @@ -871,8 +951,9 @@ class CommandArgumentParser: return None arg_states[spec.dest].reset() arg_states[spec.dest].has_invalid_choice = True - raise CommandArgumentError( - f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}" + raise InvalidValueError( + dest=spec.dest, + choices=spec.choices, ) def _raise_remaining_args_error( @@ -888,14 +969,7 @@ class CommandArgumentParser: if arg.dest not in consumed_dests and flag.startswith(token) ] - if remaining_flags: - raise CommandArgumentError( - f"Unrecognized option '{token}'. Did you mean one of: {', '.join(remaining_flags)}?" - ) - else: - raise CommandArgumentError( - f"Unrecognized option '{token}'. Use --help to see available options." - ) + raise UnrecognizedOptionError(token=token, remaining_flags=remaining_flags) def _consume_nargs( self, args: list[str], index: int, spec: Argument @@ -910,13 +984,19 @@ class CommandArgumentParser: 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}" + raise MissingValueError( + spec.dest, + expected_count=spec.nargs, + actual_count=len(args) - index, ) + # 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 == "+": if index >= len(args): + raise MissingValueError(spec.dest, expected_count=1) raise CommandArgumentError( f"Expected at least one value for '{spec.dest}'" ) @@ -1002,21 +1082,28 @@ class CommandArgumentParser: else: arg_states[spec.dest].reset() arg_states[spec.dest].has_invalid_choice = True - raise CommandArgumentError( - f"Invalid value for '{spec.dest}': {error}" - ) from error + raise InvalidValueError(dest=spec.dest, error=error) from error if spec.action == ArgumentAction.ACTION: 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" + raise MissingValueError( + dest=spec.dest, + expected_count=1, ) + # 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)" + raise MissingValueError( + spec.dest, + expected_count=spec.nargs, + actual_count=len(typed), ) + # 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) @@ -1094,7 +1181,7 @@ class CommandArgumentParser: flag = f"-{char}" arg = self._flag_map.get(flag) if not arg: - raise CommandArgumentError(f"Unrecognized option: {flag}") + raise UnrecognizedOptionError(flag) expanded.append(flag) else: return token @@ -1129,8 +1216,9 @@ class CommandArgumentParser: ) elif spec.nargs is None: try: + type_name = get_type_name(spec.type) raise CommandArgumentError( - f"Enter a {spec.type.__name__} value for '{spec.dest}'. {help_text}" + f"Enter a {type_name} value for '{spec.dest}'. {help_text}" ) except AttributeError as error: raise CommandArgumentError( @@ -1161,7 +1249,7 @@ class CommandArgumentParser: if action == ArgumentAction.HELP: if not from_validate: - self.render_help(invocation_context=invocation_context) + self.render_help(invocation_context) arg_states[spec.dest].set_consumed() raise HelpSignal() elif action == ArgumentAction.TLDR: @@ -1171,7 +1259,7 @@ class CommandArgumentParser: consumed_indices.add(index) index += 1 elif not from_validate: - self.render_tldr(invocation_context=invocation_context) + self.render_tldr(invocation_context) arg_states[spec.dest].set_consumed() raise HelpSignal() else: @@ -1187,9 +1275,7 @@ class CommandArgumentParser: except ValueError as error: arg_states[spec.dest].reset() arg_states[spec.dest].has_invalid_choice = True - raise CommandArgumentError( - f"Invalid value for '{spec.dest}': {error}" - ) from error + raise InvalidValueError(dest=spec.dest, error=error) from error if not spec.lazy_resolver or not from_validate: try: result[spec.dest] = await spec.resolver(*typed_values) @@ -1228,9 +1314,7 @@ class CommandArgumentParser: except ValueError as error: arg_states[spec.dest].reset() arg_states[spec.dest].has_invalid_choice = True - raise CommandArgumentError( - f"Invalid value for '{spec.dest}': {error}" - ) from error + raise InvalidValueError(dest=spec.dest, error=error) from error if not typed_values: self._raise_suggestion_error(spec) if spec.nargs is None: @@ -1247,9 +1331,7 @@ class CommandArgumentParser: except ValueError as error: arg_states[spec.dest].reset() arg_states[spec.dest].has_invalid_choice = True - raise CommandArgumentError( - f"Invalid value for '{spec.dest}': {error}" - ) from error + raise InvalidValueError(dest=spec.dest, error=error) from error result[spec.dest].extend(typed_values) consumed_indices.update(range(index, new_index)) index = new_index @@ -1260,9 +1342,7 @@ class CommandArgumentParser: except ValueError as error: arg_states[spec.dest].reset() arg_states[spec.dest].has_invalid_choice = True - raise CommandArgumentError( - f"Invalid value for '{spec.dest}': {error}" - ) from error + raise InvalidValueError(dest=spec.dest, error=error) from error if not typed_values and spec.nargs not in ("*", "?"): self._raise_suggestion_error(spec) if spec.nargs in (None, 1, "?"): @@ -1497,26 +1577,29 @@ class CommandArgumentParser: if isinstance(spec.nargs, int) and spec.nargs > 1: assert isinstance( result.get(spec.dest), list - ), f"Invalid value for '{spec.dest}': expected a list" + ), f"invalid value for '{spec.dest}': expected a list" if not result[spec.dest] and not spec.required: continue if spec.action == ArgumentAction.APPEND: for group in result[spec.dest]: if len(group) % spec.nargs != 0: arg_states[spec.dest].reset() - raise CommandArgumentError( - f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" + raise InvalidValueError( + dest=spec.dest, + error=f"invalid number of values: expected a multiple of {spec.nargs}", ) elif spec.action == ArgumentAction.EXTEND: if len(result[spec.dest]) % spec.nargs != 0: arg_states[spec.dest].reset() - raise CommandArgumentError( - f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" + raise InvalidValueError( + dest=spec.dest, + error=f"invalid number of values: expected a multiple of {spec.nargs}", ) elif len(result[spec.dest]) != spec.nargs: arg_states[spec.dest].reset() - raise CommandArgumentError( - f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}" + raise InvalidValueError( + dest=spec.dest, + error=f"invalid number of values: expected {spec.nargs}, got {len(result[spec.dest])}", ) if isinstance(spec.nargs, str) and spec.nargs == "+": @@ -2047,6 +2130,8 @@ class CommandArgumentParser: program_style = ( self.options_manager.get("program_style") or self.command_style ) + if self.is_runner_mode: + return f"[{program_style}]{program}[/{program_style}]" return f"[{program_style}]{program}[/{program_style}] {command_keys}" if invocation_context.is_cli_mode: @@ -2067,6 +2152,19 @@ class CommandArgumentParser: options_text = self.get_options_text() return f"{prefix} {options_text}".strip() if options_text else prefix + def render_usage( + self, + invocation_context: InvocationContext | None = None, + ) -> None: + """Render the usage string for this parser. + + Args: + invocation_context (InvocationContext | None): Optional routed invocation + context used to scope the rendered usage path. + """ + usage = self.get_usage(invocation_context) + self.console.print(f"[bold]usage:[/bold] {usage}") + def _iter_keyword_help_sections( self, ) -> Generator[tuple[str, str, list[Argument]], None, None]: @@ -2093,7 +2191,6 @@ class CommandArgumentParser: def render_help( self, - *, invocation_context: InvocationContext | None = None, ) -> None: """Render full help output for the command. @@ -2112,15 +2209,14 @@ class CommandArgumentParser: - Supports argument grouping and mutually exclusive groups - Applies styling based on configured command style """ - usage = self.get_usage(invocation_context) - self.console.print(f"[bold]usage: {usage}[/bold]\n") + self.render_usage(invocation_context) if self.help_text: - self.console.print(self.help_text + "\n") + self.console.print(f"\n{self.help_text}") if self._arguments: if self._positional: - self.console.print("[bold]positional:[/bold]") + self.console.print("\n[bold]positional:[/bold]") for arg in self._positional.values(): flags = arg.get_positional_text() arg_line = f" {flags:<30} " @@ -2167,7 +2263,7 @@ class CommandArgumentParser: if self.help_epilog: self.console.print("\n" + self.help_epilog, style="dim") - def render_tldr(self, *, invocation_context: InvocationContext | None = None) -> None: + def render_tldr(self, invocation_context: InvocationContext | None = None) -> None: """Render concise example usage (TLDR) for the command. This method displays a minimal, example-driven view of how to invoke @@ -2185,13 +2281,12 @@ class CommandArgumentParser: ) return prefix = self._get_invocation_prefix(invocation_context) - usage = self.get_usage(invocation_context) - self.console.print(f"[bold]usage:[/] {usage}\n") + self.render_usage(invocation_context) if self.help_text: - self.console.print(f"{self.help_text}\n") + self.console.print(f"\n{self.help_text}") - self.console.print("[bold]examples:[/bold]") + self.console.print("\n[bold]examples:[/bold]") for example in self._tldr_examples: usage = f"{prefix} {example.usage.strip()}" description = example.description.strip() diff --git a/falyx/parser/falyx_parser.py b/falyx/parser/falyx_parser.py index 1b91e1b..6b3ff34 100644 --- a/falyx/parser/falyx_parser.py +++ b/falyx/parser/falyx_parser.py @@ -1,179 +1,650 @@ # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed -"""Root parsing models and helpers for the Falyx CLI runtime. - -This module defines the minimal parsing layer used before namespace routing and -command-local argument parsing begin. - -It provides: - -- `RootOptions`, a lightweight container for session-scoped flags such as - verbose logging, help, TLDR, and prompt suppression. -- `FalyxParser`, a small root parser that consumes only leading global options - from argv and leaves the remaining tokens untouched for downstream routing. - -Unlike `CommandArgumentParser`, this module does not parse command-specific -arguments or attempt to resolve leaf-command inputs. Its responsibility is -intentionally narrow: identify root-level flags, determine the initial -application mode, and normalize the result into a `RootParseResult`. - -Parsing behavior is prefix-based. Root flags are consumed only from the start of -argv, and parsing stops at the first non-root token or an explicit `--` -separator. This allows the remaining arguments to be preserved exactly for later -namespace resolution and command-local parsing. - -Typical flow: - 1. Raw argv is passed to `FalyxParser.parse()`. - 2. Leading root/session flags are extracted into `RootOptions`. - 3. A `RootParseResult` is returned with either: - - `FalyxMode.HELP` when root help or TLDR was requested, or - - `FalyxMode.COMMAND` when normal routed execution should continue. - 4. Remaining argv is forwarded unchanged to the main Falyx routing layer. - -This module serves as the root-entry parsing boundary for Falyx applications. -""" from __future__ import annotations from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Any +from falyx.console import console +from falyx.exceptions import EntryNotFoundError, FalyxOptionError from falyx.mode import FalyxMode -from falyx.parser.parse_result import RootParseResult +from falyx.options_manager import OptionsManager +from falyx.parser.parse_result import ParseResult +from falyx.parser.parser_types import ( + FalyxTLDRExample, + FalyxTLDRInput, + false_none, + true_none, +) +from falyx.parser.utils import coerce_value, get_type_name + +if TYPE_CHECKING: + from falyx.falyx import Falyx + +builtin_type = type + + +class OptionAction(Enum): + STORE = "store" + STORE_TRUE = "store_true" + STORE_FALSE = "store_false" + STORE_BOOL_OPTIONAL = "store_bool_optional" + COUNT = "count" + HELP = "help" + TLDR = "tldr" + + @classmethod + def choices(cls) -> list[OptionAction]: + """Return a list of all argument actions.""" + return list(cls) + + @classmethod + def _get_alias(cls, value: str) -> str: + aliases = { + "optional": "store_bool_optional", + "true": "store_true", + "false": "store_false", + } + return aliases.get(value, value) + + @classmethod + def _missing_(cls, value: object) -> OptionAction: + if not isinstance(value, str): + raise ValueError(f"Invalid {cls.__name__}: {value!r}") + normalized = value.strip().lower() + alias = cls._get_alias(normalized) + for member in cls: + if member.value == alias: + return member + valid = ", ".join(member.value for member in cls) + raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") + + def __str__(self) -> str: + """Return the string representation of the argument action.""" + return self.value + + +class OptionScope(Enum): + ROOT = "root" + NAMESPACE = "namespace" + + @classmethod + def _missing_(cls, value: object) -> OptionScope: + 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}") @dataclass(slots=True) -class RootOptions: - """Container for root-level Falyx session flags. +class Option: + flags: tuple[str, ...] + dest: str + action: OptionAction = OptionAction.STORE + type: Any = str + default: Any = None + choices: list[str] | None = None + help: str = "" + suggestions: list[str] | None = None + scope: OptionScope = OptionScope.NAMESPACE - `RootOptions` stores the boolean flags recognized at the application - boundary before namespace routing and command-local parsing begin. These - values represent session-scoped behavior that applies to the overall Falyx - runtime rather than to any individual command. - - The model is intentionally small and lightweight. It is produced by - `FalyxParser._parse_root_options()` and then translated into a - `RootParseResult` that drives the initial execution mode and runtime - configuration. - - Attributes: - verbose: Whether verbose logging should be enabled for the session. - debug_hooks: Whether hook execution should be logged in detail. - never_prompt: Whether prompts should be suppressed for the session. - help: Whether root help output was requested. - tldr: Whether root TLDR output was requested. - """ - - verbose: bool = False - debug_hooks: bool = False - never_prompt: bool = False - help: bool = False - tldr: bool = False + def format_for_help(self) -> str: + """Return a formatted string of the option's flags for help output.""" + return ", ".join(self.flags) class FalyxParser: - """Parse root-level Falyx CLI flags into an initial runtime result. + RESERVED_DESTS: set[str] = {"help", "tldr"} - `FalyxParser` is the narrow, top-level parser used before namespace routing - and command-local argument parsing begin. Its job is to inspect only the - leading session-scoped flags in argv, determine the initial application - mode, and return a normalized `RootParseResult`. + def __init__(self, flx: Falyx) -> None: + self._flx = flx + self._options_by_dest: dict[str, Option] = {} + self._options: list[Option] = [] + self._dest_set: set[str] = set() + self._tldr_examples: list[FalyxTLDRExample] = [] + self._add_reserved_options() + self.help_option: Option | None = None + self.tldr_option: Option | None = None - Responsibilities: - - Parse only root/session flags such as verbose logging, help, TLDR, - and prompt suppression. - - Stop parsing at the first non-root token or explicit `--` separator. - - Preserve the remaining argv exactly for downstream routing. - - Translate root help or TLDR requests into `FalyxMode.HELP`. - - Translate normal execution into `FalyxMode.COMMAND`. + def get_flags(self) -> list[str]: + """Return a list of the first flag for the registered options.""" + return [option.flags[0] for option in self._options] - Design Notes: - - This parser does not resolve commands or namespaces. - - This parser does not parse command-specific arguments. - - Command-local parsing is delegated later to `CommandArgumentParser` - after Falyx routing has identified a leaf command. - - Root parsing is intentionally prefix-only so session flags apply at - the application boundary without mutating command-local argv. + def get_options(self) -> list[Option]: + """Return a list of registered options.""" + return self._options - Typical Usage: - `Falyx.run()` or another top-level entrypoint passes raw argv into - `FalyxParser.parse()`, applies the returned session options, and then - forwards the untouched remaining argv into the routed Falyx execution - flow. - - Attributes: - ROOT_FLAG_ALIASES: Mapping of recognized root CLI flags to - `RootOptions` attribute names. - """ - - ROOT_FLAG_ALIASES: dict[str, str] = { - "-n": "never_prompt", - "--never-prompt": "never_prompt", - "-v": "verbose", - "--verbose": "verbose", - "-d": "debug_hooks", - "--debug-hooks": "debug_hooks", - "?": "help", - "-h": "help", - "--help": "help", - "-T": "tldr", - "--tldr": "tldr", - } - - @classmethod - def _parse_root_options( - cls, - 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 = cls.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 - - @classmethod - def parse(cls, argv: list[str] | None = None) -> RootParseResult: - argv = argv or [] - root, remaining = cls._parse_root_options(argv) - - if root.help or root.tldr: - return RootParseResult( - mode=FalyxMode.HELP, - raw_argv=argv, - never_prompt=root.never_prompt, - verbose=root.verbose, - debug_hooks=root.debug_hooks, - tldr_requested=root.tldr, - ) - - return RootParseResult( - mode=FalyxMode.COMMAND, - raw_argv=argv, - verbose=root.verbose, - debug_hooks=root.debug_hooks, - never_prompt=root.never_prompt, - remaining_argv=remaining, + def _add_tldr(self): + """Add TLDR argument to the parser.""" + if "tldr" in self._dest_set: + return None + tldr = Option( + flags=("--tldr", "-T"), + action=OptionAction.TLDR, + help="Show quick usage examples.", + dest="tldr", + default=False, + ) + self._register_option(tldr) + self.tldr_option = tldr + + def add_tldr_example( + self, + *, + entry_key: str, + usage: str, + description: str, + ) -> None: + """Register a single namespace-level TLDR example. + + The referenced entry must resolve to a known command or namespace in the + current `Falyx` instance. Unknown entries are reported to the console and + are not added. + + Args: + entry_key (str): Command or namespace key the example is associated with. + usage (str): Example usage fragment shown after the resolved invocation path. + description (str): Short explanation displayed alongside the example. + + Raises: + EntryNotFoundError: If `entry_key` cannot be resolved to a known command or + namespace in this `Falyx` instance. + """ + entry, suggestions = self._flx.resolve_entry(entry_key) + if not entry: + raise EntryNotFoundError( + unknown_name=entry_key, + suggestions=suggestions, + message_context="TLDR example", + ) + self._tldr_examples.append( + FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description) + ) + self._add_tldr() + + def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None: + """Register multiple namespace-level TLDR examples. + + Supports either `FalyxTLDRExample` objects or shorthand tuples of + `(entry_key, usage, description)`. + + Args: + examples (list[FalyxTLDRInput]): Example definitions to validate and append. + + Raises: + FalyxError: If an example has an unsupported shape. + EntryNotFoundError: If `entry_key` cannot be resolved to a known command or + namespace in this `Falyx` instance. + """ + for example in examples: + if isinstance(example, FalyxTLDRExample): + entry, suggestions = self._flx.resolve_entry(example.entry_key) + if not entry: + raise EntryNotFoundError( + unknown_name=example.entry_key, + suggestions=suggestions, + message_context="TLDR example", + ) + self._tldr_examples.append(example) + self._add_tldr() + elif len(example) == 3: + entry_key, usage, description = example + self.add_tldr_example( + entry_key=entry_key, + usage=usage, + description=description, + ) + self._add_tldr() + else: + raise FalyxOptionError( + f"invalid TLDR example format: {example}.\n" + "examples must be either FalyxTLDRExample instances " + "or tuples of (entry_key, usage, description).", + ) + + def _add_reserved_options(self) -> None: + help = Option( + flags=("-h", "--help", "?"), + dest="help", + action=OptionAction.HELP, + help="Show root-level help output and exit.", + default=False, + ) + self._register_option(help) + self.help_option = help + + if not self._flx.disable_verbose_option: + verbose = Option( + flags=("-v", "--verbose"), + dest="verbose", + action=OptionAction.STORE_TRUE, + help="Enable verbose logging for the session.", + default=False, + scope=OptionScope.ROOT, + ) + self._register_option(verbose) + + if not self._flx.disable_debug_hooks_option: + debug_hooks = Option( + flags=("-d", "--debug-hooks"), + dest="debug_hooks", + action=OptionAction.STORE_TRUE, + help="Log hook execution in detail for the session.", + default=False, + scope=OptionScope.ROOT, + ) + self._register_option(debug_hooks) + + if not self._flx.disable_never_prompt_option: + never_prompt = Option( + flags=("-n", "--never-prompt"), + dest="never_prompt", + action=OptionAction.STORE_TRUE, + help="Suppress all prompts for the session.", + default=False, + scope=OptionScope.ROOT, + ) + self._register_option(never_prompt) + + def _register_store_bool_optional( + self, + flags: tuple[str, ...], + dest: str, + help: str, + ) -> None: + """Register a store_bool_optional action with the parser.""" + if len(flags) != 1: + raise FalyxOptionError( + "store_bool_optional action can only have a single flag" + ) + if not flags[0].startswith("--"): + raise FalyxOptionError( + "store_bool_optional action must use a long flag (e.g. --flag)" + ) + base_flag = flags[0] + negated_flag = f"--no-{base_flag.lstrip('-')}" + + argument = Option( + flags=flags, + dest=dest, + action=OptionAction.STORE_BOOL_OPTIONAL, + type=true_none, + default=None, + help=help, + ) + + negated_argument = Option( + flags=(negated_flag,), + dest=dest, + action=OptionAction.STORE_BOOL_OPTIONAL, + type=false_none, + default=None, + help=help, + ) + + self._register_option(argument) + self._register_option(negated_argument, bypass_validation=True) + + def _register_option(self, option: Option, bypass_validation: bool = False) -> None: + self._dest_set.add(option.dest) + self._options.append(option) + for flag in option.flags: + if flag in self._options and not bypass_validation: + existing = self._options_by_dest[flag] + raise FalyxOptionError( + f"flag '{flag}' is already used by argument '{existing.dest}'" + ) + self._options_by_dest[flag] = option + + def _validate_flags(self, flags: tuple[str, ...]) -> None: + if not flags: + raise FalyxOptionError("no flags provided for option") + for flag in flags: + if not isinstance(flag, str): + raise FalyxOptionError(f"invalid flag '{flag}': must be a string") + if not flag.startswith("-"): + raise FalyxOptionError(f"invalid flag '{flag}': must start with '-'") + if flag.startswith("--") and len(flag) < 3: + raise FalyxOptionError( + f"invalid flag '{flag}': long flags must have at least one character after '--'" + ) + if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2: + raise FalyxOptionError( + f"invalid flag '{flag}': short flags must be a single character" + ) + if flag in self._options_by_dest: + existing = self._options_by_dest[flag] + raise FalyxOptionError( + f"flag '{flag}' is already used by argument '{existing.dest}'" + ) + + def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str: + if dest: + if not dest.replace("_", "").isalnum(): + raise FalyxOptionError( + f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)" + ) + if dest[0].isdigit(): + raise FalyxOptionError( + f"invalid dest '{dest}': cannot start with a digit" + ) + return dest + dest = None + for flag in flags: + cleaned = flag.lstrip("-").replace("-", "_").lower() + dest = cleaned + if flag.startswith("--"): + break + assert dest is not None, "dest should not be None" + if not dest.replace("_", "").isalnum(): + raise FalyxOptionError( + f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)" + ) + if dest[0].isdigit(): + raise FalyxOptionError(f"invalid dest '{dest}': cannot start with a digit") + return dest + + def _validate_action(self, action: str | OptionAction) -> OptionAction: + if isinstance(action, OptionAction): + return action + try: + return OptionAction(action) + except ValueError as error: + raise FalyxOptionError( + f"invalid option action '{action}' is not a valid OptionAction", + hint=f"valid actions are: {', '.join(a.value for a in OptionAction)}", + ) from error + + def _resolve_default( + self, + default: Any, + action: OptionAction, + ) -> Any: + if default is None: + if action == OptionAction.STORE_TRUE: + return False + elif action == OptionAction.STORE_FALSE: + return True + elif action == OptionAction.STORE_BOOL_OPTIONAL: + return None + elif action == OptionAction.COUNT: + return 0 + elif action is OptionAction.STORE_TRUE and default is not False: + raise FalyxOptionError( + f"default value for '{action}' action must be False or None, got {default!r}" + ) + elif action is OptionAction.STORE_FALSE and default is not True: + raise FalyxOptionError( + f"default value for '{action}' action must be True or None, got {default!r}" + ) + elif action is OptionAction.STORE_BOOL_OPTIONAL: + raise FalyxOptionError( + f"default value for '{action}' action must be None, got {default!r}" + ) + elif action in (OptionAction.HELP, OptionAction.TLDR, OptionAction.COUNT): + raise FalyxOptionError(f"default value cannot be set for action '{action}'.") + return default + + def _validate_default_type( + self, + default: Any, + expected_type: Any, + dest: str, + ) -> None: + if default is None: + return None + try: + coerce_value(default, expected_type) + except Exception as error: + type_name = get_type_name(expected_type) + raise FalyxOptionError( + f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}" + ) from error + + def _normalize_choices( + self, + choices: list[str] | None, + expected_type: type, + action: OptionAction, + ) -> list[Any]: + if choices is None: + choices = [] + else: + if action in ( + OptionAction.STORE_TRUE, + OptionAction.STORE_FALSE, + OptionAction.STORE_BOOL_OPTIONAL, + ): + raise FalyxOptionError( + f"choices cannot be specified for '{action}' actions" + ) + if isinstance(choices, dict): + raise FalyxOptionError("choices cannot be a dict") + try: + choices = list(choices) + except TypeError as error: + raise FalyxOptionError( + "choices must be iterable (like list, tuple, or set)" + ) from error + for choice in choices: + try: + coerce_value(choice, expected_type) + except Exception as error: + type_name = get_type_name(expected_type) + raise FalyxOptionError( + f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}" + ) from error + return choices + + def add_option( + self, + flags: tuple[str, ...], + dest: str, + action: str | OptionAction = "store", + type: type = str, + default: Any = None, + choices: list[str] | None = None, + help: str = "", + suggestions: list[str] | None = None, + ) -> None: + self._validate_flags(flags) + dest = self._get_dest_from_flags(flags, dest) + if dest in self.RESERVED_DESTS: + raise FalyxOptionError( + f"invalid dest '{dest}': '{dest}' is reserved and cannot be used as an option dest" + ) + if dest in self._dest_set: + raise FalyxOptionError(f"duplicate option dest '{dest}'") + action = self._validate_action(action) + default = self._resolve_default(default, action) + self._validate_default_type(default, type, dest) + choices = self._normalize_choices(choices, type, action) + if default is not None and choices and default not in choices: + choices_str = ", ".join((str(choice) for choice in choices)) + raise FalyxOptionError( + f"default value {default!r} is not in allowed choices: {choices_str}" + ) + if suggestions is not None and not isinstance(suggestions, list): + type_name = get_type_name(suggestions) + raise FalyxOptionError(f"suggestions must be a list or None, got {type_name}") + if isinstance(suggestions, list) and not all( + isinstance(suggestion, str) for suggestion in suggestions + ): + raise FalyxOptionError("suggestions must be a list of strings") + if action is OptionAction.STORE_BOOL_OPTIONAL: + self._register_store_bool_optional(flags, dest, help) + return None + option = Option( + flags=flags, + dest=dest, + action=action, + type=type, + default=default, + choices=choices, + help=help, + suggestions=suggestions, + ) + self._register_option(option) + + def apply_to_options( + self, + parse_result: ParseResult, + options: OptionsManager, + ) -> None: + for dest, value in parse_result.options.items(): + options.set(dest, value, namespace_name=self_flx.namespace_name) + for dest, value in parse_result.root_options.items(): + options.set(dest, value, namespace_name="root") + + def _can_bundle_option(self, option: Option) -> bool: + return option.action in { + OptionAction.STORE_TRUE, + OptionAction.STORE_FALSE, + OptionAction.COUNT, + OptionAction.HELP, + OptionAction.TLDR, + } + + def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]: + """Expand POSIX-style bundled arguments into separate arguments.""" + expanded: list[str] = [] + for token in tokens: + if not token.startswith("-") or token.startswith("--") or len(token) <= 2: + expanded.append(token) + continue + + bundle = [f"-{char}" for char in token[1:]] + + if ( + all( + flag in self._options_by_dest + and self._can_bundle_option(self._options_by_dest[flag]) + for flag in bundle[:-1] + ) + and bundle[-1] in self._options_by_dest + ): + expanded.extend(bundle) + else: + expanded.append(token) + return expanded + + def _default_values(self) -> tuple[dict[str, Any], dict[str, Any]]: + values: dict[str, Any] = {} + root_values: dict[str, Any] = {} + + for option in self._options: + if option.scope == OptionScope.ROOT: + root_values[option.dest] = option.default + elif option.scope == OptionScope.NAMESPACE: + values.setdefault(option.dest, option.default) + else: + assert False, f"unhandled option scope: {option.scope}" + + return values, root_values + + def _consume_option( + self, + option: Option, + argv: list[str], + index: int, + values: dict[str, Any], + ) -> int: + match option.action: + case OptionAction.STORE_TRUE: + values[option.dest] = True + return index + 1 + + case OptionAction.STORE_FALSE: + values[option.dest] = False + return index + 1 + + case OptionAction.STORE_BOOL_OPTIONAL: + values[option.dest] = option.type(None) + return index + 1 + + case OptionAction.COUNT: + values[option.dest] = int(values.get(option.dest) or 0) + 1 + return index + 1 + + case OptionAction.HELP: + values[option.dest] = True + return index + 1 + + case OptionAction.TLDR: + values[option.dest] = True + return index + 1 + + case OptionAction.STORE: + value_index = index + 1 + if value_index >= len(argv): + raise FalyxOptionError(f"option '{argv[index]}' expected a value") + + raw_value = argv[value_index] + try: + value = coerce_value(raw_value, option.type) + except Exception as error: + raise FalyxOptionError( + f"invalid value for '{argv[index]}': {error}" + ) from error + + if option.choices and value not in option.choices: + choices = ", ".join(str(choice) for choice in option.choices) + raise FalyxOptionError( + f"invalid value for '{argv[index]}': expected one of {{{choices}}}" + ) + + values[option.dest] = value + return index + 2 + + raise FalyxOptionError(f"unsupported option action: {option.action}") + + def parse_args( + self, + argv: list[str] | None = None, + ) -> ParseResult: + raw_argv = argv or [] + arguments = self._resolve_posix_bundling(raw_argv) + values, root_values = self._default_values() + + index = 0 + while index < len(arguments): + token = arguments[index] + + # Explicit option terminator. Everything after belongs to routing/command. + if token == "--": + index += 1 + break + + # First non-option is the route boundary. + if not token.startswith("-"): + break + + # Unknown leading option is an error at this scope. + # This is what keeps root/namespace options honest. + option = self._options_by_dest.get(token) + if option is None: + raise FalyxOptionError( + f"unknown option '{token}' for '{self._flx.program or self._flx.title}'" + ) + + target_values = root_values if option.scope == OptionScope.ROOT else values + index = self._consume_option(option, arguments, index, target_values) + + remaining_argv = arguments[index:] + + help_requested = values.get("help", False) or values.get("tldr", False) + + return ParseResult( + mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND, + raw_argv=raw_argv, + options=values, + root_options=root_values, + remaining_argv=remaining_argv, + help=values.get("help", False), + tldr=values.get("tldr", False), + current_head=remaining_argv[0] if remaining_argv else "", ) diff --git a/falyx/parser/parse_result.py b/falyx/parser/parse_result.py index 820ed14..0fcb3c6 100644 --- a/falyx/parser/parse_result.py +++ b/falyx/parser/parse_result.py @@ -1,10 +1,10 @@ # Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed -"""Root parse result model for the Falyx CLI runtime. +"""Parse result model for the Falyx CLI runtime. -This module defines `RootParseResult`, the normalized output produced by the +This module defines `ParseResult`, the normalized output produced by the root-level Falyx parsing stage. -`RootParseResult` captures the session-scoped state derived from the initial +`ParseResult` captures the session-scoped state derived from the initial CLI parse before namespace routing or command-local argument parsing begins. It records the selected top-level mode, the original argv, root option flags, and any remaining argv that should be forwarded into the routed execution layer. @@ -17,15 +17,16 @@ The dataclass is intentionally lightweight and focused on root parsing only. It does not perform parsing, validation, or execution itself. """ from dataclasses import dataclass, field +from typing import Any from falyx.mode import FalyxMode @dataclass(slots=True) -class RootParseResult: +class ParseResult: """Represents the normalized result of root-level Falyx argument parsing. - `RootParseResult` stores the outcome of the initial CLI parse that occurs at + `ParseResult` stores the outcome of the initial CLI parse that occurs at the application boundary. It separates session-level runtime settings from the remaining argv that should continue into namespace routing and command-local parsing. @@ -37,18 +38,27 @@ class RootParseResult: Attributes: mode: Top-level runtime mode selected from the root parse. raw_argv: Original argv passed into the root parser. + options: Dictionary of parsed root-level options and their values. + root_options: Dictionary of parsed root-level options that should be + applied at the root level for all namespaces. + remaining_argv: Unconsumed argv that should be forwarded to routed + command resolution. + current_head: The current head token being processed (for error reporting). + help: Whether help output was requested at the root level. + tldr: Whether TLDR output was requested at the root level. verbose: Whether verbose logging should be enabled for the session. debug_hooks: Whether hook execution should be logged in detail. never_prompt: Whether prompts should be suppressed for the session. - remaining_argv: Unconsumed argv that should be forwarded to routed - command resolution. - tldr_requested: Whether root TLDR output was requested. """ mode: FalyxMode raw_argv: list[str] = field(default_factory=list) + options: dict[str, Any] = field(default_factory=dict) + root_options: dict[str, Any] = field(default_factory=dict) + remaining_argv: list[str] = field(default_factory=list) + current_head: str = "" + help: bool = False + tldr: bool = False verbose: bool = False debug_hooks: bool = False never_prompt: bool = False - remaining_argv: list[str] = field(default_factory=list) - tldr_requested: bool = False diff --git a/falyx/parser/utils.py b/falyx/parser/utils.py index bccb48d..1dc0849 100644 --- a/falyx/parser/utils.py +++ b/falyx/parser/utils.py @@ -23,6 +23,16 @@ from falyx.logger import logger from falyx.parser.signature import infer_args_from_func +def get_type_name(type_: Any) -> str: + if hasattr(type_, "__name__"): + return type_.__name__ + elif not isinstance(type_, type): + parent_type = type(type_) + if hasattr(parent_type, "__name__"): + return parent_type.__name__ + return str(type_) + + def coerce_bool(value: str) -> bool: """Convert a string to a boolean. diff --git a/falyx/prompt_utils.py b/falyx/prompt_utils.py index 0c1e97e..b1e32fd 100644 --- a/falyx/prompt_utils.py +++ b/falyx/prompt_utils.py @@ -8,6 +8,8 @@ Includes: - `should_prompt_user()` for conditional prompt logic. - `confirm_async()` for interactive yes/no confirmation. """ +from contextlib import contextmanager +from typing import Iterator from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import ( @@ -24,11 +26,25 @@ from falyx.themes import OneColors from falyx.validators import yes_no_validator +@contextmanager +def prompt_session_context(session: PromptSession) -> Iterator[PromptSession]: + """Temporary override for prompt session management""" + message = session.message + validator = session.validator + placeholder = session.placeholder + try: + yield session + finally: + session.message = message + session.validator = validator + session.placeholder = placeholder + + def should_prompt_user( *, confirm: bool, options: OptionsManager, - namespace: str = "default", + namespace: str = "root", override_namespace: str = "execution", ) -> bool: """Determine whether to prompt the user for confirmation. @@ -41,7 +57,7 @@ def should_prompt_user( Args: confirm (bool): The initial confirmation flag (e.g., from a command argument). options (OptionsManager): The options manager to check for override flags. - namespace (str): The primary namespace to check for options (default: "default"). + namespace (str): The primary namespace to check for options (default: "root"). override_namespace (str): The secondary namespace for overrides (default: "execution"). Returns: diff --git a/falyx/routing.py b/falyx/routing.py index ec98cfa..54b5b45 100644 --- a/falyx/routing.py +++ b/falyx/routing.py @@ -78,6 +78,8 @@ class RouteResult: specific nested namespace. leaf_argv: Remaining argv that should be delegated to the resolved command's local parser. + current_head: The current head token that routing is evaluating, used for + generating suggestions. suggestions: Suggested entry names for unresolved input. is_preview: Whether the routed invocation is in preview mode. """ @@ -88,5 +90,6 @@ class RouteResult: command: "Command | None" = None namespace_entry: FalyxNamespace | None = None leaf_argv: list[str] = field(default_factory=list) + current_head: str = "" suggestions: list[str] = field(default_factory=list) is_preview: bool = False diff --git a/falyx/selection.py b/falyx/selection.py index cc0c3d0..8163ad0 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -20,7 +20,7 @@ from rich.markup import escape from rich.table import Table from falyx.console import console -from falyx.prompt_utils import rich_text_to_prompt_text +from falyx.prompt_utils import prompt_session_context, rich_text_to_prompt_text from falyx.themes import OneColors from falyx.utils import CaseInsensitiveDict, chunks from falyx.validators import MultiIndexValidator, MultiKeyValidator @@ -292,19 +292,32 @@ async def prompt_for_index( if show_table: console.print(table, justify="center") - selection = await prompt_session.prompt_async( - message=rich_text_to_prompt_text(prompt_message), - validator=MultiIndexValidator( - min_index, - max_index, - number_selections, - separator, - allow_duplicates, - cancel_key, - ), - default=default_selection, + number_selections_str = ( + f"{number_selections} " if isinstance(number_selections, int) else "" ) + plural = "s" if number_selections != 1 else "" + placeholder = ( + f"Enter {number_selections_str}selection{plural} separated by '{separator}'" + if number_selections != 1 + else "Enter selection" + ) + + with prompt_session_context(prompt_session) as session: + selection = await session.prompt_async( + message=rich_text_to_prompt_text(prompt_message), + validator=MultiIndexValidator( + min_index, + max_index, + number_selections, + separator, + allow_duplicates, + cancel_key, + ), + default=default_selection, + placeholder=placeholder, + ) + if selection.strip() == cancel_key: return int(cancel_key) if isinstance(number_selections, int) and number_selections == 1: @@ -331,14 +344,27 @@ async def prompt_for_selection( if show_table: console.print(table, justify="center") - selected = await prompt_session.prompt_async( - message=rich_text_to_prompt_text(prompt_message), - validator=MultiKeyValidator( - keys, number_selections, separator, allow_duplicates, cancel_key - ), - default=default_selection, + number_selections_str = ( + f"{number_selections} " if isinstance(number_selections, int) else "" ) + plural = "s" if number_selections != 1 else "" + placeholder = ( + f"Enter {number_selections_str}selection{plural} separated by '{separator}'" + if number_selections != 1 + else "Enter selection" + ) + + with prompt_session_context(prompt_session) as session: + selected = await session.prompt_async( + message=rich_text_to_prompt_text(prompt_message), + validator=MultiKeyValidator( + keys, number_selections, separator, allow_duplicates, cancel_key + ), + default=default_selection, + placeholder=placeholder, + ) + if selected.strip() == cancel_key: return cancel_key if isinstance(number_selections, int) and number_selections == 1: diff --git a/falyx/validators.py b/falyx/validators.py index 281a02b..4125bba 100644 --- a/falyx/validators.py +++ b/falyx/validators.py @@ -55,7 +55,12 @@ class CommandValidator(Validator): message=self.error_message, cursor_position=len(text), ) - if route.is_preview: + if route.is_preview and route.command is None: + raise ValidationError( + message=self.error_message, + cursor_position=len(text), + ) + elif route.is_preview: return None if route.kind in { RouteKind.NAMESPACE_MENU, diff --git a/tests/test_action_basic.py b/tests/test_actions/test_action_basic.py similarity index 100% rename from tests/test_action_basic.py rename to tests/test_actions/test_action_basic.py diff --git a/tests/test_action_fallback.py b/tests/test_actions/test_action_fallback.py similarity index 100% rename from tests/test_action_fallback.py rename to tests/test_actions/test_action_fallback.py diff --git a/tests/test_action_hooks.py b/tests/test_actions/test_action_hooks.py similarity index 100% rename from tests/test_action_hooks.py rename to tests/test_actions/test_action_hooks.py diff --git a/tests/test_action_process.py b/tests/test_actions/test_action_process.py similarity index 100% rename from tests/test_action_process.py rename to tests/test_actions/test_action_process.py diff --git a/tests/test_action_retries.py b/tests/test_actions/test_action_retries.py similarity index 100% rename from tests/test_action_retries.py rename to tests/test_actions/test_action_retries.py diff --git a/tests/test_actions.py b/tests/test_actions/test_actions.py similarity index 100% rename from tests/test_actions.py rename to tests/test_actions/test_actions.py diff --git a/tests/test_chained_action_empty.py b/tests/test_actions/test_chained_action_empty.py similarity index 100% rename from tests/test_chained_action_empty.py rename to tests/test_actions/test_chained_action_empty.py diff --git a/tests/test_actions/test_load_file_action.py b/tests/test_actions/test_load_file_action.py new file mode 100644 index 0000000..c8c3fc4 --- /dev/null +++ b/tests/test_actions/test_load_file_action.py @@ -0,0 +1,100 @@ +import pytest +from rich.text import Text + +from falyx.action import LoadFileAction +from falyx.console import console as falyx_console + + +@pytest.mark.asyncio +async def test_load_json_file_action(tmp_path): + mock_data = '{"key": "value"}' + file = tmp_path / "test.json" + file.write_text(mock_data) + action = LoadFileAction(name="load-file", file_path=file, file_type="json") + result = await action() + assert result == {"key": "value"} + + +@pytest.mark.asyncio +async def test_load_yaml_file_action(tmp_path): + mock_data = "key: value" + file = tmp_path / "test.yaml" + file.write_text(mock_data) + action = LoadFileAction(name="load-file", file_path=file, file_type="yaml") + result = await action() + assert result == {"key": "value"} + + +@pytest.mark.asyncio +async def test_load_toml_file_action(tmp_path): + mock_data = 'key = "value"' + file = tmp_path / "test.toml" + file.write_text(mock_data) + action = LoadFileAction(name="load-file", file_path=file, file_type="toml") + result = await action() + assert result == {"key": "value"} + + +@pytest.mark.asyncio +async def test_load_csv_file_action(tmp_path): + mock_data = "key,value\nfoo,bar" + file = tmp_path / "test.csv" + file.write_text(mock_data) + action = LoadFileAction(name="load-file", file_path=file, file_type="csv") + result = await action() + print(result) + assert result == [["key", "value"], ["foo", "bar"]] + + +@pytest.mark.asyncio +async def test_load_tsv_file_action(tmp_path): + mock_data = "key\tvalue\nfoo\tbar" + file = tmp_path / "test.tsv" + file.write_text(mock_data) + action = LoadFileAction(name="load-file", file_path=file, file_type="tsv") + result = await action() + assert result == [["key", "value"], ["foo", "bar"]] + + +@pytest.mark.asyncio +async def test_load_file_action_invalid_path(): + action = LoadFileAction( + name="load-file", file_path="non_existent_file.json", file_type="json" + ) + with pytest.raises(FileNotFoundError): + await action() + + +@pytest.mark.asyncio +async def test_load_file_action_invalid_json(tmp_path): + invalid_json = '{"key": "value"' # Missing closing brace + file = tmp_path / "invalid.json" + file.write_text(invalid_json) + action = LoadFileAction(name="load-file", file_path=file, file_type="json") + with pytest.raises(ValueError): + await action() + + +@pytest.mark.asyncio +async def test_load_file_action_unsupported_type(tmp_path): + file = tmp_path / "test.txt" + file.write_text("Just some text") + with pytest.raises(ValueError): + LoadFileAction(name="load-file", file_path=file, file_type="unsupported") + + +@pytest.mark.asyncio +async def test_preview_of_load_file_action(tmp_path): + mock_data = '{"key": "value"}' + file = tmp_path / "test.json" + file.write_text(mock_data) + action = LoadFileAction(name="load-file", file_path=file, file_type="json") + with falyx_console.capture() as capture: + await action.preview() + captured = Text.from_ansi(capture.get()).plain + assert "LoadFileAction" in captured + assert "test.json" in captured + assert "load-file" in captured + assert "JSON" in captured + assert "key" in captured + assert "value" in captured diff --git a/tests/test_stress_actions.py b/tests/test_actions/test_stress_actions.py similarity index 100% rename from tests/test_stress_actions.py rename to tests/test_actions/test_stress_actions.py diff --git a/tests/test_command.py b/tests/test_command.py index 7d6302c..8056f37 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,5 +1,6 @@ # test_command.py import pytest +from pydantic import ValidationError from falyx.action import Action, BaseIOAction, ChainedAction from falyx.command import Command @@ -172,3 +173,15 @@ def test_command_bad_action(): with pytest.raises(TypeError) as exc_info: Command(key="TEST", description="Test Command", action="not_callable") assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction" + + +def test_command_bad_options_manager(): + """Test if Command raises an exception when options_manager is not a dict or callable.""" + with pytest.raises(ValidationError) as exc_info: + Command( + key="TEST", + description="Test Command", + action=dummy_action, + options_manager="not_a_dict_or_callable", + ) + assert "Input should be an instance of OptionsManager" in str(exc_info.value) diff --git a/tests/test_completer/test_completer.py b/tests/test_completer/test_completer.py index 26d26b0..d6d379f 100644 --- a/tests/test_completer/test_completer.py +++ b/tests/test_completer/test_completer.py @@ -118,6 +118,19 @@ def test_get_completions_namespace_boundary_suggests_help_flags(falyx): results = list(completer.get_completions(Document("OPS -"), None)) texts = completion_texts(results) + assert "-h" in texts + assert "--help" in texts + assert "-T" not in texts + assert "--tldr" not in texts + + falyx.add_tldr_example( + entry_key="R", + usage="", + description="This is a TLDR example for the R command.", + ) + results = list(completer.get_completions(Document("-"), None)) + texts = completion_texts(results) + assert "-h" in texts assert "--help" in texts assert "-T" in texts @@ -247,3 +260,46 @@ def test_ensure_quote_wraps_whitespace(falyx): assert completer._ensure_quote("hello world") == '"hello world"' assert completer._ensure_quote("hello") == "hello" + + +def test_command_suggestions_are_case_insensitive(falyx): + completer = FalyxCompleter(falyx) + + results = list(completer.get_completions(Document("r"), None)) + texts = completion_texts(results) + + assert "r" in texts + assert "run" in texts + + results = list(completer.get_completions(Document("R"), None)) + texts = completion_texts(results) + + assert "R" in texts + assert "RUN" in texts + + +def test_namespace_suggestions_are_case_insensitive(falyx): + completer = FalyxCompleter(falyx) + + results = list(completer.get_completions(Document("op"), None)) + texts = completion_texts(results) + + assert "ops" in texts + assert "operations" in texts + + results = list(completer.get_completions(Document("OP"), None)) + texts = completion_texts(results) + + assert "OPS" in texts + assert "OPERATIONS" in texts + + +def test_command_completions_after_namespace(falyx): + completer = FalyxCompleter(falyx) + + results = list(completer.get_completions(Document("OPS D --"), None)) + texts = completion_texts(results) + + assert "--target" in texts + assert "--region" in texts + assert "--help" in texts diff --git a/tests/test_execute_command.py b/tests/test_falyx/test_execute_command.py similarity index 100% rename from tests/test_execute_command.py rename to tests/test_falyx/test_execute_command.py diff --git a/tests/test_falyx/test_help.py b/tests/test_falyx/test_help.py index 470229b..58d936a 100644 --- a/tests/test_falyx/test_help.py +++ b/tests/test_falyx/test_help.py @@ -2,7 +2,7 @@ import pytest from rich.text import Text from falyx import Falyx -from falyx.console import console +from falyx.exceptions import CommandArgumentError @pytest.mark.asyncio @@ -82,17 +82,14 @@ async def test_help_command_by_tag(capsys): @pytest.mark.asyncio -async def test_help_command_empty_tags(capsys): +async def test_help_command_bad_argument(capsys): flx = Falyx() async def untagged_command(falyx: Falyx): pass - flx.add_command( - "U", "Untagged Command", untagged_command, help_text="This command has no tags." - ) - await flx.execute_command("H nonexistent_tag") - - captured = capsys.readouterr() - text = Text.from_ansi(captured.out) - assert "Unexpected positional argument: nonexistent_tag" in text.plain + flx.add_command("U", "Untagged Command", untagged_command) + with pytest.raises( + CommandArgumentError, match="Unexpected positional argument: nonexistent_tag" + ): + await flx.execute_command("H nonexistent_tag") diff --git a/tests/test_falyx/test_routing.py b/tests/test_falyx/test_routing.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_falyx/test_run.py b/tests/test_falyx/test_run.py index a289860..f599fb6 100644 --- a/tests/test_falyx/test_run.py +++ b/tests/test_falyx/test_run.py @@ -1,8 +1,72 @@ +import asyncio import sys import pytest +from rich.text import Text from falyx import Falyx +from falyx.console import console as falyx_console +from falyx.exceptions import FalyxError +from falyx.parser import ParseResult +from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal + + +async def throw_error_action(error: str): + if error == "QuitSignal": + raise QuitSignal("Quit signal triggered.") + elif error == "BackSignal": + raise BackSignal("Back signal triggered.") + elif error == "CancelSignal": + raise CancelSignal("Cancel signal triggered.") + elif error == "ValueError": + raise ValueError("This is a ValueError.") + elif error == "HelpSignal": + raise HelpSignal("Help signal triggered.") + elif error == "FalyxError": + raise FalyxError("This is a FalyxError.") + elif error == "FlowSignal": + raise FlowSignal("Flow signal triggered.") + else: + raise asyncio.CancelledError("An error occurred in the action.") + + +@pytest.fixture +def flx() -> Falyx: + sys.argv = ["falyx", "T"] + flx = Falyx() + flx.add_command( + "T", + "Test", + action=lambda: "hello", + ) + flx.add_tldr_example( + entry_key="T", + usage="", + description="This is a TLDR example for the T command.", + ) + return flx + + +@pytest.fixture +def flx_with_submenu() -> Falyx: + flx = Falyx() + submenu = Falyx("Submenu") + submenu.add_command( + "T", + "Test", + action=lambda: "hello from submenu", + ) + submenu.add_tldr_example( + entry_key="T", + usage="", + description="This is a TLDR example for the T command in the submenu.", + ) + flx.add_submenu( + "S", + "Submenu", + submenu=submenu, + ) + return flx @pytest.mark.asyncio @@ -14,3 +78,178 @@ async def test_run_basic(capsys): captured = capsys.readouterr() assert "Show this help menu." in captured.out + + +@pytest.mark.asyncio +async def test_run_default_to_menu(flx): + sys.argv = ["falyx", "T"] + flx.default_to_menu = False + + with pytest.raises(SystemExit): + await flx.run() + + await flx.run(always_start_menu=True) + + +@pytest.mark.asyncio +async def test_run_default_to_menu_help(flx): + sys.argv = ["falyx"] + flx.default_to_menu = False + with pytest.raises(SystemExit, match="0"): + with falyx_console.capture() as capture: + await flx.run() + + captured = Text.from_ansi(capture.get()).plain + assert "Show this help menu." in captured + + +@pytest.mark.asyncio +async def test_run_debug_hooks(flx): + sys.argv = ["falyx", "--debug-hooks", "T"] + + assert flx.options.get("debug_hooks") is False + + with pytest.raises(SystemExit): + await flx.run() + + assert flx.options.get("debug_hooks") is True + + +@pytest.mark.asyncio +async def test_run_never_prompt(flx): + sys.argv = ["falyx", "--never-prompt", "T"] + + assert flx.options.get("never_prompt") is False + + with pytest.raises(SystemExit): + await flx.run() + + falyx_console.print(flx.options.get_namespace_dict("default")) + + assert flx.options.get("debug_hooks") is False + assert flx.options.get("never_prompt") is True + + +@pytest.mark.asyncio +async def test_run_bad_args(flx): + sys.argv = ["falyx", "T", "--unknown-arg"] + + with pytest.raises(SystemExit, match="2"): + await flx.run() + + +@pytest.mark.asyncio +async def test_run_help(flx): + sys.argv = ["falyx", "T", "--help"] + with pytest.raises(SystemExit, match="0"): + await flx.run() + + sys.argv = ["falyx", "--help"] + with pytest.raises(SystemExit, match="0"): + await flx.run() + + sys.argv = ["falyx", "-h"] + with pytest.raises(SystemExit, match="0"): + await flx.run() + + sys.argv = ["falyx", "--tldr"] + with pytest.raises(SystemExit, match="0"): + await flx.run() + + sys.argv = ["falyx", "-T"] + with pytest.raises(SystemExit, match="0"): + await flx.run() + + +@pytest.mark.asyncio +async def test_run_entry_not_found(flx): + sys.argv = ["falyx", "UNKNOWN_COMMAND"] + + with pytest.raises(SystemExit, match="2"): + await flx.run() + + +@pytest.mark.asyncio +async def test_run_test_exceptions(flx): + flx.add_command( + "E", + "Throw Error", + action=throw_error_action, + ) + + sys.argv = ["falyx", "E", "ValueError"] + with pytest.raises(SystemExit, match="1"): + await flx.run() + + sys.argv = ["falyx", "E", "QuitSignal"] + with pytest.raises(SystemExit, match="130"): + await flx.run() + + sys.argv = ["falyx", "E", "BackSignal"] + with pytest.raises(SystemExit, match="1"): + await flx.run() + + sys.argv = ["falyx", "E", "CancelSignal"] + with pytest.raises(SystemExit, match="1"): + await flx.run() + + sys.argv = ["falyx", "E", "HelpSignal"] + with pytest.raises(SystemExit, match="1"): + await flx.run() + + sys.argv = ["falyx", "E", "FlowSignal"] + with pytest.raises(SystemExit, match="1"): + await flx.run() + + sys.argv = ["falyx", "--verbose", "E", "FalyxError"] + with pytest.raises(SystemExit, match="1"): + await flx.run() + + sys.argv = ["falyx", "E", "UnknownError"] + with pytest.raises(SystemExit, match="1"): + await flx.run() + + +@pytest.mark.asyncio +async def test_run_no_args(flx): + sys.argv = ["falyx"] + + with pytest.raises(SystemExit, match="0"): + await flx.run() + + +@pytest.mark.asyncio +async def test_run_submenu(flx_with_submenu): + sys.argv = ["falyx", "S", "T"] + + with pytest.raises(SystemExit, match="0"): + await flx_with_submenu.run() + + +@pytest.mark.asyncio +async def test_run_submenu_help(flx_with_submenu): + sys.argv = ["falyx", "S", "--help"] + + with pytest.raises(SystemExit, match="0"): + await flx_with_submenu.run() + + +@pytest.mark.asyncio +async def test_run_submenu_tldr(flx_with_submenu): + sys.argv = ["falyx", "S", "--tldr"] + + with pytest.raises(SystemExit, match="0"): + await flx_with_submenu.run() + + +@pytest.mark.asyncio +async def test_run_preview(flx): + sys.argv = ["falyx", "preview", "T"] + + with pytest.raises(SystemExit, match="0"): + with falyx_console.capture() as capture: + await flx.run() + + captured = Text.from_ansi(capture.get()).plain + assert "Command: 'T'" in captured + assert "Would call: (args=(), kwargs={})" in captured diff --git a/tests/test_falyx_parser/test_root_options.py b/tests/test_falyx_parser/test_root_options.py deleted file mode 100644 index 2c7c312..0000000 --- a/tests/test_falyx_parser/test_root_options.py +++ /dev/null @@ -1,45 +0,0 @@ -from falyx.parser.falyx_parser import FalyxParser, RootOptions - - -def get_falyx_parser(): - return FalyxParser() - - -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_command_argument_parser.py b/tests/test_parsers/test_command_argument_parser.py similarity index 81% rename from tests/test_command_argument_parser.py rename to tests/test_parsers/test_command_argument_parser.py index cd6e7b0..96a273f 100644 --- a/tests/test_command_argument_parser.py +++ b/tests/test_parsers/test_command_argument_parser.py @@ -1,8 +1,10 @@ import pytest from rich.text import Text +from falyx.action import Action from falyx.console import console as falyx_console -from falyx.exceptions import CommandArgumentError +from falyx.exceptions import CommandArgumentError, NotAFalyxError +from falyx.options_manager import OptionsManager from falyx.parser import ArgumentAction, CommandArgumentParser from falyx.signals import HelpSignal @@ -835,3 +837,175 @@ async def test_render_help(): assert "Foo help" in output assert "--bar" in output assert "Bar help" in output + + +def test_command_argument_parser_set_options_manager_invalid(): + parser = CommandArgumentParser() + + with pytest.raises(NotAFalyxError): + parser.set_options_manager("not_a_options_manager") + + with pytest.raises(NotAFalyxError): + parser.set_options_manager(123) + + with pytest.raises(NotAFalyxError): + parser.set_options_manager(None) + + +def test_command_argument_parser_set_options_manager_valid(): + parser = CommandArgumentParser() + options_manager = OptionsManager([("new_namespace", {"foo": "bar"})]) + parser.set_options_manager(options_manager) + assert parser.options_manager == options_manager + assert parser.options_manager.get("foo", namespace_name="new_namespace") == "bar" + + +def test_add_argument_invalid_required(): + parser = CommandArgumentParser() + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action=ArgumentAction.STORE_TRUE, required=True) + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action=ArgumentAction.STORE_FALSE, required=True) + with pytest.raises(CommandArgumentError): + parser.add_argument( + "--foo", action=ArgumentAction.STORE_BOOL_OPTIONAL, required=True + ) + + +def test_add_argument_invalid_choices(): + parser = CommandArgumentParser() + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="store_true", choices="not_a_list") + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", choices=123) + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", choices={"a": 1, "b": 2}) + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", choices=["a", "b"], type=int) + + +def test_add_argument_resolver_invalid(): + parser = CommandArgumentParser() + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", resolver=lambda x: x) + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", resolver=123) + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="action", resolver="not_a_function") + + +def test_add_argument_resolver_valid(): + parser = CommandArgumentParser() + + parser.add_argument( + "--foo", action="action", resolver=Action("test", lambda x: x.upper()) + ) + + +def test_add_argument_resolve_invalid_default(): + parser = CommandArgumentParser() + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="store_true", default="any value") + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="store_false", default=False) + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="store_true", default=True) + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="store_bool_optional", default=False) + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="count", default=500) + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="append", default="not a list") + + with pytest.raises(CommandArgumentError): + parser.add_argument("--foo", action="extend", default="not a list") + + with pytest.raises(CommandArgumentError): + parser.add_argument("--count", action="count", default=0) + + +@pytest.mark.asyncio +async def test_add_argument_resolve_valid_default(): + parser = CommandArgumentParser() + + parser.add_argument("--foo", action="store_true", default=False) + + parser.add_argument("--bar", action="store_false", default=True) + + parser.add_argument("--baz", action="store_bool_optional", default=None) + + parser.add_argument("--items", action="append", default=[]) + + parser.add_argument("--values", action="extend", default=[]) + + parser.add_argument("--number", action="store", nargs=1, type=int, default=0) + + result = await parser.parse_args(["--number", "5"]) + + assert result["foo"] is False + assert result["bar"] is True + assert result["baz"] is None + assert result["items"] == [] + assert result["values"] == [] + assert result["number"] == 5 + + +def test_add_argument_in_reserved_dests(): + parser = CommandArgumentParser() + + with pytest.raises( + CommandArgumentError, + match="invalid dest .*'help' is reserved and cannot be used.", + ): + parser.add_argument("--help") + + with pytest.raises( + CommandArgumentError, + match="invalid dest .*'tldr' is reserved and cannot be used.", + ): + parser.add_argument("--tldr") + + +def test_add_argument_in_reserved_dests_positional(): + parser = CommandArgumentParser() + + with pytest.raises( + CommandArgumentError, + match="invalid dest .*'help' is reserved and cannot be used.", + ): + parser.add_argument("help") + + with pytest.raises( + CommandArgumentError, + match="invalid dest .*'tldr' is reserved and cannot be used.", + ): + parser.add_argument("tldr") + + +def test_add_argument_invalid_suggestions(): + parser = CommandArgumentParser() + + with pytest.raises( + CommandArgumentError, match="suggestions must be a list or None, got int" + ): + parser.add_argument("--valid", suggestions=112445) + + +def test_add_argument_invalid_lazy_resolver(): + parser = CommandArgumentParser() + + with pytest.raises( + CommandArgumentError, match="lazy_resolver must be a boolean, got int" + ): + parser.add_argument("--valid", lazy_resolver=123) diff --git a/tests/test_parsers/test_execution_option_registration.py b/tests/test_parsers/test_execution_option_registration.py index 059d066..3ef0f42 100644 --- a/tests/test_parsers/test_execution_option_registration.py +++ b/tests/test_parsers/test_execution_option_registration.py @@ -31,6 +31,21 @@ def test_enable_execution_options_registers_retry_flags(): assert "retry_backoff" in parser._execution_dests +def test_enable_execution_options_invalid_double_registration_raises(): + parser = CommandArgumentParser() + parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) + with pytest.raises( + CommandArgumentError, match="destination 'summary' is already defined" + ): + parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) + + with pytest.raises( + CommandArgumentError, + match="destination 'summary' is already registered as an execution argument", + ): + parser._register_execution_dest("summary") + + def test_enable_execution_options_registers_confirm_flags(): parser = CommandArgumentParser() parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM})) @@ -48,12 +63,12 @@ def test_register_execution_dest_rejects_duplicates(): parser = CommandArgumentParser() parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) with pytest.raises( - CommandArgumentError, match="Destination 'summary' is already defined" + CommandArgumentError, match="destination 'summary' is already defined" ): parser.add_argument("--summary", action="store_true") with pytest.raises( - CommandArgumentError, match="Destination 'summary' is already defined" + CommandArgumentError, match="destination 'summary' is already defined" ): parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) @@ -138,6 +153,6 @@ async def test_parse_args_split_with_conflicting_execution_option_raises(): parser = CommandArgumentParser() parser.add_argument("--summary", action="store_true", help="A conflicting argument.") with pytest.raises( - CommandArgumentError, match="Destination 'summary' is already defined" + CommandArgumentError, match="destination 'summary' is already defined" ): parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) diff --git a/tests/test_parsers/test_group_builder.py b/tests/test_parsers/test_group_builder.py new file mode 100644 index 0000000..8101ce8 --- /dev/null +++ b/tests/test_parsers/test_group_builder.py @@ -0,0 +1,96 @@ +import pytest + +from falyx.exceptions import CommandArgumentError +from falyx.parser import CommandArgumentParser +from falyx.parser.command_argument_parser import _GroupBuilder + + +def test_group_builder(): + parser = CommandArgumentParser(program="test_program") + group_builder = _GroupBuilder(parser, group_name="test_group") + assert group_builder.group_name == "test_group" + assert "group='test_group'" in str(group_builder) + + group_builder = _GroupBuilder( + parser, + mutex_name="test_group", + ) + assert group_builder.mutex_name == "test_group" + assert "mutex_group='test_group'" in str(group_builder) + + with pytest.raises(CommandArgumentError): + _GroupBuilder(parser, group_name="test_group", mutex_name="test_group") + + with pytest.raises(CommandArgumentError): + _GroupBuilder(parser) + + with pytest.raises(AssertionError): + builder = _GroupBuilder(parser, group_name="test_group") + builder.group_name = None + builder.mutex_name = None + str(builder) + + +def test_adding_arguments_to_group(): + parser = CommandArgumentParser(program="test_program") + + group = parser.add_argument_group("test_group") + assert group.group_name == "test_group" + + group.add_argument("--foo", type=str, help="Foo argument") + group.add_argument("--bar", type=int, help="Bar argument") + + with pytest.raises(CommandArgumentError): + parser.add_argument_group("test_group") + + +def test_adding_arguments_to_mutex_group(): + parser = CommandArgumentParser(program="test_program") + + mutex_group = parser.add_mutually_exclusive_group("test_mutex_group") + assert mutex_group.mutex_name == "test_mutex_group" + + mutex_group.add_argument("--foo", type=str, help="Foo argument") + mutex_group.add_argument("--bar", type=int, help="Bar argument") + + with pytest.raises(CommandArgumentError): + parser.add_mutually_exclusive_group("test_mutex_group") + + +def test_adding_arguments_to_group_with_invalid_group(): + parser = CommandArgumentParser(program="test_program") + + with pytest.raises(CommandArgumentError): + parser.add_argument( + "--foo", type=str, help="Foo argument", group="non_existent_group" + ) + + with pytest.raises(CommandArgumentError): + parser.add_argument( + "--bar", type=int, help="Bar argument", mutex_group="non_existent_group" + ) + + +def test_adding_positional_arguments_to_mutex_group(): + parser = CommandArgumentParser(program="test_program") + + group = parser.add_mutually_exclusive_group("test_group") + + with pytest.raises(CommandArgumentError): + group.add_argument( + "positional_arg", type=str, help="This should fail because it's positional" + ) + + +def test_adding_required_arguments_to_mutex_group(): + parser = CommandArgumentParser(program="test_program") + + group = parser.add_mutually_exclusive_group("test_group") + + with pytest.raises(CommandArgumentError): + group.add_argument( + "--foo", + type=str, + help="This should fail because it's required", + required=True, + ) diff --git a/tests/test_parsers/test_resolve_args.py b/tests/test_parsers/test_resolve_args.py index dc0ec92..faf0e1d 100644 --- a/tests/test_parsers/test_resolve_args.py +++ b/tests/test_parsers/test_resolve_args.py @@ -69,14 +69,14 @@ async def test_resolve_args_raises_on_conflicting_execution_option(): execution_options=["summary"], ) with pytest.raises( - CommandArgumentError, match="Destination 'summary' is already defined" + CommandArgumentError, match="destination 'summary' is already defined" ): command.arg_parser.add_argument( "--summary", action="store_true", help="A conflicting argument." ) with pytest.raises( - CommandArgumentError, match="Destination 'summary' is already defined" + CommandArgumentError, match="destination 'summary' is already defined" ): command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY})) diff --git a/tests/test_parsers/test_tldr.py b/tests/test_parsers/test_tldr.py index 89023d4..0255428 100644 --- a/tests/test_parsers/test_tldr.py +++ b/tests/test_parsers/test_tldr.py @@ -2,6 +2,7 @@ import pytest from falyx.exceptions import CommandArgumentError from falyx.parser.command_argument_parser import CommandArgumentParser +from falyx.parser.parser_types import TLDRExample @pytest.mark.asyncio @@ -45,3 +46,27 @@ async def test_add_tldr_examples_in_init(): assert parser._tldr_examples[0].description == "This is the first example." assert parser._tldr_examples[1].usage == "example2" assert parser._tldr_examples[1].description == "This is the second example." + + +def test_add_tldr_example(): + parser = CommandArgumentParser() + parser.add_tldr_example("example1", "This is the first example.") + assert len(parser._tldr_examples) == 1 + assert parser._tldr_examples[0].usage == "example1" + assert parser._tldr_examples[0].description == "This is the first example." + + +def test_add_tldr_example_bad_args(): + parser = CommandArgumentParser() + with pytest.raises(TypeError): + parser.add_tldr_example("example1", "This is the first example.", "extra_arg") + + +def test_add_tldr_examples_with_tldr_example_objects(): + parser = CommandArgumentParser() + example1 = TLDRExample(usage="example1", description="This is the first example.") + example2 = TLDRExample(usage="example2", description="This is the second example.") + parser.add_tldr_examples([example1, example2]) + assert len(parser._tldr_examples) == 2 + assert parser._tldr_examples[0] == example1 + assert parser._tldr_examples[1] == example2 diff --git a/tests/test_runner/test_command_runner.py b/tests/test_runner/test_command_runner.py index dae8ad7..3d339c4 100644 --- a/tests/test_runner/test_command_runner.py +++ b/tests/test_runner/test_command_runner.py @@ -9,7 +9,13 @@ from falyx.action import Action from falyx.command import Command from falyx.command_runner import CommandRunner from falyx.console import console as falyx_console -from falyx.exceptions import CommandArgumentError, FalyxError, NotAFalyxError +from falyx.console import error_console +from falyx.exceptions import ( + CommandArgumentError, + FalyxError, + InvalidHookError, + NotAFalyxError, +) from falyx.hook_manager import HookManager, HookType from falyx.options_manager import OptionsManager from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal @@ -123,8 +129,10 @@ async def test_command_runner_initialization( command_with_no_parser, command_with_custom_parser, ): - runner = CommandRunner(command_with_parser) + runner = CommandRunner(command_with_parser, program="test_program") assert runner.command == command_with_parser + assert runner.program == "test_program" + assert runner.command.arg_parser.program == "test_program" assert isinstance(runner.options, OptionsManager) assert isinstance(runner.runner_hooks, HookManager) assert runner.console == falyx_console @@ -133,7 +141,6 @@ async def test_command_runner_initialization( assert runner.command.options_manager == runner.options assert runner.executor.options == runner.options assert runner.executor.hooks == runner.runner_hooks - assert runner.executor.console == runner.console assert runner.options.get("summary", namespace_name="execution") is None runner_no_parser = CommandRunner(command_with_no_parser) @@ -166,7 +173,6 @@ def test_command_runner_initialization_with_custom_console(command_with_parser): custom_console = Console() runner = CommandRunner(command_with_parser, console=custom_console) assert runner.console == custom_console - assert runner.executor.console == custom_console def test_command_runner_initialization_with_custom_hooks(command_with_parser): @@ -199,7 +205,9 @@ def test_command_runner_initialization_with_all_bad_components(command_with_pars console=custom_console, ) - with pytest.raises(NotAFalyxError, match="hooks must be an instance of HookManager"): + with pytest.raises( + InvalidHookError, match="hooks must be an instance of HookManager" + ): CommandRunner( command_with_parser, runner_hooks=custom_hooks, @@ -236,8 +244,6 @@ async def test_command_runner_run_with_failing_action(command_with_failing_actio with pytest.raises(FalyxError, match="boom"): await runner.run("--foo 42", wrap_errors=True) - assert await runner.run("--foo 42", wrap_errors=False, raise_on_error=False) is None - @pytest.mark.asyncio async def test_command_runner_debug_statement(command_with_parser, caplog): @@ -276,6 +282,22 @@ async def test_command_runner_run_with_retries_with_action( assert "[throw_error] All 2 retries failed." in caplog.text +@pytest.mark.asyncio +async def test_command_runner_run_with_retries_delay_with_action( + command_throwing_error, caplog +): + runner = CommandRunner(command_throwing_error) + with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."): + await runner.run("Other") + + with pytest.raises(ValueError, match="This is a ValueError."): + await runner.run("ValueError --retries 2 --retry-delay 1.0 --retry-backoff 2.0") + + assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text + assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text + assert "[throw_error] All 2 retries failed." in caplog.text + + @pytest.mark.asyncio async def test_command_runner_run_from_command_build_with_all_execution_options( command_build_with_all_execution_options, @@ -313,7 +335,7 @@ async def test_command_runner_from_command_bad_command(): CommandRunner.from_command("Not a Command") with pytest.raises( - NotAFalyxError, match="runner_hooks must be an instance of HookManager" + InvalidHookError, match="runner_hooks must be an instance of HookManager" ): CommandRunner.from_command( Command( @@ -360,7 +382,7 @@ async def test_command_runner_build_with_bad_execution_options(): @pytest.mark.asyncio async def test_command_runner_build_with_bad_runner_hooks(): with pytest.raises( - NotAFalyxError, match="runner_hooks must be an instance of HookManager" + InvalidHookError, match="runner_hooks must be an instance of HookManager" ): CommandRunner.build( key="T", @@ -438,7 +460,7 @@ async def test_command_runner_cli_with_failing_action(command_with_failing_actio await runner.cli(["--help"]) captured = Text.from_ansi(capture.get()).plain - assert "usage: falyx T" in captured + assert "usage: falyx" in captured assert "--foo" in captured assert "summary" in captured assert "retries" in captured @@ -453,54 +475,48 @@ async def test_command_runner_cli_exceptions(command_throwing_error): with pytest.raises(SystemExit, match="0"): await runner.cli(["--help"]) captured = Text.from_ansi(capture.get()).plain - assert "falyx E [--help]" in captured + assert "falyx [--help]" in captured assert "usage:" in captured assert "positional:" in captured assert "options:" in captured - assert "❌" not in captured with falyx_console.capture() as capture: with pytest.raises(SystemExit, match="2"): await runner.cli(["--not-an-arg"]) captured = Text.from_ansi(capture.get()).plain - assert "falyx E [--help]" in captured + assert "falyx [--help]" in captured assert "usage:" in captured assert "positional:" in captured assert "options:" in captured - assert "❌" in captured falyx_console.clear() - with falyx_console.capture() as capture: + with error_console.capture() as capture: with pytest.raises(SystemExit, match="1"): await runner.cli(["FalyxError"]) captured = Text.from_ansi(capture.get()).plain assert "This is a FalyxError." in captured - assert "❌ Error:" in captured + assert "error:" in captured falyx_console.clear() with falyx_console.capture() as capture: with pytest.raises(SystemExit, match="130"): await runner.cli(["QuitSignal"]) captured = Text.from_ansi(capture.get()).plain - assert "❌" not in captured with falyx_console.capture() as capture: with pytest.raises(SystemExit, match="1"): await runner.cli(["BackSignal"]) captured = Text.from_ansi(capture.get()).plain - assert "❌" not in captured with falyx_console.capture() as capture: with pytest.raises(SystemExit, match="1"): await runner.cli(["CancelSignal"]) captured = Text.from_ansi(capture.get()).plain - assert "❌" not in captured with falyx_console.capture() as capture: with pytest.raises(SystemExit, match="1"): await runner.cli(["Other"]) captured = Text.from_ansi(capture.get()).plain - assert "❌" not in captured @pytest.mark.asyncio @@ -514,3 +530,12 @@ async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch assert "Action executed with args:" in captured assert "and kwargs:" in captured assert "{'foo': 42}" in captured + + +@pytest.mark.asyncio +async def test_command_runner_run_error(command_with_parser): + runner = CommandRunner(command_with_parser) + with pytest.raises(FalyxError, match="requires either"): + await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=False) + await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True) + await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False) diff --git a/tests/test_validators/test_command_validator.py b/tests/test_validators/test_command_validator.py index 442da98..1b8cf51 100644 --- a/tests/test_validators/test_command_validator.py +++ b/tests/test_validators/test_command_validator.py @@ -40,6 +40,7 @@ async def test_command_validator_is_preview(): fake_falyx = AsyncMock() fake_route = SimpleNamespace() fake_route.is_preview = True + fake_route.command = SimpleNamespace() fake_falyx.prepare_route.return_value = (fake_route, (), {}, {}) validator = CommandValidator(fake_falyx, "Invalid!")