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:
@@ -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>")
|
||||||
|
|||||||
100
falyx/command.py
100
falyx/command.py
@@ -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:
|
||||||
|
raise NotAFalyxError(
|
||||||
|
"Command has no parser configured. "
|
||||||
|
"Provide a custom_parser or CommandArgumentParser."
|
||||||
)
|
)
|
||||||
return ((), {}, {})
|
|
||||||
if not isinstance(self.arg_parser, CommandArgumentParser):
|
if not isinstance(self.arg_parser, CommandArgumentParser):
|
||||||
logger.warning(
|
raise NotAFalyxError(
|
||||||
"[Command:%s] No argument parser configured, using default parsing.",
|
"arg_parser must be an instance of CommandArgumentParser"
|
||||||
self.key,
|
|
||||||
)
|
)
|
||||||
return ((), {}, {})
|
|
||||||
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,13 +738,24 @@ 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(
|
||||||
ExecutionOption(option) if isinstance(option, str) else option
|
ExecutionOption(option) if isinstance(option, str) else option
|
||||||
@@ -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 []:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
655
falyx/falyx.py
655
falyx/falyx.py
@@ -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
|
|
||||||
_, command, args, kwargs, execution_args = await self.get_command(
|
|
||||||
key, from_help=True
|
|
||||||
)
|
)
|
||||||
if command and command.arg_parser:
|
return None
|
||||||
command.arg_parser.render_tldr()
|
if command.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:
|
return None
|
||||||
command.arg_parser.render_help()
|
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,25 +653,35 @@ 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:
|
||||||
@@ -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] = {}
|
||||||
|
if isinstance(raw_arguments, str):
|
||||||
try:
|
try:
|
||||||
choice, *input_args = shlex.split(raw_choices)
|
tokens = shlex.split(raw_arguments)
|
||||||
except ValueError:
|
except ValueError as error:
|
||||||
return False, None, args, kwargs, execution_args
|
if from_validate:
|
||||||
is_preview, choice = self.parse_preview_command(choice)
|
raise ValidationError(
|
||||||
if is_preview and not choice and self.help_command:
|
cursor_position=len(raw_arguments), message=str(error)
|
||||||
|
) from error
|
||||||
|
self.console.print(
|
||||||
|
f"Parse error: {error}",
|
||||||
|
style=OneColors.DARK_RED,
|
||||||
|
)
|
||||||
|
return None, args, kwargs, execution_args
|
||||||
|
elif isinstance(raw_arguments, list):
|
||||||
|
tokens = raw_arguments
|
||||||
|
else:
|
||||||
|
if from_validate:
|
||||||
|
raise ValidationError(
|
||||||
|
cursor_position=len(raw_arguments),
|
||||||
|
message="TypeError",
|
||||||
|
)
|
||||||
|
return None, args, kwargs, execution_args
|
||||||
|
|
||||||
is_preview = False
|
is_preview = False
|
||||||
choice = "?"
|
if tokens and tokens[0].startswith("?"):
|
||||||
elif is_preview and not choice:
|
is_preview = True
|
||||||
# No help (list) command enabled
|
tokens[0] = tokens[0][1:]
|
||||||
if not from_validate:
|
|
||||||
self.console.print(
|
context = InvocationContext(
|
||||||
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
|
program=self.program,
|
||||||
|
typed_path=[],
|
||||||
|
mode=mode or self.options.get("mode"),
|
||||||
|
is_preview=is_preview,
|
||||||
)
|
)
|
||||||
return is_preview, None, args, kwargs, execution_args
|
|
||||||
|
|
||||||
choice = choice.upper()
|
route = await self.resolve_route(tokens, context=context)
|
||||||
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:
|
if is_preview:
|
||||||
return True, run_command, args, kwargs, execution_args
|
route.is_preview = True
|
||||||
elif self.is_cli_mode or from_help:
|
return route, args, kwargs, execution_args
|
||||||
return False, run_command, args, kwargs, execution_args
|
|
||||||
try:
|
|
||||||
args, kwargs, execution_args = await run_command.resolve_args(
|
|
||||||
input_args, from_validate
|
|
||||||
)
|
|
||||||
except (CommandArgumentError, Exception) as error:
|
|
||||||
if not from_validate:
|
|
||||||
run_command.render_help()
|
|
||||||
self.console.print(
|
|
||||||
f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValidationError(
|
|
||||||
message=str(error), cursor_position=len(raw_choices)
|
|
||||||
)
|
|
||||||
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 route.kind is RouteKind.COMMAND:
|
||||||
if fuzzy_matches:
|
assert route.command is not None
|
||||||
if not from_validate:
|
try:
|
||||||
self.console.print(
|
args, kwargs, execution_args = await route.command.resolve_args(
|
||||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
|
route.leaf_argv, from_validate=from_validate
|
||||||
"Did you mean:"
|
|
||||||
)
|
)
|
||||||
for match in fuzzy_matches:
|
except CommandArgumentError as error:
|
||||||
command = name_map[match]
|
if from_validate:
|
||||||
self.console.print(f" • [bold]{match}[/] → {command.description}")
|
|
||||||
else:
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
message=f"Unknown command '{choice}'. Did you mean: "
|
cursor_position=len(raw_arguments), message=str(error)
|
||||||
f"{', '.join(fuzzy_matches)}?",
|
) from error
|
||||||
cursor_position=len(raw_choices),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
if not from_validate:
|
route.command.render_help()
|
||||||
self.console.print(
|
self.console.print(
|
||||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
f"[{OneColors.DARK_RED}]❌ [{route.command.key}]: {error}"
|
||||||
)
|
)
|
||||||
else:
|
raise error
|
||||||
raise ValidationError(
|
except HelpSignal:
|
||||||
message=f"Unknown command '{choice}'.",
|
if not from_validate:
|
||||||
cursor_position=len(raw_choices),
|
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,
|
||||||
)
|
)
|
||||||
return is_preview, None, args, kwargs, execution_args
|
|
||||||
|
|
||||||
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,51 +1647,29 @@ 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:
|
|
||||||
if not parse_result.command:
|
|
||||||
self.console.print(
|
|
||||||
f"[{OneColors.DARK_RED}]Error: No command specified for execution mode.[/]"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
command = parse_result.command
|
|
||||||
|
|
||||||
if parse_result.is_preview:
|
|
||||||
if command is None:
|
|
||||||
sys.exit(1)
|
|
||||||
logger.info("Preview command '%s' selected.", command.key)
|
|
||||||
await command.preview()
|
|
||||||
sys.exit(0)
|
|
||||||
if not command:
|
|
||||||
sys.exit(1)
|
|
||||||
try:
|
try:
|
||||||
args, kwargs, execution_args = await command.resolve_args(
|
route, args, kwargs, execution_args = await self.prepare_route(
|
||||||
parse_result.command_argv
|
raw_arguments=parse_result.remaining_argv,
|
||||||
)
|
)
|
||||||
|
except CommandArgumentError:
|
||||||
|
sys.exit(2)
|
||||||
except HelpSignal:
|
except HelpSignal:
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except CommandArgumentError as error:
|
|
||||||
command.render_help()
|
if not route:
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}")
|
await self.render_help()
|
||||||
sys.exit(2)
|
self.console.print(
|
||||||
try:
|
f"[{OneColors.DARK_RED}]❌ Error unable to parse: {parse_result.raw_argv}"
|
||||||
logger.debug(
|
|
||||||
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
|
|
||||||
command.description,
|
|
||||||
args,
|
|
||||||
kwargs,
|
|
||||||
execution_args,
|
|
||||||
)
|
)
|
||||||
await self._executor.execute(
|
sys.exit(2)
|
||||||
command=command,
|
|
||||||
|
try:
|
||||||
|
await self._dispatch_route(
|
||||||
|
route=route,
|
||||||
args=args,
|
args=args,
|
||||||
kwargs=kwargs,
|
kwargs=kwargs,
|
||||||
execution_args=execution_args,
|
execution_args=execution_args,
|
||||||
@@ -1532,6 +1679,8 @@ class Falyx:
|
|||||||
except FalyxError as error:
|
except FalyxError as error:
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
except QuitSignal:
|
except QuitSignal:
|
||||||
logger.info("[QuitSignal]. <- Exiting run.")
|
logger.info("[QuitSignal]. <- Exiting run.")
|
||||||
sys.exit(130)
|
sys.exit(130)
|
||||||
@@ -1545,7 +1694,7 @@ class Falyx:
|
|||||||
logger.info("[asyncio.CancelledError]. <- Exiting run.")
|
logger.info("[asyncio.CancelledError]. <- Exiting run.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if not always_start_menu:
|
if route.kind is RouteKind.NAMESPACE_MENU or not always_start_menu:
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
await self.menu()
|
await self.menu()
|
||||||
|
|||||||
20
falyx/namespace.py
Normal file
20
falyx/namespace.py
Normal 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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
33
falyx/routing.py
Normal 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
|
||||||
@@ -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(
|
||||||
|
message=self.error_message,
|
||||||
|
cursor_position=len(text),
|
||||||
)
|
)
|
||||||
if is_preview:
|
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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
30
tests/test_execution_option.py
Normal file
30
tests/test_execution_option.py
Normal 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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
143
tests/test_parsers/test_execution_option_registration.py
Normal file
143
tests/test_parsers/test_execution_option_registration.py
Normal 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}))
|
||||||
241
tests/test_parsers/test_resolve_args.py
Normal file
241
tests/test_parsers/test_resolve_args.py
Normal 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")
|
||||||
516
tests/test_runner/test_command_runner.py
Normal file
516
tests/test_runner/test_command_runner.py
Normal 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
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user