feat(core): advance options/state handling and workflow execution integration
- extend OptionsManager to support multi-namespace option resolution and toggling - integrate OptionsManager more deeply across Action, ChainedAction, and ActionGroup - propagate shared runtime configuration through execution layers - refine action composition model (sequential + parallel execution semantics) - improve lifecycle consistency across BaseAction, Action, ChainedAction, and ActionGroup - begin aligning execution flow with centralized context and options handling wip: routing and root option parsing behavior still in progress
This commit is contained in:
@@ -7,7 +7,7 @@ maintaining a mutable list of named actions—such as adding, removing, or retri
|
||||
actions by name—without duplicating logic across composite action types.
|
||||
"""
|
||||
|
||||
from typing import Sequence
|
||||
from typing import Any, Sequence
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||
name: str,
|
||||
actions: (
|
||||
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
|
||||
| Any
|
||||
| None
|
||||
) = None,
|
||||
*,
|
||||
|
||||
@@ -185,6 +185,7 @@ class LoadFileAction(BaseAction):
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Failed to parse %s: %s", self.file_path.name, error)
|
||||
raise
|
||||
return value
|
||||
|
||||
async def _run(self, *args, **kwargs) -> Any:
|
||||
@@ -241,7 +242,7 @@ class LoadFileAction(BaseAction):
|
||||
for line in preview_lines:
|
||||
content_tree.add(f"[dim]{line}[/]")
|
||||
elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}:
|
||||
raw = self.load_file()
|
||||
raw = await self.load_file()
|
||||
if raw is not None:
|
||||
preview_str = (
|
||||
json.dumps(raw, indent=2)
|
||||
|
||||
@@ -88,7 +88,12 @@ class SelectionAction(BaseAction):
|
||||
allow_duplicates (bool): Whether duplicate selections are allowed.
|
||||
inject_last_result (bool): If True, attempts to inject the last result as default.
|
||||
inject_into (str): The keyword name for injected value (default: "last_result").
|
||||
return_type (SelectionReturnType | str): The type of result to return.
|
||||
return_type (SelectionReturnType | str): The type of result to return. Options:
|
||||
- KEY: Return the selected key(s) only.
|
||||
- VALUE: Return the value(s) associated with the selected key(s).
|
||||
- DESCRIPTION: Return the description(s) of the selected item(s).
|
||||
- DESCRIPTION_VALUE: Return a dict of {description: value} pairs.
|
||||
- ITEMS: Return full `SelectionOption` objects as a dict {key: SelectionOption}.
|
||||
prompt_session (PromptSession | None): Reused or customized prompt_toolkit session.
|
||||
never_prompt (bool): If True, skips prompting and uses default_selection or last_result.
|
||||
show_table (bool): Whether to render the selection table before prompting.
|
||||
|
||||
@@ -46,6 +46,7 @@ from typing import Any, Awaitable, Callable
|
||||
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||
from rich.style import Style
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import Action
|
||||
@@ -53,7 +54,7 @@ from falyx.action.base_action import BaseAction
|
||||
from falyx.console import console
|
||||
from falyx.context import ExecutionContext, InvocationContext
|
||||
from falyx.debug import register_debug_hooks
|
||||
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||
from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError
|
||||
from falyx.execution_option import ExecutionOption
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
@@ -121,14 +122,14 @@ class Command(BaseModel):
|
||||
aliases (list[str], optional): Alternate names for invocation.
|
||||
help_text (str): Help description shown in CLI/menu.
|
||||
help_epilog (str): Additional help content.
|
||||
style (str): Rich style used for rendering.
|
||||
style (Style | str): Rich style used for rendering.
|
||||
confirm (bool): Whether confirmation is required before execution.
|
||||
confirm_message (str): Confirmation prompt text.
|
||||
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||
spinner (bool): Enable spinner during execution.
|
||||
spinner_message (str): Spinner message text.
|
||||
spinner_type (str): Rich Spinner animation type (e.g., dots, line, etc.).
|
||||
spinner_style (str): Rich style for the spinner.
|
||||
spinner_style (Style | str): Rich style for the spinner.
|
||||
spinner_speed (float): Spinner speed multiplier.
|
||||
hooks (HookManager | None): Hook manager for lifecycle events.
|
||||
tags (list[str], optional): Tags for grouping and filtering.
|
||||
@@ -150,6 +151,8 @@ class Command(BaseModel):
|
||||
Override help rendering.
|
||||
custom_tldr (Callable[[], str | None] | None):
|
||||
Override TLDR rendering.
|
||||
custom_usage (Callable[[], str | None] | None):
|
||||
Override usage rendering.
|
||||
auto_args (bool): Auto-generate arguments from action signature.
|
||||
arg_metadata (dict[str, Any], optional): Metadata for arguments.
|
||||
simple_help_signature (bool): Use simplified help formatting.
|
||||
@@ -179,14 +182,14 @@ class Command(BaseModel):
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
help_text: str = ""
|
||||
help_epilog: str = ""
|
||||
style: str = OneColors.WHITE
|
||||
style: Style | str = OneColors.WHITE
|
||||
confirm: bool = False
|
||||
confirm_message: str = "Are you sure?"
|
||||
preview_before_confirm: bool = True
|
||||
spinner: bool = False
|
||||
spinner_message: str = "Processing..."
|
||||
spinner_type: str = "dots"
|
||||
spinner_style: str = OneColors.CYAN
|
||||
spinner_style: Style | str = OneColors.CYAN
|
||||
spinner_speed: float = 1.0
|
||||
hooks: "HookManager" = Field(default_factory=HookManager)
|
||||
retry: bool = False
|
||||
@@ -200,8 +203,9 @@ class Command(BaseModel):
|
||||
arguments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
argument_config: Callable[[CommandArgumentParser], None] | None = None
|
||||
custom_parser: ArgParserProtocol | None = None
|
||||
custom_help: Callable[[], None] | None = None
|
||||
custom_tldr: Callable[[], None] | None = None
|
||||
custom_help: Callable[[], str | None] | None = None
|
||||
custom_tldr: Callable[[], str | None] | None = None
|
||||
custom_usage: Callable[[], str | None] | None = None
|
||||
auto_args: bool = True
|
||||
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
|
||||
simple_help_signature: bool = False
|
||||
@@ -482,6 +486,13 @@ class Command(BaseModel):
|
||||
|
||||
return FormattedText(prompt)
|
||||
|
||||
@property
|
||||
def primary_alias(self) -> str:
|
||||
"""Get the primary alias for the command, used in help displays."""
|
||||
if self.aliases:
|
||||
return self.aliases[0].lower()
|
||||
return self.key
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
"""Generate a help string for the command arguments."""
|
||||
@@ -527,7 +538,7 @@ class Command(BaseModel):
|
||||
- Formatting may vary depending on CLI vs menu mode.
|
||||
"""
|
||||
if self.arg_parser and not self.simple_help_signature:
|
||||
usage = self.arg_parser.get_usage(invocation_context=invocation_context)
|
||||
usage = self.arg_parser.get_usage(invocation_context)
|
||||
description = f"[dim]{self.help_text or self.description}[/dim]"
|
||||
if self.tags:
|
||||
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
|
||||
@@ -549,6 +560,18 @@ class Command(BaseModel):
|
||||
if self._context:
|
||||
self._context.log_summary()
|
||||
|
||||
def render_usage(self, invocation_context: InvocationContext | None = None) -> None:
|
||||
"""Render the usage information for the command."""
|
||||
if callable(self.custom_usage):
|
||||
output = self.custom_usage()
|
||||
if output:
|
||||
console.print(output)
|
||||
return
|
||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||
self.arg_parser.render_usage(invocation_context)
|
||||
else:
|
||||
console.print(f"[bold]usage:[/] {self.key}")
|
||||
|
||||
def render_help(self, invocation_context: InvocationContext | None = None) -> bool:
|
||||
"""Display the help message for the command."""
|
||||
if callable(self.custom_help):
|
||||
@@ -557,7 +580,7 @@ class Command(BaseModel):
|
||||
console.print(output)
|
||||
return True
|
||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||
self.arg_parser.render_help(invocation_context=invocation_context)
|
||||
self.arg_parser.render_help(invocation_context)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -569,7 +592,7 @@ class Command(BaseModel):
|
||||
console.print(output)
|
||||
return True
|
||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||
self.arg_parser.render_tldr(invocation_context=invocation_context)
|
||||
self.arg_parser.render_tldr(invocation_context)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -617,14 +640,14 @@ class Command(BaseModel):
|
||||
aliases: list[str] | None = None,
|
||||
help_text: str = "",
|
||||
help_epilog: str = "",
|
||||
style: str = OneColors.WHITE,
|
||||
style: Style | str = OneColors.WHITE,
|
||||
confirm: bool = False,
|
||||
confirm_message: str = "Are you sure?",
|
||||
preview_before_confirm: bool = True,
|
||||
spinner: bool = False,
|
||||
spinner_message: str = "Processing...",
|
||||
spinner_type: str = "dots",
|
||||
spinner_style: str = OneColors.CYAN,
|
||||
spinner_style: Style | str = OneColors.CYAN,
|
||||
spinner_speed: float = 1.0,
|
||||
options_manager: OptionsManager | None = None,
|
||||
hooks: HookManager | None = None,
|
||||
@@ -645,6 +668,7 @@ class Command(BaseModel):
|
||||
custom_parser: ArgParserProtocol | None = None,
|
||||
custom_help: Callable[[], str | None] | None = None,
|
||||
custom_tldr: Callable[[], str | None] | None = None,
|
||||
custom_usage: Callable[[], str | None] | None = None,
|
||||
auto_args: bool = True,
|
||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||
simple_help_signature: bool = False,
|
||||
@@ -679,14 +703,14 @@ class Command(BaseModel):
|
||||
aliases (list[str] | None): Optional alternate names for invocation.
|
||||
help_text (str): Help text shown in command help output.
|
||||
help_epilog (str): Additional help text shown after the main help body.
|
||||
style (str): Rich style used when rendering the command.
|
||||
style (Style | str): Rich style used when rendering the command.
|
||||
confirm (bool): Whether confirmation is required before execution.
|
||||
confirm_message (str): Confirmation prompt text.
|
||||
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||
spinner (bool): Whether to enable spinner lifecycle hooks.
|
||||
spinner_message (str): Spinner message text.
|
||||
spinner_type (str): Spinner animation type.
|
||||
spinner_style (str): Spinner style.
|
||||
spinner_style (Style | str): Spinner style.
|
||||
spinner_speed (float): Spinner speed multiplier.
|
||||
options_manager (OptionsManager | None): Shared options manager for the
|
||||
command and its parser.
|
||||
@@ -721,6 +745,8 @@ class Command(BaseModel):
|
||||
renderer.
|
||||
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
||||
renderer.
|
||||
custom_usage (Callable[[], str | None] | None): Optional custom usage
|
||||
renderer.
|
||||
auto_args (bool): Whether to infer arguments automatically from the action
|
||||
signature when explicit definitions are not provided.
|
||||
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
|
||||
@@ -735,8 +761,8 @@ class Command(BaseModel):
|
||||
|
||||
Raises:
|
||||
NotAFalyxError: If `arg_parser` is provided but is not a
|
||||
`CommandArgumentParser` instance, or if `hooks` is provided but is not
|
||||
a `HookManager` instance.
|
||||
`CommandArgumentParser` instance.
|
||||
InvalidHookError: If `hooks` is provided but is not a `HookManager` instance.
|
||||
|
||||
Notes:
|
||||
- Execution options supplied as strings are converted to
|
||||
@@ -757,7 +783,7 @@ class Command(BaseModel):
|
||||
options_manager = options_manager or OptionsManager()
|
||||
|
||||
if hooks and not isinstance(hooks, HookManager):
|
||||
raise NotAFalyxError("hooks must be an instance of HookManager.")
|
||||
raise InvalidHookError("hooks must be an instance of HookManager.")
|
||||
hooks = hooks or HookManager()
|
||||
|
||||
if retry_policy and not isinstance(retry_policy, RetryPolicy):
|
||||
@@ -805,6 +831,7 @@ class Command(BaseModel):
|
||||
custom_parser=custom_parser,
|
||||
custom_help=custom_help,
|
||||
custom_tldr=custom_tldr,
|
||||
custom_usage=custom_usage,
|
||||
auto_args=auto_args,
|
||||
arg_metadata=arg_metadata or {},
|
||||
simple_help_signature=simple_help_signature,
|
||||
|
||||
@@ -39,7 +39,7 @@ Design Notes:
|
||||
duplication across Falyx runtime entrypoints.
|
||||
|
||||
Typical Usage:
|
||||
executor = CommandExecutor(options=options, hooks=hooks, console=console)
|
||||
executor = CommandExecutor(options=options, hooks=hooks)
|
||||
result = await executor.execute(
|
||||
command=command,
|
||||
args=args,
|
||||
@@ -51,8 +51,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.action import Action
|
||||
from falyx.command import Command
|
||||
from falyx.context import ExecutionContext
|
||||
@@ -61,7 +59,6 @@ from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.themes import OneColors
|
||||
|
||||
|
||||
class CommandExecutor:
|
||||
@@ -81,15 +78,13 @@ class CommandExecutor:
|
||||
- Apply scoped runtime overrides using `OptionsManager`
|
||||
- Trigger executor-level hooks before and after command execution
|
||||
- Create and manage an executor-level `ExecutionContext`
|
||||
- Render execution errors to the configured console
|
||||
- Control whether errors are raised, wrapped, or suppressed
|
||||
- Control whether errors are raised or wrapped
|
||||
- Emit optional execution summaries
|
||||
|
||||
Attributes:
|
||||
options (OptionsManager): Shared options manager used to apply scoped
|
||||
execution overrides.
|
||||
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
|
||||
console (Console): Rich console used for user-facing error output.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -97,11 +92,9 @@ class CommandExecutor:
|
||||
*,
|
||||
options: OptionsManager,
|
||||
hooks: HookManager,
|
||||
console: Console,
|
||||
) -> None:
|
||||
self.options = options
|
||||
self.hooks = hooks
|
||||
self.console = console
|
||||
|
||||
def _debug_hooks(self, command: Command) -> None:
|
||||
"""Log executor-level and command-level hook registrations for debugging.
|
||||
@@ -112,7 +105,7 @@ class CommandExecutor:
|
||||
Args:
|
||||
command (Command): The command about to be executed.
|
||||
"""
|
||||
logger.debug("Executor hooks:\n%s", str(self.hooks))
|
||||
logger.debug("executor hooks:\n%s", str(self.hooks))
|
||||
logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks))
|
||||
|
||||
def _apply_retry_overrides(
|
||||
@@ -164,7 +157,7 @@ class CommandExecutor:
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] Retry requested, but action is not an Action instance.",
|
||||
command.description,
|
||||
command.key,
|
||||
)
|
||||
|
||||
def _execution_option_overrides(
|
||||
@@ -189,30 +182,6 @@ class CommandExecutor:
|
||||
"skip_confirm": execution_args.get("skip_confirm", False),
|
||||
}
|
||||
|
||||
async def _handle_action_error(
|
||||
self, selected_command: Command, error: Exception
|
||||
) -> None:
|
||||
"""Render and log a command execution error.
|
||||
|
||||
This helper logs the full exception details for debugging and prints a
|
||||
user-facing error message to the configured console.
|
||||
|
||||
Args:
|
||||
selected_command (Command): The command that failed.
|
||||
error (Exception): The exception raised during command execution.
|
||||
"""
|
||||
logger.debug(
|
||||
"[%s] '%s' failed with error: %s",
|
||||
selected_command.key,
|
||||
selected_command.description,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
||||
f"{selected_command.description}:[/] {error}"
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
@@ -277,6 +246,11 @@ class CommandExecutor:
|
||||
- Summary output is only emitted when the `summary` execution option is
|
||||
present in `execution_args`.
|
||||
"""
|
||||
if not (raise_on_error or wrap_errors):
|
||||
raise FalyxError(
|
||||
"CommandExecutor.execute() requires either raise_on_error=True "
|
||||
"or wrap_errors=True."
|
||||
)
|
||||
self._debug_hooks(command)
|
||||
self._apply_retry_overrides(command, execution_args)
|
||||
overrides = self._execution_option_overrides(execution_args)
|
||||
@@ -307,24 +281,25 @@ class CommandExecutor:
|
||||
except (KeyboardInterrupt, EOFError) as error:
|
||||
logger.info(
|
||||
"[execute] '%s' interrupted by user.",
|
||||
command.description,
|
||||
command.key,
|
||||
)
|
||||
if wrap_errors:
|
||||
raise FalyxError(
|
||||
f"[execute] ⚠️ '{command.description}' interrupted by user."
|
||||
f"[execute] '{command.key}' interrupted by user."
|
||||
) from error
|
||||
if raise_on_error:
|
||||
raise error
|
||||
raise error
|
||||
except Exception as error:
|
||||
logger.debug(
|
||||
"[execute] '%s' failed: %s",
|
||||
command.key,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
await self._handle_action_error(command, error)
|
||||
if wrap_errors:
|
||||
raise FalyxError(
|
||||
f"[execute] '{command.description}' failed: {error}"
|
||||
) from error
|
||||
if raise_on_error:
|
||||
raise error
|
||||
raise FalyxError(f"[execute] '{command.key}' failed: {error}") from error
|
||||
raise error
|
||||
finally:
|
||||
context.stop_timer()
|
||||
await self.hooks.trigger(HookType.AFTER, context)
|
||||
|
||||
@@ -57,7 +57,13 @@ from falyx.action import BaseAction
|
||||
from falyx.command import Command
|
||||
from falyx.command_executor import CommandExecutor
|
||||
from falyx.console import console as falyx_console
|
||||
from falyx.exceptions import CommandArgumentError, FalyxError, NotAFalyxError
|
||||
from falyx.console import error_console, print_error
|
||||
from falyx.exceptions import (
|
||||
CommandArgumentError,
|
||||
FalyxError,
|
||||
InvalidHookError,
|
||||
NotAFalyxError,
|
||||
)
|
||||
from falyx.execution_option import ExecutionOption
|
||||
from falyx.hook_manager import HookManager
|
||||
from falyx.logger import logger
|
||||
@@ -85,6 +91,7 @@ class CommandRunner:
|
||||
|
||||
Attributes:
|
||||
command (Command): The command executed by this runner.
|
||||
program (str): Program name used in CLI usage text and help output.
|
||||
options (OptionsManager): Shared options manager used by the command,
|
||||
parser, and executor.
|
||||
runner_hooks (HookManager): Executor-level hooks used during execution.
|
||||
@@ -97,6 +104,7 @@ class CommandRunner:
|
||||
self,
|
||||
command: Command,
|
||||
*,
|
||||
program: str | None = None,
|
||||
options: OptionsManager | None = None,
|
||||
runner_hooks: HookManager | None = None,
|
||||
console: Console | None = None,
|
||||
@@ -109,6 +117,9 @@ class CommandRunner:
|
||||
|
||||
Args:
|
||||
command (Command): The command to execute.
|
||||
program (str | None): Program name used in CLI usage text, invocation-path
|
||||
rendering, and built-in help output. If `None`, an empty program name is
|
||||
used.
|
||||
options (OptionsManager | None): Optional shared options manager. If
|
||||
omitted, a new `OptionsManager` is created.
|
||||
runner_hooks (HookManager | None): Optional executor-level hook manager. If
|
||||
@@ -117,16 +128,22 @@ class CommandRunner:
|
||||
the default Falyx console is used.
|
||||
"""
|
||||
self.command = command
|
||||
self.program = program or ""
|
||||
self.options = self._get_options(options)
|
||||
self.runner_hooks = self._get_hooks(runner_hooks)
|
||||
self.console = self._get_console(console)
|
||||
self.error_console = error_console
|
||||
self.command.options_manager = self.options
|
||||
if program:
|
||||
self.command.program = program
|
||||
if isinstance(self.command.arg_parser, CommandArgumentParser):
|
||||
self.command.arg_parser.set_options_manager(self.options)
|
||||
self.command.arg_parser.is_runner_mode = True
|
||||
if program:
|
||||
self.command.arg_parser.program = program
|
||||
self.executor = CommandExecutor(
|
||||
options=self.options,
|
||||
hooks=self.runner_hooks,
|
||||
console=self.console,
|
||||
)
|
||||
self.options.from_mapping(values={}, namespace_name="execution")
|
||||
|
||||
@@ -152,7 +169,7 @@ class CommandRunner:
|
||||
elif isinstance(hooks, HookManager):
|
||||
return hooks
|
||||
else:
|
||||
raise NotAFalyxError("hooks must be an instance of HookManager or None.")
|
||||
raise InvalidHookError("hooks must be an instance of HookManager or None.")
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -253,10 +270,10 @@ class CommandRunner:
|
||||
sys.exit(0)
|
||||
except CommandArgumentError as error:
|
||||
self.command.render_help()
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{self.command.key}'] {error}")
|
||||
print_error(message=error)
|
||||
sys.exit(2)
|
||||
except FalyxError as error:
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
||||
print_error(message=error)
|
||||
sys.exit(1)
|
||||
except QuitSignal:
|
||||
logger.info("[QuitSignal]. <- Exiting run.")
|
||||
@@ -276,6 +293,7 @@ class CommandRunner:
|
||||
cls,
|
||||
command: Command,
|
||||
*,
|
||||
program: str | None = None,
|
||||
runner_hooks: HookManager | None = None,
|
||||
options: OptionsManager | None = None,
|
||||
console: Console | None = None,
|
||||
@@ -288,6 +306,9 @@ class CommandRunner:
|
||||
|
||||
Args:
|
||||
command (Command): Existing command instance to wrap.
|
||||
program (str | None): Program name used in CLI usage text, invocation-path
|
||||
rendering, and built-in help output. If `None`, an empty program name is
|
||||
used.
|
||||
runner_hooks (HookManager | None): Optional executor-level hook manager
|
||||
for the runner.
|
||||
options (OptionsManager | None): Optional shared options manager.
|
||||
@@ -303,9 +324,10 @@ class CommandRunner:
|
||||
if not isinstance(command, Command):
|
||||
raise NotAFalyxError("command must be an instance of Command.")
|
||||
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
||||
raise NotAFalyxError("runner_hooks must be an instance of HookManager.")
|
||||
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
|
||||
return cls(
|
||||
command=command,
|
||||
program=program,
|
||||
options=options,
|
||||
runner_hooks=runner_hooks,
|
||||
console=console,
|
||||
@@ -318,6 +340,7 @@ class CommandRunner:
|
||||
description: str,
|
||||
action: BaseAction | Callable[..., Any],
|
||||
*,
|
||||
program: str | None = None,
|
||||
runner_hooks: HookManager | None = None,
|
||||
args: tuple = (),
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
@@ -352,6 +375,8 @@ class CommandRunner:
|
||||
execution_options: list[ExecutionOption | str] | None = None,
|
||||
custom_parser: ArgParserProtocol | None = None,
|
||||
custom_help: Callable[[], str | None] | None = None,
|
||||
custom_tldr: Callable[[], str | None] | None = None,
|
||||
custom_usage: Callable[[], str | None] | None = None,
|
||||
auto_args: bool = True,
|
||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||
simple_help_signature: bool = False,
|
||||
@@ -369,6 +394,9 @@ class CommandRunner:
|
||||
description (str): Short description of the command.
|
||||
action (BaseAction | Callable[..., Any]): Underlying execution logic for
|
||||
the command.
|
||||
program (str | None): Program name used in CLI usage text, invocation-path
|
||||
rendering, and built-in help output. If `None`, an empty program name is
|
||||
used.
|
||||
runner_hooks (HookManager | None): Optional executor-level hooks for the
|
||||
runner.
|
||||
args (tuple): Static positional arguments applied to the command.
|
||||
@@ -418,6 +446,10 @@ class CommandRunner:
|
||||
implementation.
|
||||
custom_help (Callable[[], str | None] | None): Optional custom help
|
||||
renderer.
|
||||
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
||||
renderer.
|
||||
custom_usage (Callable[[], str | None] | None): Optional custom usage
|
||||
renderer.
|
||||
auto_args (bool): Whether to infer arguments automatically from the
|
||||
action signature.
|
||||
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional
|
||||
@@ -432,8 +464,9 @@ class CommandRunner:
|
||||
CommandRunner: A runner wrapping the newly built command.
|
||||
|
||||
Raises:
|
||||
NotAFalyxError: If `runner_hooks` is provided but is not a
|
||||
`HookManager` instance.
|
||||
NotAFalyxError: If `arg_parser` is provided but is not a
|
||||
`CommandArgumentParser` instance.
|
||||
InvalidHookError: If `runner_hooks` is provided but is not a `HookManager`
|
||||
|
||||
Notes:
|
||||
- This method is intended as a standalone convenience factory.
|
||||
@@ -445,6 +478,7 @@ class CommandRunner:
|
||||
key=key,
|
||||
description=description,
|
||||
action=action,
|
||||
program=program,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
hidden=hidden,
|
||||
@@ -478,6 +512,8 @@ class CommandRunner:
|
||||
argument_config=argument_config,
|
||||
custom_parser=custom_parser,
|
||||
custom_help=custom_help,
|
||||
custom_tldr=custom_tldr,
|
||||
custom_usage=custom_usage,
|
||||
auto_args=auto_args,
|
||||
arg_metadata=arg_metadata,
|
||||
simple_help_signature=simple_help_signature,
|
||||
@@ -485,7 +521,7 @@ class CommandRunner:
|
||||
)
|
||||
|
||||
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
||||
raise NotAFalyxError("runner_hooks must be an instance of HookManager.")
|
||||
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
|
||||
|
||||
return cls(
|
||||
command=command,
|
||||
|
||||
@@ -140,12 +140,11 @@ class FalyxCompleter(Completer):
|
||||
suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
|
||||
|
||||
# Only here should namespace-level help/TLDR be suggested.
|
||||
if not route.command and (not route.stub or route.stub.startswith("-")):
|
||||
suggestions.extend(
|
||||
flag
|
||||
for flag in ("-h", "--help", "-T", "--tldr")
|
||||
if flag.startswith(route.stub)
|
||||
)
|
||||
# TODO: better completer in FalyxParser
|
||||
if not route.command: # and (not route.stub or route.stub.startswith("-")):
|
||||
for flag in route.namespace.parser._options_by_dest:
|
||||
if flag.startswith(route.stub):
|
||||
suggestions.append(flag)
|
||||
|
||||
if route.is_preview:
|
||||
suggestions = [f"?{s}" for s in suggestions]
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
"""Global console instance for Falyx CLI applications."""
|
||||
from rich.console import Console
|
||||
|
||||
from falyx.themes import get_nord_theme
|
||||
from falyx.themes import OneColors, get_nord_theme
|
||||
|
||||
console = Console(color_system="truecolor", theme=get_nord_theme())
|
||||
error_console = Console(color_system="truecolor", theme=get_nord_theme(), stderr=True)
|
||||
|
||||
|
||||
def print_error(
|
||||
message: str | Exception,
|
||||
*,
|
||||
hint: str | None = None,
|
||||
) -> None:
|
||||
error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}")
|
||||
if hint:
|
||||
error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}")
|
||||
|
||||
@@ -10,8 +10,6 @@ It provides:
|
||||
- `SharedContext` for transient shared state across grouped or chained
|
||||
actions, including propagated results, indexed errors, and arbitrary
|
||||
shared data.
|
||||
- `InvocationSegment` for representing a single styled token within a
|
||||
rendered invocation path.
|
||||
- `InvocationContext` for capturing the current routed command path as an
|
||||
immutable value object that supports both plain-text and Rich-markup
|
||||
rendering.
|
||||
@@ -30,8 +28,10 @@ from typing import Any
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.style import Style
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.display_types import StyledSegment
|
||||
from falyx.mode import FalyxMode
|
||||
|
||||
|
||||
@@ -292,24 +292,6 @@ class SharedContext(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class InvocationSegment(BaseModel):
|
||||
"""Styled path segment used to build an invocation display path.
|
||||
|
||||
`InvocationSegment` represents a single token within an `InvocationContext`,
|
||||
such as a namespace key, command key, or alias. It stores the raw display
|
||||
text and an optional Rich style so invocation paths can be rendered either
|
||||
as plain text or styled markup.
|
||||
|
||||
Attributes:
|
||||
text (str): Display text for this path segment.
|
||||
style (str | None): Optional Rich style applied when rendering this
|
||||
segment in markup output.
|
||||
"""
|
||||
|
||||
text: str
|
||||
style: str | None = None
|
||||
|
||||
|
||||
class InvocationContext(BaseModel):
|
||||
"""Immutable invocation-path context for routed Falyx help and execution.
|
||||
|
||||
@@ -324,11 +306,11 @@ class InvocationContext(BaseModel):
|
||||
|
||||
Attributes:
|
||||
program (str): Root program name used in CLI-mode help and usage output.
|
||||
program_style (str): Rich style applied to the program name when rendering
|
||||
program_style (Style | str): Rich style applied to the program name when rendering
|
||||
`markup_path`.
|
||||
typed_path (list[str]): Raw invocation tokens collected during routing,
|
||||
excluding the root program name.
|
||||
segments (list[InvocationSegment]): Styled path segments used to render the
|
||||
segments (list[StyledSegment]): Styled path segments used to render the
|
||||
invocation path with Rich markup.
|
||||
mode (FalyxMode): Active Falyx mode for this invocation context. This is
|
||||
used to determine whether the path should include the program name.
|
||||
@@ -337,12 +319,14 @@ class InvocationContext(BaseModel):
|
||||
"""
|
||||
|
||||
program: str = ""
|
||||
program_style: str = ""
|
||||
program_style: Style | str = ""
|
||||
typed_path: list[str] = Field(default_factory=list)
|
||||
segments: list[InvocationSegment] = Field(default_factory=list)
|
||||
segments: list[StyledSegment] = Field(default_factory=list)
|
||||
mode: FalyxMode = FalyxMode.MENU
|
||||
is_preview: bool = False
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@property
|
||||
def is_cli_mode(self) -> bool:
|
||||
"""Whether this context should render using CLI path semantics.
|
||||
@@ -357,7 +341,7 @@ class InvocationContext(BaseModel):
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
style: str | None = None,
|
||||
style: Style | str | None = None,
|
||||
) -> InvocationContext:
|
||||
"""Return a new context with one additional path segment appended.
|
||||
|
||||
@@ -377,7 +361,7 @@ class InvocationContext(BaseModel):
|
||||
program=self.program,
|
||||
program_style=self.program_style,
|
||||
typed_path=[*self.typed_path, token],
|
||||
segments=[*self.segments, InvocationSegment(text=token, style=style)],
|
||||
segments=[*self.segments, StyledSegment(text=token, style=style)],
|
||||
mode=self.mode,
|
||||
is_preview=self.is_preview,
|
||||
)
|
||||
@@ -427,7 +411,7 @@ class InvocationContext(BaseModel):
|
||||
|
||||
In CLI mode, the root program name is included and styled with
|
||||
`program_style` when provided. Each path segment is escaped and styled
|
||||
using its associated `InvocationSegment.style` value when present.
|
||||
using its associated `StyledSegment.style` value when present.
|
||||
|
||||
Returns:
|
||||
str: Rich-markup invocation path suitable for help and usage rendering.
|
||||
|
||||
33
falyx/display_types.py
Normal file
33
falyx/display_types.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||
"""Display types for Falyx.
|
||||
|
||||
This module defines data models used for representing styled display elements in
|
||||
Falyx's CLI output, such as command paths, namespaces, and TLDR examples. These
|
||||
models are designed to be simple containers for the raw text and styling
|
||||
information needed to render consistent and visually appealing CLI interfaces using
|
||||
the Rich library.
|
||||
|
||||
It provides:
|
||||
- `StyledSegment` for representing a single styled token.
|
||||
"""
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from rich.style import Style
|
||||
|
||||
|
||||
class StyledSegment(BaseModel):
|
||||
"""Styled path segment used to build Rich styled markup.
|
||||
|
||||
`StyledSegment` represents a single token. It stores the raw display
|
||||
text and an optional Rich style so text can be rendered either
|
||||
as plain text or styled markup.
|
||||
|
||||
Attributes:
|
||||
text (str): Display text for this path segment.
|
||||
style (str | None): Optional Rich style applied when rendering this
|
||||
segment in markup output.
|
||||
"""
|
||||
|
||||
text: str
|
||||
style: Style | str | None = None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
@@ -17,7 +17,8 @@ Exception Hierarchy:
|
||||
├── EmptyChainError
|
||||
├── EmptyGroupError
|
||||
├── EmptyPoolError
|
||||
└── CommandArgumentError
|
||||
├── CommandArgumentError
|
||||
└── EntryNotFoundError
|
||||
|
||||
These are raised internally throughout the Falyx system to signal user-facing or
|
||||
developer-facing problems that should be caught and reported.
|
||||
@@ -25,7 +26,16 @@ developer-facing problems that should be caught and reported.
|
||||
|
||||
|
||||
class FalyxError(Exception):
|
||||
"""Custom exception for the Falyx class."""
|
||||
"""Base exception class for all Falyx CLI framework errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
hint: str | None = None,
|
||||
):
|
||||
if message:
|
||||
super().__init__(message)
|
||||
self.hint = hint
|
||||
|
||||
|
||||
class CommandAlreadyExistsError(FalyxError):
|
||||
@@ -60,5 +70,152 @@ class EmptyPoolError(FalyxError):
|
||||
"""Exception raised when the pool is empty."""
|
||||
|
||||
|
||||
class CommandArgumentError(FalyxError):
|
||||
class UsageError(FalyxError):
|
||||
"""Exception raised when there is an error in the command usage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
hint: str | None = None,
|
||||
show_short_usage: bool = True,
|
||||
):
|
||||
super().__init__(message, hint)
|
||||
self.show_short_usage = show_short_usage
|
||||
|
||||
|
||||
class FalyxOptionError(UsageError):
|
||||
"""Exception raised when there is an error in the Falyx option parser."""
|
||||
|
||||
|
||||
class CommandArgumentError(UsageError):
|
||||
"""Exception raised when there is an error in the command argument parser."""
|
||||
|
||||
|
||||
class ArgumentGroupError(CommandArgumentError):
|
||||
"""Exception raised when there is an error in the argument group."""
|
||||
|
||||
|
||||
class ArgumentParsingError(CommandArgumentError):
|
||||
"""Exception raised when there is an error during argument parsing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
hint: str | None = None,
|
||||
show_short_usage: bool = True,
|
||||
command_key: str | None = None,
|
||||
dest: str | None = None,
|
||||
token: str | None = None,
|
||||
):
|
||||
self.command_key = command_key
|
||||
self.dest = dest
|
||||
self.token = token
|
||||
super().__init__(message, hint, show_short_usage)
|
||||
|
||||
|
||||
class EntryNotFoundError(UsageError):
|
||||
"""Exception raised when a routing entry is not found."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unknown_name: str,
|
||||
suggestions: list[str] | None = None,
|
||||
message_context: str = "",
|
||||
show_short_usage: bool = True,
|
||||
):
|
||||
self.unknown_name = unknown_name
|
||||
self.suggestions = suggestions
|
||||
self.message_context = message_context
|
||||
super().__init__(
|
||||
self.build_message(),
|
||||
self.build_hint(),
|
||||
show_short_usage,
|
||||
)
|
||||
|
||||
def build_message(self) -> str:
|
||||
prefix = f"{self.message_context}: " if self.message_context else ""
|
||||
return f"{prefix}unknown command or namespace '{self.unknown_name}'."
|
||||
|
||||
def build_hint(self) -> str | None:
|
||||
if self.suggestions:
|
||||
return f"did you mean: {', '.join(self.suggestions[:10])}?"
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class UnrecognizedOptionError(ArgumentParsingError):
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
remaining_flags: list[str] | None = None,
|
||||
show_short_usage: bool = True,
|
||||
):
|
||||
self.remaining_flags = remaining_flags
|
||||
self.token = token
|
||||
super().__init__(
|
||||
self.build_message(),
|
||||
self.build_hint(),
|
||||
show_short_usage=show_short_usage,
|
||||
token=token,
|
||||
)
|
||||
|
||||
def build_message(self) -> str:
|
||||
return f"unrecognized option '{self.token}'"
|
||||
|
||||
def build_hint(self) -> str:
|
||||
if self.remaining_flags:
|
||||
return f"did you mean one of: {', '.join(self.remaining_flags)}?"
|
||||
return "use --help to see available options"
|
||||
|
||||
|
||||
class InvalidValueError(ArgumentParsingError):
|
||||
def __init__(
|
||||
self,
|
||||
dest: str | None = None,
|
||||
choices: list[str] | None = None,
|
||||
expected: str | None = None,
|
||||
error: Exception | str | None = None,
|
||||
show_short_usage: bool = True,
|
||||
):
|
||||
self.choices = choices
|
||||
self.expected = expected
|
||||
self.error = error
|
||||
self.dest = dest
|
||||
super().__init__(
|
||||
self.build_message(),
|
||||
self.build_hint(),
|
||||
show_short_usage=show_short_usage,
|
||||
dest=dest,
|
||||
)
|
||||
|
||||
def build_message(self) -> str:
|
||||
if self.dest and self.choices:
|
||||
return f"invalid value for '{self.dest}'"
|
||||
elif self.dest and self.error:
|
||||
return f"invalid value for '{self.dest}': {self.error}"
|
||||
elif self.dest and self.expected:
|
||||
return f"invalid value for '{self.dest}': expected {self.expected}"
|
||||
else:
|
||||
return "invalid command argument value."
|
||||
|
||||
def build_hint(self) -> str | None:
|
||||
if self.dest and self.choices:
|
||||
return f"the value for '{self.dest}' must be one of {{{', '.join(self.choices)}}}."
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class MissingValueError(ArgumentParsingError):
|
||||
def __init__(
|
||||
self,
|
||||
dest: str,
|
||||
expected_count: int | None = None,
|
||||
actual_count: int | None = None,
|
||||
):
|
||||
self.expected_count = expected_count
|
||||
self.actual_count = actual_count
|
||||
self.dest = dest
|
||||
|
||||
|
||||
class TokenizationError(UsageError):
|
||||
raw_input: str | None = None
|
||||
|
||||
1102
falyx/falyx.py
1102
falyx/falyx.py
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,9 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.style import StyleType
|
||||
|
||||
from falyx.context import InvocationContext
|
||||
from falyx.themes import OneColors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -35,15 +38,15 @@ class FalyxNamespace:
|
||||
resolution, completion, help output, and menu rendering.
|
||||
|
||||
Attributes:
|
||||
key: Primary identifier used to enter the namespace.
|
||||
description: User-facing description of the namespace.
|
||||
namespace: Nested `Falyx` instance activated when this namespace is
|
||||
key (str): Primary identifier used to enter the namespace.
|
||||
description (str): User-facing namespace description.
|
||||
namespace (Falyx): Nested `Falyx` instance activated when this namespace is
|
||||
selected.
|
||||
aliases: Optional alternate names that may also resolve to the same
|
||||
aliases (list[str]): Optional alternate names that may also resolve to the same
|
||||
namespace.
|
||||
help_text: Optional short help text used in listings or help output.
|
||||
style: Rich style used when rendering the namespace key or aliases.
|
||||
hidden: Whether the namespace should be omitted from visible menus and
|
||||
help_text (str): Optional short help text used in listings or help output.
|
||||
style (StyleType): Rich style used when rendering the namespace key or aliases.
|
||||
hidden (bool): Whether the namespace should be omitted from visible menus and
|
||||
help listings.
|
||||
"""
|
||||
|
||||
@@ -52,5 +55,14 @@ class FalyxNamespace:
|
||||
namespace: Falyx
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
help_text: str = ""
|
||||
style: str = OneColors.CYAN
|
||||
style: StyleType = OneColors.CYAN
|
||||
hidden: bool = False
|
||||
|
||||
def get_help_signature(
|
||||
self, invocation_context: InvocationContext
|
||||
) -> tuple[str, str, str | None]:
|
||||
"""Returns the usage signature for this namespace, used in help rendering."""
|
||||
usage = f"{self.key} {self.namespace._get_usage_fragment(invocation_context)}"
|
||||
if self.aliases:
|
||||
usage += f" (aliases: {', '.join(self.aliases)})"
|
||||
return usage, self.description, self.help_text
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||
"""Manages global or scoped CLI options across namespaces for Falyx commands.
|
||||
"""Option state management for Falyx CLI runtimes.
|
||||
|
||||
The `OptionsManager` provides a centralized interface for retrieving, setting, toggling,
|
||||
and introspecting options defined in `argparse.Namespace` objects. It is used internally
|
||||
by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc.
|
||||
This module defines `OptionsManager`, a small utility responsible for
|
||||
storing, retrieving, and temporarily overriding runtime option values across
|
||||
named namespaces.
|
||||
|
||||
Each option is stored under a namespace key (e.g., "default", "user_config") to
|
||||
support multiple sources of configuration.
|
||||
Falyx uses this manager to hold global session- and execution-scoped flags such
|
||||
as verbosity, prompt suppression, confirmation behavior, and other mutable
|
||||
runtime settings. Options are stored in isolated namespace dictionaries so
|
||||
different layers of the runtime can share one manager without clobbering each
|
||||
other's state.
|
||||
|
||||
Key Features:
|
||||
- Safe getter/setter for typed option resolution
|
||||
- Toggle support for boolean options (used by bottom bar toggles, etc.)
|
||||
- Callable getter/toggler wrappers for dynamic UI bindings
|
||||
- Namespace merging via `from_namespace`
|
||||
In addition to basic get/set operations, the manager provides helpers for:
|
||||
|
||||
Typical Usage:
|
||||
- toggling boolean flags
|
||||
- exposing option access as zero-argument callables for UI bindings
|
||||
- temporarily overriding a namespace within a context manager
|
||||
- holding a shared `SpinnerManager` for spinner lifecycle integration
|
||||
|
||||
Typical usage:
|
||||
```
|
||||
options = OptionsManager()
|
||||
options.from_namespace(args, namespace_name="default")
|
||||
options.from_mapping({"verbose": True})
|
||||
if options.get("verbose"):
|
||||
...
|
||||
options.toggle("force_confirm")
|
||||
value_fn = options.get_value_getter("dry_run")
|
||||
toggle_fn = options.get_toggle_function("debug")
|
||||
|
||||
Used by:
|
||||
- Falyx CLI runtime configuration
|
||||
- Bottom bar toggles
|
||||
- Dynamic flag injection into commands and actions
|
||||
with options.override_namespace({"skip_confirm": True}, "execution"):
|
||||
...
|
||||
```
|
||||
|
||||
Attributes:
|
||||
options (defaultdict[str, dict[str, Any]]): Mapping of namespace names to
|
||||
option dictionaries.
|
||||
spinners (SpinnerManager): Shared spinner manager available to runtime
|
||||
components that need coordinated spinner rendering.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
@@ -37,17 +44,40 @@ from falyx.spinner_manager import SpinnerManager
|
||||
|
||||
|
||||
class OptionsManager:
|
||||
"""Manages CLI option state across multiple argparse namespaces.
|
||||
"""Manage mutable option values across named runtime namespaces.
|
||||
|
||||
Allows dynamic retrieval, setting, toggling, and introspection of command-line
|
||||
options. Supports named namespaces (e.g., "default") and is used throughout
|
||||
Falyx for runtime configuration and bottom bar toggle integration.
|
||||
`OptionsManager` is the central store for Falyx runtime flags. Each option
|
||||
is stored under a namespace name such as `"default"` or `"execution"`,
|
||||
allowing global settings and temporary execution-scoped overrides to
|
||||
coexist in one shared object.
|
||||
|
||||
The manager supports direct reads and writes, boolean toggling, namespace
|
||||
snapshots, and temporary override contexts. It also exposes small callable
|
||||
wrappers that are useful when integrating option reads or toggles into UI
|
||||
components such as bottom-bar controls or key bindings.
|
||||
|
||||
Args:
|
||||
namespaces (list[tuple[str, dict[str, Any]]] | None): Optional initial
|
||||
namespace/value pairs to preload into the manager.
|
||||
|
||||
Attributes:
|
||||
options (defaultdict[str, dict[str, Any]]): Internal namespace-to-option
|
||||
mapping.
|
||||
spinners (SpinnerManager): Shared spinner manager used by other Falyx
|
||||
runtime components.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
namespaces: list[tuple[str, dict[str, Any]]] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the option manager.
|
||||
|
||||
Args:
|
||||
namespaces (list[tuple[str, dict[str, Any]]] | None): Optional list
|
||||
of `(namespace_name, values)` pairs to load during
|
||||
initialization.
|
||||
"""
|
||||
self.options: defaultdict = defaultdict(dict)
|
||||
self.spinners = SpinnerManager()
|
||||
if namespaces:
|
||||
@@ -59,7 +89,16 @@ class OptionsManager:
|
||||
values: Mapping[str, Any],
|
||||
namespace_name: str = "default",
|
||||
) -> None:
|
||||
"""Load options from a mapping, optionally with a prefix for namespacing."""
|
||||
"""Merge option values into a namespace.
|
||||
|
||||
Existing keys in the target namespace are updated in place. Missing
|
||||
namespaces are created automatically.
|
||||
|
||||
Args:
|
||||
values (Mapping[str, Any]): Mapping of option names to values.
|
||||
namespace_name (str): Target namespace to update. Defaults to
|
||||
`"default"`.
|
||||
"""
|
||||
self.options[namespace_name].update(dict(values))
|
||||
|
||||
def get(
|
||||
@@ -68,7 +107,18 @@ class OptionsManager:
|
||||
default: Any = None,
|
||||
namespace_name: str = "default",
|
||||
) -> Any:
|
||||
"""Get the value of an option."""
|
||||
"""Return an option value from a namespace.
|
||||
|
||||
Args:
|
||||
option_name (str): Name of the option to retrieve.
|
||||
default (Any): Value to return when the option is not present.
|
||||
Defaults to `None`.
|
||||
namespace_name (str): Namespace to read from. Defaults to
|
||||
`"default"`.
|
||||
|
||||
Returns:
|
||||
Any: The stored option value if present, otherwise `default`.
|
||||
"""
|
||||
return self.options[namespace_name].get(option_name, default)
|
||||
|
||||
def set(
|
||||
@@ -77,7 +127,13 @@ class OptionsManager:
|
||||
value: Any,
|
||||
namespace_name: str = "default",
|
||||
) -> None:
|
||||
"""Set the value of an option."""
|
||||
"""Store an option value in a namespace.
|
||||
|
||||
Args:
|
||||
option_name (str): Name of the option to set.
|
||||
value (Any): Value to store.
|
||||
namespace_name (str): Namespace to update. Defaults to `"default"`.
|
||||
"""
|
||||
self.options[namespace_name][option_name] = value
|
||||
|
||||
def has_option(
|
||||
@@ -85,7 +141,16 @@ class OptionsManager:
|
||||
option_name: str,
|
||||
namespace_name: str = "default",
|
||||
) -> bool:
|
||||
"""Check if an option exists in the namespace."""
|
||||
"""Return whether an option exists in a namespace.
|
||||
|
||||
Args:
|
||||
option_name (str): Name of the option to check.
|
||||
namespace_name (str): Namespace to inspect. Defaults to `"default"`.
|
||||
|
||||
Returns:
|
||||
bool: `True` if the option exists in the namespace, otherwise
|
||||
`False`.
|
||||
"""
|
||||
return option_name in self.options[namespace_name]
|
||||
|
||||
def toggle(
|
||||
@@ -93,7 +158,16 @@ class OptionsManager:
|
||||
option_name: str,
|
||||
namespace_name: str = "default",
|
||||
) -> None:
|
||||
"""Toggle a boolean option."""
|
||||
"""Invert a boolean option in place.
|
||||
|
||||
Args:
|
||||
option_name (str): Name of the option to toggle.
|
||||
namespace_name (str): Namespace containing the option. Defaults to
|
||||
`"default"`.
|
||||
|
||||
Raises:
|
||||
TypeError: If the target option is missing or is not a boolean.
|
||||
"""
|
||||
current = self.get(option_name, namespace_name=namespace_name)
|
||||
if not isinstance(current, bool):
|
||||
raise TypeError(
|
||||
@@ -109,7 +183,20 @@ class OptionsManager:
|
||||
option_name: str,
|
||||
namespace_name: str = "default",
|
||||
) -> Callable[[], Any]:
|
||||
"""Get the value of an option as a getter function."""
|
||||
"""Return a zero-argument callable that reads an option value.
|
||||
|
||||
This is useful for UI integrations that expect a callback instead of an
|
||||
eagerly evaluated value.
|
||||
|
||||
Args:
|
||||
option_name (str): Name of the option to read.
|
||||
namespace_name (str): Namespace to read from. Defaults to
|
||||
`"default"`.
|
||||
|
||||
Returns:
|
||||
Callable[[], Any]: Function that returns the current option value
|
||||
when called.
|
||||
"""
|
||||
|
||||
def _getter() -> Any:
|
||||
return self.get(option_name, namespace_name=namespace_name)
|
||||
@@ -121,7 +208,19 @@ class OptionsManager:
|
||||
option_name: str,
|
||||
namespace_name: str = "default",
|
||||
) -> Callable[[], None]:
|
||||
"""Get the toggle function for a boolean option."""
|
||||
"""Return a zero-argument callable that toggles a boolean option.
|
||||
|
||||
This is useful for key bindings, bottom-bar toggles, or other UI hooks
|
||||
that need a callable action.
|
||||
|
||||
Args:
|
||||
option_name (str): Name of the boolean option to toggle.
|
||||
namespace_name (str): Namespace containing the option. Defaults to
|
||||
`"default"`.
|
||||
|
||||
Returns:
|
||||
Callable[[], None]: Function that toggles the option when called.
|
||||
"""
|
||||
|
||||
def _toggle() -> None:
|
||||
self.toggle(option_name, namespace_name=namespace_name)
|
||||
@@ -129,7 +228,17 @@ class OptionsManager:
|
||||
return _toggle
|
||||
|
||||
def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]:
|
||||
"""Return all options in a namespace as a dictionary."""
|
||||
"""Return a shallow copy of one namespace's option dictionary.
|
||||
|
||||
Args:
|
||||
namespace_name (str): Namespace to snapshot.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Copy of the namespace's stored options.
|
||||
|
||||
Raises:
|
||||
ValueError: If the requested namespace does not exist.
|
||||
"""
|
||||
if namespace_name not in self.options:
|
||||
raise ValueError(f"Namespace '{namespace_name}' not found.")
|
||||
return dict(self.options[namespace_name])
|
||||
@@ -140,7 +249,24 @@ class OptionsManager:
|
||||
overrides: Mapping[str, Any],
|
||||
namespace_name: str = "execution",
|
||||
) -> Iterator[None]:
|
||||
"""Temporarily override options in a namespace within a context."""
|
||||
"""Temporarily apply option overrides within a namespace.
|
||||
|
||||
The current namespace contents are copied before the overrides are
|
||||
applied. When the context exits, the original namespace state is
|
||||
restored, even if an exception is raised inside the context block.
|
||||
|
||||
Args:
|
||||
overrides (Mapping[str, Any]): Temporary option values to merge into
|
||||
the namespace.
|
||||
namespace_name (str): Namespace to override. Defaults to
|
||||
`"execution"`.
|
||||
|
||||
Yields:
|
||||
None: Control is yielded to the wrapped context block.
|
||||
|
||||
Raises:
|
||||
ValueError: If the namespace does not already exist.
|
||||
"""
|
||||
original = self.get_namespace_dict(namespace_name)
|
||||
try:
|
||||
self.from_mapping(values=overrides, namespace_name=namespace_name)
|
||||
|
||||
@@ -8,12 +8,12 @@ from .argument import Argument
|
||||
from .argument_action import ArgumentAction
|
||||
from .command_argument_parser import CommandArgumentParser
|
||||
from .falyx_parser import FalyxParser
|
||||
from .parse_result import RootParseResult
|
||||
from .parse_result import ParseResult
|
||||
|
||||
__all__ = [
|
||||
"Argument",
|
||||
"ArgumentAction",
|
||||
"CommandArgumentParser",
|
||||
"FalyxParser",
|
||||
"RootParseResult",
|
||||
"ParseResult",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||
"""CommandArgumentParser implementation for the Falyx CLI framework.
|
||||
"""CommandArgumentParser for the Falyx CLI framework.
|
||||
|
||||
This module provides a structured, extensible argument parsing system designed
|
||||
specifically for Falyx commands. It replaces traditional argparse usage with a
|
||||
@@ -49,17 +49,26 @@ from __future__ import annotations
|
||||
from collections import Counter, defaultdict
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator, Iterable, Sequence
|
||||
from typing import Any, Generator, Iterable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel
|
||||
from rich.style import StyleType
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.console import console
|
||||
from falyx.context import InvocationContext
|
||||
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||
from falyx.exceptions import (
|
||||
ArgumentGroupError,
|
||||
ArgumentParsingError,
|
||||
CommandArgumentError,
|
||||
InvalidValueError,
|
||||
MissingValueError,
|
||||
NotAFalyxError,
|
||||
UnrecognizedOptionError,
|
||||
)
|
||||
from falyx.execution_option import ExecutionOption
|
||||
from falyx.mode import FalyxMode
|
||||
from falyx.options_manager import OptionsManager
|
||||
@@ -73,9 +82,11 @@ from falyx.parser.parser_types import (
|
||||
false_none,
|
||||
true_none,
|
||||
)
|
||||
from falyx.parser.utils import coerce_value
|
||||
from falyx.parser.utils import coerce_value, get_type_name
|
||||
from falyx.signals import HelpSignal
|
||||
|
||||
builtin_type = type
|
||||
|
||||
|
||||
class _GroupBuilder:
|
||||
"""Helper for assigning arguments to a named group or mutex group.
|
||||
@@ -99,15 +110,52 @@ class _GroupBuilder:
|
||||
self.parser = parser
|
||||
self.group_name = group_name
|
||||
self.mutex_name = mutex_name
|
||||
if group_name and mutex_name:
|
||||
raise ArgumentGroupError("cannot specify both group_name and mutex_name")
|
||||
if not group_name and not mutex_name:
|
||||
raise ArgumentGroupError("must specify either group_name or mutex_name")
|
||||
|
||||
def add_argument(self, *flags, **kwargs) -> None:
|
||||
def add_argument(
|
||||
self,
|
||||
*flags,
|
||||
action: str | ArgumentAction = "store",
|
||||
nargs: int | str | None = None,
|
||||
default: Any = None,
|
||||
type: Any = str,
|
||||
choices: Iterable | None = None,
|
||||
required: bool = False,
|
||||
help: str = "",
|
||||
dest: str | None = None,
|
||||
resolver: BaseAction | None = None,
|
||||
lazy_resolver: bool = True,
|
||||
suggestions: list[str] | None = None,
|
||||
) -> None:
|
||||
self.parser.add_argument(
|
||||
*flags,
|
||||
action=action,
|
||||
nargs=nargs,
|
||||
default=default,
|
||||
type=type,
|
||||
choices=choices,
|
||||
required=required,
|
||||
help=help,
|
||||
dest=dest,
|
||||
resolver=resolver,
|
||||
lazy_resolver=lazy_resolver,
|
||||
suggestions=suggestions,
|
||||
group=self.group_name,
|
||||
mutex_group=self.mutex_name,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.group_name:
|
||||
return f"GroupBuilder(group='{self.group_name}')"
|
||||
elif self.mutex_name:
|
||||
return f"GroupBuilder(mutex_group='{self.mutex_name}')"
|
||||
assert (
|
||||
False
|
||||
), "Invalid GroupBuilder state: neither group_name nor mutex_name is set"
|
||||
|
||||
|
||||
class CommandArgumentParser:
|
||||
"""
|
||||
@@ -136,7 +184,7 @@ class CommandArgumentParser:
|
||||
self,
|
||||
command_key: str = "",
|
||||
command_description: str = "",
|
||||
command_style: str = "bold",
|
||||
command_style: StyleType = "bold",
|
||||
help_text: str = "",
|
||||
help_epilog: str = "",
|
||||
aliases: list[str] | None = None,
|
||||
@@ -148,7 +196,7 @@ class CommandArgumentParser:
|
||||
self.console: Console = console
|
||||
self.command_key: str = command_key
|
||||
self.command_description: str = command_description
|
||||
self.command_style: str = command_style
|
||||
self.command_style: StyleType = command_style
|
||||
self.help_text: str = help_text
|
||||
self.help_epilog: str = help_epilog
|
||||
self.aliases: list[str] = aliases or []
|
||||
@@ -172,11 +220,22 @@ class CommandArgumentParser:
|
||||
if tldr_examples:
|
||||
self.add_tldr_examples(tldr_examples)
|
||||
self.options_manager: OptionsManager = options_manager or OptionsManager()
|
||||
self._is_runner_mode: bool = False
|
||||
|
||||
def mark_as_help_command(self) -> None:
|
||||
"""Mark this parser as the help command parser."""
|
||||
self._is_help_command = True
|
||||
|
||||
@property
|
||||
def is_runner_mode(self) -> bool:
|
||||
"""Check if the parser is being used in a CommandRunner context."""
|
||||
return self._is_runner_mode
|
||||
|
||||
@is_runner_mode.setter
|
||||
def is_runner_mode(self, is_runner_mode: bool) -> None:
|
||||
"""Set whether the parser is being used in a CommandRunner context."""
|
||||
self._is_runner_mode = is_runner_mode
|
||||
|
||||
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||
"""Set the options manager for the parser."""
|
||||
if not isinstance(options_manager, OptionsManager):
|
||||
@@ -238,7 +297,7 @@ class CommandArgumentParser:
|
||||
"""Register a destination as an execution argument."""
|
||||
if dest in self._execution_dests:
|
||||
raise CommandArgumentError(
|
||||
f"Destination '{dest}' is already registered as an execution argument"
|
||||
f"destination '{dest}' is already registered as an execution argument"
|
||||
)
|
||||
self._execution_dests.add(dest)
|
||||
|
||||
@@ -288,9 +347,9 @@ class CommandArgumentParser:
|
||||
)
|
||||
else:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid TLDR example format: {example}. "
|
||||
"Examples must be either TLDRExample instances "
|
||||
"or tuples of (usage, description)."
|
||||
f"invalid TLDR example format: {example}.",
|
||||
hint="examples must be either TLDRExample instances "
|
||||
"or tuples of (usage, description).",
|
||||
)
|
||||
|
||||
if "tldr" not in self._dest_set:
|
||||
@@ -302,7 +361,7 @@ class CommandArgumentParser:
|
||||
description: str = "",
|
||||
) -> _GroupBuilder:
|
||||
if name in self._argument_groups:
|
||||
raise CommandArgumentError(f"Argument group '{name}' already exists")
|
||||
raise ArgumentGroupError(f"argument group '{name}' already exists")
|
||||
self._argument_groups[name] = ArgumentGroup(name=name, description=description)
|
||||
return _GroupBuilder(self, group_name=name)
|
||||
|
||||
@@ -314,7 +373,7 @@ class CommandArgumentParser:
|
||||
description: str = "",
|
||||
) -> _GroupBuilder:
|
||||
if name in self._mutex_groups:
|
||||
raise CommandArgumentError(f"Mutex group '{name}' already exists")
|
||||
raise ArgumentGroupError(f"mutex group '{name}' already exists")
|
||||
self._mutex_groups[name] = MutuallyExclusiveGroup(
|
||||
name=name,
|
||||
required=required,
|
||||
@@ -329,7 +388,7 @@ class CommandArgumentParser:
|
||||
positional = True
|
||||
|
||||
if positional and len(flags) > 1:
|
||||
raise CommandArgumentError("Positional arguments cannot have multiple flags")
|
||||
raise CommandArgumentError("positional arguments cannot have multiple flags")
|
||||
return positional
|
||||
|
||||
def _validate_groups(
|
||||
@@ -342,22 +401,23 @@ class CommandArgumentParser:
|
||||
"""Validate that the specified groups exist and are compatible."""
|
||||
if group is not None:
|
||||
if group not in self._argument_groups:
|
||||
raise CommandArgumentError(f"Argument group '{group}' does not exist")
|
||||
raise ArgumentGroupError(f"argument group '{group}' does not exist")
|
||||
|
||||
if mutex_group is not None:
|
||||
if mutex_group not in self._mutex_groups:
|
||||
raise CommandArgumentError(
|
||||
f"Mutually exclusive group '{mutex_group}' does not exist"
|
||||
raise ArgumentGroupError(
|
||||
f"mutually exclusive group '{mutex_group}' does not exist"
|
||||
)
|
||||
|
||||
if positional and mutex_group is not None:
|
||||
raise CommandArgumentError(
|
||||
"Positional arguments cannot belong to a mutually exclusive group"
|
||||
raise ArgumentGroupError(
|
||||
"positional arguments cannot belong to a mutually exclusive group"
|
||||
)
|
||||
|
||||
if required and mutex_group is not None:
|
||||
raise CommandArgumentError(
|
||||
"Arguments inside a mutually exclusive group should not be individually required; "
|
||||
"make the group required instead."
|
||||
raise ArgumentGroupError(
|
||||
"arguments inside a mutually exclusive group cannot be individually required",
|
||||
hint="make the group required instead",
|
||||
)
|
||||
|
||||
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
|
||||
@@ -365,27 +425,31 @@ class CommandArgumentParser:
|
||||
if dest:
|
||||
if not dest.replace("_", "").isalnum():
|
||||
raise CommandArgumentError(
|
||||
"dest must be a valid identifier (letters, digits, and underscores only)"
|
||||
f"invalid dest '{dest}' must be a valid identifier (letters, digits, and underscores only)"
|
||||
)
|
||||
if dest[0].isdigit():
|
||||
raise CommandArgumentError("dest must not start with a digit")
|
||||
raise CommandArgumentError(
|
||||
f"invalid dest '{dest}': cannot start with a digit"
|
||||
)
|
||||
return dest
|
||||
dest = None
|
||||
for flag in flags:
|
||||
if flag.startswith("--"):
|
||||
dest = flag.lstrip("-").replace("-", "_").lower()
|
||||
dest = flag.lstrip("-").replace("-", "_")
|
||||
break
|
||||
elif flag.startswith("-"):
|
||||
dest = flag.lstrip("-").replace("-", "_").lower()
|
||||
dest = flag.lstrip("-").replace("-", "_")
|
||||
else:
|
||||
dest = flag.replace("-", "_").lower()
|
||||
dest = flag.replace("-", "_")
|
||||
assert dest is not None, "dest should not be None"
|
||||
if not dest.replace("_", "").isalnum():
|
||||
raise CommandArgumentError(
|
||||
"dest must be a valid identifier (letters, digits, and underscores only)"
|
||||
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
|
||||
)
|
||||
if dest[0].isdigit():
|
||||
raise CommandArgumentError("dest must not start with a digit")
|
||||
raise CommandArgumentError(
|
||||
f"invalid dest '{dest}': cannot start with a digit"
|
||||
)
|
||||
return dest
|
||||
|
||||
def _determine_required(
|
||||
@@ -405,7 +469,7 @@ class CommandArgumentParser:
|
||||
ArgumentAction.TLDR,
|
||||
):
|
||||
raise CommandArgumentError(
|
||||
f"Argument with action {action} cannot be required"
|
||||
f"argument with action '{action}' cannot be required"
|
||||
)
|
||||
return True
|
||||
if positional:
|
||||
@@ -441,7 +505,7 @@ class CommandArgumentParser:
|
||||
):
|
||||
if nargs is not None:
|
||||
raise CommandArgumentError(
|
||||
f"nargs cannot be specified for {action} actions"
|
||||
f"nargs cannot be specified for '{action}' actions"
|
||||
)
|
||||
return None
|
||||
if nargs is None:
|
||||
@@ -452,7 +516,7 @@ class CommandArgumentParser:
|
||||
raise CommandArgumentError("nargs must be a positive integer")
|
||||
elif isinstance(nargs, str):
|
||||
if nargs not in allowed_nargs:
|
||||
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
|
||||
raise CommandArgumentError(f"invalid nargs value: {nargs}")
|
||||
else:
|
||||
raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
|
||||
return nargs
|
||||
@@ -461,14 +525,16 @@ class CommandArgumentParser:
|
||||
self, choices: Iterable | None, expected_type: Any, action: ArgumentAction
|
||||
) -> list[Any]:
|
||||
"""Normalize and validate choices for the argument."""
|
||||
if choices is not None:
|
||||
if choices is None:
|
||||
choices = []
|
||||
else:
|
||||
if action in (
|
||||
ArgumentAction.STORE_TRUE,
|
||||
ArgumentAction.STORE_FALSE,
|
||||
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
):
|
||||
raise CommandArgumentError(
|
||||
f"choices cannot be specified for {action} actions"
|
||||
f"choices cannot be specified for '{action}' actions"
|
||||
)
|
||||
if isinstance(choices, dict):
|
||||
raise CommandArgumentError("choices cannot be a dict")
|
||||
@@ -478,14 +544,13 @@ class CommandArgumentParser:
|
||||
raise CommandArgumentError(
|
||||
"choices must be iterable (like list, tuple, or set)"
|
||||
) from error
|
||||
else:
|
||||
choices = []
|
||||
for choice in choices:
|
||||
try:
|
||||
coerce_value(choice, expected_type)
|
||||
except Exception as error:
|
||||
type_name = get_type_name(expected_type)
|
||||
raise CommandArgumentError(
|
||||
f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}"
|
||||
f"invalid choice {choice!r}: cannot be coerced to {type_name} error: {error}"
|
||||
) from error
|
||||
return choices
|
||||
|
||||
@@ -493,26 +558,30 @@ class CommandArgumentParser:
|
||||
self, default: Any, expected_type: type, dest: str
|
||||
) -> None:
|
||||
"""Validate the default value type."""
|
||||
if default is not None:
|
||||
try:
|
||||
coerce_value(default, expected_type)
|
||||
except Exception as error:
|
||||
raise CommandArgumentError(
|
||||
f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
|
||||
) from error
|
||||
if default is None:
|
||||
return None
|
||||
try:
|
||||
coerce_value(default, expected_type)
|
||||
except Exception as error:
|
||||
type_name = get_type_name(expected_type)
|
||||
raise CommandArgumentError(
|
||||
f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
|
||||
) from error
|
||||
|
||||
def _validate_default_list_type(
|
||||
self, default: list[Any], expected_type: type, dest: str
|
||||
) -> None:
|
||||
"""Validate the default value type for a list."""
|
||||
if isinstance(default, list):
|
||||
for item in default:
|
||||
try:
|
||||
coerce_value(item, expected_type)
|
||||
except Exception as error:
|
||||
raise CommandArgumentError(
|
||||
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
|
||||
) from error
|
||||
if not isinstance(default, list):
|
||||
return None
|
||||
for item in default:
|
||||
try:
|
||||
coerce_value(item, expected_type)
|
||||
except Exception as error:
|
||||
type_name = get_type_name(expected_type)
|
||||
raise CommandArgumentError(
|
||||
f"invalid default list value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
|
||||
) from error
|
||||
|
||||
def _validate_resolver(
|
||||
self, action: ArgumentAction, resolver: BaseAction | None
|
||||
@@ -524,7 +593,7 @@ class CommandArgumentParser:
|
||||
raise CommandArgumentError("resolver must be provided for ACTION action")
|
||||
elif action != ArgumentAction.ACTION and resolver is not None:
|
||||
raise CommandArgumentError(
|
||||
f"resolver should not be provided for action {action}"
|
||||
f"resolver should not be provided for action '{action}'"
|
||||
)
|
||||
|
||||
if not isinstance(resolver, BaseAction):
|
||||
@@ -540,7 +609,8 @@ class CommandArgumentParser:
|
||||
action = ArgumentAction(action)
|
||||
except ValueError as error:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid action '{action}' is not a valid ArgumentAction"
|
||||
f"invalid action '{action}' is not a valid ArgumentAction",
|
||||
hint=f"valid actions are: {', '.join([a.value for a in ArgumentAction])}",
|
||||
) from error
|
||||
if action in (
|
||||
ArgumentAction.STORE_TRUE,
|
||||
@@ -552,7 +622,7 @@ class CommandArgumentParser:
|
||||
):
|
||||
if positional:
|
||||
raise CommandArgumentError(
|
||||
f"Action '{action}' cannot be used with positional arguments"
|
||||
f"action '{action}' cannot be used with positional arguments"
|
||||
)
|
||||
|
||||
return action
|
||||
@@ -579,49 +649,55 @@ class CommandArgumentParser:
|
||||
return []
|
||||
else:
|
||||
return None
|
||||
elif action in (
|
||||
ArgumentAction.STORE_TRUE,
|
||||
ArgumentAction.STORE_FALSE,
|
||||
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||
):
|
||||
elif action is ArgumentAction.STORE_TRUE and default is not False:
|
||||
raise CommandArgumentError(
|
||||
f"Default value cannot be set for action {action}. It is a boolean flag."
|
||||
f"default value for '{action}' action must be False or None, got {default!r}"
|
||||
)
|
||||
elif action is ArgumentAction.STORE_FALSE and default is not True:
|
||||
raise CommandArgumentError(
|
||||
f"default value for '{action}' action must be True or None, got {default!r}"
|
||||
)
|
||||
elif action is ArgumentAction.STORE_BOOL_OPTIONAL:
|
||||
raise CommandArgumentError(
|
||||
f"default value for '{action}' action must be None, got {default!r}"
|
||||
)
|
||||
elif action in (ArgumentAction.HELP, ArgumentAction.TLDR, ArgumentAction.COUNT):
|
||||
raise CommandArgumentError(
|
||||
f"Default value cannot be set for action {action}."
|
||||
f"default value cannot be set for action '{action}'."
|
||||
)
|
||||
|
||||
if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance(
|
||||
default, list
|
||||
):
|
||||
type_name = get_type_name(default)
|
||||
raise CommandArgumentError(
|
||||
f"Default value for action {action} must be a list, got {type(default).__name__}"
|
||||
f"default value for action '{action}' must be a list, got {type_name}"
|
||||
)
|
||||
if isinstance(nargs, int) and nargs == 1:
|
||||
if not isinstance(default, list):
|
||||
default = [default]
|
||||
if isinstance(nargs, int) or nargs in ("*", "+"):
|
||||
if not isinstance(default, list):
|
||||
type_name = get_type_name(default)
|
||||
raise CommandArgumentError(
|
||||
f"Default value for action {action} with nargs {nargs} must be a list, got {type(default).__name__}"
|
||||
f"default value for action '{action}' with nargs {nargs} must be a list, got {type_name}"
|
||||
)
|
||||
return default
|
||||
|
||||
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
||||
"""Validate the flags provided for the argument."""
|
||||
if not flags:
|
||||
raise CommandArgumentError("No flags provided")
|
||||
raise CommandArgumentError("no flags provided for argument")
|
||||
for flag in flags:
|
||||
if not isinstance(flag, str):
|
||||
raise CommandArgumentError(f"Flag '{flag}' must be a string")
|
||||
raise CommandArgumentError(f"invalid flag '{flag}' must be a string")
|
||||
if flag.startswith("--") and len(flag) < 3:
|
||||
raise CommandArgumentError(
|
||||
f"Flag '{flag}' must be at least 3 characters long"
|
||||
f"invalid flag '{flag}': long flags must have at least one character after '--'"
|
||||
)
|
||||
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
|
||||
raise CommandArgumentError(
|
||||
f"Flag '{flag}' must be a single character or start with '--'"
|
||||
f"invalid flag '{flag}': short flags must be a single character"
|
||||
)
|
||||
|
||||
def _register_store_bool_optional(
|
||||
@@ -654,7 +730,6 @@ class CommandArgumentParser:
|
||||
group=group,
|
||||
mutex_group=mutex_group,
|
||||
)
|
||||
|
||||
negated_argument = Argument(
|
||||
flags=(negated_flag,),
|
||||
dest=dest,
|
||||
@@ -677,7 +752,7 @@ class CommandArgumentParser:
|
||||
if flag in self._flag_map and not bypass_validation:
|
||||
existing = self._flag_map[flag]
|
||||
raise CommandArgumentError(
|
||||
f"Flag '{flag}' is already used by argument '{existing.dest}'"
|
||||
f"flag '{flag}' is already used by argument '{existing.dest}'"
|
||||
)
|
||||
|
||||
for flag in argument.flags:
|
||||
@@ -719,8 +794,7 @@ class CommandArgumentParser:
|
||||
group: str | None = None,
|
||||
mutex_group: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Define a new argument for the parser.
|
||||
"""Define a new argument for the parser.
|
||||
|
||||
Supports positional and flagged arguments, type coercion, default values,
|
||||
validation rules, and optional resolution via `BaseAction`.
|
||||
@@ -741,19 +815,18 @@ class CommandArgumentParser:
|
||||
group (str | None): Optional argument group name for help organization.
|
||||
mutex_group (str | None): Optional mutually exclusive group name.
|
||||
"""
|
||||
expected_type = type
|
||||
self._validate_flags(flags)
|
||||
positional = self._is_positional(flags)
|
||||
dest = self._get_dest_from_flags(flags, dest)
|
||||
if dest in self._dest_set:
|
||||
raise CommandArgumentError(
|
||||
f"Destination '{dest}' is already defined.\n"
|
||||
"Merging multiple arguments into the same dest (e.g. positional + flagged) "
|
||||
"is not supported. Define a unique 'dest' for each argument."
|
||||
)
|
||||
if dest in self.RESERVED_DESTS:
|
||||
raise CommandArgumentError(
|
||||
f"Destination '{dest}' is reserved and cannot be used."
|
||||
f"invalid dest '{dest}': '{dest}' is reserved and cannot be used."
|
||||
)
|
||||
if dest in self._dest_set:
|
||||
raise CommandArgumentError(
|
||||
f"destination '{dest}' is already defined.",
|
||||
hint="merging multiple arguments into the same dest (e.g. positional + flagged) "
|
||||
"is not supported. Define a unique 'dest' for each argument.",
|
||||
)
|
||||
|
||||
self._validate_groups(group, mutex_group, positional, required)
|
||||
@@ -768,51 +841,58 @@ class CommandArgumentParser:
|
||||
and default is not None
|
||||
):
|
||||
if isinstance(default, list):
|
||||
self._validate_default_list_type(default, expected_type, dest)
|
||||
self._validate_default_list_type(default, type, dest)
|
||||
else:
|
||||
self._validate_default_type(default, expected_type, dest)
|
||||
choices = self._normalize_choices(choices, expected_type, action)
|
||||
self._validate_default_type(default, type, dest)
|
||||
choices = self._normalize_choices(choices, type, action)
|
||||
if default is not None and choices:
|
||||
choices_str = ", ".join((str(choice) for choice in choices))
|
||||
if isinstance(default, list):
|
||||
if not all(choice in choices for choice in default):
|
||||
raise CommandArgumentError(
|
||||
f"Default list value {default!r} for '{dest}' must be a subset of choices: {choices}"
|
||||
f"default list value {default!r} for '{dest}' must be a subset of choices: {choices_str}"
|
||||
)
|
||||
elif default not in choices:
|
||||
# If default is not in choices, raise an error
|
||||
raise CommandArgumentError(
|
||||
f"Default value '{default}' not in allowed choices: {choices}"
|
||||
f"default value '{default}' not in allowed choices: {choices_str}"
|
||||
)
|
||||
required = self._determine_required(required, positional, nargs, action)
|
||||
if not isinstance(suggestions, Sequence) and suggestions is not None:
|
||||
if suggestions is not None and not isinstance(suggestions, list):
|
||||
type_name = get_type_name(suggestions)
|
||||
raise CommandArgumentError(
|
||||
f"suggestions must be a list or None, got {type(suggestions)}"
|
||||
f"suggestions must be a list or None, got {type_name}"
|
||||
)
|
||||
if isinstance(suggestions, list) and not all(
|
||||
isinstance(suggestion, str) for suggestion in suggestions
|
||||
):
|
||||
raise CommandArgumentError("suggestions must be a list of strings")
|
||||
if not isinstance(lazy_resolver, bool):
|
||||
type_name = get_type_name(lazy_resolver)
|
||||
raise CommandArgumentError(
|
||||
f"lazy_resolver must be a boolean, got {type(lazy_resolver)}"
|
||||
f"lazy_resolver must be a boolean, got {type_name}"
|
||||
)
|
||||
if action == ArgumentAction.STORE_BOOL_OPTIONAL:
|
||||
self._register_store_bool_optional(flags, dest, help, group, mutex_group)
|
||||
else:
|
||||
argument = Argument(
|
||||
flags=flags,
|
||||
dest=dest,
|
||||
action=action,
|
||||
type=expected_type,
|
||||
default=default,
|
||||
choices=choices,
|
||||
required=required,
|
||||
help=help,
|
||||
nargs=nargs,
|
||||
positional=positional,
|
||||
resolver=resolver,
|
||||
lazy_resolver=lazy_resolver,
|
||||
suggestions=suggestions,
|
||||
group=group,
|
||||
mutex_group=mutex_group,
|
||||
)
|
||||
self._register_argument(argument)
|
||||
return None
|
||||
argument = Argument(
|
||||
flags=flags,
|
||||
dest=dest,
|
||||
action=action,
|
||||
type=type,
|
||||
default=default,
|
||||
choices=choices,
|
||||
required=required,
|
||||
help=help,
|
||||
nargs=nargs,
|
||||
positional=positional,
|
||||
resolver=resolver,
|
||||
lazy_resolver=lazy_resolver,
|
||||
suggestions=suggestions,
|
||||
group=group,
|
||||
mutex_group=mutex_group,
|
||||
)
|
||||
self._register_argument(argument)
|
||||
|
||||
def get_argument(self, dest: str) -> Argument | None:
|
||||
"""Return the Argument object for a given destination name.
|
||||
@@ -871,8 +951,9 @@ class CommandArgumentParser:
|
||||
return None
|
||||
arg_states[spec.dest].reset()
|
||||
arg_states[spec.dest].has_invalid_choice = True
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}"
|
||||
raise InvalidValueError(
|
||||
dest=spec.dest,
|
||||
choices=spec.choices,
|
||||
)
|
||||
|
||||
def _raise_remaining_args_error(
|
||||
@@ -888,14 +969,7 @@ class CommandArgumentParser:
|
||||
if arg.dest not in consumed_dests and flag.startswith(token)
|
||||
]
|
||||
|
||||
if remaining_flags:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(remaining_flags)}?"
|
||||
)
|
||||
else:
|
||||
raise CommandArgumentError(
|
||||
f"Unrecognized option '{token}'. Use --help to see available options."
|
||||
)
|
||||
raise UnrecognizedOptionError(token=token, remaining_flags=remaining_flags)
|
||||
|
||||
def _consume_nargs(
|
||||
self, args: list[str], index: int, spec: Argument
|
||||
@@ -910,13 +984,19 @@ class CommandArgumentParser:
|
||||
values = []
|
||||
if isinstance(spec.nargs, int):
|
||||
if index + spec.nargs > len(args):
|
||||
raise CommandArgumentError(
|
||||
f"Expected {spec.nargs} value(s) for '{spec.dest}' but got {len(args) - index}"
|
||||
raise MissingValueError(
|
||||
spec.dest,
|
||||
expected_count=spec.nargs,
|
||||
actual_count=len(args) - index,
|
||||
)
|
||||
# raise CommandArgumentError(
|
||||
# f"Expected {spec.nargs} value(s) for '{spec.dest}' but got {len(args) - index}"
|
||||
# )
|
||||
values = args[index : index + spec.nargs]
|
||||
return values, index + spec.nargs
|
||||
elif spec.nargs == "+":
|
||||
if index >= len(args):
|
||||
raise MissingValueError(spec.dest, expected_count=1)
|
||||
raise CommandArgumentError(
|
||||
f"Expected at least one value for '{spec.dest}'"
|
||||
)
|
||||
@@ -1002,21 +1082,28 @@ class CommandArgumentParser:
|
||||
else:
|
||||
arg_states[spec.dest].reset()
|
||||
arg_states[spec.dest].has_invalid_choice = True
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': {error}"
|
||||
) from error
|
||||
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||
if spec.action == ArgumentAction.ACTION:
|
||||
assert isinstance(
|
||||
spec.resolver, BaseAction
|
||||
), "resolver should be an instance of BaseAction"
|
||||
if spec.nargs == "+" and len(typed) == 0:
|
||||
raise CommandArgumentError(
|
||||
f"Argument '{spec.dest}' requires at least one value"
|
||||
raise MissingValueError(
|
||||
dest=spec.dest,
|
||||
expected_count=1,
|
||||
)
|
||||
# raise CommandArgumentError(
|
||||
# f"Argument '{spec.dest}' requires at least one value"
|
||||
# )
|
||||
if isinstance(spec.nargs, int) and len(typed) != spec.nargs:
|
||||
raise CommandArgumentError(
|
||||
f"Argument '{spec.dest}' requires exactly {spec.nargs} value(s)"
|
||||
raise MissingValueError(
|
||||
spec.dest,
|
||||
expected_count=spec.nargs,
|
||||
actual_count=len(typed),
|
||||
)
|
||||
# raise CommandArgumentError(
|
||||
# f"Argument '{spec.dest}' requires exactly {spec.nargs} value(s)"
|
||||
# )
|
||||
if not spec.lazy_resolver or not from_validate:
|
||||
try:
|
||||
result[spec.dest] = await spec.resolver(*typed)
|
||||
@@ -1094,7 +1181,7 @@ class CommandArgumentParser:
|
||||
flag = f"-{char}"
|
||||
arg = self._flag_map.get(flag)
|
||||
if not arg:
|
||||
raise CommandArgumentError(f"Unrecognized option: {flag}")
|
||||
raise UnrecognizedOptionError(flag)
|
||||
expanded.append(flag)
|
||||
else:
|
||||
return token
|
||||
@@ -1129,8 +1216,9 @@ class CommandArgumentParser:
|
||||
)
|
||||
elif spec.nargs is None:
|
||||
try:
|
||||
type_name = get_type_name(spec.type)
|
||||
raise CommandArgumentError(
|
||||
f"Enter a {spec.type.__name__} value for '{spec.dest}'. {help_text}"
|
||||
f"Enter a {type_name} value for '{spec.dest}'. {help_text}"
|
||||
)
|
||||
except AttributeError as error:
|
||||
raise CommandArgumentError(
|
||||
@@ -1161,7 +1249,7 @@ class CommandArgumentParser:
|
||||
|
||||
if action == ArgumentAction.HELP:
|
||||
if not from_validate:
|
||||
self.render_help(invocation_context=invocation_context)
|
||||
self.render_help(invocation_context)
|
||||
arg_states[spec.dest].set_consumed()
|
||||
raise HelpSignal()
|
||||
elif action == ArgumentAction.TLDR:
|
||||
@@ -1171,7 +1259,7 @@ class CommandArgumentParser:
|
||||
consumed_indices.add(index)
|
||||
index += 1
|
||||
elif not from_validate:
|
||||
self.render_tldr(invocation_context=invocation_context)
|
||||
self.render_tldr(invocation_context)
|
||||
arg_states[spec.dest].set_consumed()
|
||||
raise HelpSignal()
|
||||
else:
|
||||
@@ -1187,9 +1275,7 @@ class CommandArgumentParser:
|
||||
except ValueError as error:
|
||||
arg_states[spec.dest].reset()
|
||||
arg_states[spec.dest].has_invalid_choice = True
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': {error}"
|
||||
) from error
|
||||
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||
if not spec.lazy_resolver or not from_validate:
|
||||
try:
|
||||
result[spec.dest] = await spec.resolver(*typed_values)
|
||||
@@ -1228,9 +1314,7 @@ class CommandArgumentParser:
|
||||
except ValueError as error:
|
||||
arg_states[spec.dest].reset()
|
||||
arg_states[spec.dest].has_invalid_choice = True
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': {error}"
|
||||
) from error
|
||||
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||
if not typed_values:
|
||||
self._raise_suggestion_error(spec)
|
||||
if spec.nargs is None:
|
||||
@@ -1247,9 +1331,7 @@ class CommandArgumentParser:
|
||||
except ValueError as error:
|
||||
arg_states[spec.dest].reset()
|
||||
arg_states[spec.dest].has_invalid_choice = True
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': {error}"
|
||||
) from error
|
||||
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||
result[spec.dest].extend(typed_values)
|
||||
consumed_indices.update(range(index, new_index))
|
||||
index = new_index
|
||||
@@ -1260,9 +1342,7 @@ class CommandArgumentParser:
|
||||
except ValueError as error:
|
||||
arg_states[spec.dest].reset()
|
||||
arg_states[spec.dest].has_invalid_choice = True
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': {error}"
|
||||
) from error
|
||||
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||
if not typed_values and spec.nargs not in ("*", "?"):
|
||||
self._raise_suggestion_error(spec)
|
||||
if spec.nargs in (None, 1, "?"):
|
||||
@@ -1497,26 +1577,29 @@ class CommandArgumentParser:
|
||||
if isinstance(spec.nargs, int) and spec.nargs > 1:
|
||||
assert isinstance(
|
||||
result.get(spec.dest), list
|
||||
), f"Invalid value for '{spec.dest}': expected a list"
|
||||
), f"invalid value for '{spec.dest}': expected a list"
|
||||
if not result[spec.dest] and not spec.required:
|
||||
continue
|
||||
if spec.action == ArgumentAction.APPEND:
|
||||
for group in result[spec.dest]:
|
||||
if len(group) % spec.nargs != 0:
|
||||
arg_states[spec.dest].reset()
|
||||
raise CommandArgumentError(
|
||||
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
|
||||
raise InvalidValueError(
|
||||
dest=spec.dest,
|
||||
error=f"invalid number of values: expected a multiple of {spec.nargs}",
|
||||
)
|
||||
elif spec.action == ArgumentAction.EXTEND:
|
||||
if len(result[spec.dest]) % spec.nargs != 0:
|
||||
arg_states[spec.dest].reset()
|
||||
raise CommandArgumentError(
|
||||
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
|
||||
raise InvalidValueError(
|
||||
dest=spec.dest,
|
||||
error=f"invalid number of values: expected a multiple of {spec.nargs}",
|
||||
)
|
||||
elif len(result[spec.dest]) != spec.nargs:
|
||||
arg_states[spec.dest].reset()
|
||||
raise CommandArgumentError(
|
||||
f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}"
|
||||
raise InvalidValueError(
|
||||
dest=spec.dest,
|
||||
error=f"invalid number of values: expected {spec.nargs}, got {len(result[spec.dest])}",
|
||||
)
|
||||
|
||||
if isinstance(spec.nargs, str) and spec.nargs == "+":
|
||||
@@ -2047,6 +2130,8 @@ class CommandArgumentParser:
|
||||
program_style = (
|
||||
self.options_manager.get("program_style") or self.command_style
|
||||
)
|
||||
if self.is_runner_mode:
|
||||
return f"[{program_style}]{program}[/{program_style}]"
|
||||
return f"[{program_style}]{program}[/{program_style}] {command_keys}"
|
||||
|
||||
if invocation_context.is_cli_mode:
|
||||
@@ -2067,6 +2152,19 @@ class CommandArgumentParser:
|
||||
options_text = self.get_options_text()
|
||||
return f"{prefix} {options_text}".strip() if options_text else prefix
|
||||
|
||||
def render_usage(
|
||||
self,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> None:
|
||||
"""Render the usage string for this parser.
|
||||
|
||||
Args:
|
||||
invocation_context (InvocationContext | None): Optional routed invocation
|
||||
context used to scope the rendered usage path.
|
||||
"""
|
||||
usage = self.get_usage(invocation_context)
|
||||
self.console.print(f"[bold]usage:[/bold] {usage}")
|
||||
|
||||
def _iter_keyword_help_sections(
|
||||
self,
|
||||
) -> Generator[tuple[str, str, list[Argument]], None, None]:
|
||||
@@ -2093,7 +2191,6 @@ class CommandArgumentParser:
|
||||
|
||||
def render_help(
|
||||
self,
|
||||
*,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> None:
|
||||
"""Render full help output for the command.
|
||||
@@ -2112,15 +2209,14 @@ class CommandArgumentParser:
|
||||
- Supports argument grouping and mutually exclusive groups
|
||||
- Applies styling based on configured command style
|
||||
"""
|
||||
usage = self.get_usage(invocation_context)
|
||||
self.console.print(f"[bold]usage: {usage}[/bold]\n")
|
||||
self.render_usage(invocation_context)
|
||||
|
||||
if self.help_text:
|
||||
self.console.print(self.help_text + "\n")
|
||||
self.console.print(f"\n{self.help_text}")
|
||||
|
||||
if self._arguments:
|
||||
if self._positional:
|
||||
self.console.print("[bold]positional:[/bold]")
|
||||
self.console.print("\n[bold]positional:[/bold]")
|
||||
for arg in self._positional.values():
|
||||
flags = arg.get_positional_text()
|
||||
arg_line = f" {flags:<30} "
|
||||
@@ -2167,7 +2263,7 @@ class CommandArgumentParser:
|
||||
if self.help_epilog:
|
||||
self.console.print("\n" + self.help_epilog, style="dim")
|
||||
|
||||
def render_tldr(self, *, invocation_context: InvocationContext | None = None) -> None:
|
||||
def render_tldr(self, invocation_context: InvocationContext | None = None) -> None:
|
||||
"""Render concise example usage (TLDR) for the command.
|
||||
|
||||
This method displays a minimal, example-driven view of how to invoke
|
||||
@@ -2185,13 +2281,12 @@ class CommandArgumentParser:
|
||||
)
|
||||
return
|
||||
prefix = self._get_invocation_prefix(invocation_context)
|
||||
usage = self.get_usage(invocation_context)
|
||||
self.console.print(f"[bold]usage:[/] {usage}\n")
|
||||
self.render_usage(invocation_context)
|
||||
|
||||
if self.help_text:
|
||||
self.console.print(f"{self.help_text}\n")
|
||||
self.console.print(f"\n{self.help_text}")
|
||||
|
||||
self.console.print("[bold]examples:[/bold]")
|
||||
self.console.print("\n[bold]examples:[/bold]")
|
||||
for example in self._tldr_examples:
|
||||
usage = f"{prefix} {example.usage.strip()}"
|
||||
description = example.description.strip()
|
||||
|
||||
@@ -1,179 +1,650 @@
|
||||
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||
"""Root parsing models and helpers for the Falyx CLI runtime.
|
||||
|
||||
This module defines the minimal parsing layer used before namespace routing and
|
||||
command-local argument parsing begin.
|
||||
|
||||
It provides:
|
||||
|
||||
- `RootOptions`, a lightweight container for session-scoped flags such as
|
||||
verbose logging, help, TLDR, and prompt suppression.
|
||||
- `FalyxParser`, a small root parser that consumes only leading global options
|
||||
from argv and leaves the remaining tokens untouched for downstream routing.
|
||||
|
||||
Unlike `CommandArgumentParser`, this module does not parse command-specific
|
||||
arguments or attempt to resolve leaf-command inputs. Its responsibility is
|
||||
intentionally narrow: identify root-level flags, determine the initial
|
||||
application mode, and normalize the result into a `RootParseResult`.
|
||||
|
||||
Parsing behavior is prefix-based. Root flags are consumed only from the start of
|
||||
argv, and parsing stops at the first non-root token or an explicit `--`
|
||||
separator. This allows the remaining arguments to be preserved exactly for later
|
||||
namespace resolution and command-local parsing.
|
||||
|
||||
Typical flow:
|
||||
1. Raw argv is passed to `FalyxParser.parse()`.
|
||||
2. Leading root/session flags are extracted into `RootOptions`.
|
||||
3. A `RootParseResult` is returned with either:
|
||||
- `FalyxMode.HELP` when root help or TLDR was requested, or
|
||||
- `FalyxMode.COMMAND` when normal routed execution should continue.
|
||||
4. Remaining argv is forwarded unchanged to the main Falyx routing layer.
|
||||
|
||||
This module serves as the root-entry parsing boundary for Falyx applications.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
|
||||
from falyx.mode import FalyxMode
|
||||
from falyx.parser.parse_result import RootParseResult
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser.parse_result import ParseResult
|
||||
from falyx.parser.parser_types import (
|
||||
FalyxTLDRExample,
|
||||
FalyxTLDRInput,
|
||||
false_none,
|
||||
true_none,
|
||||
)
|
||||
from falyx.parser.utils import coerce_value, get_type_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from falyx.falyx import Falyx
|
||||
|
||||
builtin_type = type
|
||||
|
||||
|
||||
class OptionAction(Enum):
|
||||
STORE = "store"
|
||||
STORE_TRUE = "store_true"
|
||||
STORE_FALSE = "store_false"
|
||||
STORE_BOOL_OPTIONAL = "store_bool_optional"
|
||||
COUNT = "count"
|
||||
HELP = "help"
|
||||
TLDR = "tldr"
|
||||
|
||||
@classmethod
|
||||
def choices(cls) -> list[OptionAction]:
|
||||
"""Return a list of all argument actions."""
|
||||
return list(cls)
|
||||
|
||||
@classmethod
|
||||
def _get_alias(cls, value: str) -> str:
|
||||
aliases = {
|
||||
"optional": "store_bool_optional",
|
||||
"true": "store_true",
|
||||
"false": "store_false",
|
||||
}
|
||||
return aliases.get(value, value)
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> OptionAction:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
|
||||
normalized = value.strip().lower()
|
||||
alias = cls._get_alias(normalized)
|
||||
for member in cls:
|
||||
if member.value == alias:
|
||||
return member
|
||||
valid = ", ".join(member.value for member in cls)
|
||||
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return the string representation of the argument action."""
|
||||
return self.value
|
||||
|
||||
|
||||
class OptionScope(Enum):
|
||||
ROOT = "root"
|
||||
NAMESPACE = "namespace"
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> OptionScope:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
|
||||
normalized = value.strip().lower()
|
||||
for member in cls:
|
||||
if member.value == normalized:
|
||||
return member
|
||||
valid = ", ".join(member.value for member in cls)
|
||||
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RootOptions:
|
||||
"""Container for root-level Falyx session flags.
|
||||
class Option:
|
||||
flags: tuple[str, ...]
|
||||
dest: str
|
||||
action: OptionAction = OptionAction.STORE
|
||||
type: Any = str
|
||||
default: Any = None
|
||||
choices: list[str] | None = None
|
||||
help: str = ""
|
||||
suggestions: list[str] | None = None
|
||||
scope: OptionScope = OptionScope.NAMESPACE
|
||||
|
||||
`RootOptions` stores the boolean flags recognized at the application
|
||||
boundary before namespace routing and command-local parsing begin. These
|
||||
values represent session-scoped behavior that applies to the overall Falyx
|
||||
runtime rather than to any individual command.
|
||||
|
||||
The model is intentionally small and lightweight. It is produced by
|
||||
`FalyxParser._parse_root_options()` and then translated into a
|
||||
`RootParseResult` that drives the initial execution mode and runtime
|
||||
configuration.
|
||||
|
||||
Attributes:
|
||||
verbose: Whether verbose logging should be enabled for the session.
|
||||
debug_hooks: Whether hook execution should be logged in detail.
|
||||
never_prompt: Whether prompts should be suppressed for the session.
|
||||
help: Whether root help output was requested.
|
||||
tldr: Whether root TLDR output was requested.
|
||||
"""
|
||||
|
||||
verbose: bool = False
|
||||
debug_hooks: bool = False
|
||||
never_prompt: bool = False
|
||||
help: bool = False
|
||||
tldr: bool = False
|
||||
def format_for_help(self) -> str:
|
||||
"""Return a formatted string of the option's flags for help output."""
|
||||
return ", ".join(self.flags)
|
||||
|
||||
|
||||
class FalyxParser:
|
||||
"""Parse root-level Falyx CLI flags into an initial runtime result.
|
||||
RESERVED_DESTS: set[str] = {"help", "tldr"}
|
||||
|
||||
`FalyxParser` is the narrow, top-level parser used before namespace routing
|
||||
and command-local argument parsing begin. Its job is to inspect only the
|
||||
leading session-scoped flags in argv, determine the initial application
|
||||
mode, and return a normalized `RootParseResult`.
|
||||
def __init__(self, flx: Falyx) -> None:
|
||||
self._flx = flx
|
||||
self._options_by_dest: dict[str, Option] = {}
|
||||
self._options: list[Option] = []
|
||||
self._dest_set: set[str] = set()
|
||||
self._tldr_examples: list[FalyxTLDRExample] = []
|
||||
self._add_reserved_options()
|
||||
self.help_option: Option | None = None
|
||||
self.tldr_option: Option | None = None
|
||||
|
||||
Responsibilities:
|
||||
- Parse only root/session flags such as verbose logging, help, TLDR,
|
||||
and prompt suppression.
|
||||
- Stop parsing at the first non-root token or explicit `--` separator.
|
||||
- Preserve the remaining argv exactly for downstream routing.
|
||||
- Translate root help or TLDR requests into `FalyxMode.HELP`.
|
||||
- Translate normal execution into `FalyxMode.COMMAND`.
|
||||
def get_flags(self) -> list[str]:
|
||||
"""Return a list of the first flag for the registered options."""
|
||||
return [option.flags[0] for option in self._options]
|
||||
|
||||
Design Notes:
|
||||
- This parser does not resolve commands or namespaces.
|
||||
- This parser does not parse command-specific arguments.
|
||||
- Command-local parsing is delegated later to `CommandArgumentParser`
|
||||
after Falyx routing has identified a leaf command.
|
||||
- Root parsing is intentionally prefix-only so session flags apply at
|
||||
the application boundary without mutating command-local argv.
|
||||
def get_options(self) -> list[Option]:
|
||||
"""Return a list of registered options."""
|
||||
return self._options
|
||||
|
||||
Typical Usage:
|
||||
`Falyx.run()` or another top-level entrypoint passes raw argv into
|
||||
`FalyxParser.parse()`, applies the returned session options, and then
|
||||
forwards the untouched remaining argv into the routed Falyx execution
|
||||
flow.
|
||||
|
||||
Attributes:
|
||||
ROOT_FLAG_ALIASES: Mapping of recognized root CLI flags to
|
||||
`RootOptions` attribute names.
|
||||
"""
|
||||
|
||||
ROOT_FLAG_ALIASES: dict[str, str] = {
|
||||
"-n": "never_prompt",
|
||||
"--never-prompt": "never_prompt",
|
||||
"-v": "verbose",
|
||||
"--verbose": "verbose",
|
||||
"-d": "debug_hooks",
|
||||
"--debug-hooks": "debug_hooks",
|
||||
"?": "help",
|
||||
"-h": "help",
|
||||
"--help": "help",
|
||||
"-T": "tldr",
|
||||
"--tldr": "tldr",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _parse_root_options(
|
||||
cls,
|
||||
argv: list[str],
|
||||
) -> tuple[RootOptions, list[str]]:
|
||||
"""Parse only root/session flags from the start of argv.
|
||||
|
||||
Parsing stops at the first token that is not a recognized root flag.
|
||||
Remaining tokens are returned untouched for later routing.
|
||||
|
||||
Examples:
|
||||
["--verbose", "deploy", "--env", "prod"]
|
||||
-> (RootOptions(verbose=True), ["deploy", "--env", "prod"])
|
||||
|
||||
["deploy", "--verbose"]
|
||||
-> (RootOptions(), ["deploy", "--verbose"])
|
||||
"""
|
||||
options = RootOptions()
|
||||
remaining_start = 0
|
||||
|
||||
for index, token in enumerate(argv):
|
||||
if token == "--":
|
||||
remaining_start = index + 1
|
||||
break
|
||||
|
||||
attr = cls.ROOT_FLAG_ALIASES.get(token)
|
||||
if attr is None:
|
||||
remaining_start = index
|
||||
break
|
||||
|
||||
setattr(options, attr, True)
|
||||
else:
|
||||
remaining_start = len(argv)
|
||||
|
||||
remaining = argv[remaining_start:]
|
||||
return options, remaining
|
||||
|
||||
@classmethod
|
||||
def parse(cls, argv: list[str] | None = None) -> RootParseResult:
|
||||
argv = argv or []
|
||||
root, remaining = cls._parse_root_options(argv)
|
||||
|
||||
if root.help or root.tldr:
|
||||
return RootParseResult(
|
||||
mode=FalyxMode.HELP,
|
||||
raw_argv=argv,
|
||||
never_prompt=root.never_prompt,
|
||||
verbose=root.verbose,
|
||||
debug_hooks=root.debug_hooks,
|
||||
tldr_requested=root.tldr,
|
||||
)
|
||||
|
||||
return RootParseResult(
|
||||
mode=FalyxMode.COMMAND,
|
||||
raw_argv=argv,
|
||||
verbose=root.verbose,
|
||||
debug_hooks=root.debug_hooks,
|
||||
never_prompt=root.never_prompt,
|
||||
remaining_argv=remaining,
|
||||
def _add_tldr(self):
|
||||
"""Add TLDR argument to the parser."""
|
||||
if "tldr" in self._dest_set:
|
||||
return None
|
||||
tldr = Option(
|
||||
flags=("--tldr", "-T"),
|
||||
action=OptionAction.TLDR,
|
||||
help="Show quick usage examples.",
|
||||
dest="tldr",
|
||||
default=False,
|
||||
)
|
||||
self._register_option(tldr)
|
||||
self.tldr_option = tldr
|
||||
|
||||
def add_tldr_example(
|
||||
self,
|
||||
*,
|
||||
entry_key: str,
|
||||
usage: str,
|
||||
description: str,
|
||||
) -> None:
|
||||
"""Register a single namespace-level TLDR example.
|
||||
|
||||
The referenced entry must resolve to a known command or namespace in the
|
||||
current `Falyx` instance. Unknown entries are reported to the console and
|
||||
are not added.
|
||||
|
||||
Args:
|
||||
entry_key (str): Command or namespace key the example is associated with.
|
||||
usage (str): Example usage fragment shown after the resolved invocation path.
|
||||
description (str): Short explanation displayed alongside the example.
|
||||
|
||||
Raises:
|
||||
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
|
||||
namespace in this `Falyx` instance.
|
||||
"""
|
||||
entry, suggestions = self._flx.resolve_entry(entry_key)
|
||||
if not entry:
|
||||
raise EntryNotFoundError(
|
||||
unknown_name=entry_key,
|
||||
suggestions=suggestions,
|
||||
message_context="TLDR example",
|
||||
)
|
||||
self._tldr_examples.append(
|
||||
FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description)
|
||||
)
|
||||
self._add_tldr()
|
||||
|
||||
def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None:
|
||||
"""Register multiple namespace-level TLDR examples.
|
||||
|
||||
Supports either `FalyxTLDRExample` objects or shorthand tuples of
|
||||
`(entry_key, usage, description)`.
|
||||
|
||||
Args:
|
||||
examples (list[FalyxTLDRInput]): Example definitions to validate and append.
|
||||
|
||||
Raises:
|
||||
FalyxError: If an example has an unsupported shape.
|
||||
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
|
||||
namespace in this `Falyx` instance.
|
||||
"""
|
||||
for example in examples:
|
||||
if isinstance(example, FalyxTLDRExample):
|
||||
entry, suggestions = self._flx.resolve_entry(example.entry_key)
|
||||
if not entry:
|
||||
raise EntryNotFoundError(
|
||||
unknown_name=example.entry_key,
|
||||
suggestions=suggestions,
|
||||
message_context="TLDR example",
|
||||
)
|
||||
self._tldr_examples.append(example)
|
||||
self._add_tldr()
|
||||
elif len(example) == 3:
|
||||
entry_key, usage, description = example
|
||||
self.add_tldr_example(
|
||||
entry_key=entry_key,
|
||||
usage=usage,
|
||||
description=description,
|
||||
)
|
||||
self._add_tldr()
|
||||
else:
|
||||
raise FalyxOptionError(
|
||||
f"invalid TLDR example format: {example}.\n"
|
||||
"examples must be either FalyxTLDRExample instances "
|
||||
"or tuples of (entry_key, usage, description).",
|
||||
)
|
||||
|
||||
def _add_reserved_options(self) -> None:
|
||||
help = Option(
|
||||
flags=("-h", "--help", "?"),
|
||||
dest="help",
|
||||
action=OptionAction.HELP,
|
||||
help="Show root-level help output and exit.",
|
||||
default=False,
|
||||
)
|
||||
self._register_option(help)
|
||||
self.help_option = help
|
||||
|
||||
if not self._flx.disable_verbose_option:
|
||||
verbose = Option(
|
||||
flags=("-v", "--verbose"),
|
||||
dest="verbose",
|
||||
action=OptionAction.STORE_TRUE,
|
||||
help="Enable verbose logging for the session.",
|
||||
default=False,
|
||||
scope=OptionScope.ROOT,
|
||||
)
|
||||
self._register_option(verbose)
|
||||
|
||||
if not self._flx.disable_debug_hooks_option:
|
||||
debug_hooks = Option(
|
||||
flags=("-d", "--debug-hooks"),
|
||||
dest="debug_hooks",
|
||||
action=OptionAction.STORE_TRUE,
|
||||
help="Log hook execution in detail for the session.",
|
||||
default=False,
|
||||
scope=OptionScope.ROOT,
|
||||
)
|
||||
self._register_option(debug_hooks)
|
||||
|
||||
if not self._flx.disable_never_prompt_option:
|
||||
never_prompt = Option(
|
||||
flags=("-n", "--never-prompt"),
|
||||
dest="never_prompt",
|
||||
action=OptionAction.STORE_TRUE,
|
||||
help="Suppress all prompts for the session.",
|
||||
default=False,
|
||||
scope=OptionScope.ROOT,
|
||||
)
|
||||
self._register_option(never_prompt)
|
||||
|
||||
def _register_store_bool_optional(
|
||||
self,
|
||||
flags: tuple[str, ...],
|
||||
dest: str,
|
||||
help: str,
|
||||
) -> None:
|
||||
"""Register a store_bool_optional action with the parser."""
|
||||
if len(flags) != 1:
|
||||
raise FalyxOptionError(
|
||||
"store_bool_optional action can only have a single flag"
|
||||
)
|
||||
if not flags[0].startswith("--"):
|
||||
raise FalyxOptionError(
|
||||
"store_bool_optional action must use a long flag (e.g. --flag)"
|
||||
)
|
||||
base_flag = flags[0]
|
||||
negated_flag = f"--no-{base_flag.lstrip('-')}"
|
||||
|
||||
argument = Option(
|
||||
flags=flags,
|
||||
dest=dest,
|
||||
action=OptionAction.STORE_BOOL_OPTIONAL,
|
||||
type=true_none,
|
||||
default=None,
|
||||
help=help,
|
||||
)
|
||||
|
||||
negated_argument = Option(
|
||||
flags=(negated_flag,),
|
||||
dest=dest,
|
||||
action=OptionAction.STORE_BOOL_OPTIONAL,
|
||||
type=false_none,
|
||||
default=None,
|
||||
help=help,
|
||||
)
|
||||
|
||||
self._register_option(argument)
|
||||
self._register_option(negated_argument, bypass_validation=True)
|
||||
|
||||
def _register_option(self, option: Option, bypass_validation: bool = False) -> None:
|
||||
self._dest_set.add(option.dest)
|
||||
self._options.append(option)
|
||||
for flag in option.flags:
|
||||
if flag in self._options and not bypass_validation:
|
||||
existing = self._options_by_dest[flag]
|
||||
raise FalyxOptionError(
|
||||
f"flag '{flag}' is already used by argument '{existing.dest}'"
|
||||
)
|
||||
self._options_by_dest[flag] = option
|
||||
|
||||
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
||||
if not flags:
|
||||
raise FalyxOptionError("no flags provided for option")
|
||||
for flag in flags:
|
||||
if not isinstance(flag, str):
|
||||
raise FalyxOptionError(f"invalid flag '{flag}': must be a string")
|
||||
if not flag.startswith("-"):
|
||||
raise FalyxOptionError(f"invalid flag '{flag}': must start with '-'")
|
||||
if flag.startswith("--") and len(flag) < 3:
|
||||
raise FalyxOptionError(
|
||||
f"invalid flag '{flag}': long flags must have at least one character after '--'"
|
||||
)
|
||||
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
|
||||
raise FalyxOptionError(
|
||||
f"invalid flag '{flag}': short flags must be a single character"
|
||||
)
|
||||
if flag in self._options_by_dest:
|
||||
existing = self._options_by_dest[flag]
|
||||
raise FalyxOptionError(
|
||||
f"flag '{flag}' is already used by argument '{existing.dest}'"
|
||||
)
|
||||
|
||||
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
|
||||
if dest:
|
||||
if not dest.replace("_", "").isalnum():
|
||||
raise FalyxOptionError(
|
||||
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
|
||||
)
|
||||
if dest[0].isdigit():
|
||||
raise FalyxOptionError(
|
||||
f"invalid dest '{dest}': cannot start with a digit"
|
||||
)
|
||||
return dest
|
||||
dest = None
|
||||
for flag in flags:
|
||||
cleaned = flag.lstrip("-").replace("-", "_").lower()
|
||||
dest = cleaned
|
||||
if flag.startswith("--"):
|
||||
break
|
||||
assert dest is not None, "dest should not be None"
|
||||
if not dest.replace("_", "").isalnum():
|
||||
raise FalyxOptionError(
|
||||
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
|
||||
)
|
||||
if dest[0].isdigit():
|
||||
raise FalyxOptionError(f"invalid dest '{dest}': cannot start with a digit")
|
||||
return dest
|
||||
|
||||
def _validate_action(self, action: str | OptionAction) -> OptionAction:
|
||||
if isinstance(action, OptionAction):
|
||||
return action
|
||||
try:
|
||||
return OptionAction(action)
|
||||
except ValueError as error:
|
||||
raise FalyxOptionError(
|
||||
f"invalid option action '{action}' is not a valid OptionAction",
|
||||
hint=f"valid actions are: {', '.join(a.value for a in OptionAction)}",
|
||||
) from error
|
||||
|
||||
def _resolve_default(
|
||||
self,
|
||||
default: Any,
|
||||
action: OptionAction,
|
||||
) -> Any:
|
||||
if default is None:
|
||||
if action == OptionAction.STORE_TRUE:
|
||||
return False
|
||||
elif action == OptionAction.STORE_FALSE:
|
||||
return True
|
||||
elif action == OptionAction.STORE_BOOL_OPTIONAL:
|
||||
return None
|
||||
elif action == OptionAction.COUNT:
|
||||
return 0
|
||||
elif action is OptionAction.STORE_TRUE and default is not False:
|
||||
raise FalyxOptionError(
|
||||
f"default value for '{action}' action must be False or None, got {default!r}"
|
||||
)
|
||||
elif action is OptionAction.STORE_FALSE and default is not True:
|
||||
raise FalyxOptionError(
|
||||
f"default value for '{action}' action must be True or None, got {default!r}"
|
||||
)
|
||||
elif action is OptionAction.STORE_BOOL_OPTIONAL:
|
||||
raise FalyxOptionError(
|
||||
f"default value for '{action}' action must be None, got {default!r}"
|
||||
)
|
||||
elif action in (OptionAction.HELP, OptionAction.TLDR, OptionAction.COUNT):
|
||||
raise FalyxOptionError(f"default value cannot be set for action '{action}'.")
|
||||
return default
|
||||
|
||||
def _validate_default_type(
|
||||
self,
|
||||
default: Any,
|
||||
expected_type: Any,
|
||||
dest: str,
|
||||
) -> None:
|
||||
if default is None:
|
||||
return None
|
||||
try:
|
||||
coerce_value(default, expected_type)
|
||||
except Exception as error:
|
||||
type_name = get_type_name(expected_type)
|
||||
raise FalyxOptionError(
|
||||
f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
|
||||
) from error
|
||||
|
||||
def _normalize_choices(
|
||||
self,
|
||||
choices: list[str] | None,
|
||||
expected_type: type,
|
||||
action: OptionAction,
|
||||
) -> list[Any]:
|
||||
if choices is None:
|
||||
choices = []
|
||||
else:
|
||||
if action in (
|
||||
OptionAction.STORE_TRUE,
|
||||
OptionAction.STORE_FALSE,
|
||||
OptionAction.STORE_BOOL_OPTIONAL,
|
||||
):
|
||||
raise FalyxOptionError(
|
||||
f"choices cannot be specified for '{action}' actions"
|
||||
)
|
||||
if isinstance(choices, dict):
|
||||
raise FalyxOptionError("choices cannot be a dict")
|
||||
try:
|
||||
choices = list(choices)
|
||||
except TypeError as error:
|
||||
raise FalyxOptionError(
|
||||
"choices must be iterable (like list, tuple, or set)"
|
||||
) from error
|
||||
for choice in choices:
|
||||
try:
|
||||
coerce_value(choice, expected_type)
|
||||
except Exception as error:
|
||||
type_name = get_type_name(expected_type)
|
||||
raise FalyxOptionError(
|
||||
f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}"
|
||||
) from error
|
||||
return choices
|
||||
|
||||
def add_option(
|
||||
self,
|
||||
flags: tuple[str, ...],
|
||||
dest: str,
|
||||
action: str | OptionAction = "store",
|
||||
type: type = str,
|
||||
default: Any = None,
|
||||
choices: list[str] | None = None,
|
||||
help: str = "",
|
||||
suggestions: list[str] | None = None,
|
||||
) -> None:
|
||||
self._validate_flags(flags)
|
||||
dest = self._get_dest_from_flags(flags, dest)
|
||||
if dest in self.RESERVED_DESTS:
|
||||
raise FalyxOptionError(
|
||||
f"invalid dest '{dest}': '{dest}' is reserved and cannot be used as an option dest"
|
||||
)
|
||||
if dest in self._dest_set:
|
||||
raise FalyxOptionError(f"duplicate option dest '{dest}'")
|
||||
action = self._validate_action(action)
|
||||
default = self._resolve_default(default, action)
|
||||
self._validate_default_type(default, type, dest)
|
||||
choices = self._normalize_choices(choices, type, action)
|
||||
if default is not None and choices and default not in choices:
|
||||
choices_str = ", ".join((str(choice) for choice in choices))
|
||||
raise FalyxOptionError(
|
||||
f"default value {default!r} is not in allowed choices: {choices_str}"
|
||||
)
|
||||
if suggestions is not None and not isinstance(suggestions, list):
|
||||
type_name = get_type_name(suggestions)
|
||||
raise FalyxOptionError(f"suggestions must be a list or None, got {type_name}")
|
||||
if isinstance(suggestions, list) and not all(
|
||||
isinstance(suggestion, str) for suggestion in suggestions
|
||||
):
|
||||
raise FalyxOptionError("suggestions must be a list of strings")
|
||||
if action is OptionAction.STORE_BOOL_OPTIONAL:
|
||||
self._register_store_bool_optional(flags, dest, help)
|
||||
return None
|
||||
option = Option(
|
||||
flags=flags,
|
||||
dest=dest,
|
||||
action=action,
|
||||
type=type,
|
||||
default=default,
|
||||
choices=choices,
|
||||
help=help,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
self._register_option(option)
|
||||
|
||||
def apply_to_options(
|
||||
self,
|
||||
parse_result: ParseResult,
|
||||
options: OptionsManager,
|
||||
) -> None:
|
||||
for dest, value in parse_result.options.items():
|
||||
options.set(dest, value, namespace_name=self_flx.namespace_name)
|
||||
for dest, value in parse_result.root_options.items():
|
||||
options.set(dest, value, namespace_name="root")
|
||||
|
||||
def _can_bundle_option(self, option: Option) -> bool:
|
||||
return option.action in {
|
||||
OptionAction.STORE_TRUE,
|
||||
OptionAction.STORE_FALSE,
|
||||
OptionAction.COUNT,
|
||||
OptionAction.HELP,
|
||||
OptionAction.TLDR,
|
||||
}
|
||||
|
||||
def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]:
|
||||
"""Expand POSIX-style bundled arguments into separate arguments."""
|
||||
expanded: list[str] = []
|
||||
for token in tokens:
|
||||
if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
|
||||
expanded.append(token)
|
||||
continue
|
||||
|
||||
bundle = [f"-{char}" for char in token[1:]]
|
||||
|
||||
if (
|
||||
all(
|
||||
flag in self._options_by_dest
|
||||
and self._can_bundle_option(self._options_by_dest[flag])
|
||||
for flag in bundle[:-1]
|
||||
)
|
||||
and bundle[-1] in self._options_by_dest
|
||||
):
|
||||
expanded.extend(bundle)
|
||||
else:
|
||||
expanded.append(token)
|
||||
return expanded
|
||||
|
||||
def _default_values(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
values: dict[str, Any] = {}
|
||||
root_values: dict[str, Any] = {}
|
||||
|
||||
for option in self._options:
|
||||
if option.scope == OptionScope.ROOT:
|
||||
root_values[option.dest] = option.default
|
||||
elif option.scope == OptionScope.NAMESPACE:
|
||||
values.setdefault(option.dest, option.default)
|
||||
else:
|
||||
assert False, f"unhandled option scope: {option.scope}"
|
||||
|
||||
return values, root_values
|
||||
|
||||
def _consume_option(
|
||||
self,
|
||||
option: Option,
|
||||
argv: list[str],
|
||||
index: int,
|
||||
values: dict[str, Any],
|
||||
) -> int:
|
||||
match option.action:
|
||||
case OptionAction.STORE_TRUE:
|
||||
values[option.dest] = True
|
||||
return index + 1
|
||||
|
||||
case OptionAction.STORE_FALSE:
|
||||
values[option.dest] = False
|
||||
return index + 1
|
||||
|
||||
case OptionAction.STORE_BOOL_OPTIONAL:
|
||||
values[option.dest] = option.type(None)
|
||||
return index + 1
|
||||
|
||||
case OptionAction.COUNT:
|
||||
values[option.dest] = int(values.get(option.dest) or 0) + 1
|
||||
return index + 1
|
||||
|
||||
case OptionAction.HELP:
|
||||
values[option.dest] = True
|
||||
return index + 1
|
||||
|
||||
case OptionAction.TLDR:
|
||||
values[option.dest] = True
|
||||
return index + 1
|
||||
|
||||
case OptionAction.STORE:
|
||||
value_index = index + 1
|
||||
if value_index >= len(argv):
|
||||
raise FalyxOptionError(f"option '{argv[index]}' expected a value")
|
||||
|
||||
raw_value = argv[value_index]
|
||||
try:
|
||||
value = coerce_value(raw_value, option.type)
|
||||
except Exception as error:
|
||||
raise FalyxOptionError(
|
||||
f"invalid value for '{argv[index]}': {error}"
|
||||
) from error
|
||||
|
||||
if option.choices and value not in option.choices:
|
||||
choices = ", ".join(str(choice) for choice in option.choices)
|
||||
raise FalyxOptionError(
|
||||
f"invalid value for '{argv[index]}': expected one of {{{choices}}}"
|
||||
)
|
||||
|
||||
values[option.dest] = value
|
||||
return index + 2
|
||||
|
||||
raise FalyxOptionError(f"unsupported option action: {option.action}")
|
||||
|
||||
def parse_args(
|
||||
self,
|
||||
argv: list[str] | None = None,
|
||||
) -> ParseResult:
|
||||
raw_argv = argv or []
|
||||
arguments = self._resolve_posix_bundling(raw_argv)
|
||||
values, root_values = self._default_values()
|
||||
|
||||
index = 0
|
||||
while index < len(arguments):
|
||||
token = arguments[index]
|
||||
|
||||
# Explicit option terminator. Everything after belongs to routing/command.
|
||||
if token == "--":
|
||||
index += 1
|
||||
break
|
||||
|
||||
# First non-option is the route boundary.
|
||||
if not token.startswith("-"):
|
||||
break
|
||||
|
||||
# Unknown leading option is an error at this scope.
|
||||
# This is what keeps root/namespace options honest.
|
||||
option = self._options_by_dest.get(token)
|
||||
if option is None:
|
||||
raise FalyxOptionError(
|
||||
f"unknown option '{token}' for '{self._flx.program or self._flx.title}'"
|
||||
)
|
||||
|
||||
target_values = root_values if option.scope == OptionScope.ROOT else values
|
||||
index = self._consume_option(option, arguments, index, target_values)
|
||||
|
||||
remaining_argv = arguments[index:]
|
||||
|
||||
help_requested = values.get("help", False) or values.get("tldr", False)
|
||||
|
||||
return ParseResult(
|
||||
mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND,
|
||||
raw_argv=raw_argv,
|
||||
options=values,
|
||||
root_options=root_values,
|
||||
remaining_argv=remaining_argv,
|
||||
help=values.get("help", False),
|
||||
tldr=values.get("tldr", False),
|
||||
current_head=remaining_argv[0] if remaining_argv else "",
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
||||
"""Root parse result model for the Falyx CLI runtime.
|
||||
"""Parse result model for the Falyx CLI runtime.
|
||||
|
||||
This module defines `RootParseResult`, the normalized output produced by the
|
||||
This module defines `ParseResult`, the normalized output produced by the
|
||||
root-level Falyx parsing stage.
|
||||
|
||||
`RootParseResult` captures the session-scoped state derived from the initial
|
||||
`ParseResult` captures the session-scoped state derived from the initial
|
||||
CLI parse before namespace routing or command-local argument parsing begins. It
|
||||
records the selected top-level mode, the original argv, root option flags, and
|
||||
any remaining argv that should be forwarded into the routed execution layer.
|
||||
@@ -17,15 +17,16 @@ The dataclass is intentionally lightweight and focused on root parsing only. It
|
||||
does not perform parsing, validation, or execution itself.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from falyx.mode import FalyxMode
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RootParseResult:
|
||||
class ParseResult:
|
||||
"""Represents the normalized result of root-level Falyx argument parsing.
|
||||
|
||||
`RootParseResult` stores the outcome of the initial CLI parse that occurs at
|
||||
`ParseResult` stores the outcome of the initial CLI parse that occurs at
|
||||
the application boundary. It separates session-level runtime settings from
|
||||
the remaining argv that should continue into namespace routing and
|
||||
command-local parsing.
|
||||
@@ -37,18 +38,27 @@ class RootParseResult:
|
||||
Attributes:
|
||||
mode: Top-level runtime mode selected from the root parse.
|
||||
raw_argv: Original argv passed into the root parser.
|
||||
options: Dictionary of parsed root-level options and their values.
|
||||
root_options: Dictionary of parsed root-level options that should be
|
||||
applied at the root level for all namespaces.
|
||||
remaining_argv: Unconsumed argv that should be forwarded to routed
|
||||
command resolution.
|
||||
current_head: The current head token being processed (for error reporting).
|
||||
help: Whether help output was requested at the root level.
|
||||
tldr: Whether TLDR output was requested at the root level.
|
||||
verbose: Whether verbose logging should be enabled for the session.
|
||||
debug_hooks: Whether hook execution should be logged in detail.
|
||||
never_prompt: Whether prompts should be suppressed for the session.
|
||||
remaining_argv: Unconsumed argv that should be forwarded to routed
|
||||
command resolution.
|
||||
tldr_requested: Whether root TLDR output was requested.
|
||||
"""
|
||||
|
||||
mode: FalyxMode
|
||||
raw_argv: list[str] = field(default_factory=list)
|
||||
options: dict[str, Any] = field(default_factory=dict)
|
||||
root_options: dict[str, Any] = field(default_factory=dict)
|
||||
remaining_argv: list[str] = field(default_factory=list)
|
||||
current_head: str = ""
|
||||
help: bool = False
|
||||
tldr: bool = False
|
||||
verbose: bool = False
|
||||
debug_hooks: bool = False
|
||||
never_prompt: bool = False
|
||||
remaining_argv: list[str] = field(default_factory=list)
|
||||
tldr_requested: bool = False
|
||||
|
||||
@@ -23,6 +23,16 @@ from falyx.logger import logger
|
||||
from falyx.parser.signature import infer_args_from_func
|
||||
|
||||
|
||||
def get_type_name(type_: Any) -> str:
|
||||
if hasattr(type_, "__name__"):
|
||||
return type_.__name__
|
||||
elif not isinstance(type_, type):
|
||||
parent_type = type(type_)
|
||||
if hasattr(parent_type, "__name__"):
|
||||
return parent_type.__name__
|
||||
return str(type_)
|
||||
|
||||
|
||||
def coerce_bool(value: str) -> bool:
|
||||
"""Convert a string to a boolean.
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ Includes:
|
||||
- `should_prompt_user()` for conditional prompt logic.
|
||||
- `confirm_async()` for interactive yes/no confirmation.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import (
|
||||
@@ -24,11 +26,25 @@ from falyx.themes import OneColors
|
||||
from falyx.validators import yes_no_validator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def prompt_session_context(session: PromptSession) -> Iterator[PromptSession]:
|
||||
"""Temporary override for prompt session management"""
|
||||
message = session.message
|
||||
validator = session.validator
|
||||
placeholder = session.placeholder
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.message = message
|
||||
session.validator = validator
|
||||
session.placeholder = placeholder
|
||||
|
||||
|
||||
def should_prompt_user(
|
||||
*,
|
||||
confirm: bool,
|
||||
options: OptionsManager,
|
||||
namespace: str = "default",
|
||||
namespace: str = "root",
|
||||
override_namespace: str = "execution",
|
||||
) -> bool:
|
||||
"""Determine whether to prompt the user for confirmation.
|
||||
@@ -41,7 +57,7 @@ def should_prompt_user(
|
||||
Args:
|
||||
confirm (bool): The initial confirmation flag (e.g., from a command argument).
|
||||
options (OptionsManager): The options manager to check for override flags.
|
||||
namespace (str): The primary namespace to check for options (default: "default").
|
||||
namespace (str): The primary namespace to check for options (default: "root").
|
||||
override_namespace (str): The secondary namespace for overrides (default: "execution").
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -78,6 +78,8 @@ class RouteResult:
|
||||
specific nested namespace.
|
||||
leaf_argv: Remaining argv that should be delegated to the resolved
|
||||
command's local parser.
|
||||
current_head: The current head token that routing is evaluating, used for
|
||||
generating suggestions.
|
||||
suggestions: Suggested entry names for unresolved input.
|
||||
is_preview: Whether the routed invocation is in preview mode.
|
||||
"""
|
||||
@@ -88,5 +90,6 @@ class RouteResult:
|
||||
command: "Command | None" = None
|
||||
namespace_entry: FalyxNamespace | None = None
|
||||
leaf_argv: list[str] = field(default_factory=list)
|
||||
current_head: str = ""
|
||||
suggestions: list[str] = field(default_factory=list)
|
||||
is_preview: bool = False
|
||||
|
||||
@@ -20,7 +20,7 @@ from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.prompt_utils import rich_text_to_prompt_text
|
||||
from falyx.prompt_utils import prompt_session_context, rich_text_to_prompt_text
|
||||
from falyx.themes import OneColors
|
||||
from falyx.utils import CaseInsensitiveDict, chunks
|
||||
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
||||
@@ -292,19 +292,32 @@ async def prompt_for_index(
|
||||
if show_table:
|
||||
console.print(table, justify="center")
|
||||
|
||||
selection = await prompt_session.prompt_async(
|
||||
message=rich_text_to_prompt_text(prompt_message),
|
||||
validator=MultiIndexValidator(
|
||||
min_index,
|
||||
max_index,
|
||||
number_selections,
|
||||
separator,
|
||||
allow_duplicates,
|
||||
cancel_key,
|
||||
),
|
||||
default=default_selection,
|
||||
number_selections_str = (
|
||||
f"{number_selections} " if isinstance(number_selections, int) else ""
|
||||
)
|
||||
|
||||
plural = "s" if number_selections != 1 else ""
|
||||
placeholder = (
|
||||
f"Enter {number_selections_str}selection{plural} separated by '{separator}'"
|
||||
if number_selections != 1
|
||||
else "Enter selection"
|
||||
)
|
||||
|
||||
with prompt_session_context(prompt_session) as session:
|
||||
selection = await session.prompt_async(
|
||||
message=rich_text_to_prompt_text(prompt_message),
|
||||
validator=MultiIndexValidator(
|
||||
min_index,
|
||||
max_index,
|
||||
number_selections,
|
||||
separator,
|
||||
allow_duplicates,
|
||||
cancel_key,
|
||||
),
|
||||
default=default_selection,
|
||||
placeholder=placeholder,
|
||||
)
|
||||
|
||||
if selection.strip() == cancel_key:
|
||||
return int(cancel_key)
|
||||
if isinstance(number_selections, int) and number_selections == 1:
|
||||
@@ -331,14 +344,27 @@ async def prompt_for_selection(
|
||||
if show_table:
|
||||
console.print(table, justify="center")
|
||||
|
||||
selected = await prompt_session.prompt_async(
|
||||
message=rich_text_to_prompt_text(prompt_message),
|
||||
validator=MultiKeyValidator(
|
||||
keys, number_selections, separator, allow_duplicates, cancel_key
|
||||
),
|
||||
default=default_selection,
|
||||
number_selections_str = (
|
||||
f"{number_selections} " if isinstance(number_selections, int) else ""
|
||||
)
|
||||
|
||||
plural = "s" if number_selections != 1 else ""
|
||||
placeholder = (
|
||||
f"Enter {number_selections_str}selection{plural} separated by '{separator}'"
|
||||
if number_selections != 1
|
||||
else "Enter selection"
|
||||
)
|
||||
|
||||
with prompt_session_context(prompt_session) as session:
|
||||
selected = await session.prompt_async(
|
||||
message=rich_text_to_prompt_text(prompt_message),
|
||||
validator=MultiKeyValidator(
|
||||
keys, number_selections, separator, allow_duplicates, cancel_key
|
||||
),
|
||||
default=default_selection,
|
||||
placeholder=placeholder,
|
||||
)
|
||||
|
||||
if selected.strip() == cancel_key:
|
||||
return cancel_key
|
||||
if isinstance(number_selections, int) and number_selections == 1:
|
||||
|
||||
@@ -55,7 +55,12 @@ class CommandValidator(Validator):
|
||||
message=self.error_message,
|
||||
cursor_position=len(text),
|
||||
)
|
||||
if route.is_preview:
|
||||
if route.is_preview and route.command is None:
|
||||
raise ValidationError(
|
||||
message=self.error_message,
|
||||
cursor_position=len(text),
|
||||
)
|
||||
elif route.is_preview:
|
||||
return None
|
||||
if route.kind in {
|
||||
RouteKind.NAMESPACE_MENU,
|
||||
|
||||
100
tests/test_actions/test_load_file_action.py
Normal file
100
tests/test_actions/test_load_file_action.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
from rich.text import Text
|
||||
|
||||
from falyx.action import LoadFileAction
|
||||
from falyx.console import console as falyx_console
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_json_file_action(tmp_path):
|
||||
mock_data = '{"key": "value"}'
|
||||
file = tmp_path / "test.json"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
|
||||
result = await action()
|
||||
assert result == {"key": "value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_yaml_file_action(tmp_path):
|
||||
mock_data = "key: value"
|
||||
file = tmp_path / "test.yaml"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="yaml")
|
||||
result = await action()
|
||||
assert result == {"key": "value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_toml_file_action(tmp_path):
|
||||
mock_data = 'key = "value"'
|
||||
file = tmp_path / "test.toml"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="toml")
|
||||
result = await action()
|
||||
assert result == {"key": "value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_csv_file_action(tmp_path):
|
||||
mock_data = "key,value\nfoo,bar"
|
||||
file = tmp_path / "test.csv"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="csv")
|
||||
result = await action()
|
||||
print(result)
|
||||
assert result == [["key", "value"], ["foo", "bar"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_tsv_file_action(tmp_path):
|
||||
mock_data = "key\tvalue\nfoo\tbar"
|
||||
file = tmp_path / "test.tsv"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="tsv")
|
||||
result = await action()
|
||||
assert result == [["key", "value"], ["foo", "bar"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_file_action_invalid_path():
|
||||
action = LoadFileAction(
|
||||
name="load-file", file_path="non_existent_file.json", file_type="json"
|
||||
)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await action()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_file_action_invalid_json(tmp_path):
|
||||
invalid_json = '{"key": "value"' # Missing closing brace
|
||||
file = tmp_path / "invalid.json"
|
||||
file.write_text(invalid_json)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
|
||||
with pytest.raises(ValueError):
|
||||
await action()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_file_action_unsupported_type(tmp_path):
|
||||
file = tmp_path / "test.txt"
|
||||
file.write_text("Just some text")
|
||||
with pytest.raises(ValueError):
|
||||
LoadFileAction(name="load-file", file_path=file, file_type="unsupported")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_of_load_file_action(tmp_path):
|
||||
mock_data = '{"key": "value"}'
|
||||
file = tmp_path / "test.json"
|
||||
file.write_text(mock_data)
|
||||
action = LoadFileAction(name="load-file", file_path=file, file_type="json")
|
||||
with falyx_console.capture() as capture:
|
||||
await action.preview()
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "LoadFileAction" in captured
|
||||
assert "test.json" in captured
|
||||
assert "load-file" in captured
|
||||
assert "JSON" in captured
|
||||
assert "key" in captured
|
||||
assert "value" in captured
|
||||
@@ -1,5 +1,6 @@
|
||||
# test_command.py
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from falyx.action import Action, BaseIOAction, ChainedAction
|
||||
from falyx.command import Command
|
||||
@@ -172,3 +173,15 @@ def test_command_bad_action():
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
Command(key="TEST", description="Test Command", action="not_callable")
|
||||
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"
|
||||
|
||||
|
||||
def test_command_bad_options_manager():
|
||||
"""Test if Command raises an exception when options_manager is not a dict or callable."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Command(
|
||||
key="TEST",
|
||||
description="Test Command",
|
||||
action=dummy_action,
|
||||
options_manager="not_a_dict_or_callable",
|
||||
)
|
||||
assert "Input should be an instance of OptionsManager" in str(exc_info.value)
|
||||
|
||||
@@ -118,6 +118,19 @@ def test_get_completions_namespace_boundary_suggests_help_flags(falyx):
|
||||
results = list(completer.get_completions(Document("OPS -"), None))
|
||||
texts = completion_texts(results)
|
||||
|
||||
assert "-h" in texts
|
||||
assert "--help" in texts
|
||||
assert "-T" not in texts
|
||||
assert "--tldr" not in texts
|
||||
|
||||
falyx.add_tldr_example(
|
||||
entry_key="R",
|
||||
usage="",
|
||||
description="This is a TLDR example for the R command.",
|
||||
)
|
||||
results = list(completer.get_completions(Document("-"), None))
|
||||
texts = completion_texts(results)
|
||||
|
||||
assert "-h" in texts
|
||||
assert "--help" in texts
|
||||
assert "-T" in texts
|
||||
@@ -247,3 +260,46 @@ def test_ensure_quote_wraps_whitespace(falyx):
|
||||
|
||||
assert completer._ensure_quote("hello world") == '"hello world"'
|
||||
assert completer._ensure_quote("hello") == "hello"
|
||||
|
||||
|
||||
def test_command_suggestions_are_case_insensitive(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
|
||||
results = list(completer.get_completions(Document("r"), None))
|
||||
texts = completion_texts(results)
|
||||
|
||||
assert "r" in texts
|
||||
assert "run" in texts
|
||||
|
||||
results = list(completer.get_completions(Document("R"), None))
|
||||
texts = completion_texts(results)
|
||||
|
||||
assert "R" in texts
|
||||
assert "RUN" in texts
|
||||
|
||||
|
||||
def test_namespace_suggestions_are_case_insensitive(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
|
||||
results = list(completer.get_completions(Document("op"), None))
|
||||
texts = completion_texts(results)
|
||||
|
||||
assert "ops" in texts
|
||||
assert "operations" in texts
|
||||
|
||||
results = list(completer.get_completions(Document("OP"), None))
|
||||
texts = completion_texts(results)
|
||||
|
||||
assert "OPS" in texts
|
||||
assert "OPERATIONS" in texts
|
||||
|
||||
|
||||
def test_command_completions_after_namespace(falyx):
|
||||
completer = FalyxCompleter(falyx)
|
||||
|
||||
results = list(completer.get_completions(Document("OPS D --"), None))
|
||||
texts = completion_texts(results)
|
||||
|
||||
assert "--target" in texts
|
||||
assert "--region" in texts
|
||||
assert "--help" in texts
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytest
|
||||
from rich.text import Text
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.console import console
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -82,17 +82,14 @@ async def test_help_command_by_tag(capsys):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_command_empty_tags(capsys):
|
||||
async def test_help_command_bad_argument(capsys):
|
||||
flx = Falyx()
|
||||
|
||||
async def untagged_command(falyx: Falyx):
|
||||
pass
|
||||
|
||||
flx.add_command(
|
||||
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
|
||||
)
|
||||
await flx.execute_command("H nonexistent_tag")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
text = Text.from_ansi(captured.out)
|
||||
assert "Unexpected positional argument: nonexistent_tag" in text.plain
|
||||
flx.add_command("U", "Untagged Command", untagged_command)
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Unexpected positional argument: nonexistent_tag"
|
||||
):
|
||||
await flx.execute_command("H nonexistent_tag")
|
||||
|
||||
0
tests/test_falyx/test_routing.py
Normal file
0
tests/test_falyx/test_routing.py
Normal file
@@ -1,8 +1,72 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from rich.text import Text
|
||||
|
||||
from falyx import Falyx
|
||||
from falyx.console import console as falyx_console
|
||||
from falyx.exceptions import FalyxError
|
||||
from falyx.parser import ParseResult
|
||||
from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
|
||||
|
||||
|
||||
async def throw_error_action(error: str):
|
||||
if error == "QuitSignal":
|
||||
raise QuitSignal("Quit signal triggered.")
|
||||
elif error == "BackSignal":
|
||||
raise BackSignal("Back signal triggered.")
|
||||
elif error == "CancelSignal":
|
||||
raise CancelSignal("Cancel signal triggered.")
|
||||
elif error == "ValueError":
|
||||
raise ValueError("This is a ValueError.")
|
||||
elif error == "HelpSignal":
|
||||
raise HelpSignal("Help signal triggered.")
|
||||
elif error == "FalyxError":
|
||||
raise FalyxError("This is a FalyxError.")
|
||||
elif error == "FlowSignal":
|
||||
raise FlowSignal("Flow signal triggered.")
|
||||
else:
|
||||
raise asyncio.CancelledError("An error occurred in the action.")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flx() -> Falyx:
|
||||
sys.argv = ["falyx", "T"]
|
||||
flx = Falyx()
|
||||
flx.add_command(
|
||||
"T",
|
||||
"Test",
|
||||
action=lambda: "hello",
|
||||
)
|
||||
flx.add_tldr_example(
|
||||
entry_key="T",
|
||||
usage="",
|
||||
description="This is a TLDR example for the T command.",
|
||||
)
|
||||
return flx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flx_with_submenu() -> Falyx:
|
||||
flx = Falyx()
|
||||
submenu = Falyx("Submenu")
|
||||
submenu.add_command(
|
||||
"T",
|
||||
"Test",
|
||||
action=lambda: "hello from submenu",
|
||||
)
|
||||
submenu.add_tldr_example(
|
||||
entry_key="T",
|
||||
usage="",
|
||||
description="This is a TLDR example for the T command in the submenu.",
|
||||
)
|
||||
flx.add_submenu(
|
||||
"S",
|
||||
"Submenu",
|
||||
submenu=submenu,
|
||||
)
|
||||
return flx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -14,3 +78,178 @@ async def test_run_basic(capsys):
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Show this help menu." in captured.out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_default_to_menu(flx):
|
||||
sys.argv = ["falyx", "T"]
|
||||
flx.default_to_menu = False
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
await flx.run()
|
||||
|
||||
await flx.run(always_start_menu=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_default_to_menu_help(flx):
|
||||
sys.argv = ["falyx"]
|
||||
flx.default_to_menu = False
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
with falyx_console.capture() as capture:
|
||||
await flx.run()
|
||||
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "Show this help menu." in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_debug_hooks(flx):
|
||||
sys.argv = ["falyx", "--debug-hooks", "T"]
|
||||
|
||||
assert flx.options.get("debug_hooks") is False
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
await flx.run()
|
||||
|
||||
assert flx.options.get("debug_hooks") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_never_prompt(flx):
|
||||
sys.argv = ["falyx", "--never-prompt", "T"]
|
||||
|
||||
assert flx.options.get("never_prompt") is False
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
await flx.run()
|
||||
|
||||
falyx_console.print(flx.options.get_namespace_dict("default"))
|
||||
|
||||
assert flx.options.get("debug_hooks") is False
|
||||
assert flx.options.get("never_prompt") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_bad_args(flx):
|
||||
sys.argv = ["falyx", "T", "--unknown-arg"]
|
||||
|
||||
with pytest.raises(SystemExit, match="2"):
|
||||
await flx.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_help(flx):
|
||||
sys.argv = ["falyx", "T", "--help"]
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "--help"]
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "-h"]
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "--tldr"]
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "-T"]
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_entry_not_found(flx):
|
||||
sys.argv = ["falyx", "UNKNOWN_COMMAND"]
|
||||
|
||||
with pytest.raises(SystemExit, match="2"):
|
||||
await flx.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_test_exceptions(flx):
|
||||
flx.add_command(
|
||||
"E",
|
||||
"Throw Error",
|
||||
action=throw_error_action,
|
||||
)
|
||||
|
||||
sys.argv = ["falyx", "E", "ValueError"]
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "E", "QuitSignal"]
|
||||
with pytest.raises(SystemExit, match="130"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "E", "BackSignal"]
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "E", "CancelSignal"]
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "E", "HelpSignal"]
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "E", "FlowSignal"]
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "--verbose", "E", "FalyxError"]
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await flx.run()
|
||||
|
||||
sys.argv = ["falyx", "E", "UnknownError"]
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await flx.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_no_args(flx):
|
||||
sys.argv = ["falyx"]
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_submenu(flx_with_submenu):
|
||||
sys.argv = ["falyx", "S", "T"]
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx_with_submenu.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_submenu_help(flx_with_submenu):
|
||||
sys.argv = ["falyx", "S", "--help"]
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx_with_submenu.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_submenu_tldr(flx_with_submenu):
|
||||
sys.argv = ["falyx", "S", "--tldr"]
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await flx_with_submenu.run()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_preview(flx):
|
||||
sys.argv = ["falyx", "preview", "T"]
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
with falyx_console.capture() as capture:
|
||||
await flx.run()
|
||||
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "Command: 'T'" in captured
|
||||
assert "Would call: <lambda>(args=(), kwargs={})" in captured
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
from falyx.parser.falyx_parser import FalyxParser, RootOptions
|
||||
|
||||
|
||||
def get_falyx_parser():
|
||||
return FalyxParser()
|
||||
|
||||
|
||||
def test_parse_root_options_empty():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options([])
|
||||
assert opts == RootOptions()
|
||||
assert remaining == []
|
||||
|
||||
|
||||
def test_parse_root_options_consumes_known_leading_flags():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options(
|
||||
["--verbose", "--never-prompt", "deploy", "--env", "prod"]
|
||||
)
|
||||
assert opts.verbose is True
|
||||
assert opts.never_prompt is True
|
||||
assert remaining == ["deploy", "--env", "prod"]
|
||||
|
||||
|
||||
def test_parse_root_options_stops_at_first_non_root_token():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options(["deploy", "--verbose"])
|
||||
assert opts == RootOptions()
|
||||
assert remaining == ["deploy", "--verbose"]
|
||||
|
||||
|
||||
def test_parse_root_options_supports_help():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options(["--help"])
|
||||
assert opts.help is True
|
||||
assert remaining == []
|
||||
|
||||
|
||||
def test_parse_root_options_supports_double_dash_separator():
|
||||
parser = get_falyx_parser()
|
||||
opts, remaining = parser._parse_root_options(
|
||||
["--verbose", "--", "deploy", "--verbose"]
|
||||
)
|
||||
assert opts.verbose is True
|
||||
assert remaining == ["deploy", "--verbose"]
|
||||
@@ -1,8 +1,10 @@
|
||||
import pytest
|
||||
from rich.text import Text
|
||||
|
||||
from falyx.action import Action
|
||||
from falyx.console import console as falyx_console
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||
from falyx.signals import HelpSignal
|
||||
|
||||
@@ -835,3 +837,175 @@ async def test_render_help():
|
||||
assert "Foo help" in output
|
||||
assert "--bar" in output
|
||||
assert "Bar help" in output
|
||||
|
||||
|
||||
def test_command_argument_parser_set_options_manager_invalid():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
with pytest.raises(NotAFalyxError):
|
||||
parser.set_options_manager("not_a_options_manager")
|
||||
|
||||
with pytest.raises(NotAFalyxError):
|
||||
parser.set_options_manager(123)
|
||||
|
||||
with pytest.raises(NotAFalyxError):
|
||||
parser.set_options_manager(None)
|
||||
|
||||
|
||||
def test_command_argument_parser_set_options_manager_valid():
|
||||
parser = CommandArgumentParser()
|
||||
options_manager = OptionsManager([("new_namespace", {"foo": "bar"})])
|
||||
parser.set_options_manager(options_manager)
|
||||
assert parser.options_manager == options_manager
|
||||
assert parser.options_manager.get("foo", namespace_name="new_namespace") == "bar"
|
||||
|
||||
|
||||
def test_add_argument_invalid_required():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action=ArgumentAction.STORE_TRUE, required=True)
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action=ArgumentAction.STORE_FALSE, required=True)
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument(
|
||||
"--foo", action=ArgumentAction.STORE_BOOL_OPTIONAL, required=True
|
||||
)
|
||||
|
||||
|
||||
def test_add_argument_invalid_choices():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="store_true", choices="not_a_list")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", choices=123)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", choices={"a": 1, "b": 2})
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", choices=["a", "b"], type=int)
|
||||
|
||||
|
||||
def test_add_argument_resolver_invalid():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", resolver=lambda x: x)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", resolver=123)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="action", resolver="not_a_function")
|
||||
|
||||
|
||||
def test_add_argument_resolver_valid():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
parser.add_argument(
|
||||
"--foo", action="action", resolver=Action("test", lambda x: x.upper())
|
||||
)
|
||||
|
||||
|
||||
def test_add_argument_resolve_invalid_default():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="store_true", default="any value")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="store_false", default=False)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="store_true", default=True)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="store_bool_optional", default=False)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="count", default=500)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="append", default="not a list")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--foo", action="extend", default="not a list")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument("--count", action="count", default=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_argument_resolve_valid_default():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
parser.add_argument("--foo", action="store_true", default=False)
|
||||
|
||||
parser.add_argument("--bar", action="store_false", default=True)
|
||||
|
||||
parser.add_argument("--baz", action="store_bool_optional", default=None)
|
||||
|
||||
parser.add_argument("--items", action="append", default=[])
|
||||
|
||||
parser.add_argument("--values", action="extend", default=[])
|
||||
|
||||
parser.add_argument("--number", action="store", nargs=1, type=int, default=0)
|
||||
|
||||
result = await parser.parse_args(["--number", "5"])
|
||||
|
||||
assert result["foo"] is False
|
||||
assert result["bar"] is True
|
||||
assert result["baz"] is None
|
||||
assert result["items"] == []
|
||||
assert result["values"] == []
|
||||
assert result["number"] == 5
|
||||
|
||||
|
||||
def test_add_argument_in_reserved_dests():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError,
|
||||
match="invalid dest .*'help' is reserved and cannot be used.",
|
||||
):
|
||||
parser.add_argument("--help")
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError,
|
||||
match="invalid dest .*'tldr' is reserved and cannot be used.",
|
||||
):
|
||||
parser.add_argument("--tldr")
|
||||
|
||||
|
||||
def test_add_argument_in_reserved_dests_positional():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError,
|
||||
match="invalid dest .*'help' is reserved and cannot be used.",
|
||||
):
|
||||
parser.add_argument("help")
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError,
|
||||
match="invalid dest .*'tldr' is reserved and cannot be used.",
|
||||
):
|
||||
parser.add_argument("tldr")
|
||||
|
||||
|
||||
def test_add_argument_invalid_suggestions():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="suggestions must be a list or None, got int"
|
||||
):
|
||||
parser.add_argument("--valid", suggestions=112445)
|
||||
|
||||
|
||||
def test_add_argument_invalid_lazy_resolver():
|
||||
parser = CommandArgumentParser()
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="lazy_resolver must be a boolean, got int"
|
||||
):
|
||||
parser.add_argument("--valid", lazy_resolver=123)
|
||||
@@ -31,6 +31,21 @@ def test_enable_execution_options_registers_retry_flags():
|
||||
assert "retry_backoff" in parser._execution_dests
|
||||
|
||||
|
||||
def test_enable_execution_options_invalid_double_registration_raises():
|
||||
parser = CommandArgumentParser()
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
):
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError,
|
||||
match="destination 'summary' is already registered as an execution argument",
|
||||
):
|
||||
parser._register_execution_dest("summary")
|
||||
|
||||
|
||||
def test_enable_execution_options_registers_confirm_flags():
|
||||
parser = CommandArgumentParser()
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM}))
|
||||
@@ -48,12 +63,12 @@ def test_register_execution_dest_rejects_duplicates():
|
||||
parser = CommandArgumentParser()
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
):
|
||||
parser.add_argument("--summary", action="store_true")
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
):
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
|
||||
@@ -138,6 +153,6 @@ async def test_parse_args_split_with_conflicting_execution_option_raises():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_argument("--summary", action="store_true", help="A conflicting argument.")
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
):
|
||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
|
||||
96
tests/test_parsers/test_group_builder.py
Normal file
96
tests/test_parsers/test_group_builder.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser import CommandArgumentParser
|
||||
from falyx.parser.command_argument_parser import _GroupBuilder
|
||||
|
||||
|
||||
def test_group_builder():
|
||||
parser = CommandArgumentParser(program="test_program")
|
||||
group_builder = _GroupBuilder(parser, group_name="test_group")
|
||||
assert group_builder.group_name == "test_group"
|
||||
assert "group='test_group'" in str(group_builder)
|
||||
|
||||
group_builder = _GroupBuilder(
|
||||
parser,
|
||||
mutex_name="test_group",
|
||||
)
|
||||
assert group_builder.mutex_name == "test_group"
|
||||
assert "mutex_group='test_group'" in str(group_builder)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
_GroupBuilder(parser, group_name="test_group", mutex_name="test_group")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
_GroupBuilder(parser)
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
builder = _GroupBuilder(parser, group_name="test_group")
|
||||
builder.group_name = None
|
||||
builder.mutex_name = None
|
||||
str(builder)
|
||||
|
||||
|
||||
def test_adding_arguments_to_group():
|
||||
parser = CommandArgumentParser(program="test_program")
|
||||
|
||||
group = parser.add_argument_group("test_group")
|
||||
assert group.group_name == "test_group"
|
||||
|
||||
group.add_argument("--foo", type=str, help="Foo argument")
|
||||
group.add_argument("--bar", type=int, help="Bar argument")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument_group("test_group")
|
||||
|
||||
|
||||
def test_adding_arguments_to_mutex_group():
|
||||
parser = CommandArgumentParser(program="test_program")
|
||||
|
||||
mutex_group = parser.add_mutually_exclusive_group("test_mutex_group")
|
||||
assert mutex_group.mutex_name == "test_mutex_group"
|
||||
|
||||
mutex_group.add_argument("--foo", type=str, help="Foo argument")
|
||||
mutex_group.add_argument("--bar", type=int, help="Bar argument")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_mutually_exclusive_group("test_mutex_group")
|
||||
|
||||
|
||||
def test_adding_arguments_to_group_with_invalid_group():
|
||||
parser = CommandArgumentParser(program="test_program")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument(
|
||||
"--foo", type=str, help="Foo argument", group="non_existent_group"
|
||||
)
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_argument(
|
||||
"--bar", type=int, help="Bar argument", mutex_group="non_existent_group"
|
||||
)
|
||||
|
||||
|
||||
def test_adding_positional_arguments_to_mutex_group():
|
||||
parser = CommandArgumentParser(program="test_program")
|
||||
|
||||
group = parser.add_mutually_exclusive_group("test_group")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
group.add_argument(
|
||||
"positional_arg", type=str, help="This should fail because it's positional"
|
||||
)
|
||||
|
||||
|
||||
def test_adding_required_arguments_to_mutex_group():
|
||||
parser = CommandArgumentParser(program="test_program")
|
||||
|
||||
group = parser.add_mutually_exclusive_group("test_group")
|
||||
|
||||
with pytest.raises(CommandArgumentError):
|
||||
group.add_argument(
|
||||
"--foo",
|
||||
type=str,
|
||||
help="This should fail because it's required",
|
||||
required=True,
|
||||
)
|
||||
@@ -69,14 +69,14 @@ async def test_resolve_args_raises_on_conflicting_execution_option():
|
||||
execution_options=["summary"],
|
||||
)
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
):
|
||||
command.arg_parser.add_argument(
|
||||
"--summary", action="store_true", help="A conflicting argument."
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
||||
CommandArgumentError, match="destination 'summary' is already defined"
|
||||
):
|
||||
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
from falyx.parser.parser_types import TLDRExample
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -45,3 +46,27 @@ async def test_add_tldr_examples_in_init():
|
||||
assert parser._tldr_examples[0].description == "This is the first example."
|
||||
assert parser._tldr_examples[1].usage == "example2"
|
||||
assert parser._tldr_examples[1].description == "This is the second example."
|
||||
|
||||
|
||||
def test_add_tldr_example():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_tldr_example("example1", "This is the first example.")
|
||||
assert len(parser._tldr_examples) == 1
|
||||
assert parser._tldr_examples[0].usage == "example1"
|
||||
assert parser._tldr_examples[0].description == "This is the first example."
|
||||
|
||||
|
||||
def test_add_tldr_example_bad_args():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(TypeError):
|
||||
parser.add_tldr_example("example1", "This is the first example.", "extra_arg")
|
||||
|
||||
|
||||
def test_add_tldr_examples_with_tldr_example_objects():
|
||||
parser = CommandArgumentParser()
|
||||
example1 = TLDRExample(usage="example1", description="This is the first example.")
|
||||
example2 = TLDRExample(usage="example2", description="This is the second example.")
|
||||
parser.add_tldr_examples([example1, example2])
|
||||
assert len(parser._tldr_examples) == 2
|
||||
assert parser._tldr_examples[0] == example1
|
||||
assert parser._tldr_examples[1] == example2
|
||||
|
||||
@@ -9,7 +9,13 @@ from falyx.action import Action
|
||||
from falyx.command import Command
|
||||
from falyx.command_runner import CommandRunner
|
||||
from falyx.console import console as falyx_console
|
||||
from falyx.exceptions import CommandArgumentError, FalyxError, NotAFalyxError
|
||||
from falyx.console import error_console
|
||||
from falyx.exceptions import (
|
||||
CommandArgumentError,
|
||||
FalyxError,
|
||||
InvalidHookError,
|
||||
NotAFalyxError,
|
||||
)
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||
@@ -123,8 +129,10 @@ async def test_command_runner_initialization(
|
||||
command_with_no_parser,
|
||||
command_with_custom_parser,
|
||||
):
|
||||
runner = CommandRunner(command_with_parser)
|
||||
runner = CommandRunner(command_with_parser, program="test_program")
|
||||
assert runner.command == command_with_parser
|
||||
assert runner.program == "test_program"
|
||||
assert runner.command.arg_parser.program == "test_program"
|
||||
assert isinstance(runner.options, OptionsManager)
|
||||
assert isinstance(runner.runner_hooks, HookManager)
|
||||
assert runner.console == falyx_console
|
||||
@@ -133,7 +141,6 @@ async def test_command_runner_initialization(
|
||||
assert runner.command.options_manager == runner.options
|
||||
assert runner.executor.options == runner.options
|
||||
assert runner.executor.hooks == runner.runner_hooks
|
||||
assert runner.executor.console == runner.console
|
||||
assert runner.options.get("summary", namespace_name="execution") is None
|
||||
|
||||
runner_no_parser = CommandRunner(command_with_no_parser)
|
||||
@@ -166,7 +173,6 @@ def test_command_runner_initialization_with_custom_console(command_with_parser):
|
||||
custom_console = Console()
|
||||
runner = CommandRunner(command_with_parser, console=custom_console)
|
||||
assert runner.console == custom_console
|
||||
assert runner.executor.console == custom_console
|
||||
|
||||
|
||||
def test_command_runner_initialization_with_custom_hooks(command_with_parser):
|
||||
@@ -199,7 +205,9 @@ def test_command_runner_initialization_with_all_bad_components(command_with_pars
|
||||
console=custom_console,
|
||||
)
|
||||
|
||||
with pytest.raises(NotAFalyxError, match="hooks must be an instance of HookManager"):
|
||||
with pytest.raises(
|
||||
InvalidHookError, match="hooks must be an instance of HookManager"
|
||||
):
|
||||
CommandRunner(
|
||||
command_with_parser,
|
||||
runner_hooks=custom_hooks,
|
||||
@@ -236,8 +244,6 @@ async def test_command_runner_run_with_failing_action(command_with_failing_actio
|
||||
with pytest.raises(FalyxError, match="boom"):
|
||||
await runner.run("--foo 42", wrap_errors=True)
|
||||
|
||||
assert await runner.run("--foo 42", wrap_errors=False, raise_on_error=False) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_debug_statement(command_with_parser, caplog):
|
||||
@@ -276,6 +282,22 @@ async def test_command_runner_run_with_retries_with_action(
|
||||
assert "[throw_error] All 2 retries failed." in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_run_with_retries_delay_with_action(
|
||||
command_throwing_error, caplog
|
||||
):
|
||||
runner = CommandRunner(command_throwing_error)
|
||||
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
|
||||
await runner.run("Other")
|
||||
|
||||
with pytest.raises(ValueError, match="This is a ValueError."):
|
||||
await runner.run("ValueError --retries 2 --retry-delay 1.0 --retry-backoff 2.0")
|
||||
|
||||
assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text
|
||||
assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text
|
||||
assert "[throw_error] All 2 retries failed." in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_run_from_command_build_with_all_execution_options(
|
||||
command_build_with_all_execution_options,
|
||||
@@ -313,7 +335,7 @@ async def test_command_runner_from_command_bad_command():
|
||||
CommandRunner.from_command("Not a Command")
|
||||
|
||||
with pytest.raises(
|
||||
NotAFalyxError, match="runner_hooks must be an instance of HookManager"
|
||||
InvalidHookError, match="runner_hooks must be an instance of HookManager"
|
||||
):
|
||||
CommandRunner.from_command(
|
||||
Command(
|
||||
@@ -360,7 +382,7 @@ async def test_command_runner_build_with_bad_execution_options():
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_build_with_bad_runner_hooks():
|
||||
with pytest.raises(
|
||||
NotAFalyxError, match="runner_hooks must be an instance of HookManager"
|
||||
InvalidHookError, match="runner_hooks must be an instance of HookManager"
|
||||
):
|
||||
CommandRunner.build(
|
||||
key="T",
|
||||
@@ -438,7 +460,7 @@ async def test_command_runner_cli_with_failing_action(command_with_failing_actio
|
||||
await runner.cli(["--help"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
|
||||
assert "usage: falyx T" in captured
|
||||
assert "usage: falyx" in captured
|
||||
assert "--foo" in captured
|
||||
assert "summary" in captured
|
||||
assert "retries" in captured
|
||||
@@ -453,54 +475,48 @@ async def test_command_runner_cli_exceptions(command_throwing_error):
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
await runner.cli(["--help"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "falyx E [--help]" in captured
|
||||
assert "falyx [--help]" in captured
|
||||
assert "usage:" in captured
|
||||
assert "positional:" in captured
|
||||
assert "options:" in captured
|
||||
assert "❌" not in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="2"):
|
||||
await runner.cli(["--not-an-arg"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "falyx E [--help]" in captured
|
||||
assert "falyx [--help]" in captured
|
||||
assert "usage:" in captured
|
||||
assert "positional:" in captured
|
||||
assert "options:" in captured
|
||||
assert "❌" in captured
|
||||
falyx_console.clear()
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with error_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli(["FalyxError"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "This is a FalyxError." in captured
|
||||
assert "❌ Error:" in captured
|
||||
assert "error:" in captured
|
||||
falyx_console.clear()
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="130"):
|
||||
await runner.cli(["QuitSignal"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "❌" not in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli(["BackSignal"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "❌" not in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli(["CancelSignal"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "❌" not in captured
|
||||
|
||||
with falyx_console.capture() as capture:
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
await runner.cli(["Other"])
|
||||
captured = Text.from_ansi(capture.get()).plain
|
||||
assert "❌" not in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -514,3 +530,12 @@ async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch
|
||||
assert "Action executed with args:" in captured
|
||||
assert "and kwargs:" in captured
|
||||
assert "{'foo': 42}" in captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_runner_run_error(command_with_parser):
|
||||
runner = CommandRunner(command_with_parser)
|
||||
with pytest.raises(FalyxError, match="requires either"):
|
||||
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=False)
|
||||
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True)
|
||||
await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False)
|
||||
|
||||
@@ -40,6 +40,7 @@ async def test_command_validator_is_preview():
|
||||
fake_falyx = AsyncMock()
|
||||
fake_route = SimpleNamespace()
|
||||
fake_route.is_preview = True
|
||||
fake_route.command = SimpleNamespace()
|
||||
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
|
||||
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user