feat: add recursive namespace routing and standalone runner polish

- introduce namespace-aware routing with RootParseResult, RouteResult, and InvocationContext
- register submenus as FalyxNamespace entries and resolve them through _entry_map
- refactor FalyxParser to parse only root options and leave recursive routing to Falyx
- add prepare_route, resolve_route, and route dispatch flow to Falyx
- update validator and completer to understand namespace entries and route results
- unify help/TLDR rendering APIs and add custom_tldr support on Command
- tighten Command.resolve_args error handling and parser type validation
- improve CommandRunner dependency validation and argv handling
- add BottomBar.has_items and improve wrapped executor error messages
- add tests for execution options, resolve_args, command runner, and route-aware validation
This commit is contained in:
2026-04-11 11:57:03 -04:00
parent 5d8f3aa603
commit 30cb8b97b5
26 changed files with 1658 additions and 493 deletions

View File

@@ -72,6 +72,11 @@ class BottomBar:
self.toggle_keys: list[str] = [] self.toggle_keys: list[str] = []
self.key_bindings = key_bindings or KeyBindings() self.key_bindings = key_bindings or KeyBindings()
@property
def has_items(self) -> bool:
"""Check if the bottom bar has any registered items."""
return bool(self._named_items)
@staticmethod @staticmethod
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML: def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML:
return HTML(f"<style fg='{fg}' bg='{bg}'>{label}: {value:^{width}}</style>") return HTML(f"<style fg='{fg}' bg='{bg}'>{label}: {value:^{width}}</style>")

View File

@@ -53,13 +53,12 @@ from falyx.action.base_action import BaseAction
from falyx.console import console from falyx.console import console
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
from falyx.exceptions import NotAFalyxError from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.execution_option import ExecutionOption from falyx.execution_option import ExecutionOption
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.hooks import spinner_before_hook, spinner_teardown_hook from falyx.hooks import spinner_before_hook, spinner_teardown_hook
from falyx.logger import logger from falyx.logger import logger
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parser.command_argument_parser import CommandArgumentParser from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.parser.signature import infer_args_from_func from falyx.parser.signature import infer_args_from_func
@@ -149,6 +148,8 @@ class Command(BaseModel):
Override parser logic entirely. Override parser logic entirely.
custom_help (Callable[[], str | None] | None): custom_help (Callable[[], str | None] | None):
Override help rendering. Override help rendering.
custom_tldr (Callable[[], str | None] | None):
Override TLDR rendering.
auto_args (bool): Auto-generate arguments from action signature. auto_args (bool): Auto-generate arguments from action signature.
arg_metadata (dict[str, Any], optional): Metadata for arguments. arg_metadata (dict[str, Any], optional): Metadata for arguments.
simple_help_signature (bool): Use simplified help formatting. simple_help_signature (bool): Use simplified help formatting.
@@ -199,7 +200,8 @@ class Command(BaseModel):
arguments: list[dict[str, Any]] = Field(default_factory=list) arguments: list[dict[str, Any]] = Field(default_factory=list)
argument_config: Callable[[CommandArgumentParser], None] | None = None argument_config: Callable[[CommandArgumentParser], None] | None = None
custom_parser: ArgParserProtocol | None = None custom_parser: ArgParserProtocol | None = None
custom_help: Callable[[], str | None] | None = None custom_help: Callable[[], None] | None = None
custom_tldr: Callable[[], None] | None = None
auto_args: bool = True auto_args: bool = True
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
simple_help_signature: bool = False simple_help_signature: bool = False
@@ -236,7 +238,7 @@ class Command(BaseModel):
- Handles help/preview signals raised during parsing. - Handles help/preview signals raised during parsing.
Args: Args:
args (list[str] | None): CLI-style argument tokens. args (list[str] | str | None): CLI-style argument tokens or a single string.
from_validate (bool): Whether parsing is occurring in validation mode from_validate (bool): Whether parsing is occurring in validation mode
(e.g. prompt_toolkit validator). When True, may suppress eager (e.g. prompt_toolkit validator). When True, may suppress eager
resolution or defer certain errors. resolution or defer certain errors.
@@ -257,35 +259,38 @@ class Command(BaseModel):
- This method is the canonical boundary between CLI parsing and - This method is the canonical boundary between CLI parsing and
execution semantics. execution semantics.
""" """
if callable(self.custom_parser): if self.custom_parser is not None:
if not callable(self.custom_parser):
raise NotAFalyxError(
"custom_parser must be a callable that implements ArgParserProtocol."
)
if isinstance(raw_args, str): if isinstance(raw_args, str):
try: try:
raw_args = shlex.split(raw_args) raw_args = shlex.split(raw_args)
except ValueError: except ValueError as error:
logger.warning( raise CommandArgumentError(
"[Command:%s] Failed to split arguments: %s", f"[{self.key}] Failed to parse arguments: {error}"
self.key, ) from error
raw_args,
)
return ((), {}, {})
return self.custom_parser(raw_args) return self.custom_parser(raw_args)
if isinstance(raw_args, str): if isinstance(raw_args, str):
try: try:
raw_args = shlex.split(raw_args) raw_args = shlex.split(raw_args)
except ValueError: except ValueError as error:
logger.warning( raise CommandArgumentError(
"[Command:%s] Failed to split arguments: %s", f"[{self.key}] Failed to parse arguments: {error}"
self.key, ) from error
raw_args,
) if self.arg_parser is None:
return ((), {}, {}) raise NotAFalyxError(
if not isinstance(self.arg_parser, CommandArgumentParser): "Command has no parser configured. "
logger.warning( "Provide a custom_parser or CommandArgumentParser."
"[Command:%s] No argument parser configured, using default parsing.",
self.key,
) )
return ((), {}, {}) if not isinstance(self.arg_parser, CommandArgumentParser):
raise NotAFalyxError(
"arg_parser must be an instance of CommandArgumentParser"
)
return await self.arg_parser.parse_args_split( return await self.arg_parser.parse_args_split(
raw_args, from_validate=from_validate raw_args, from_validate=from_validate
) )
@@ -506,19 +511,15 @@ class Command(BaseModel):
tuple: tuple:
- str: Usage string (e.g. "falyx D | deploy [--help] region") - str: Usage string (e.g. "falyx D | deploy [--help] region")
- str: Command description - str: Command description
- str | None: Optional tag/category label - str: Optional tag/category label
Notes: Notes:
- This is the primary interface used by help menus, CLI help output, - This is the primary interface used by help menus, CLI help output,
and command listings. and command listings.
- Formatting may vary depending on CLI vs menu mode. - Formatting may vary depending on CLI vs menu mode.
""" """
is_cli_mode = self.options_manager.get("mode") != FalyxMode.MENU
program = f"{self.program} " if is_cli_mode else ""
if self.arg_parser and not self.simple_help_signature: if self.arg_parser and not self.simple_help_signature:
usage = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}" usage = self.arg_parser.get_usage()
description = f"[dim]{self.help_text or self.description}[/dim]" description = f"[dim]{self.help_text or self.description}[/dim]"
if self.tags: if self.tags:
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]" tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
@@ -531,7 +532,7 @@ class Command(BaseModel):
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases] + [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
) )
return ( return (
f"[{self.style}]{program}[/]{command_keys}", f"{command_keys}",
f"[dim]{self.help_text or self.description}[/dim]", f"[dim]{self.help_text or self.description}[/dim]",
"", "",
) )
@@ -552,6 +553,18 @@ class Command(BaseModel):
return True return True
return False return False
def render_tldr(self) -> bool:
"""Display the TLDR message for the command."""
if callable(self.custom_tldr):
output = self.custom_tldr()
if output:
console.print(output)
return True
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_tldr()
return True
return False
async def preview(self) -> None: async def preview(self) -> None:
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}'{self.description}" label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}'{self.description}"
@@ -623,6 +636,7 @@ class Command(BaseModel):
execution_options: list[ExecutionOption | str] | None = None, execution_options: list[ExecutionOption | str] | None = None,
custom_parser: ArgParserProtocol | None = None, custom_parser: ArgParserProtocol | None = None,
custom_help: Callable[[], str | None] | None = None, custom_help: Callable[[], str | None] | None = None,
custom_tldr: Callable[[], str | None] | None = None,
auto_args: bool = True, auto_args: bool = True,
arg_metadata: dict[str, str | dict[str, Any]] | None = None, arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool = False, simple_help_signature: bool = False,
@@ -697,6 +711,8 @@ class Command(BaseModel):
implementation that overrides normal parser behavior. implementation that overrides normal parser behavior.
custom_help (Callable[[], str | None] | None): Optional custom help custom_help (Callable[[], str | None] | None): Optional custom help
renderer. renderer.
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
renderer.
auto_args (bool): Whether to infer arguments automatically from the action auto_args (bool): Whether to infer arguments automatically from the action
signature when explicit definitions are not provided. signature when explicit definitions are not provided.
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
@@ -722,12 +738,23 @@ class Command(BaseModel):
- This method is the canonical command-construction path used by higher- - This method is the canonical command-construction path used by higher-
level APIs such as `Falyx.add_command()` and `CommandRunner.build()`. level APIs such as `Falyx.add_command()` and `CommandRunner.build()`.
""" """
if arg_parser: if arg_parser and not isinstance(arg_parser, CommandArgumentParser):
if not isinstance(arg_parser, CommandArgumentParser): raise NotAFalyxError(
raise NotAFalyxError( "arg_parser must be an instance of CommandArgumentParser."
"arg_parser must be an instance of CommandArgumentParser." )
) arg_parser = arg_parser
arg_parser = arg_parser
if options_manager and not isinstance(options_manager, OptionsManager):
raise NotAFalyxError("options_manager must be an instance of OptionsManager.")
options_manager = options_manager or OptionsManager()
if hooks and not isinstance(hooks, HookManager):
raise NotAFalyxError("hooks must be an instance of HookManager.")
hooks = hooks or HookManager()
if retry_policy and not isinstance(retry_policy, RetryPolicy):
raise NotAFalyxError("retry_policy must be an instance of RetryPolicy.")
retry_policy = retry_policy or RetryPolicy()
if execution_options: if execution_options:
parsed_execution_options = frozenset( parsed_execution_options = frozenset(
@@ -737,8 +764,6 @@ class Command(BaseModel):
else: else:
parsed_execution_options = frozenset() parsed_execution_options = frozenset()
options_manager = options_manager or OptionsManager()
command = Command( command = Command(
key=key, key=key,
description=description, description=description,
@@ -760,9 +785,10 @@ class Command(BaseModel):
spinner_speed=spinner_speed, spinner_speed=spinner_speed,
tags=tags if tags else [], tags=tags if tags else [],
logging_hooks=logging_hooks, logging_hooks=logging_hooks,
hooks=hooks,
retry=retry, retry=retry,
retry_all=retry_all, retry_all=retry_all,
retry_policy=retry_policy or RetryPolicy(), retry_policy=retry_policy,
options_manager=options_manager, options_manager=options_manager,
arg_parser=arg_parser, arg_parser=arg_parser,
execution_options=parsed_execution_options, execution_options=parsed_execution_options,
@@ -770,6 +796,7 @@ class Command(BaseModel):
argument_config=argument_config, argument_config=argument_config,
custom_parser=custom_parser, custom_parser=custom_parser,
custom_help=custom_help, custom_help=custom_help,
custom_tldr=custom_tldr,
auto_args=auto_args, auto_args=auto_args,
arg_metadata=arg_metadata or {}, arg_metadata=arg_metadata or {},
simple_help_signature=simple_help_signature, simple_help_signature=simple_help_signature,
@@ -777,11 +804,6 @@ class Command(BaseModel):
program=program, program=program,
) )
if hooks:
if not isinstance(hooks, HookManager):
raise NotAFalyxError("hooks must be an instance of HookManager.")
command.hooks = hooks
for hook in before_hooks or []: for hook in before_hooks or []:
command.hooks.register(HookType.BEFORE, hook) command.hooks.register(HookType.BEFORE, hook)
for hook in success_hooks or []: for hook in success_hooks or []:

View File

@@ -320,7 +320,9 @@ class CommandExecutor:
await self.hooks.trigger(HookType.ON_ERROR, context) await self.hooks.trigger(HookType.ON_ERROR, context)
await self._handle_action_error(command, error) await self._handle_action_error(command, error)
if wrap_errors: if wrap_errors:
raise FalyxError(f"[execute] '{command.description}' failed.") from error raise FalyxError(
f"[execute] '{command.description}' failed: {error}"
) from error
if raise_on_error: if raise_on_error:
raise error raise error
finally: finally:

View File

@@ -87,7 +87,7 @@ class CommandRunner:
command (Command): The command executed by this runner. command (Command): The command executed by this runner.
options (OptionsManager): Shared options manager used by the command, options (OptionsManager): Shared options manager used by the command,
parser, and executor. parser, and executor.
hooks (HookManager): Executor-level hooks used during execution. runner_hooks (HookManager): Executor-level hooks used during execution.
console (Console): Rich console used for user-facing output. console (Console): Rich console used for user-facing output.
executor (CommandExecutor): Shared execution engine used to run the executor (CommandExecutor): Shared execution engine used to run the
bound command. bound command.
@@ -98,7 +98,7 @@ class CommandRunner:
command: Command, command: Command,
*, *,
options: OptionsManager | None = None, options: OptionsManager | None = None,
hooks: HookManager | None = None, runner_hooks: HookManager | None = None,
console: Console | None = None, console: Console | None = None,
) -> None: ) -> None:
"""Initialize a `CommandRunner` for a single command. """Initialize a `CommandRunner` for a single command.
@@ -111,28 +111,52 @@ class CommandRunner:
command (Command): The command to execute. command (Command): The command to execute.
options (OptionsManager | None): Optional shared options manager. If options (OptionsManager | None): Optional shared options manager. If
omitted, a new `OptionsManager` is created. omitted, a new `OptionsManager` is created.
hooks (HookManager | None): Optional executor-level hook manager. If runner_hooks (HookManager | None): Optional executor-level hook manager. If
omitted, a new `HookManager` is created. omitted, a new `HookManager` is created.
console (Console | None): Optional Rich console for output. If omitted, console (Console | None): Optional Rich console for output. If omitted,
the default Falyx console is used. the default Falyx console is used.
""" """
self.command = command self.command = command
self.options = options or OptionsManager() self.options = self._get_options(options)
self.hooks = hooks or HookManager() self.runner_hooks = self._get_hooks(runner_hooks)
self.console = console or falyx_console self.console = self._get_console(console)
self.command.options_manager = self.options self.command.options_manager = self.options
if isinstance(self.command.arg_parser, CommandArgumentParser): if isinstance(self.command.arg_parser, CommandArgumentParser):
self.command.arg_parser.set_options_manager(self.options) self.command.arg_parser.set_options_manager(self.options)
self.executor = CommandExecutor( self.executor = CommandExecutor(
options=self.options, options=self.options,
hooks=self.hooks, hooks=self.runner_hooks,
console=self.console, console=self.console,
) )
self.options.from_mapping(values={}, namespace_name="execution") self.options.from_mapping(values={}, namespace_name="execution")
def _get_console(self, console) -> Console:
if console is None:
return falyx_console
elif isinstance(console, Console):
return console
else:
raise NotAFalyxError("console must be an instance of rich.Console or None.")
def _get_options(self, options) -> OptionsManager:
if options is None:
return OptionsManager()
elif isinstance(options, OptionsManager):
return options
else:
raise NotAFalyxError("options must be an instance of OptionsManager or None.")
def _get_hooks(self, hooks) -> HookManager:
if hooks is None:
return HookManager()
elif isinstance(hooks, HookManager):
return hooks
else:
raise NotAFalyxError("hooks must be an instance of HookManager or None.")
async def run( async def run(
self, self,
argv: list[str] | None = None, argv: list[str] | str | None = None,
raise_on_error: bool = True, raise_on_error: bool = True,
wrap_errors: bool = False, wrap_errors: bool = False,
summary_last_result: bool = False, summary_last_result: bool = False,
@@ -145,8 +169,9 @@ class CommandRunner:
then delegates execution to the internal `CommandExecutor`. then delegates execution to the internal `CommandExecutor`.
Args: Args:
argv (list[str] | None): Optional argv-style argument tokens. If argv (list[str] | str | None): Optional argv-style argument tokens or
omitted, `sys.argv[1:]` is used. string (uses `shlex.split()` if a string is provided). If omitted,
`sys.argv[1:]` is used.
Returns: Returns:
Any: The result returned by the bound command. Any: The result returned by the bound command.
@@ -176,7 +201,7 @@ class CommandRunner:
async def cli( async def cli(
self, self,
argv: list[str] | None = None, argv: list[str] | str | None = None,
summary_last_result: bool = False, summary_last_result: bool = False,
) -> Any: ) -> Any:
"""Run the bound command as a shell-oriented CLI entrypoint. """Run the bound command as a shell-oriented CLI entrypoint.
@@ -197,8 +222,9 @@ class CommandRunner:
- Exits with status code `130` for quit/interrupt-style termination - Exits with status code `130` for quit/interrupt-style termination
Args: Args:
argv (list[str] | None): Optional argv-style argument tokens. If omitted, argv (list[str] | str | None): Optional argv-style argument tokens or string
`sys.argv[1:]` is used by `run()`. (uses `shlex.split()` if a string is provided). If omitted, `sys.argv[1:]`
is used by `run()`.
summary_last_result (bool): Whether summary output should include the last summary_last_result (bool): Whether summary output should include the last
recorded result when summary reporting is enabled. recorded result when summary reporting is enabled.
@@ -274,12 +300,14 @@ class CommandRunner:
NotAFalyxError: If `runner_hooks` is provided but is not a NotAFalyxError: If `runner_hooks` is provided but is not a
`HookManager` instance. `HookManager` instance.
""" """
if not isinstance(command, Command):
raise NotAFalyxError("command must be an instance of Command.")
if runner_hooks and not isinstance(runner_hooks, HookManager): if runner_hooks and not isinstance(runner_hooks, HookManager):
raise NotAFalyxError("runner_hooks must be an instance of HookManager.") raise NotAFalyxError("runner_hooks must be an instance of HookManager.")
return cls( return cls(
command=command, command=command,
options=options, options=options,
hooks=runner_hooks, runner_hooks=runner_hooks,
console=console, console=console,
) )
@@ -462,6 +490,6 @@ class CommandRunner:
return cls( return cls(
command=command, command=command,
options=options, options=options,
hooks=runner_hooks, runner_hooks=runner_hooks,
console=console, console=console,
) )

View File

@@ -27,6 +27,8 @@ from typing import TYPE_CHECKING, Iterable
from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document from prompt_toolkit.document import Document
from falyx.namespace import FalyxNamespace
if TYPE_CHECKING: if TYPE_CHECKING:
from falyx import Falyx from falyx import Falyx
@@ -35,7 +37,7 @@ class FalyxCompleter(Completer):
"""Prompt Toolkit completer for Falyx CLI command input. """Prompt Toolkit completer for Falyx CLI command input.
This completer provides real-time, context-aware suggestions for: This completer provides real-time, context-aware suggestions for:
- Command keys and aliases (resolved via Falyx._name_map) - Command keys and aliases (resolved via Falyx._entry_map)
- CLI argument flags and values for each command - CLI argument flags and values for each command
- Suggestions and choices defined in the associated CommandArgumentParser - Suggestions and choices defined in the associated CommandArgumentParser
@@ -89,14 +91,14 @@ class FalyxCompleter(Completer):
def _resolve_command_for_completion(self, token: str): def _resolve_command_for_completion(self, token: str):
normalized = token.upper().strip() normalized = token.upper().strip()
name_map = self.falyx._name_map entry_map = self.falyx._entry_map
if normalized in name_map: if normalized in entry_map:
return name_map[normalized] return entry_map[normalized]
matches = [] matches = []
seen = set() seen = set()
for key, command in name_map.items(): for key, command in entry_map.items():
if key.startswith(normalized) and id(command) not in seen: if key.startswith(normalized) and id(command) not in seen:
matches.append(command) matches.append(command)
seen.add(id(command)) seen.add(id(command))
@@ -146,6 +148,13 @@ class FalyxCompleter(Completer):
# Identify command # Identify command
command_key = tokens[0].upper() command_key = tokens[0].upper()
command = self._resolve_command_for_completion(command_key) command = self._resolve_command_for_completion(command_key)
if isinstance(command, FalyxNamespace):
completer = command.namespace._get_completer()
for completion in completer.get_completions(
Document(" ".join(tokens[1:])), complete_event
):
yield completion
return
if not command or not command.arg_parser: if not command or not command.arg_parser:
return return

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
""" """Configuration loader and schema definitions for the Falyx CLI framework.
Configuration loader and schema definitions for the Falyx CLI framework.
This module supports config-driven initialization of CLI commands and submenus This module supports config-driven initialization of CLI commands and submenus
from YAML or TOML files. It enables declarative command definitions, auto-imports from YAML or TOML files. It enables declarative command definitions, auto-imports

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
""" """Context management for Falyx CLI.
Execution context management for Falyx CLI actions.
This module defines `ExecutionContext` and `SharedContext`, which are responsible for This module defines `ExecutionContext` and `SharedContext`, which are responsible for
capturing per-action and cross-action metadata during CLI workflow execution. These capturing per-action and cross-action metadata during CLI workflow execution. These
@@ -26,6 +25,7 @@ from pydantic import BaseModel, ConfigDict, Field
from rich.console import Console from rich.console import Console
from falyx.console import console from falyx.console import console
from falyx.mode import FalyxMode
class ExecutionContext(BaseModel): class ExecutionContext(BaseModel):
@@ -285,6 +285,30 @@ class SharedContext(BaseModel):
) )
class InvocationContext(BaseModel):
program: str = ""
typed_path: list[str] = Field(default_factory=list)
mode: FalyxMode = FalyxMode.MENU
is_preview: bool = False
@property
def is_cli_mode(self) -> bool:
return self.mode != FalyxMode.MENU
def child(self, token: str) -> InvocationContext:
return InvocationContext(
program=self.program,
typed_path=[*self.typed_path, token],
mode=self.mode,
is_preview=self.is_preview,
)
def display_path(self) -> str:
if self.is_cli_mode:
return " ".join([self.program, *self.typed_path]).strip()
return " ".join(self.typed_path).strip()
if __name__ == "__main__": if __name__ == "__main__":
import asyncio import asyncio

View File

@@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
""" """Defines all custom exception classes used in the Falyx CLI framework.
Defines all custom exception classes used in the Falyx CLI framework.
These exceptions provide structured error handling for common failure cases, These exceptions provide structured error handling for common failure cases,
including command conflicts, invalid actions or hooks, parser errors, and execution guards including command conflicts, invalid actions or hooks, parser errors, and execution guards
@@ -26,11 +25,11 @@ developer-facing problems that should be caught and reported.
class FalyxError(Exception): class FalyxError(Exception):
"""Custom exception for the Menu class.""" """Custom exception for the Falyx class."""
class CommandAlreadyExistsError(FalyxError): class CommandAlreadyExistsError(FalyxError):
"""Exception raised when an command with the same key already exists in the menu.""" """Exception raised when an command with the same key already exists in the Falyx instance."""
class InvalidHookError(FalyxError): class InvalidHookError(FalyxError):
@@ -42,7 +41,7 @@ class InvalidActionError(FalyxError):
class NotAFalyxError(FalyxError): class NotAFalyxError(FalyxError):
"""Exception raised when the provided submenu is not an instance of Menu.""" """Exception raised when the provided object is not an instance of a Falyx class."""
class CircuitBreakerOpen(FalyxError): class CircuitBreakerOpen(FalyxError):
@@ -54,11 +53,11 @@ class EmptyChainError(FalyxError):
class EmptyGroupError(FalyxError): class EmptyGroupError(FalyxError):
"""Exception raised when the chain is empty.""" """Exception raised when the group is empty."""
class EmptyPoolError(FalyxError): class EmptyPoolError(FalyxError):
"""Exception raised when the chain is empty.""" """Exception raised when the pool is empty."""
class CommandArgumentError(FalyxError): class CommandArgumentError(FalyxError):

View File

@@ -57,11 +57,13 @@ from rich.text import Text
from falyx.action.action import Action from falyx.action.action import Action
from falyx.action.base_action import BaseAction from falyx.action.base_action import BaseAction
from falyx.action.signal_action import SignalAction
from falyx.bottom_bar import BottomBar from falyx.bottom_bar import BottomBar
from falyx.command import Command from falyx.command import Command
from falyx.command_executor import CommandExecutor from falyx.command_executor import CommandExecutor
from falyx.completer import FalyxCompleter from falyx.completer import FalyxCompleter
from falyx.console import console from falyx.console import console
from falyx.context import InvocationContext
from falyx.debug import log_after, log_before, log_error, log_success from falyx.debug import log_after, log_before, log_error, log_success
from falyx.exceptions import ( from falyx.exceptions import (
CommandAlreadyExistsError, CommandAlreadyExistsError,
@@ -75,14 +77,16 @@ from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.mode import FalyxMode from falyx.mode import FalyxMode
from falyx.namespace import FalyxNamespace
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parser import CommandArgumentParser, FalyxParser, ParseResult from falyx.parser import CommandArgumentParser, FalyxParser, RootParseResult
from falyx.prompt_utils import rich_text_to_prompt_text from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy 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, HelpSignal, QuitSignal
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, _noop, chunks, ensure_async from falyx.utils import CaseInsensitiveDict, chunks, ensure_async
from falyx.validators import CommandValidator from falyx.validators import CommandValidator
from falyx.version import __version__ from falyx.version import __version__
@@ -209,11 +213,11 @@ class Falyx:
self.columns: int = columns self.columns: int = columns
self.commands: dict[str, Command] = CaseInsensitiveDict() self.commands: dict[str, Command] = CaseInsensitiveDict()
self.builtins: dict[str, Command] = CaseInsensitiveDict() self.builtins: dict[str, Command] = CaseInsensitiveDict()
self.namespaces: dict[str, FalyxNamespace] = CaseInsensitiveDict()
self.console: Console = console self.console: Console = console
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
self.exit_message: str | Markdown | dict[str, Any] = exit_message self.exit_message: str | Markdown | dict[str, Any] = exit_message
self.hooks: HookManager = HookManager() self.hooks: HookManager = HookManager()
self.last_run_command: Command | None = None
self.key_bindings: KeyBindings = key_bindings or KeyBindings() self.key_bindings: KeyBindings = key_bindings or KeyBindings()
self.bottom_bar: BottomBar | str | Callable[[], None] | None = bottom_bar self.bottom_bar: BottomBar | str | Callable[[], None] | None = bottom_bar
self._never_prompt: bool = never_prompt self._never_prompt: bool = never_prompt
@@ -247,6 +251,21 @@ class Falyx:
console=self.console, console=self.console,
) )
def get_current_invocation_context(self) -> InvocationContext:
"""Returns the current invocation context."""
return InvocationContext(
program=self.program,
typed_path=[],
mode=self.options.get("mode"),
)
def format_invocation_path(
self, program: str, typed_path: list[str], *, cli_mode: bool
) -> str:
if cli_mode:
return " ".join([program, *typed_path]).strip()
return " ".join(typed_path).strip()
@property @property
def is_cli_mode(self) -> bool: def is_cli_mode(self) -> bool:
"""Checks if the current mode is a CLI mode.""" """Checks if the current mode is a CLI mode."""
@@ -280,27 +299,30 @@ class Falyx:
if not self.options.get("program_style"): if not self.options.get("program_style"):
self.options.set("program_style", self.program_style) self.options.set("program_style", self.program_style)
if not self.options.get("invocation_path"):
self.options.set("invocation_path", self.program)
@property @property
def _name_map(self) -> dict[str, Command]: def _entry_map(self) -> dict[str, Command | FalyxNamespace]:
"""Builds a mapping of all valid input names to Command objects. """Builds a mapping of all valid input names to Command objects.
If a collision occurs, logs a warning and keeps the first If a collision occurs, logs a warning and keeps the first
registered command. registered command.
""" """
mapping: dict[str, Command] = {} mapping: dict[str, Command | FalyxNamespace] = {}
def register(name: str, command: Command): def register(name: str, entry: Command | FalyxNamespace):
norm = name.upper().strip() norm = name.upper().strip()
if norm in mapping: if norm in mapping:
existing = mapping[norm] existing = mapping[norm]
if existing is not command: if existing is not entry:
raise CommandAlreadyExistsError( raise CommandAlreadyExistsError(
f"Identifier '{norm}' is already registered.\n" f"Identifier '{norm}' is already registered.\n"
f"Existing command: {mapping[norm].key}\n" f"Existing entry: {mapping[norm].key}\n"
f"New command: {command.key}" f"New entry: {entry.key}"
) )
else: else:
mapping[norm] = command mapping[norm] = entry
for special in [self.exit_command, self.history_command]: for special in [self.exit_command, self.history_command]:
if special: if special:
@@ -320,6 +342,11 @@ class Falyx:
for alias in command.aliases: for alias in command.aliases:
register(alias, command) register(alias, command)
register(command.description, command) register(command.description, command)
for namespace in self.namespaces.values():
register(namespace.key, namespace)
for alias in namespace.aliases:
register(alias, namespace)
return mapping return mapping
def get_title(self) -> str: def get_title(self) -> str:
@@ -335,7 +362,7 @@ class Falyx:
exit_command = Command( exit_command = Command(
key="X", key="X",
description="Exit", description="Exit",
action=Action("Exit", action=_noop), action=SignalAction("Exit", QuitSignal()),
aliases=["EXIT", "QUIT"], aliases=["EXIT", "QUIT"],
style=OneColors.DARK_RED, style=OneColors.DARK_RED,
simple_help_signature=True, simple_help_signature=True,
@@ -454,42 +481,37 @@ class Falyx:
) )
return choice(tips) return choice(tips)
async def _render_command_tldr(self, key: str | None = None) -> None: async def _render_command_tldr(self, command: Command) -> None:
"""Renders the TLDR examples for a command, if available.""" """Renders the TLDR examples for a command, if available."""
if not key and self.help_command: if not isinstance(command, Command):
key = "H" self.console.print(
if not key: f"Entry '{command.key}' is not a command.", style=OneColors.DARK_RED
self.console.print("[bold]No command specified for TLDR examples.[/bold]") )
return None return None
_, command, args, kwargs, execution_args = await self.get_command( if command.render_tldr():
key, from_help=True
)
if command and command.arg_parser:
command.arg_parser.render_tldr()
if self.enable_help_tips: if self.enable_help_tips:
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
elif command and not command.arg_parser: else:
self.console.print( self.console.print(
f"[bold]No TLDR examples available for '{command.description}'.[/bold]" f"[bold]No TLDR examples available for '{command.description}'.[/bold]"
) )
else:
self.console.print(f"[bold]No command found for '{key}'.[/bold]")
async def _render_command_help(self, key: str) -> None: async def _render_command_help(self, command: Command, tldr: bool = False) -> None:
"""Renders the detailed help for a command, if available.""" """Renders the detailed help for a command, if available."""
_, command, args, kwargs, execution_args = await self.get_command( if not isinstance(command, Command):
key, from_help=True self.console.print(
) f"Entry '{command.key}' is not a command.", style=OneColors.DARK_RED
if command and command.arg_parser: )
command.arg_parser.render_help() return None
if tldr:
await self._render_command_tldr(command)
elif command.render_help():
if self.enable_help_tips: if self.enable_help_tips:
self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}") self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
elif command and not command.arg_parser: else:
self.console.print( self.console.print(
f"[bold]No detailed help available for '{command.description}'.[/bold]" f"[bold]No detailed help available for '{command.description}'.[/bold]"
) )
else:
self.console.print(f"[bold]No command found for '{key}'.[/bold]")
async def _render_tag_help(self, tag: str) -> None: async def _render_tag_help(self, tag: str) -> None:
"""Renders a list of commands matching a specific tag.""" """Renders a list of commands matching a specific tag."""
@@ -557,6 +579,30 @@ class Falyx:
if self.enable_help_tips: if self.enable_help_tips:
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}") self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
async def _render_unknown_route(self, route: RouteResult) -> None:
context = route.context
typed_key = context.typed_path[0].upper()
await route.namespace.render_namespace_help(context)
self.console.print(
f"[{OneColors.DARK_RED}]❌ Unknown Command or FalyxNamespace [{typed_key}]"
)
return None
async def render_namespace_help(
self, context: InvocationContext, tldr: bool = False
) -> None:
if context.mode is FalyxMode.MENU:
await self._render_menu_help()
else:
print(
self.format_invocation_path(
context.program,
context.typed_path,
cli_mode=True,
)
)
await self._render_cli_help()
async def _render_cli_help(self) -> None: async def _render_cli_help(self) -> None:
"""Renders the CLI help menu with all available commands and options.""" """Renders the CLI help menu with all available commands and options."""
usage = self.usage or "[GLOBAL OPTIONS] [COMMAND] [OPTIONS]" usage = self.usage or "[GLOBAL OPTIONS] [COMMAND] [OPTIONS]"
@@ -607,26 +653,36 @@ class Falyx:
if self.enable_help_tips: if self.enable_help_tips:
self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}") self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
async def _render_help( async def render_help(
self, self,
tag: str = "", tag: str = "",
key: str | None = None, key: str | None = None,
tldr: bool = False, tldr: bool = False,
) -> None: ) -> None:
"""Renders the help menu with command details, usage examples, and tips.""" """Renders the help menu with command details, usage examples, and tips."""
if tldr:
await self._render_command_tldr(key)
return None
if key: if key:
await self._render_command_help(key) entry, suggestions = self.resolve_entry(key)
return None if suggestions:
if tag: self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown entry '{key}'. Did you mean:[/]"
f"{', '.join(suggestions)[:10]}"
)
elif isinstance(entry, Command):
await self._render_command_help(entry, tldr)
elif isinstance(entry, FalyxNamespace):
await entry.namespace.render_namespace_help(
self.get_current_invocation_context(), tldr
)
else:
self.console.print(
f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]"
)
elif tag:
await self._render_tag_help(tag) await self._render_tag_help(tag)
return None elif self.options.get("mode") == FalyxMode.MENU:
if self.options.get("mode") == FalyxMode.MENU:
await self._render_menu_help() await self._render_menu_help()
return None else:
await self._render_cli_help() await self._render_cli_help()
def _get_help_command(self) -> Command: def _get_help_command(self) -> Command:
"""Returns the help command for the menu.""" """Returns the help command for the menu."""
@@ -667,7 +723,7 @@ class Falyx:
aliases=["HELP", "?"], aliases=["HELP", "?"],
description="Help", description="Help",
help_text="Show this help menu.", help_text="Show this help menu.",
action=Action("Help", self._render_help), action=Action("Help", self.render_help),
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
arg_parser=parser, arg_parser=parser,
ignore_in_history=True, ignore_in_history=True,
@@ -675,15 +731,11 @@ class Falyx:
program=self.program, program=self.program,
) )
async def _preview(self, command_key: str) -> None: async def _preview(self, key: str) -> None:
"""Previews the execution of a command without actually running it.""" """Previews the execution of a command without actually running it."""
_, command, args, kwargs, execution_args = await self.get_command( command = await self.resolve_command(key)
command_key, from_help=True
)
if not command: if not command:
self.console.print( self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{key}' not found.")
f"[{OneColors.DARK_RED}]❌ Command '{command_key}' not found."
)
return None return None
self.console.print(f"Preview of command '{command.key}': {command.description}") self.console.print(f"Preview of command '{command.key}': {command.description}")
await command.preview() await command.preview()
@@ -699,7 +751,7 @@ class Falyx:
help_text="Preview the execution of a command without running it.", help_text="Preview the execution of a command without running it.",
) )
preview_parser.add_argument( preview_parser.add_argument(
"command_key", "key",
help="The key or alias of the command to preview.", help="The key or alias of the command to preview.",
) )
preview_parser.add_tldr_examples( preview_parser.add_tldr_examples(
@@ -747,7 +799,7 @@ class Falyx:
"""Adds a built-in command to Falyx.""" """Adds a built-in command to Falyx."""
self._validate_command_aliases(command.key, command.aliases) self._validate_command_aliases(command.key, command.aliases)
self.builtins[command.key.upper()] = command self.builtins[command.key.upper()] = command
_ = self._name_map _ = self._entry_map
def _register_default_builtins(self) -> None: def _register_default_builtins(self) -> None:
"""Registers the default built-in commands for Falyx.""" """Registers the default built-in commands for Falyx."""
@@ -761,19 +813,15 @@ class Falyx:
def _get_validator_error_message(self) -> str: def _get_validator_error_message(self) -> str:
"""Validator to check if the input is a valid command.""" """Validator to check if the input is a valid command."""
keys = {self.exit_command.key.upper()} visible = self.iter_visible_entries(
keys.update({alias.upper() for alias in self.exit_command.aliases}) include_help=True,
if self.history_command: include_history=True,
keys.add(self.history_command.key.upper()) include_exit=True,
keys.update({alias.upper() for alias in self.history_command.aliases}) )
keys = {entry.key.upper() for entry in visible}
for command in self.builtins.values(): for entry in visible:
keys.add(command.key.upper()) for alias in entry.aliases:
keys.update({alias.upper() for alias in command.aliases}) keys.add(alias.upper())
for command in self.commands.values():
keys.add(command.key.upper())
keys.update({alias.upper() for alias in command.aliases})
commands_str = ", ".join(sorted(keys)) commands_str = ", ".join(sorted(keys))
@@ -799,9 +847,7 @@ class Falyx:
def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None: def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None:
"""Sets the bottom bar for the menu.""" """Sets the bottom bar for the menu."""
if bottom_bar is None: if bottom_bar is None:
self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar( self._bottom_bar = BottomBar(self.columns, self.key_bindings)
self.columns, self.key_bindings
)
elif isinstance(bottom_bar, BottomBar): elif isinstance(bottom_bar, BottomBar):
bottom_bar.key_bindings = self.key_bindings bottom_bar.key_bindings = self.key_bindings
self._bottom_bar = bottom_bar self._bottom_bar = bottom_bar
@@ -815,7 +861,7 @@ class Falyx:
def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None: def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
"""Returns the bottom bar for the menu.""" """Returns the bottom bar for the menu."""
if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items: if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar.has_items:
return self.bottom_bar.render return self.bottom_bar.render
elif callable(self.bottom_bar): elif callable(self.bottom_bar):
return self.bottom_bar return self.bottom_bar
@@ -916,7 +962,7 @@ class Falyx:
) -> None: ) -> None:
"""Updates the back command of the menu.""" """Updates the back command of the menu."""
self._validate_command_aliases(key, aliases) self._validate_command_aliases(key, aliases)
action = action or Action(description, action=_noop) action = action or SignalAction(description, QuitSignal())
if not callable(action): if not callable(action):
raise InvalidActionError("Action must be a callable.") raise InvalidActionError("Action must be a callable.")
self.exit_command = Command( self.exit_command = Command(
@@ -936,15 +982,32 @@ class Falyx:
self.exit_command.arg_parser.add_tldr_examples([("", help_text)]) self.exit_command.arg_parser.add_tldr_examples([("", help_text)])
def add_submenu( def add_submenu(
self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN self,
key: str,
description: str,
submenu: Falyx,
*,
style: str = OneColors.CYAN,
aliases: list[str] | None = None,
help_text: str = "",
) -> None: ) -> None:
"""Adds a submenu to the menu.""" """Adds a submenu to the menu."""
if not isinstance(submenu, Falyx): if not isinstance(submenu, Falyx):
raise NotAFalyxError("submenu must be an instance of Falyx.") raise NotAFalyxError("submenu must be an instance of Falyx.")
self._validate_command_aliases(key, [])
self.add_command( self._validate_command_aliases(key, aliases)
key, description, submenu.menu, style=style, simple_help_signature=True
entry = FalyxNamespace(
key=key,
description=description,
namespace=submenu,
aliases=aliases or [],
help_text=help_text or f"Open the {description} namespace.",
style=style,
) )
self.namespaces[key] = entry
if submenu.exit_command.key == "X": if submenu.exit_command.key == "X":
submenu.update_exit_command( submenu.update_exit_command(
key="B", key="B",
@@ -971,7 +1034,7 @@ class Falyx:
raise FalyxError("command must be an instance of Command.") raise FalyxError("command must be an instance of Command.")
self._validate_command_aliases(command.key, command.aliases) self._validate_command_aliases(command.key, command.aliases)
self.commands[command.key] = command self.commands[command.key] = command
_ = self._name_map _ = self._entry_map
def add_command( def add_command(
self, self,
@@ -1064,7 +1127,7 @@ class Falyx:
) )
self.commands[key] = command self.commands[key] = command
_ = self._name_map _ = self._entry_map
return command return command
def get_bottom_row(self) -> list[str]: def get_bottom_row(self) -> list[str]:
@@ -1086,18 +1149,39 @@ class Falyx:
) )
return bottom_row return bottom_row
def iter_visible_entries(
self,
*,
include_builtins: bool = False,
include_help: bool = False,
include_history: bool = False,
include_exit: bool = False,
) -> list[Command | FalyxNamespace]:
visible: list[Command | FalyxNamespace] = []
visible.extend([cmd for cmd in self.commands.values() if not cmd.hidden])
visible.extend([ns for ns in self.namespaces.values() if not ns.hidden])
if include_builtins:
visible.extend([cmd for cmd in self.builtins.values() if not cmd.hidden])
if include_help:
visible.append(self.help_command)
if include_history and self.history_command:
visible.append(self.history_command)
if include_exit:
visible.append(self.exit_command)
return visible
def build_default_table(self) -> Table: def build_default_table(self) -> Table:
"""Build the standard table layout. """Build the standard table layout.
Developers can subclass or call this in custom tables. Developers can subclass or call this in custom tables.
""" """
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) # type: ignore[arg-type]
visible_commands = [item for item in self.commands.items() if not item[1].hidden] visible = self.iter_visible_entries()
for chunk in chunks(visible_commands, self.columns): for chunk in chunks(visible, self.columns):
row = [] row = []
for key, command in chunk: for entry in chunk:
escaped_key = escape(f"[{key}]") escaped_key = escape(f"[{entry.key}]")
row.append(f"{escaped_key} [{command.style}]{command.description}") row.append(f"{escaped_key} [{entry.style}]{entry.description}")
table.add_row(*row) table.add_row(*row)
bottom_row = self.get_bottom_row() bottom_row = self.get_bottom_row()
for row in chunks(bottom_row, self.columns): for row in chunks(bottom_row, self.columns):
@@ -1139,105 +1223,176 @@ class Falyx:
return True, input_str[1:].strip() return True, input_str[1:].strip()
return False, input_str.strip() return False, input_str.strip()
async def get_command( def resolve_entry(
self, raw_choices: str, from_validate=False, from_help=False self,
) -> tuple[bool, Command | None, tuple, dict[str, Any], dict[str, Any]]: token: str,
"""Returns the selected command based on user input. ) -> tuple[Command | FalyxNamespace | None, list[str]]:
normalized = token.upper().strip()
Supports keys, aliases, and abbreviations. # exact match
""" if normalized in self._entry_map:
args = () return self._entry_map[normalized], []
# unique prefix match
prefix_matches = []
seen = set()
for key, entry in self._entry_map.items():
if key.startswith(normalized) and id(entry) not in seen:
prefix_matches.append(entry)
seen.add(id(entry))
if len(prefix_matches) == 1:
return prefix_matches[0], []
suggestions = get_close_matches(
normalized, list(self._entry_map.keys()), n=3, cutoff=0.7
)
return None, suggestions
async def prepare_route(
self,
raw_arguments: list[str] | str,
*,
mode: FalyxMode | None = None,
from_validate: bool = False,
) -> tuple[RouteResult | None, tuple, dict[str, Any], dict[str, Any]]:
args: tuple = ()
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = {}
execution_args: dict[str, Any] = {} execution_args: dict[str, Any] = {}
try: if isinstance(raw_arguments, str):
choice, *input_args = shlex.split(raw_choices)
except ValueError:
return False, None, args, kwargs, execution_args
is_preview, choice = self.parse_preview_command(choice)
if is_preview and not choice and self.help_command:
is_preview = False
choice = "?"
elif is_preview and not choice:
# No help (list) command enabled
if not from_validate:
self.console.print(
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
)
return is_preview, None, args, kwargs, execution_args
choice = choice.upper()
name_map = self._name_map
run_command = None
if name_map.get(choice):
run_command = name_map[choice]
else:
prefix_matches = [
command for key, command in name_map.items() if key.startswith(choice)
]
if len(prefix_matches) == 1:
run_command = prefix_matches[0]
if run_command:
if not from_validate:
logger.info("Command '%s' selected.", run_command.key)
if is_preview:
return True, run_command, args, kwargs, execution_args
elif self.is_cli_mode or from_help:
return False, run_command, args, kwargs, execution_args
try: try:
args, kwargs, execution_args = await run_command.resolve_args( tokens = shlex.split(raw_arguments)
input_args, from_validate except ValueError as error:
) if from_validate:
except (CommandArgumentError, Exception) as error:
if not from_validate:
run_command.render_help()
self.console.print(
f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}"
)
else:
raise ValidationError( raise ValidationError(
message=str(error), cursor_position=len(raw_choices) cursor_position=len(raw_arguments), message=str(error)
) ) from error
return is_preview, None, args, kwargs, execution_args
except HelpSignal:
return True, None, args, kwargs, execution_args
return is_preview, run_command, args, kwargs, execution_args
fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
if fuzzy_matches:
if not from_validate:
self.console.print( self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. " f"Parse error: {error}",
"Did you mean:" style=OneColors.DARK_RED,
)
for match in fuzzy_matches:
command = name_map[match]
self.console.print(f" • [bold]{match}[/] → {command.description}")
else:
raise ValidationError(
message=f"Unknown command '{choice}'. Did you mean: "
f"{', '.join(fuzzy_matches)}?",
cursor_position=len(raw_choices),
) )
return None, args, kwargs, execution_args
elif isinstance(raw_arguments, list):
tokens = raw_arguments
else: else:
if not from_validate: if from_validate:
self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
)
else:
raise ValidationError( raise ValidationError(
message=f"Unknown command '{choice}'.", cursor_position=len(raw_arguments),
cursor_position=len(raw_choices), message="TypeError",
) )
return is_preview, None, args, kwargs, execution_args return None, args, kwargs, execution_args
is_preview = False
if tokens and tokens[0].startswith("?"):
is_preview = True
tokens[0] = tokens[0][1:]
context = InvocationContext(
program=self.program,
typed_path=[],
mode=mode or self.options.get("mode"),
is_preview=is_preview,
)
route = await self.resolve_route(tokens, context=context)
if is_preview:
route.is_preview = True
return route, args, kwargs, execution_args
if route.kind is RouteKind.COMMAND:
assert route.command is not None
try:
args, kwargs, execution_args = await route.command.resolve_args(
route.leaf_argv, from_validate=from_validate
)
except CommandArgumentError as error:
if from_validate:
raise ValidationError(
cursor_position=len(raw_arguments), message=str(error)
) from error
else:
route.command.render_help()
self.console.print(
f"[{OneColors.DARK_RED}]❌ [{route.command.key}]: {error}"
)
raise error
except HelpSignal:
if not from_validate:
raise
return route, args, kwargs, execution_args
return route, args, kwargs, execution_args
async def _dispatch_route(
self,
route: RouteResult,
*,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
execution_args: dict[str, Any] | None = None,
raise_on_error: bool = False,
wrap_errors: bool = True,
summary_last_result: bool = False,
) -> Any | None:
if route.kind is RouteKind.NAMESPACE_MENU:
await route.namespace.menu()
return None
if route.kind is RouteKind.NAMESPACE_HELP:
await route.namespace.render_namespace_help(route.context)
return None
if route.kind is RouteKind.NAMESPACE_TLDR:
await route.namespace.render_namespace_help(route.context, tldr=True)
return None
if route.kind is RouteKind.UNKNOWN:
await self._render_unknown_route(route)
return None
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
command = route.command
if route.is_preview:
logger.info("Preview command '%s' selected.", command.key)
await command.preview()
return None
logger.debug(
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
route.command.description,
args,
kwargs,
execution_args,
)
return await self._executor.execute(
command=route.command,
args=args,
kwargs=kwargs or {},
execution_args=execution_args or {},
raise_on_error=raise_on_error,
wrap_errors=wrap_errors,
summary_last_result=summary_last_result,
)
async def execute_command( async def execute_command(
self, self,
raw_arguments: str, raw_arguments: list[str] | str,
*, *,
raise_on_error: bool = False, raise_on_error: bool = False,
wrap_errors: bool = True, wrap_errors: bool = True,
summary_last_result: bool = False, summary_last_result: bool = False,
mode: FalyxMode = FalyxMode.MENU,
) -> Any | None: ) -> Any | None:
"""Execute a command from a raw CLI-style input string. """Execute a command from a raw CLI-style input string.
@@ -1247,10 +1402,9 @@ class Falyx:
Behavior: Behavior:
- Resolves the command and its parsed `args`, `kwargs`, and - Resolves the command and its parsed `args`, `kwargs`, and
`execution_args` via `get_command()`. `execution_args` via `prepare_route()`.
- Returns `None` when help output is triggered, argument parsing fails, - Returns `None` when help output is triggered, argument parsing fails,
the command cannot be found, or preview mode is requested. the command cannot be found, or preview mode is requested.
- Updates `last_run_command` when a valid command is resolved.
- Raises `QuitSignal` if the resolved command is the configured exit - Raises `QuitSignal` if the resolved command is the configured exit
command. command.
- For normal execution, forwards the resolved command and execution - For normal execution, forwards the resolved command and execution
@@ -1281,53 +1435,86 @@ class Falyx:
command from a raw input string outside the interactive menu loop. command from a raw input string outside the interactive menu loop.
""" """
try: try:
is_preview, command, args, kwargs, execution_args = await self.get_command( route, args, kwargs, execution_args = await self.prepare_route(
raw_arguments raw_arguments, mode=mode
) )
except (CommandArgumentError, Exception):
return None
except HelpSignal: except HelpSignal:
return None return None
except CommandArgumentError as error:
logger.error( if route is None:
"Argument parsing error for input '%s': %s", raw_arguments, error
)
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{raw_arguments}'] {error}[/]")
return None return None
if not command: return await self._dispatch_route(
logger.error("Command not found for input '%s'", raw_arguments) route=route,
self.console.print(
f"[{OneColors.DARK_RED}]❌ ['{raw_arguments}'] Command not found.[/]"
)
return None
self.last_run_command = command
if is_preview:
logger.info("Preview command '%s' selected.", command.key)
await command.preview()
return None
if command == self.exit_command:
logger.info("Back selected: exiting %s", self.get_title())
raise QuitSignal()
logger.debug(
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
command.description,
args,
kwargs,
execution_args,
)
return await self._executor.execute(
command=command,
args=args, args=args,
kwargs=kwargs or {}, kwargs=kwargs,
execution_args=execution_args or {}, execution_args=execution_args,
raise_on_error=raise_on_error, raise_on_error=raise_on_error,
wrap_errors=wrap_errors, wrap_errors=wrap_errors,
summary_last_result=summary_last_result, summary_last_result=summary_last_result,
) )
async def resolve_route(
self,
tokens: list[str],
*,
context: InvocationContext,
) -> RouteResult:
# 1. No more tokens -> this namespace itself was targeted
if not tokens:
return RouteResult(
kind=RouteKind.NAMESPACE_MENU,
namespace=self,
context=context,
)
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=context,
)
if head in {"-T", "--tldr"}:
return RouteResult(
kind=RouteKind.NAMESPACE_TLDR,
namespace=self,
context=context,
)
# 3. 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=context,
suggestions=suggestions,
)
child_context = context.child(head)
# 4. Namespace entry -> recurse with remaining tokens
if isinstance(entry, FalyxNamespace):
return await entry.namespace.resolve_route(
tail,
context=child_context,
)
# 5. Leaf command -> stop routing; leave tail untouched for leaf parser
return RouteResult(
kind=RouteKind.COMMAND,
namespace=self,
context=child_context,
command=entry,
leaf_argv=tail,
)
async def process_command(self) -> None: async def process_command(self) -> None:
"""Processes the action of the selected command.""" """Processes the action of the selected command."""
app = get_app() app = get_app()
@@ -1388,7 +1575,7 @@ class Falyx:
if self.exit_message: if self.exit_message:
self.print_message(self.exit_message) self.print_message(self.exit_message)
def _apply_parse_result(self, result: ParseResult) -> None: def _apply_parse_result(self, result: RootParseResult) -> None:
"""Applies the parsed CLI arguments to the menu options.""" """Applies the parsed CLI arguments to the menu options."""
self.options.set("mode", result.mode) self.options.set("mode", result.mode)
@@ -1419,22 +1606,6 @@ class Falyx:
CLI arguments, configures runtime state, and dispatches execution based CLI arguments, configures runtime state, and dispatches execution based
on the resolved mode. on the resolved mode.
Execution Pipeline:
1. Parse CLI input via `FalyxParser` into a `ParseResult`
2. Optionally invoke a user-provided callback with the parse result
3. Apply root-level options (e.g. verbose, debug hooks, prompt behavior)
4. Dispatch based on `ParseResult.mode`:
- HELP: Render help output and exit
- COMMAND: Execute a resolved command
- MENU: Launch interactive menu loop
- ERROR: Render error and exit
Command Execution:
- Arguments are parsed via `CommandArgumentParser`
- Execution options (e.g. retries, confirmation flags) are separated
- Execution-scoped overrides are applied using `OptionsManager`
- Commands are executed via `CommandExecutor.execute()` with full lifecycle hooks
Callback Behavior: Callback Behavior:
- If provided, `callback` is executed after parsing but before dispatch - If provided, `callback` is executed after parsing but before dispatch
- Supports both sync and async callables - Supports both sync and async callables
@@ -1466,9 +1637,7 @@ class Falyx:
>>> asyncio.run(flx.run()) >>> asyncio.run(flx.run())
``` ```
""" """
parse_result = FalyxParser.parse(sys.argv[1:])
falyx_parser = FalyxParser(self)
parse_result = falyx_parser.parse(sys.argv[1:])
if callback: if callback:
if not callable(callback): if not callable(callback):
@@ -1478,74 +1647,54 @@ class Falyx:
self._apply_parse_result(parse_result) self._apply_parse_result(parse_result)
if parse_result.mode == FalyxMode.ERROR:
await self._render_help()
self.console.print(f"[{OneColors.DARK_RED}]Error: {parse_result.error}[/]")
sys.exit(1)
if parse_result.mode == FalyxMode.HELP: if parse_result.mode == FalyxMode.HELP:
await self._render_help() await self.render_help()
sys.exit(0) sys.exit(0)
if parse_result.mode == FalyxMode.COMMAND: try:
if not parse_result.command: route, args, kwargs, execution_args = await self.prepare_route(
self.console.print( raw_arguments=parse_result.remaining_argv,
f"[{OneColors.DARK_RED}]Error: No command specified for execution mode.[/]" )
) except CommandArgumentError:
sys.exit(1) sys.exit(2)
command = parse_result.command except HelpSignal:
sys.exit(0)
if parse_result.is_preview: if not route:
if command is None: await self.render_help()
sys.exit(1) self.console.print(
logger.info("Preview command '%s' selected.", command.key) f"[{OneColors.DARK_RED}]❌ Error unable to parse: {parse_result.raw_argv}"
await command.preview() )
sys.exit(0) sys.exit(2)
if not command:
sys.exit(1)
try:
args, kwargs, execution_args = await command.resolve_args(
parse_result.command_argv
)
except HelpSignal:
sys.exit(0)
except CommandArgumentError as error:
command.render_help()
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}")
sys.exit(2)
try:
logger.debug(
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
command.description,
args,
kwargs,
execution_args,
)
await self._executor.execute(
command=command,
args=args,
kwargs=kwargs,
execution_args=execution_args,
raise_on_error=False,
wrap_errors=True,
)
except FalyxError as error:
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
sys.exit(1)
except QuitSignal:
logger.info("[QuitSignal]. <- Exiting run.")
sys.exit(130)
except BackSignal:
logger.info("[BackSignal]. <- Exiting run.")
sys.exit(1)
except CancelSignal:
logger.info("[CancelSignal]. <- Exiting run.")
sys.exit(1)
except asyncio.CancelledError:
logger.info("[asyncio.CancelledError]. <- Exiting run.")
sys.exit(1)
if not always_start_menu: try:
sys.exit(0) await self._dispatch_route(
route=route,
args=args,
kwargs=kwargs,
execution_args=execution_args,
raise_on_error=False,
wrap_errors=True,
)
except FalyxError as error:
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
sys.exit(1)
except Exception:
sys.exit(1)
except QuitSignal:
logger.info("[QuitSignal]. <- Exiting run.")
sys.exit(130)
except BackSignal:
logger.info("[BackSignal]. <- Exiting run.")
sys.exit(1)
except CancelSignal:
logger.info("[CancelSignal]. <- Exiting run.")
sys.exit(1)
except asyncio.CancelledError:
logger.info("[asyncio.CancelledError]. <- Exiting run.")
sys.exit(1)
if route.kind is RouteKind.NAMESPACE_MENU or not always_start_menu:
sys.exit(0)
await self.menu() await self.menu()

20
falyx/namespace.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from falyx.themes import OneColors
if TYPE_CHECKING:
from falyx.falyx import Falyx
@dataclass
class FalyxNamespace:
key: str
description: str
namespace: Falyx
aliases: list[str] = field(default_factory=list)
help_text: str = ""
style: str = OneColors.CYAN
hidden: bool = False

View File

@@ -9,7 +9,7 @@ from .argument import Argument
from .argument_action import ArgumentAction from .argument_action import ArgumentAction
from .command_argument_parser import CommandArgumentParser from .command_argument_parser import CommandArgumentParser
from .falyx_parser import FalyxParser from .falyx_parser import FalyxParser
from .parse_result import ParseResult from .parse_result import RootParseResult
__all__ = [ __all__ = [
"Argument", "Argument",

View File

@@ -58,7 +58,7 @@ from rich.panel import Panel
from falyx.action.base_action import BaseAction from falyx.action.base_action import BaseAction
from falyx.console import console from falyx.console import console
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.execution_option import ExecutionOption from falyx.execution_option import ExecutionOption
from falyx.mode import FalyxMode from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
@@ -170,7 +170,7 @@ class CommandArgumentParser:
def set_options_manager(self, options_manager: OptionsManager) -> None: def set_options_manager(self, options_manager: OptionsManager) -> None:
"""Set the options manager for the parser.""" """Set the options manager for the parser."""
if not isinstance(options_manager, OptionsManager): if not isinstance(options_manager, OptionsManager):
raise ValueError("options_manager must be an instance of OptionsManager") raise NotAFalyxError("options_manager must be an instance of OptionsManager")
self.options_manager = options_manager self.options_manager = options_manager
def enable_execution_options( def enable_execution_options(

View File

@@ -2,15 +2,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from difflib import get_close_matches
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from falyx.mode import FalyxMode from falyx.mode import FalyxMode
from falyx.parser.parse_result import ParseResult from falyx.parser.parse_result import RootParseResult
if TYPE_CHECKING:
from falyx.command import Command
from falyx.falyx import Falyx
@dataclass(slots=True) @dataclass(slots=True)
@@ -18,7 +13,6 @@ class RootOptions:
verbose: bool = False verbose: bool = False
debug_hooks: bool = False debug_hooks: bool = False
never_prompt: bool = False never_prompt: bool = False
version: bool = False
help: bool = False help: bool = False
@@ -42,11 +36,9 @@ class FalyxParser:
"--help": "help", "--help": "help",
} }
def __init__(self, falyx: Falyx) -> None: @classmethod
self.falyx = falyx
def _parse_root_options( def _parse_root_options(
self, cls,
argv: list[str], argv: list[str],
) -> tuple[RootOptions, list[str]]: ) -> tuple[RootOptions, list[str]]:
"""Parse only root/session flags from the start of argv. """Parse only root/session flags from the start of argv.
@@ -69,7 +61,7 @@ class FalyxParser:
remaining_start = index + 1 remaining_start = index + 1
break break
attr = self.ROOT_FLAG_ALIASES.get(token) attr = cls.ROOT_FLAG_ALIASES.get(token)
if attr is None: if attr is None:
remaining_start = index remaining_start = index
break break
@@ -81,79 +73,13 @@ class FalyxParser:
remaining = argv[remaining_start:] remaining = argv[remaining_start:]
return options, remaining return options, remaining
def resolve_command(self, token: str) -> tuple[Command | None, list[str]]: @classmethod
"""Resolve a command by key, alias, or unique prefix. def parse(cls, argv: list[str] | None = None) -> RootParseResult:
Returns:
(command, suggestions)
"""
normalized = token.upper().strip()
name_map = self.falyx._name_map
if normalized in name_map:
return name_map[normalized], []
prefix_matches = []
seen = set()
for key, command in name_map.items():
if key.startswith(normalized) and id(command) not in seen:
prefix_matches.append(command)
seen.add(id(command))
if len(prefix_matches) == 1:
return prefix_matches[0], []
suggestions = get_close_matches(
normalized, list(name_map.keys()), n=3, cutoff=0.7
)
return None, suggestions
def _parse_command(
self,
argv: list[str],
root: RootOptions,
remaining: list[str],
) -> ParseResult:
raw_name = remaining[0]
is_preview = raw_name.startswith("?")
command_name = raw_name[1:] if is_preview else raw_name
command, suggestions = self.resolve_command(command_name)
if not command:
sugguestions_text = (
f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
)
return ParseResult(
mode=FalyxMode.ERROR,
raw_argv=argv,
command_name=command_name,
command_argv=remaining[1:],
verbose=root.verbose,
debug_hooks=root.debug_hooks,
never_prompt=root.never_prompt,
error=f"Unknown command '{command_name}'.{sugguestions_text}",
)
command_argv = remaining[1:]
return ParseResult(
mode=FalyxMode.COMMAND,
raw_argv=argv,
command_name=command_name,
command=command,
command_argv=command_argv,
is_preview=is_preview,
verbose=root.verbose,
debug_hooks=root.debug_hooks,
never_prompt=root.never_prompt,
)
def parse(self, argv: list[str] | None = None) -> ParseResult:
argv = argv or [] argv = argv or []
root, remaining = self._parse_root_options(argv) root, remaining = cls._parse_root_options(argv)
if root.help: if root.help:
return ParseResult( return RootParseResult(
mode=FalyxMode.HELP, mode=FalyxMode.HELP,
raw_argv=argv, raw_argv=argv,
never_prompt=root.never_prompt, never_prompt=root.never_prompt,
@@ -161,15 +87,11 @@ class FalyxParser:
debug_hooks=root.debug_hooks, debug_hooks=root.debug_hooks,
) )
if not remaining: return RootParseResult(
return ParseResult( mode=FalyxMode.COMMAND,
mode=FalyxMode.MENU, raw_argv=argv,
raw_argv=argv, verbose=root.verbose,
verbose=root.verbose, debug_hooks=root.debug_hooks,
debug_hooks=root.debug_hooks, never_prompt=root.never_prompt,
never_prompt=root.never_prompt, remaining_argv=remaining,
) )
head, *tail = remaining
return self._parse_command(argv, root, remaining)

View File

@@ -1,24 +1,14 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from falyx.mode import FalyxMode from falyx.mode import FalyxMode
if TYPE_CHECKING:
from falyx.command import Command
@dataclass(slots=True) @dataclass(slots=True)
class ParseResult: class RootParseResult:
mode: FalyxMode mode: FalyxMode
raw_argv: list[str] = field(default_factory=list) raw_argv: list[str] = field(default_factory=list)
verbose: bool = False verbose: bool = False
debug_hooks: bool = False debug_hooks: bool = False
never_prompt: bool = False never_prompt: bool = False
command_name: str = "" remaining_argv: list[str] = field(default_factory=list)
command: Command | None = None
command_argv: list[str] = field(default_factory=list)
is_preview: bool = False
error: str | None = None

View File

@@ -29,4 +29,6 @@ class ActionFactoryProtocol(Protocol):
@runtime_checkable @runtime_checkable
class ArgParserProtocol(Protocol): class ArgParserProtocol(Protocol):
def __call__(self, args: list[str]) -> tuple[tuple, dict, dict]: ... def __call__(
self, args: list[str]
) -> tuple[tuple, dict[str, Any], dict[str, Any]]: ...

33
falyx/routing.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING
from falyx.context import InvocationContext
from falyx.namespace import FalyxNamespace
if TYPE_CHECKING:
from falyx.command import Command
from falyx.falyx import Falyx
class RouteKind(Enum):
COMMAND = "command"
NAMESPACE_MENU = "namespace_menu"
NAMESPACE_HELP = "namespace_help"
NAMESPACE_TLDR = "namespace_tldr"
UNKNOWN = "unknown"
@dataclass(slots=True)
class RouteResult:
kind: RouteKind
namespace: "Falyx"
context: InvocationContext
command: "Command | None" = None
namespace_entry: FalyxNamespace | None = None
leaf_argv: list[str] = field(default_factory=list)
typed_path: list[str] = field(default_factory=list)
suggestions: list[str] = field(default_factory=list)
is_preview: bool = False

View File

@@ -22,6 +22,8 @@ from typing import TYPE_CHECKING, KeysView, Sequence
from prompt_toolkit.validation import ValidationError, Validator from prompt_toolkit.validation import ValidationError, Validator
from falyx.routing import RouteKind
if TYPE_CHECKING: if TYPE_CHECKING:
from falyx.falyx import Falyx from falyx.falyx import Falyx
@@ -48,12 +50,28 @@ class CommandValidator(Validator):
message=self.error_message, message=self.error_message,
cursor_position=len(text), cursor_position=len(text),
) )
is_preview, choice, _, __, ___ = await self.falyx.get_command( route, _, __, ___ = await self.falyx.prepare_route(text, from_validate=True)
text, from_validate=True if not route:
) raise ValidationError(
if is_preview: message=self.error_message,
cursor_position=len(text),
)
if route.is_preview:
return None return None
if not choice: if route.kind in {
RouteKind.NAMESPACE_MENU,
RouteKind.NAMESPACE_HELP,
RouteKind.NAMESPACE_TLDR,
}:
return None
if route.kind is RouteKind.COMMAND and route.command is None:
raise ValidationError(
message=self.error_message,
cursor_position=len(text),
)
elif route.kind is RouteKind.COMMAND:
return None
if route.kind is RouteKind.UNKNOWN:
raise ValidationError( raise ValidationError(
message=self.error_message, message=self.error_message,
cursor_position=len(text), cursor_position=len(text),

View File

@@ -1,5 +1,7 @@
import pytest import pytest
from rich.text import Text
from falyx.console import console as falyx_console
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parser import ArgumentAction, CommandArgumentParser from falyx.parser import ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal from falyx.signals import HelpSignal
@@ -825,4 +827,11 @@ async def test_render_help():
parser.add_argument("--foo", type=str, help="Foo help") parser.add_argument("--foo", type=str, help="Foo help")
parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help") parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help")
assert parser.render_help() is None with falyx_console.capture() as capture:
parser.render_help()
output = Text.from_ansi(capture.get()).plain
assert "usage:" in output
assert "--foo" in output
assert "Foo help" in output
assert "--bar" in output
assert "Bar help" in output

View File

@@ -17,7 +17,7 @@ def fake_falyx():
help_command=SimpleNamespace(key="H", aliases=["HELP"]), help_command=SimpleNamespace(key="H", aliases=["HELP"]),
history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]), history_command=SimpleNamespace(key="Y", aliases=["HISTORY"]),
commands={"R": fake_command}, commands={"R": fake_command},
_name_map={"R": fake_command, "RUN": fake_command, "X": fake_command}, _entry_map={"R": fake_command, "RUN": fake_command, "X": fake_command},
) )

View File

@@ -0,0 +1,30 @@
import pytest
from falyx.execution_option import ExecutionOption
def test_execution_option_accepts_valid_string_values():
assert ExecutionOption("summary") == ExecutionOption.SUMMARY
assert ExecutionOption("retry") == ExecutionOption.RETRY
assert ExecutionOption("confirm") == ExecutionOption.CONFIRM
def test_execution_option_rejects_invalid_string():
with pytest.raises(ValueError, match="Invalid ExecutionOption: 'invalid'"):
ExecutionOption("invalid")
def test_execution_option_normalizes_case_and_whitespace():
assert ExecutionOption(" SUMMARY ") == ExecutionOption.SUMMARY
assert ExecutionOption("ReTrY") == ExecutionOption.RETRY
assert ExecutionOption("\tconfirm\n") == ExecutionOption.CONFIRM
def test_execution_option_rejects_non_string():
with pytest.raises(ValueError, match="Invalid ExecutionOption: 123"):
ExecutionOption(123)
def test_execution_option_error_lists_valid_values():
with pytest.raises(ValueError, match="Must be one of: summary, retry, confirm"):
ExecutionOption("invalid")

View File

@@ -51,7 +51,7 @@ async def test_render_help(capsys):
aliases=["SC"], aliases=["SC"],
help_text="This is a sample command.", help_text="This is a sample command.",
) )
await flx._render_help() await flx.render_help()
captured = capsys.readouterr() captured = capsys.readouterr()
assert "This is a sample command." in captured.out assert "This is a sample command." in captured.out
@@ -75,7 +75,6 @@ async def test_help_command_by_tag(capsys):
await flx.execute_command("H -t tag1") await flx.execute_command("H -t tag1")
captured = capsys.readouterr() captured = capsys.readouterr()
print(captured.out)
text = Text.from_ansi(captured.out) text = Text.from_ansi(captured.out)
assert "tag1" in text.plain assert "tag1" in text.plain
assert "This command is tagged." in text.plain assert "This command is tagged." in text.plain

View File

@@ -1,10 +1,8 @@
from falyx import Falyx
from falyx.parser.falyx_parser import FalyxParser, RootOptions from falyx.parser.falyx_parser import FalyxParser, RootOptions
def get_falyx_parser(): def get_falyx_parser():
falyx = Falyx() return FalyxParser()
return FalyxParser(falyx=falyx)
def test_parse_root_options_empty(): def test_parse_root_options_empty():

View File

@@ -0,0 +1,143 @@
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.execution_option import ExecutionOption
from falyx.parser import CommandArgumentParser
def test_enable_execution_options_registers_summary_flag():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
assert "--summary" in parser._flag_map
assert "--summary" in parser._keyword
assert "--summary" in parser._flag_map
assert "summary" in parser._execution_dests
def test_enable_execution_options_registers_retry_flags():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.RETRY}))
assert "--retries" in parser._flag_map
assert "--retries" in parser._keyword
assert "--retries" in parser._flag_map
assert "retries" in parser._execution_dests
assert "--retry-delay" in parser._flag_map
assert "--retry-delay" in parser._keyword
assert "--retry-delay" in parser._flag_map
assert "retry_delay" in parser._execution_dests
assert "--retry-backoff" in parser._flag_map
assert "--retry-backoff" in parser._keyword
assert "--retry-backoff" in parser._flag_map
assert "retry_backoff" in parser._execution_dests
def test_enable_execution_options_registers_confirm_flags():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM}))
assert "--confirm" in parser._flag_map
assert "--confirm" in parser._keyword
assert "--confirm" in parser._flag_map
assert "force_confirm" in parser._execution_dests
assert "--skip-confirm" in parser._flag_map
assert "--skip-confirm" in parser._keyword
assert "--skip-confirm" in parser._flag_map
assert "skip_confirm" in parser._execution_dests
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"
):
parser.add_argument("--summary", action="store_true")
with pytest.raises(
CommandArgumentError, match="Destination 'summary' is already defined"
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
@pytest.mark.asyncio
async def test_parse_args_split_with_execution_options_returns_correct_execution_args():
parser = CommandArgumentParser()
parser.add_argument("foo", type=int, help="A business argument.")
parser.add_argument("--bar", type=int, help="A business argument.")
parser.enable_execution_options(
frozenset({ExecutionOption.SUMMARY, ExecutionOption.RETRY})
)
args, kwargs, execution_args = await parser.parse_args_split(
["50", "--bar", "42", "--summary", "--retries", "3"]
)
assert args == (50,)
assert kwargs == {"bar": 42}
assert execution_args == {
"summary": True,
"retries": 3,
"retry_delay": 0.0,
"retry_backoff": 0.0,
}
@pytest.mark.asyncio
async def test_parse_args_split_with_all_execution_options_returns_correct_execution_args():
parser = CommandArgumentParser()
parser.add_argument("foo", type=int, help="A business argument.")
parser.add_argument("--bar", type=int, help="A business argument.")
parser.enable_execution_options(
frozenset(
{
ExecutionOption.SUMMARY,
ExecutionOption.RETRY,
ExecutionOption.CONFIRM,
}
)
)
args, kwargs, execution_args = await parser.parse_args_split(
[
"50",
"--bar",
"42",
"--summary",
"--retries",
"3",
"--confirm",
]
)
assert args == (50,)
assert kwargs == {"bar": 42}
assert execution_args == {
"summary": True,
"retries": 3,
"retry_delay": 0.0,
"retry_backoff": 0.0,
"force_confirm": True,
"skip_confirm": False,
}
@pytest.mark.asyncio
async def test_parse_args_split_with_no_execution_options_returns_empty_execution_args():
parser = CommandArgumentParser()
parser.add_argument("foo", type=int, help="A business argument.")
parser.add_argument("--bar", type=int, help="A business argument.")
args, kwargs, execution_args = await parser.parse_args_split(["50", "--bar", "42"])
assert args == (50,)
assert kwargs == {"bar": 42}
assert execution_args == {}
@pytest.mark.asyncio
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"
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))

View File

@@ -0,0 +1,241 @@
import pytest
from falyx.command import Command
from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.execution_option import ExecutionOption
@pytest.mark.asyncio
async def test_resolve_args_separates_business_and_execution_options():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["summary", "retry"],
)
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
args, kwargs, execution_args = await command.resolve_args(
["--foo", "42", "--summary", "--retries", "3"]
)
assert args == ()
assert kwargs == {"foo": 42}
assert execution_args == {
"summary": True,
"retries": 3,
"retry_delay": 0.0,
"retry_backoff": 0.0,
}
args, kwargs, execution_args = await command.arg_parser.parse_args_split(
["--foo", "42", "--summary", "--retries", "3"]
)
assert args == ()
assert kwargs == {"foo": 42}
assert execution_args == {
"summary": True,
"retries": 3,
"retry_delay": 0.0,
"retry_backoff": 0.0,
}
@pytest.mark.asyncio
async def test_parse_args_split_with_no_execution_options_returns_empty_execution_args():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
)
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
args, kwargs, execution_args = await command.arg_parser.parse_args_split(
["--foo", "42"]
)
assert args == ()
assert kwargs == {"foo": 42}
assert execution_args == {}
@pytest.mark.asyncio
async def test_resolve_args_raises_on_conflicting_execution_option():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["summary"],
)
with pytest.raises(
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"
):
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
@pytest.mark.asyncio
async def test_resolve_args_mix_of_business_and_execution_options():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["retry"],
)
command.arg_parser.add_argument("--summary", type=str, help="A business argument.")
args, kwargs, execution_args = await command.resolve_args(
["--summary", "test", "--retries", "5", "--retry-delay", "2"]
)
assert args == ()
assert kwargs == {"summary": "test"}
assert execution_args == {"retries": 5, "retry_delay": 2.0, "retry_backoff": 0.0}
@pytest.mark.asyncio
async def test_resolve_args_with_no_arguments():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["summary"],
)
args, kwargs, execution_args = await command.resolve_args([])
assert args == ()
assert kwargs == {}
assert execution_args == {"summary": False}
@pytest.mark.asyncio
async def test_resolve_args_with_confirmation_options():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["confirm"],
)
args, kwargs, execution_args = await command.resolve_args(["--confirm"])
assert args == ()
assert kwargs == {}
assert execution_args == {"force_confirm": True, "skip_confirm": False}
args, kwargs, execution_args = await command.resolve_args(["--skip-confirm"])
assert args == ()
assert kwargs == {}
assert execution_args == {"force_confirm": False, "skip_confirm": True}
@pytest.mark.asyncio
async def test_resolve_args_with_all_execution_options():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["summary", "retry", "confirm"],
)
args, kwargs, execution_args = await command.resolve_args(
["--summary", "--retries", "3", "--confirm"]
)
assert args == ()
assert kwargs == {}
assert execution_args == {
"summary": True,
"retries": 3,
"retry_delay": 0.0,
"retry_backoff": 0.0,
"force_confirm": True,
"skip_confirm": False,
}
@pytest.mark.asyncio
async def test_resolve_args_with_raw_string_input():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["summary"],
)
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
args, kwargs, execution_args = await command.resolve_args("--foo 42 --summary")
assert args == ()
assert kwargs == {"foo": 42}
assert execution_args == {"summary": True}
@pytest.mark.asyncio
async def test_resolve_args_with_no_arg_parser():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["summary"],
)
command.arg_parser = None
with pytest.raises(
NotAFalyxError,
match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
):
await command.resolve_args("--summary")
@pytest.mark.asyncio
async def test_resolve_args_with_custom_parser():
def parse_args_split(arg_list):
return (arg_list,), {}, {"custom_execution_arg": True}
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["summary"],
)
command.custom_parser = parse_args_split
args, kwargs, execution_args = await command.resolve_args("--summary")
assert args == (["--summary"],)
assert kwargs == {}
assert execution_args == {"custom_execution_arg": True}
# TODO: is this the right behavior? Should we expect the custom parser to handle non string inputs as well? Does this actually happen?
args, kwargs, execution_args = await command.resolve_args(2235235)
assert args == (2235235,)
assert kwargs == {}
assert execution_args == {"custom_execution_arg": True}
with pytest.raises(CommandArgumentError, match="Failed to parse arguments:"):
args, kwargs, execution_args = await command.resolve_args("unbalanced 'quotes")
@pytest.mark.asyncio
async def test_resolve_args_str_unbalanced_quotes():
command = Command.build(
key="T",
description="Test Command",
action=lambda: None,
execution_options=["summary"],
)
command.arg_parser.add_argument("--foo", type=str, help="A business argument.")
with pytest.raises(CommandArgumentError, match="Failed to parse arguments:"):
await command.resolve_args("--foo 'unbalanced quotes")

View File

@@ -0,0 +1,516 @@
import asyncio
import sys
import pytest
from rich.console import Console
from rich.text import Text
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.hook_manager import HookManager, HookType
from falyx.options_manager import OptionsManager
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
async def ok_action(*args, **kwargs):
falyx_console.print("Action executed with args:", args, "and kwargs:", kwargs)
return "ok"
async def failing_action(*args, **kwargs):
raise RuntimeError("boom")
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.")
else:
raise asyncio.CancelledError("An error occurred in the action.")
@pytest.fixture
def command_throwing_error():
command = Command(
key="E",
description="Error Command",
action=Action("throw_error", throw_error_action),
execution_options=["retry"],
)
return command
@pytest.fixture
def command_with_parser():
command = Command(
key="T",
description="Test Command",
action=ok_action,
)
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
return command
@pytest.fixture
def command_with_no_parser():
command = Command(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary"],
)
command.arg_parser = None
return command
@pytest.fixture
def command_with_custom_parser():
def parse_args_split(arg_list):
return (arg_list,), {}, {"custom_execution_arg": True}
command = Command(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary"],
)
command.custom_parser = parse_args_split
return command
@pytest.fixture
def command_with_failing_action():
command = Command(
key="T",
description="Test Command",
action=failing_action,
execution_options=["summary", "retry"],
)
command.arg_parser.add_argument("--foo", type=int, help="A business argument.")
return command
@pytest.fixture
def command_build_with_all_execution_options():
return Command.build(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary", "retry", "confirm"],
)
@pytest.fixture
def console():
return Console(record=True)
@pytest.mark.asyncio
async def test_command_runner_initialization(
command_with_parser,
command_with_no_parser,
command_with_custom_parser,
):
runner = CommandRunner(command_with_parser)
assert runner.command == command_with_parser
assert isinstance(runner.options, OptionsManager)
assert isinstance(runner.runner_hooks, HookManager)
assert runner.console == falyx_console
assert runner.command.options_manager == runner.options
assert runner.command.arg_parser.options_manager == runner.options
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)
assert runner_no_parser.command == command_with_no_parser
assert runner_no_parser.command.arg_parser is None
CommandRunner(command_with_no_parser)
with pytest.raises(
NotAFalyxError,
match="Command has no parser configured. Provide a custom_parser or CommandArgumentParser.",
):
await runner_no_parser.run("--summary")
runner_custom_parser = CommandRunner(command_with_custom_parser)
assert runner_custom_parser.command == command_with_custom_parser
assert runner_custom_parser.command.custom_parser is not None
def test_command_runner_initialization_with_custom_options(command_with_parser):
custom_options = OptionsManager([("default", {"summary": True})])
runner = CommandRunner(command_with_parser, options=custom_options)
assert runner.options == custom_options
assert runner.options.get("summary", namespace_name="default") is True
assert runner.command.options_manager == runner.options
assert runner.command.arg_parser.options_manager == runner.options
assert runner.command.options_manager == runner.options
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):
custom_hooks = HookManager()
custom_hooks.register("before", lambda context: print("Before hook"))
runner = CommandRunner(command_with_parser, runner_hooks=custom_hooks)
assert runner.runner_hooks == custom_hooks
assert runner.executor.hooks == custom_hooks
assert runner.runner_hooks._hooks[HookType.BEFORE]
def test_command_runner_initialization_with_all_bad_components(command_with_parser):
custom_options = "Not an OptionsManager"
custom_console = 23456
custom_hooks = "Not a HookManager"
with pytest.raises(
NotAFalyxError, match="options must be an instance of OptionsManager"
):
CommandRunner(
command_with_parser,
options=custom_options,
)
with pytest.raises(
NotAFalyxError, match="console must be an instance of rich.Console"
):
CommandRunner(
command_with_parser,
console=custom_console,
)
with pytest.raises(NotAFalyxError, match="hooks must be an instance of HookManager"):
CommandRunner(
command_with_parser,
runner_hooks=custom_hooks,
)
@pytest.mark.asyncio
async def test_command_runner_run(command_with_parser):
runner = CommandRunner(command_with_parser)
with falyx_console.capture() as capture:
result = await runner.run("--foo 42")
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 42}" in captured
falyx_console.clear()
with falyx_console.capture() as capture:
result = await runner.run(["--foo", "123"])
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 123}" in captured
@pytest.mark.asyncio
async def test_command_runner_run_with_failing_action(command_with_failing_action):
runner = CommandRunner(command_with_failing_action)
with pytest.raises(RuntimeError, match="boom"):
await runner.run("--foo 42")
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):
caplog.set_level("DEBUG")
runner = CommandRunner(command_with_parser)
await runner.run("--foo 42")
assert (
"Executing command 'Test Command' with args=(), kwargs={'foo': 42}" in caplog.text
)
@pytest.mark.asyncio
async def test_command_runner_run_with_retries_non_action(
command_with_failing_action, caplog
):
runner = CommandRunner(command_with_failing_action)
with pytest.raises(RuntimeError, match="boom"):
await runner.run("--foo 42 --retries 2")
assert "Retry requested, but action is not an Action instance." in caplog.text
@pytest.mark.asyncio
async def test_command_runner_run_with_retries_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")
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,
):
runner = CommandRunner.from_command(command_build_with_all_execution_options)
with falyx_console.capture() as capture:
result = await runner.run("--summary")
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "Execution History" in captured
with falyx_console.capture() as capture:
result = await runner.run("--summary", summary_last_result=True)
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "Command(key='T', description='Test Command' action=" in captured
assert "ok" in captured
with falyx_console.capture() as capture:
result = await runner.run("--summary", summary_last_result=False)
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "Execution History" in captured
@pytest.mark.asyncio
async def test_command_runner_from_command_bad_command():
with pytest.raises(NotAFalyxError, match="command must be an instance of Command"):
CommandRunner.from_command("Not a Command")
with pytest.raises(
NotAFalyxError, match="runner_hooks must be an instance of HookManager"
):
CommandRunner.from_command(
Command(
key="T",
description="Test Command",
action=ok_action,
),
runner_hooks="Not a HookManager",
)
@pytest.mark.asyncio
async def test_command_runner_build():
runner = CommandRunner.build(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary", "retry"],
)
assert isinstance(runner, CommandRunner)
with falyx_console.capture() as capture:
result = await runner.run("--summary --retries 2")
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "Execution History" in captured
@pytest.mark.asyncio
async def test_command_runner_build_with_bad_execution_options():
with pytest.raises(
ValueError,
match="Invalid ExecutionOption: 'invalid_option'. Must be one of:",
):
CommandRunner.build(
key="T",
description="Test Command",
action=ok_action,
execution_options=["summary", "invalid_option"],
)
@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"
):
CommandRunner.build(
key="T",
description="Test Command",
action=ok_action,
runner_hooks="Not a HookManager",
)
@pytest.mark.asyncio
async def test_command_runner_uses_sys_argv(command_with_parser, monkeypatch):
runner = CommandRunner(command_with_parser)
test_args = ["program_name", "--foo", "42"]
monkeypatch.setattr(sys, "argv", test_args)
with falyx_console.capture() as capture:
result = await runner.run()
captured = Text.from_ansi(capture.get()).plain
assert result == "ok"
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_cli(command_with_parser):
runner = CommandRunner(command_with_parser)
with falyx_console.capture() as capture:
await runner.cli("--foo 42")
captured = Text.from_ansi(capture.get()).plain
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 42}" in captured
@pytest.mark.asyncio
async def test_command_runnner_run_propogates_exeptions(command_throwing_error):
runner = CommandRunner(command_throwing_error)
with pytest.raises(QuitSignal, match="Quit signal triggered."):
await runner.run("QuitSignal")
with pytest.raises(BackSignal, match="Back signal triggered."):
await runner.run("BackSignal")
with pytest.raises(CancelSignal, match="Cancel signal triggered."):
await runner.run("CancelSignal")
with pytest.raises(ValueError, match="This is a ValueError."):
await runner.run("ValueError")
with pytest.raises(HelpSignal, match="Help signal triggered."):
await runner.run("HelpSignal")
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
await runner.run("Other")
with pytest.raises(
CommandArgumentError,
match=r"\[E\] Failed to parse arguments: No closing quotation",
):
await runner.run("Mismatched'")
@pytest.mark.asyncio
async def test_command_runner_cli_with_failing_action(command_with_failing_action):
runner = CommandRunner(command_with_failing_action)
with pytest.raises(SystemExit, match="1"):
await runner.cli("--foo 42")
with pytest.raises(SystemExit, match="2"):
await runner.cli("--foo 42 --bar 123")
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="0"):
await runner.cli(["--help"])
captured = Text.from_ansi(capture.get()).plain
assert "usage: falyx T" in captured
assert "--foo" in captured
assert "summary" in captured
assert "retries" in captured
assert "A business argument." in captured
@pytest.mark.asyncio
async def test_command_runner_cli_exceptions(command_throwing_error):
runner = CommandRunner(command_throwing_error)
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="0"):
await runner.cli(["--help"])
captured = Text.from_ansi(capture.get()).plain
assert "falyx E [--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 "usage:" in captured
assert "positional:" in captured
assert "options:" in captured
assert "" in captured
falyx_console.clear()
with falyx_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
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
async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch):
runner = CommandRunner(command_with_parser)
test_args = ["program_name", "--foo", "42"]
monkeypatch.setattr(sys, "argv", test_args)
with falyx_console.capture() as capture:
await runner.cli()
captured = Text.from_ansi(capture.get()).plain
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 42}" in captured

View File

@@ -1,42 +1,49 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from prompt_toolkit.document import Document from prompt_toolkit.document import Document
from prompt_toolkit.validation import ValidationError from prompt_toolkit.validation import ValidationError
from falyx.routing import RouteKind
from falyx.validators import CommandValidator from falyx.validators import CommandValidator
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_command_validator_validates_command(): async def test_command_validator_validates_command():
fake_falyx = AsyncMock() fake_falyx = AsyncMock()
fake_falyx.get_command.return_value = (False, object(), (), {}, {}) fake_route = SimpleNamespace()
fake_route.is_preview = False
fake_route.kind = RouteKind.NAMESPACE_HELP
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
validator = CommandValidator(fake_falyx, "Invalid!") validator = CommandValidator(fake_falyx, "Invalid!")
await validator.validate_async(Document("valid")) await validator.validate_async(Document("valid"))
fake_falyx.get_command.assert_awaited_once() fake_falyx.prepare_route.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_command_validator_rejects_invalid_command(): async def test_command_validator_rejects_invalid_command():
fake_falyx = AsyncMock() fake_falyx = AsyncMock()
fake_falyx.get_command.return_value = (False, None, (), {}, {}) fake_falyx.prepare_route.return_value = (None, (), {}, {})
validator = CommandValidator(fake_falyx, "Invalid!") validator = CommandValidator(fake_falyx, "Invalid!")
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await validator.validate_async(Document("not_a_command")) await validator.validate_async(Document(""))
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await validator.validate_async(Document("")) await validator.validate_async(Document("not_a_command"))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_command_validator_is_preview(): async def test_command_validator_is_preview():
fake_falyx = AsyncMock() fake_falyx = AsyncMock()
fake_falyx.get_command.return_value = (True, None, (), {}, {}) fake_route = SimpleNamespace()
fake_route.is_preview = True
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
validator = CommandValidator(fake_falyx, "Invalid!") validator = CommandValidator(fake_falyx, "Invalid!")
await validator.validate_async(Document("?preview_command")) await validator.validate_async(Document("?preview_command"))
fake_falyx.get_command.assert_awaited_once_with( fake_falyx.prepare_route.assert_awaited_once_with(
"?preview_command", from_validate=True "?preview_command", from_validate=True
) )