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.
|
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
|
from falyx.action.base_action import BaseAction
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||||||
name: str,
|
name: str,
|
||||||
actions: (
|
actions: (
|
||||||
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
|
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
|
||||||
|
| Any
|
||||||
| None
|
| None
|
||||||
) = None,
|
) = None,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ class LoadFileAction(BaseAction):
|
|||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error("Failed to parse %s: %s", self.file_path.name, error)
|
logger.error("Failed to parse %s: %s", self.file_path.name, error)
|
||||||
|
raise
|
||||||
return value
|
return value
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> Any:
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
@@ -241,7 +242,7 @@ class LoadFileAction(BaseAction):
|
|||||||
for line in preview_lines:
|
for line in preview_lines:
|
||||||
content_tree.add(f"[dim]{line}[/]")
|
content_tree.add(f"[dim]{line}[/]")
|
||||||
elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}:
|
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:
|
if raw is not None:
|
||||||
preview_str = (
|
preview_str = (
|
||||||
json.dumps(raw, indent=2)
|
json.dumps(raw, indent=2)
|
||||||
|
|||||||
@@ -88,7 +88,12 @@ class SelectionAction(BaseAction):
|
|||||||
allow_duplicates (bool): Whether duplicate selections are allowed.
|
allow_duplicates (bool): Whether duplicate selections are allowed.
|
||||||
inject_last_result (bool): If True, attempts to inject the last result as default.
|
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").
|
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.
|
prompt_session (PromptSession | None): Reused or customized prompt_toolkit session.
|
||||||
never_prompt (bool): If True, skips prompting and uses default_selection or last_result.
|
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.
|
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 prompt_toolkit.formatted_text import FormattedText
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||||
|
from rich.style import Style
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action.action import Action
|
from falyx.action.action import Action
|
||||||
@@ -53,7 +54,7 @@ from falyx.action.base_action import BaseAction
|
|||||||
from falyx.console import console
|
from falyx.console import console
|
||||||
from falyx.context import ExecutionContext, InvocationContext
|
from falyx.context import ExecutionContext, InvocationContext
|
||||||
from falyx.debug import register_debug_hooks
|
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_option import ExecutionOption
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
@@ -121,14 +122,14 @@ class Command(BaseModel):
|
|||||||
aliases (list[str], optional): Alternate names for invocation.
|
aliases (list[str], optional): Alternate names for invocation.
|
||||||
help_text (str): Help description shown in CLI/menu.
|
help_text (str): Help description shown in CLI/menu.
|
||||||
help_epilog (str): Additional help content.
|
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 (bool): Whether confirmation is required before execution.
|
||||||
confirm_message (str): Confirmation prompt text.
|
confirm_message (str): Confirmation prompt text.
|
||||||
preview_before_confirm (bool): Whether to preview before confirmation.
|
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||||
spinner (bool): Enable spinner during execution.
|
spinner (bool): Enable spinner during execution.
|
||||||
spinner_message (str): Spinner message text.
|
spinner_message (str): Spinner message text.
|
||||||
spinner_type (str): Rich Spinner animation type (e.g., dots, line, etc.).
|
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.
|
spinner_speed (float): Spinner speed multiplier.
|
||||||
hooks (HookManager | None): Hook manager for lifecycle events.
|
hooks (HookManager | None): Hook manager for lifecycle events.
|
||||||
tags (list[str], optional): Tags for grouping and filtering.
|
tags (list[str], optional): Tags for grouping and filtering.
|
||||||
@@ -150,6 +151,8 @@ class Command(BaseModel):
|
|||||||
Override help rendering.
|
Override help rendering.
|
||||||
custom_tldr (Callable[[], str | None] | None):
|
custom_tldr (Callable[[], str | None] | None):
|
||||||
Override TLDR rendering.
|
Override TLDR rendering.
|
||||||
|
custom_usage (Callable[[], str | None] | None):
|
||||||
|
Override usage rendering.
|
||||||
auto_args (bool): Auto-generate arguments from action signature.
|
auto_args (bool): Auto-generate arguments from action signature.
|
||||||
arg_metadata (dict[str, Any], optional): Metadata for arguments.
|
arg_metadata (dict[str, Any], optional): Metadata for arguments.
|
||||||
simple_help_signature (bool): Use simplified help formatting.
|
simple_help_signature (bool): Use simplified help formatting.
|
||||||
@@ -179,14 +182,14 @@ class Command(BaseModel):
|
|||||||
aliases: list[str] = Field(default_factory=list)
|
aliases: list[str] = Field(default_factory=list)
|
||||||
help_text: str = ""
|
help_text: str = ""
|
||||||
help_epilog: str = ""
|
help_epilog: str = ""
|
||||||
style: str = OneColors.WHITE
|
style: Style | str = OneColors.WHITE
|
||||||
confirm: bool = False
|
confirm: bool = False
|
||||||
confirm_message: str = "Are you sure?"
|
confirm_message: str = "Are you sure?"
|
||||||
preview_before_confirm: bool = True
|
preview_before_confirm: bool = True
|
||||||
spinner: bool = False
|
spinner: bool = False
|
||||||
spinner_message: str = "Processing..."
|
spinner_message: str = "Processing..."
|
||||||
spinner_type: str = "dots"
|
spinner_type: str = "dots"
|
||||||
spinner_style: str = OneColors.CYAN
|
spinner_style: Style | str = OneColors.CYAN
|
||||||
spinner_speed: float = 1.0
|
spinner_speed: float = 1.0
|
||||||
hooks: "HookManager" = Field(default_factory=HookManager)
|
hooks: "HookManager" = Field(default_factory=HookManager)
|
||||||
retry: bool = False
|
retry: bool = False
|
||||||
@@ -200,8 +203,9 @@ class Command(BaseModel):
|
|||||||
arguments: list[dict[str, Any]] = Field(default_factory=list)
|
arguments: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
argument_config: Callable[[CommandArgumentParser], None] | None = None
|
argument_config: Callable[[CommandArgumentParser], None] | None = None
|
||||||
custom_parser: ArgParserProtocol | None = None
|
custom_parser: ArgParserProtocol | None = None
|
||||||
custom_help: Callable[[], None] | None = None
|
custom_help: Callable[[], str | None] | None = None
|
||||||
custom_tldr: Callable[[], None] | None = None
|
custom_tldr: Callable[[], str | None] | None = None
|
||||||
|
custom_usage: Callable[[], str | None] | None = None
|
||||||
auto_args: bool = True
|
auto_args: bool = True
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
|
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
|
||||||
simple_help_signature: bool = False
|
simple_help_signature: bool = False
|
||||||
@@ -482,6 +486,13 @@ class Command(BaseModel):
|
|||||||
|
|
||||||
return FormattedText(prompt)
|
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
|
@property
|
||||||
def usage(self) -> str:
|
def usage(self) -> str:
|
||||||
"""Generate a help string for the command arguments."""
|
"""Generate a help string for the command arguments."""
|
||||||
@@ -527,7 +538,7 @@ class Command(BaseModel):
|
|||||||
- Formatting may vary depending on CLI vs menu mode.
|
- Formatting may vary depending on CLI vs menu mode.
|
||||||
"""
|
"""
|
||||||
if self.arg_parser and not self.simple_help_signature:
|
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]"
|
description = f"[dim]{self.help_text or self.description}[/dim]"
|
||||||
if self.tags:
|
if self.tags:
|
||||||
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
|
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
|
||||||
@@ -549,6 +560,18 @@ class Command(BaseModel):
|
|||||||
if self._context:
|
if self._context:
|
||||||
self._context.log_summary()
|
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:
|
def render_help(self, invocation_context: InvocationContext | None = None) -> bool:
|
||||||
"""Display the help message for the command."""
|
"""Display the help message for the command."""
|
||||||
if callable(self.custom_help):
|
if callable(self.custom_help):
|
||||||
@@ -557,7 +580,7 @@ class Command(BaseModel):
|
|||||||
console.print(output)
|
console.print(output)
|
||||||
return True
|
return True
|
||||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -569,7 +592,7 @@ class Command(BaseModel):
|
|||||||
console.print(output)
|
console.print(output)
|
||||||
return True
|
return True
|
||||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -617,14 +640,14 @@ class Command(BaseModel):
|
|||||||
aliases: list[str] | None = None,
|
aliases: list[str] | None = None,
|
||||||
help_text: str = "",
|
help_text: str = "",
|
||||||
help_epilog: str = "",
|
help_epilog: str = "",
|
||||||
style: str = OneColors.WHITE,
|
style: Style | str = OneColors.WHITE,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
confirm_message: str = "Are you sure?",
|
confirm_message: str = "Are you sure?",
|
||||||
preview_before_confirm: bool = True,
|
preview_before_confirm: bool = True,
|
||||||
spinner: bool = False,
|
spinner: bool = False,
|
||||||
spinner_message: str = "Processing...",
|
spinner_message: str = "Processing...",
|
||||||
spinner_type: str = "dots",
|
spinner_type: str = "dots",
|
||||||
spinner_style: str = OneColors.CYAN,
|
spinner_style: Style | str = OneColors.CYAN,
|
||||||
spinner_speed: float = 1.0,
|
spinner_speed: float = 1.0,
|
||||||
options_manager: OptionsManager | None = None,
|
options_manager: OptionsManager | None = None,
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
@@ -645,6 +668,7 @@ class Command(BaseModel):
|
|||||||
custom_parser: ArgParserProtocol | None = None,
|
custom_parser: ArgParserProtocol | None = None,
|
||||||
custom_help: Callable[[], str | None] | None = None,
|
custom_help: Callable[[], str | None] | None = None,
|
||||||
custom_tldr: Callable[[], str | None] | None = None,
|
custom_tldr: Callable[[], str | None] | None = None,
|
||||||
|
custom_usage: Callable[[], str | None] | None = None,
|
||||||
auto_args: bool = True,
|
auto_args: bool = True,
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
simple_help_signature: bool = False,
|
simple_help_signature: bool = False,
|
||||||
@@ -679,14 +703,14 @@ class Command(BaseModel):
|
|||||||
aliases (list[str] | None): Optional alternate names for invocation.
|
aliases (list[str] | None): Optional alternate names for invocation.
|
||||||
help_text (str): Help text shown in command help output.
|
help_text (str): Help text shown in command help output.
|
||||||
help_epilog (str): Additional help text shown after the main help body.
|
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 (bool): Whether confirmation is required before execution.
|
||||||
confirm_message (str): Confirmation prompt text.
|
confirm_message (str): Confirmation prompt text.
|
||||||
preview_before_confirm (bool): Whether to preview before confirmation.
|
preview_before_confirm (bool): Whether to preview before confirmation.
|
||||||
spinner (bool): Whether to enable spinner lifecycle hooks.
|
spinner (bool): Whether to enable spinner lifecycle hooks.
|
||||||
spinner_message (str): Spinner message text.
|
spinner_message (str): Spinner message text.
|
||||||
spinner_type (str): Spinner animation type.
|
spinner_type (str): Spinner animation type.
|
||||||
spinner_style (str): Spinner style.
|
spinner_style (Style | str): Spinner style.
|
||||||
spinner_speed (float): Spinner speed multiplier.
|
spinner_speed (float): Spinner speed multiplier.
|
||||||
options_manager (OptionsManager | None): Shared options manager for the
|
options_manager (OptionsManager | None): Shared options manager for the
|
||||||
command and its parser.
|
command and its parser.
|
||||||
@@ -721,6 +745,8 @@ class Command(BaseModel):
|
|||||||
renderer.
|
renderer.
|
||||||
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
||||||
renderer.
|
renderer.
|
||||||
|
custom_usage (Callable[[], str | None] | None): Optional custom usage
|
||||||
|
renderer.
|
||||||
auto_args (bool): Whether to infer arguments automatically from the action
|
auto_args (bool): Whether to infer arguments automatically from the action
|
||||||
signature when explicit definitions are not provided.
|
signature when explicit definitions are not provided.
|
||||||
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
|
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
|
||||||
@@ -735,8 +761,8 @@ class Command(BaseModel):
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotAFalyxError: If `arg_parser` is provided but is not a
|
NotAFalyxError: If `arg_parser` is provided but is not a
|
||||||
`CommandArgumentParser` instance, or if `hooks` is provided but is not
|
`CommandArgumentParser` instance.
|
||||||
a `HookManager` instance.
|
InvalidHookError: If `hooks` is provided but is not a `HookManager` instance.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Execution options supplied as strings are converted to
|
- Execution options supplied as strings are converted to
|
||||||
@@ -757,7 +783,7 @@ class Command(BaseModel):
|
|||||||
options_manager = options_manager or OptionsManager()
|
options_manager = options_manager or OptionsManager()
|
||||||
|
|
||||||
if hooks and not isinstance(hooks, HookManager):
|
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()
|
hooks = hooks or HookManager()
|
||||||
|
|
||||||
if retry_policy and not isinstance(retry_policy, RetryPolicy):
|
if retry_policy and not isinstance(retry_policy, RetryPolicy):
|
||||||
@@ -805,6 +831,7 @@ class Command(BaseModel):
|
|||||||
custom_parser=custom_parser,
|
custom_parser=custom_parser,
|
||||||
custom_help=custom_help,
|
custom_help=custom_help,
|
||||||
custom_tldr=custom_tldr,
|
custom_tldr=custom_tldr,
|
||||||
|
custom_usage=custom_usage,
|
||||||
auto_args=auto_args,
|
auto_args=auto_args,
|
||||||
arg_metadata=arg_metadata or {},
|
arg_metadata=arg_metadata or {},
|
||||||
simple_help_signature=simple_help_signature,
|
simple_help_signature=simple_help_signature,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Design Notes:
|
|||||||
duplication across Falyx runtime entrypoints.
|
duplication across Falyx runtime entrypoints.
|
||||||
|
|
||||||
Typical Usage:
|
Typical Usage:
|
||||||
executor = CommandExecutor(options=options, hooks=hooks, console=console)
|
executor = CommandExecutor(options=options, hooks=hooks)
|
||||||
result = await executor.execute(
|
result = await executor.execute(
|
||||||
command=command,
|
command=command,
|
||||||
args=args,
|
args=args,
|
||||||
@@ -51,8 +51,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from falyx.action import Action
|
from falyx.action import Action
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
from falyx.context import ExecutionContext
|
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.hook_manager import HookManager, HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.themes import OneColors
|
|
||||||
|
|
||||||
|
|
||||||
class CommandExecutor:
|
class CommandExecutor:
|
||||||
@@ -81,15 +78,13 @@ class CommandExecutor:
|
|||||||
- Apply scoped runtime overrides using `OptionsManager`
|
- Apply scoped runtime overrides using `OptionsManager`
|
||||||
- Trigger executor-level hooks before and after command execution
|
- Trigger executor-level hooks before and after command execution
|
||||||
- Create and manage an executor-level `ExecutionContext`
|
- Create and manage an executor-level `ExecutionContext`
|
||||||
- Render execution errors to the configured console
|
- Control whether errors are raised or wrapped
|
||||||
- Control whether errors are raised, wrapped, or suppressed
|
|
||||||
- Emit optional execution summaries
|
- Emit optional execution summaries
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
options (OptionsManager): Shared options manager used to apply scoped
|
options (OptionsManager): Shared options manager used to apply scoped
|
||||||
execution overrides.
|
execution overrides.
|
||||||
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
|
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
|
||||||
console (Console): Rich console used for user-facing error output.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -97,11 +92,9 @@ class CommandExecutor:
|
|||||||
*,
|
*,
|
||||||
options: OptionsManager,
|
options: OptionsManager,
|
||||||
hooks: HookManager,
|
hooks: HookManager,
|
||||||
console: Console,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.options = options
|
self.options = options
|
||||||
self.hooks = hooks
|
self.hooks = hooks
|
||||||
self.console = console
|
|
||||||
|
|
||||||
def _debug_hooks(self, command: Command) -> None:
|
def _debug_hooks(self, command: Command) -> None:
|
||||||
"""Log executor-level and command-level hook registrations for debugging.
|
"""Log executor-level and command-level hook registrations for debugging.
|
||||||
@@ -112,7 +105,7 @@ class CommandExecutor:
|
|||||||
Args:
|
Args:
|
||||||
command (Command): The command about to be executed.
|
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))
|
logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks))
|
||||||
|
|
||||||
def _apply_retry_overrides(
|
def _apply_retry_overrides(
|
||||||
@@ -164,7 +157,7 @@ class CommandExecutor:
|
|||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[%s] Retry requested, but action is not an Action instance.",
|
"[%s] Retry requested, but action is not an Action instance.",
|
||||||
command.description,
|
command.key,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _execution_option_overrides(
|
def _execution_option_overrides(
|
||||||
@@ -189,30 +182,6 @@ class CommandExecutor:
|
|||||||
"skip_confirm": execution_args.get("skip_confirm", False),
|
"skip_confirm": execution_args.get("skip_confirm", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _handle_action_error(
|
|
||||||
self, selected_command: Command, error: Exception
|
|
||||||
) -> None:
|
|
||||||
"""Render and log a command execution error.
|
|
||||||
|
|
||||||
This helper logs the full exception details for debugging and prints a
|
|
||||||
user-facing error message to the configured console.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
selected_command (Command): The command that failed.
|
|
||||||
error (Exception): The exception raised during command execution.
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
"[%s] '%s' failed with error: %s",
|
|
||||||
selected_command.key,
|
|
||||||
selected_command.description,
|
|
||||||
error,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
self.console.print(
|
|
||||||
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
|
||||||
f"{selected_command.description}:[/] {error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -277,6 +246,11 @@ class CommandExecutor:
|
|||||||
- Summary output is only emitted when the `summary` execution option is
|
- Summary output is only emitted when the `summary` execution option is
|
||||||
present in `execution_args`.
|
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._debug_hooks(command)
|
||||||
self._apply_retry_overrides(command, execution_args)
|
self._apply_retry_overrides(command, execution_args)
|
||||||
overrides = self._execution_option_overrides(execution_args)
|
overrides = self._execution_option_overrides(execution_args)
|
||||||
@@ -307,23 +281,24 @@ class CommandExecutor:
|
|||||||
except (KeyboardInterrupt, EOFError) as error:
|
except (KeyboardInterrupt, EOFError) as error:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[execute] '%s' interrupted by user.",
|
"[execute] '%s' interrupted by user.",
|
||||||
command.description,
|
command.key,
|
||||||
)
|
)
|
||||||
if wrap_errors:
|
if wrap_errors:
|
||||||
raise FalyxError(
|
raise FalyxError(
|
||||||
f"[execute] ⚠️ '{command.description}' interrupted by user."
|
f"[execute] '{command.key}' interrupted by user."
|
||||||
) from error
|
) from error
|
||||||
if raise_on_error:
|
|
||||||
raise error
|
raise error
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
logger.debug(
|
||||||
|
"[execute] '%s' failed: %s",
|
||||||
|
command.key,
|
||||||
|
error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
context.exception = error
|
context.exception = error
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
await self._handle_action_error(command, error)
|
|
||||||
if wrap_errors:
|
if wrap_errors:
|
||||||
raise FalyxError(
|
raise FalyxError(f"[execute] '{command.key}' failed: {error}") from error
|
||||||
f"[execute] '{command.description}' failed: {error}"
|
|
||||||
) from error
|
|
||||||
if raise_on_error:
|
|
||||||
raise error
|
raise error
|
||||||
finally:
|
finally:
|
||||||
context.stop_timer()
|
context.stop_timer()
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ from falyx.action import BaseAction
|
|||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
from falyx.command_executor import CommandExecutor
|
from falyx.command_executor import CommandExecutor
|
||||||
from falyx.console import console as falyx_console
|
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.execution_option import ExecutionOption
|
||||||
from falyx.hook_manager import HookManager
|
from falyx.hook_manager import HookManager
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
@@ -85,6 +91,7 @@ class CommandRunner:
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
command (Command): The command executed by this runner.
|
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,
|
options (OptionsManager): Shared options manager used by the command,
|
||||||
parser, and executor.
|
parser, and executor.
|
||||||
runner_hooks (HookManager): Executor-level hooks used during execution.
|
runner_hooks (HookManager): Executor-level hooks used during execution.
|
||||||
@@ -97,6 +104,7 @@ class CommandRunner:
|
|||||||
self,
|
self,
|
||||||
command: Command,
|
command: Command,
|
||||||
*,
|
*,
|
||||||
|
program: str | None = None,
|
||||||
options: OptionsManager | None = None,
|
options: OptionsManager | None = None,
|
||||||
runner_hooks: HookManager | None = None,
|
runner_hooks: HookManager | None = None,
|
||||||
console: Console | None = None,
|
console: Console | None = None,
|
||||||
@@ -109,6 +117,9 @@ class CommandRunner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
command (Command): The command to execute.
|
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
|
options (OptionsManager | None): Optional shared options manager. If
|
||||||
omitted, a new `OptionsManager` is created.
|
omitted, a new `OptionsManager` is created.
|
||||||
runner_hooks (HookManager | None): Optional executor-level hook manager. If
|
runner_hooks (HookManager | None): Optional executor-level hook manager. If
|
||||||
@@ -117,16 +128,22 @@ class CommandRunner:
|
|||||||
the default Falyx console is used.
|
the default Falyx console is used.
|
||||||
"""
|
"""
|
||||||
self.command = command
|
self.command = command
|
||||||
|
self.program = program or ""
|
||||||
self.options = self._get_options(options)
|
self.options = self._get_options(options)
|
||||||
self.runner_hooks = self._get_hooks(runner_hooks)
|
self.runner_hooks = self._get_hooks(runner_hooks)
|
||||||
self.console = self._get_console(console)
|
self.console = self._get_console(console)
|
||||||
|
self.error_console = error_console
|
||||||
self.command.options_manager = self.options
|
self.command.options_manager = self.options
|
||||||
|
if program:
|
||||||
|
self.command.program = program
|
||||||
if isinstance(self.command.arg_parser, CommandArgumentParser):
|
if isinstance(self.command.arg_parser, CommandArgumentParser):
|
||||||
self.command.arg_parser.set_options_manager(self.options)
|
self.command.arg_parser.set_options_manager(self.options)
|
||||||
|
self.command.arg_parser.is_runner_mode = True
|
||||||
|
if program:
|
||||||
|
self.command.arg_parser.program = program
|
||||||
self.executor = CommandExecutor(
|
self.executor = CommandExecutor(
|
||||||
options=self.options,
|
options=self.options,
|
||||||
hooks=self.runner_hooks,
|
hooks=self.runner_hooks,
|
||||||
console=self.console,
|
|
||||||
)
|
)
|
||||||
self.options.from_mapping(values={}, namespace_name="execution")
|
self.options.from_mapping(values={}, namespace_name="execution")
|
||||||
|
|
||||||
@@ -152,7 +169,7 @@ class CommandRunner:
|
|||||||
elif isinstance(hooks, HookManager):
|
elif isinstance(hooks, HookManager):
|
||||||
return hooks
|
return hooks
|
||||||
else:
|
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(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -253,10 +270,10 @@ class CommandRunner:
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except CommandArgumentError as error:
|
except CommandArgumentError as error:
|
||||||
self.command.render_help()
|
self.command.render_help()
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{self.command.key}'] {error}")
|
print_error(message=error)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
except FalyxError as error:
|
except FalyxError as error:
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
print_error(message=error)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except QuitSignal:
|
except QuitSignal:
|
||||||
logger.info("[QuitSignal]. <- Exiting run.")
|
logger.info("[QuitSignal]. <- Exiting run.")
|
||||||
@@ -276,6 +293,7 @@ class CommandRunner:
|
|||||||
cls,
|
cls,
|
||||||
command: Command,
|
command: Command,
|
||||||
*,
|
*,
|
||||||
|
program: str | None = None,
|
||||||
runner_hooks: HookManager | None = None,
|
runner_hooks: HookManager | None = None,
|
||||||
options: OptionsManager | None = None,
|
options: OptionsManager | None = None,
|
||||||
console: Console | None = None,
|
console: Console | None = None,
|
||||||
@@ -288,6 +306,9 @@ class CommandRunner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
command (Command): Existing command instance to wrap.
|
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
|
runner_hooks (HookManager | None): Optional executor-level hook manager
|
||||||
for the runner.
|
for the runner.
|
||||||
options (OptionsManager | None): Optional shared options manager.
|
options (OptionsManager | None): Optional shared options manager.
|
||||||
@@ -303,9 +324,10 @@ class CommandRunner:
|
|||||||
if not isinstance(command, Command):
|
if not isinstance(command, Command):
|
||||||
raise NotAFalyxError("command must be an instance of Command.")
|
raise NotAFalyxError("command must be an instance of Command.")
|
||||||
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
||||||
raise NotAFalyxError("runner_hooks must be an instance of HookManager.")
|
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
|
||||||
return cls(
|
return cls(
|
||||||
command=command,
|
command=command,
|
||||||
|
program=program,
|
||||||
options=options,
|
options=options,
|
||||||
runner_hooks=runner_hooks,
|
runner_hooks=runner_hooks,
|
||||||
console=console,
|
console=console,
|
||||||
@@ -318,6 +340,7 @@ class CommandRunner:
|
|||||||
description: str,
|
description: str,
|
||||||
action: BaseAction | Callable[..., Any],
|
action: BaseAction | Callable[..., Any],
|
||||||
*,
|
*,
|
||||||
|
program: str | None = None,
|
||||||
runner_hooks: HookManager | None = None,
|
runner_hooks: HookManager | None = None,
|
||||||
args: tuple = (),
|
args: tuple = (),
|
||||||
kwargs: dict[str, Any] | None = None,
|
kwargs: dict[str, Any] | None = None,
|
||||||
@@ -352,6 +375,8 @@ class CommandRunner:
|
|||||||
execution_options: list[ExecutionOption | str] | None = None,
|
execution_options: list[ExecutionOption | str] | None = None,
|
||||||
custom_parser: ArgParserProtocol | None = None,
|
custom_parser: ArgParserProtocol | None = None,
|
||||||
custom_help: Callable[[], str | None] | None = None,
|
custom_help: Callable[[], str | None] | None = None,
|
||||||
|
custom_tldr: Callable[[], str | None] | None = None,
|
||||||
|
custom_usage: Callable[[], str | None] | None = None,
|
||||||
auto_args: bool = True,
|
auto_args: bool = True,
|
||||||
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
|
||||||
simple_help_signature: bool = False,
|
simple_help_signature: bool = False,
|
||||||
@@ -369,6 +394,9 @@ class CommandRunner:
|
|||||||
description (str): Short description of the command.
|
description (str): Short description of the command.
|
||||||
action (BaseAction | Callable[..., Any]): Underlying execution logic for
|
action (BaseAction | Callable[..., Any]): Underlying execution logic for
|
||||||
the command.
|
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_hooks (HookManager | None): Optional executor-level hooks for the
|
||||||
runner.
|
runner.
|
||||||
args (tuple): Static positional arguments applied to the command.
|
args (tuple): Static positional arguments applied to the command.
|
||||||
@@ -418,6 +446,10 @@ class CommandRunner:
|
|||||||
implementation.
|
implementation.
|
||||||
custom_help (Callable[[], str | None] | None): Optional custom help
|
custom_help (Callable[[], str | None] | None): Optional custom help
|
||||||
renderer.
|
renderer.
|
||||||
|
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
|
||||||
|
renderer.
|
||||||
|
custom_usage (Callable[[], str | None] | None): Optional custom usage
|
||||||
|
renderer.
|
||||||
auto_args (bool): Whether to infer arguments automatically from the
|
auto_args (bool): Whether to infer arguments automatically from the
|
||||||
action signature.
|
action signature.
|
||||||
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional
|
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional
|
||||||
@@ -432,8 +464,9 @@ class CommandRunner:
|
|||||||
CommandRunner: A runner wrapping the newly built command.
|
CommandRunner: A runner wrapping the newly built command.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotAFalyxError: If `runner_hooks` is provided but is not a
|
NotAFalyxError: If `arg_parser` is provided but is not a
|
||||||
`HookManager` instance.
|
`CommandArgumentParser` instance.
|
||||||
|
InvalidHookError: If `runner_hooks` is provided but is not a `HookManager`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- This method is intended as a standalone convenience factory.
|
- This method is intended as a standalone convenience factory.
|
||||||
@@ -445,6 +478,7 @@ class CommandRunner:
|
|||||||
key=key,
|
key=key,
|
||||||
description=description,
|
description=description,
|
||||||
action=action,
|
action=action,
|
||||||
|
program=program,
|
||||||
args=args,
|
args=args,
|
||||||
kwargs=kwargs,
|
kwargs=kwargs,
|
||||||
hidden=hidden,
|
hidden=hidden,
|
||||||
@@ -478,6 +512,8 @@ class CommandRunner:
|
|||||||
argument_config=argument_config,
|
argument_config=argument_config,
|
||||||
custom_parser=custom_parser,
|
custom_parser=custom_parser,
|
||||||
custom_help=custom_help,
|
custom_help=custom_help,
|
||||||
|
custom_tldr=custom_tldr,
|
||||||
|
custom_usage=custom_usage,
|
||||||
auto_args=auto_args,
|
auto_args=auto_args,
|
||||||
arg_metadata=arg_metadata,
|
arg_metadata=arg_metadata,
|
||||||
simple_help_signature=simple_help_signature,
|
simple_help_signature=simple_help_signature,
|
||||||
@@ -485,7 +521,7 @@ class CommandRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
if runner_hooks and not isinstance(runner_hooks, HookManager):
|
||||||
raise NotAFalyxError("runner_hooks must be an instance of HookManager.")
|
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
command=command,
|
command=command,
|
||||||
|
|||||||
@@ -140,12 +140,11 @@ class FalyxCompleter(Completer):
|
|||||||
suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
|
suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
|
||||||
|
|
||||||
# Only here should namespace-level help/TLDR be suggested.
|
# Only here should namespace-level help/TLDR be suggested.
|
||||||
if not route.command and (not route.stub or route.stub.startswith("-")):
|
# TODO: better completer in FalyxParser
|
||||||
suggestions.extend(
|
if not route.command: # and (not route.stub or route.stub.startswith("-")):
|
||||||
flag
|
for flag in route.namespace.parser._options_by_dest:
|
||||||
for flag in ("-h", "--help", "-T", "--tldr")
|
if flag.startswith(route.stub):
|
||||||
if flag.startswith(route.stub)
|
suggestions.append(flag)
|
||||||
)
|
|
||||||
|
|
||||||
if route.is_preview:
|
if route.is_preview:
|
||||||
suggestions = [f"?{s}" for s in suggestions]
|
suggestions = [f"?{s}" for s in suggestions]
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
"""Global console instance for Falyx CLI applications."""
|
"""Global console instance for Falyx CLI applications."""
|
||||||
from rich.console import Console
|
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())
|
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
|
- `SharedContext` for transient shared state across grouped or chained
|
||||||
actions, including propagated results, indexed errors, and arbitrary
|
actions, including propagated results, indexed errors, and arbitrary
|
||||||
shared data.
|
shared data.
|
||||||
- `InvocationSegment` for representing a single styled token within a
|
|
||||||
rendered invocation path.
|
|
||||||
- `InvocationContext` for capturing the current routed command path as an
|
- `InvocationContext` for capturing the current routed command path as an
|
||||||
immutable value object that supports both plain-text and Rich-markup
|
immutable value object that supports both plain-text and Rich-markup
|
||||||
rendering.
|
rendering.
|
||||||
@@ -30,8 +28,10 @@ from typing import Any
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
|
from rich.style import Style
|
||||||
|
|
||||||
from falyx.console import console
|
from falyx.console import console
|
||||||
|
from falyx.display_types import StyledSegment
|
||||||
from falyx.mode import FalyxMode
|
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):
|
class InvocationContext(BaseModel):
|
||||||
"""Immutable invocation-path context for routed Falyx help and execution.
|
"""Immutable invocation-path context for routed Falyx help and execution.
|
||||||
|
|
||||||
@@ -324,11 +306,11 @@ class InvocationContext(BaseModel):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
program (str): Root program name used in CLI-mode help and usage output.
|
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`.
|
`markup_path`.
|
||||||
typed_path (list[str]): Raw invocation tokens collected during routing,
|
typed_path (list[str]): Raw invocation tokens collected during routing,
|
||||||
excluding the root program name.
|
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.
|
invocation path with Rich markup.
|
||||||
mode (FalyxMode): Active Falyx mode for this invocation context. This is
|
mode (FalyxMode): Active Falyx mode for this invocation context. This is
|
||||||
used to determine whether the path should include the program name.
|
used to determine whether the path should include the program name.
|
||||||
@@ -337,12 +319,14 @@ class InvocationContext(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
program: str = ""
|
program: str = ""
|
||||||
program_style: str = ""
|
program_style: Style | str = ""
|
||||||
typed_path: list[str] = Field(default_factory=list)
|
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
|
mode: FalyxMode = FalyxMode.MENU
|
||||||
is_preview: bool = False
|
is_preview: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cli_mode(self) -> bool:
|
def is_cli_mode(self) -> bool:
|
||||||
"""Whether this context should render using CLI path semantics.
|
"""Whether this context should render using CLI path semantics.
|
||||||
@@ -357,7 +341,7 @@ class InvocationContext(BaseModel):
|
|||||||
self,
|
self,
|
||||||
token: str,
|
token: str,
|
||||||
*,
|
*,
|
||||||
style: str | None = None,
|
style: Style | str | None = None,
|
||||||
) -> InvocationContext:
|
) -> InvocationContext:
|
||||||
"""Return a new context with one additional path segment appended.
|
"""Return a new context with one additional path segment appended.
|
||||||
|
|
||||||
@@ -377,7 +361,7 @@ class InvocationContext(BaseModel):
|
|||||||
program=self.program,
|
program=self.program,
|
||||||
program_style=self.program_style,
|
program_style=self.program_style,
|
||||||
typed_path=[*self.typed_path, token],
|
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,
|
mode=self.mode,
|
||||||
is_preview=self.is_preview,
|
is_preview=self.is_preview,
|
||||||
)
|
)
|
||||||
@@ -427,7 +411,7 @@ class InvocationContext(BaseModel):
|
|||||||
|
|
||||||
In CLI mode, the root program name is included and styled with
|
In CLI mode, the root program name is included and styled with
|
||||||
`program_style` when provided. Each path segment is escaped and styled
|
`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:
|
Returns:
|
||||||
str: Rich-markup invocation path suitable for help and usage rendering.
|
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
|
├── EmptyChainError
|
||||||
├── EmptyGroupError
|
├── EmptyGroupError
|
||||||
├── EmptyPoolError
|
├── EmptyPoolError
|
||||||
└── CommandArgumentError
|
├── CommandArgumentError
|
||||||
|
└── EntryNotFoundError
|
||||||
|
|
||||||
These are raised internally throughout the Falyx system to signal user-facing or
|
These are raised internally throughout the Falyx system to signal user-facing or
|
||||||
developer-facing problems that should be caught and reported.
|
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):
|
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):
|
class CommandAlreadyExistsError(FalyxError):
|
||||||
@@ -60,5 +70,152 @@ class EmptyPoolError(FalyxError):
|
|||||||
"""Exception raised when the pool is empty."""
|
"""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."""
|
"""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
|
||||||
|
|||||||
914
falyx/falyx.py
914
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 dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from rich.style import StyleType
|
||||||
|
|
||||||
|
from falyx.context import InvocationContext
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -35,15 +38,15 @@ class FalyxNamespace:
|
|||||||
resolution, completion, help output, and menu rendering.
|
resolution, completion, help output, and menu rendering.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
key: Primary identifier used to enter the namespace.
|
key (str): Primary identifier used to enter the namespace.
|
||||||
description: User-facing description of the namespace.
|
description (str): User-facing namespace description.
|
||||||
namespace: Nested `Falyx` instance activated when this namespace is
|
namespace (Falyx): Nested `Falyx` instance activated when this namespace is
|
||||||
selected.
|
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.
|
namespace.
|
||||||
help_text: Optional short help text used in listings or help output.
|
help_text (str): Optional short help text used in listings or help output.
|
||||||
style: Rich style used when rendering the namespace key or aliases.
|
style (StyleType): Rich style used when rendering the namespace key or aliases.
|
||||||
hidden: Whether the namespace should be omitted from visible menus and
|
hidden (bool): Whether the namespace should be omitted from visible menus and
|
||||||
help listings.
|
help listings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -52,5 +55,14 @@ class FalyxNamespace:
|
|||||||
namespace: Falyx
|
namespace: Falyx
|
||||||
aliases: list[str] = field(default_factory=list)
|
aliases: list[str] = field(default_factory=list)
|
||||||
help_text: str = ""
|
help_text: str = ""
|
||||||
style: str = OneColors.CYAN
|
style: StyleType = OneColors.CYAN
|
||||||
hidden: bool = False
|
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
|
# 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,
|
This module defines `OptionsManager`, a small utility responsible for
|
||||||
and introspecting options defined in `argparse.Namespace` objects. It is used internally
|
storing, retrieving, and temporarily overriding runtime option values across
|
||||||
by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc.
|
named namespaces.
|
||||||
|
|
||||||
Each option is stored under a namespace key (e.g., "default", "user_config") to
|
Falyx uses this manager to hold global session- and execution-scoped flags such
|
||||||
support multiple sources of configuration.
|
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:
|
In addition to basic get/set operations, the manager provides helpers for:
|
||||||
- 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`
|
|
||||||
|
|
||||||
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 = OptionsManager()
|
||||||
options.from_namespace(args, namespace_name="default")
|
options.from_mapping({"verbose": True})
|
||||||
if options.get("verbose"):
|
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:
|
with options.override_namespace({"skip_confirm": True}, "execution"):
|
||||||
- Falyx CLI runtime configuration
|
...
|
||||||
- Bottom bar toggles
|
```
|
||||||
- Dynamic flag injection into commands and actions
|
|
||||||
|
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 collections import defaultdict
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@@ -37,17 +44,40 @@ from falyx.spinner_manager import SpinnerManager
|
|||||||
|
|
||||||
|
|
||||||
class OptionsManager:
|
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
|
`OptionsManager` is the central store for Falyx runtime flags. Each option
|
||||||
options. Supports named namespaces (e.g., "default") and is used throughout
|
is stored under a namespace name such as `"default"` or `"execution"`,
|
||||||
Falyx for runtime configuration and bottom bar toggle integration.
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
namespaces: list[tuple[str, dict[str, Any]]] | None = None,
|
namespaces: list[tuple[str, dict[str, Any]]] | None = 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.options: defaultdict = defaultdict(dict)
|
||||||
self.spinners = SpinnerManager()
|
self.spinners = SpinnerManager()
|
||||||
if namespaces:
|
if namespaces:
|
||||||
@@ -59,7 +89,16 @@ class OptionsManager:
|
|||||||
values: Mapping[str, Any],
|
values: Mapping[str, Any],
|
||||||
namespace_name: str = "default",
|
namespace_name: str = "default",
|
||||||
) -> None:
|
) -> 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))
|
self.options[namespace_name].update(dict(values))
|
||||||
|
|
||||||
def get(
|
def get(
|
||||||
@@ -68,7 +107,18 @@ class OptionsManager:
|
|||||||
default: Any = None,
|
default: Any = None,
|
||||||
namespace_name: str = "default",
|
namespace_name: str = "default",
|
||||||
) -> Any:
|
) -> 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)
|
return self.options[namespace_name].get(option_name, default)
|
||||||
|
|
||||||
def set(
|
def set(
|
||||||
@@ -77,7 +127,13 @@ class OptionsManager:
|
|||||||
value: Any,
|
value: Any,
|
||||||
namespace_name: str = "default",
|
namespace_name: str = "default",
|
||||||
) -> None:
|
) -> 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
|
self.options[namespace_name][option_name] = value
|
||||||
|
|
||||||
def has_option(
|
def has_option(
|
||||||
@@ -85,7 +141,16 @@ class OptionsManager:
|
|||||||
option_name: str,
|
option_name: str,
|
||||||
namespace_name: str = "default",
|
namespace_name: str = "default",
|
||||||
) -> bool:
|
) -> 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]
|
return option_name in self.options[namespace_name]
|
||||||
|
|
||||||
def toggle(
|
def toggle(
|
||||||
@@ -93,7 +158,16 @@ class OptionsManager:
|
|||||||
option_name: str,
|
option_name: str,
|
||||||
namespace_name: str = "default",
|
namespace_name: str = "default",
|
||||||
) -> None:
|
) -> 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)
|
current = self.get(option_name, namespace_name=namespace_name)
|
||||||
if not isinstance(current, bool):
|
if not isinstance(current, bool):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
@@ -109,7 +183,20 @@ class OptionsManager:
|
|||||||
option_name: str,
|
option_name: str,
|
||||||
namespace_name: str = "default",
|
namespace_name: str = "default",
|
||||||
) -> Callable[[], Any]:
|
) -> 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:
|
def _getter() -> Any:
|
||||||
return self.get(option_name, namespace_name=namespace_name)
|
return self.get(option_name, namespace_name=namespace_name)
|
||||||
@@ -121,7 +208,19 @@ class OptionsManager:
|
|||||||
option_name: str,
|
option_name: str,
|
||||||
namespace_name: str = "default",
|
namespace_name: str = "default",
|
||||||
) -> Callable[[], None]:
|
) -> 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:
|
def _toggle() -> None:
|
||||||
self.toggle(option_name, namespace_name=namespace_name)
|
self.toggle(option_name, namespace_name=namespace_name)
|
||||||
@@ -129,7 +228,17 @@ class OptionsManager:
|
|||||||
return _toggle
|
return _toggle
|
||||||
|
|
||||||
def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]:
|
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:
|
if namespace_name not in self.options:
|
||||||
raise ValueError(f"Namespace '{namespace_name}' not found.")
|
raise ValueError(f"Namespace '{namespace_name}' not found.")
|
||||||
return dict(self.options[namespace_name])
|
return dict(self.options[namespace_name])
|
||||||
@@ -140,7 +249,24 @@ class OptionsManager:
|
|||||||
overrides: Mapping[str, Any],
|
overrides: Mapping[str, Any],
|
||||||
namespace_name: str = "execution",
|
namespace_name: str = "execution",
|
||||||
) -> Iterator[None]:
|
) -> 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)
|
original = self.get_namespace_dict(namespace_name)
|
||||||
try:
|
try:
|
||||||
self.from_mapping(values=overrides, namespace_name=namespace_name)
|
self.from_mapping(values=overrides, namespace_name=namespace_name)
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ from .argument import Argument
|
|||||||
from .argument_action import ArgumentAction
|
from .argument_action import ArgumentAction
|
||||||
from .command_argument_parser import CommandArgumentParser
|
from .command_argument_parser import CommandArgumentParser
|
||||||
from .falyx_parser import FalyxParser
|
from .falyx_parser import FalyxParser
|
||||||
from .parse_result import RootParseResult
|
from .parse_result import ParseResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Argument",
|
"Argument",
|
||||||
"ArgumentAction",
|
"ArgumentAction",
|
||||||
"CommandArgumentParser",
|
"CommandArgumentParser",
|
||||||
"FalyxParser",
|
"FalyxParser",
|
||||||
"RootParseResult",
|
"ParseResult",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
# 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
|
This module provides a structured, extensible argument parsing system designed
|
||||||
specifically for Falyx commands. It replaces traditional argparse usage with a
|
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 collections import Counter, defaultdict
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Generator, Iterable, Sequence
|
from typing import Any, Generator, Iterable
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from rich.padding import Padding
|
from rich.padding import Padding
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
|
from rich.style import StyleType
|
||||||
|
|
||||||
from falyx.action.base_action import BaseAction
|
from falyx.action.base_action import BaseAction
|
||||||
from falyx.console import console
|
from falyx.console import console
|
||||||
from falyx.context import InvocationContext
|
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.execution_option import ExecutionOption
|
||||||
from falyx.mode import FalyxMode
|
from falyx.mode import FalyxMode
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
@@ -73,9 +82,11 @@ from falyx.parser.parser_types import (
|
|||||||
false_none,
|
false_none,
|
||||||
true_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
|
from falyx.signals import HelpSignal
|
||||||
|
|
||||||
|
builtin_type = type
|
||||||
|
|
||||||
|
|
||||||
class _GroupBuilder:
|
class _GroupBuilder:
|
||||||
"""Helper for assigning arguments to a named group or mutex group.
|
"""Helper for assigning arguments to a named group or mutex group.
|
||||||
@@ -99,15 +110,52 @@ class _GroupBuilder:
|
|||||||
self.parser = parser
|
self.parser = parser
|
||||||
self.group_name = group_name
|
self.group_name = group_name
|
||||||
self.mutex_name = mutex_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(
|
self.parser.add_argument(
|
||||||
*flags,
|
*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,
|
group=self.group_name,
|
||||||
mutex_group=self.mutex_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:
|
class CommandArgumentParser:
|
||||||
"""
|
"""
|
||||||
@@ -136,7 +184,7 @@ class CommandArgumentParser:
|
|||||||
self,
|
self,
|
||||||
command_key: str = "",
|
command_key: str = "",
|
||||||
command_description: str = "",
|
command_description: str = "",
|
||||||
command_style: str = "bold",
|
command_style: StyleType = "bold",
|
||||||
help_text: str = "",
|
help_text: str = "",
|
||||||
help_epilog: str = "",
|
help_epilog: str = "",
|
||||||
aliases: list[str] | None = None,
|
aliases: list[str] | None = None,
|
||||||
@@ -148,7 +196,7 @@ class CommandArgumentParser:
|
|||||||
self.console: Console = console
|
self.console: Console = console
|
||||||
self.command_key: str = command_key
|
self.command_key: str = command_key
|
||||||
self.command_description: str = command_description
|
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_text: str = help_text
|
||||||
self.help_epilog: str = help_epilog
|
self.help_epilog: str = help_epilog
|
||||||
self.aliases: list[str] = aliases or []
|
self.aliases: list[str] = aliases or []
|
||||||
@@ -172,11 +220,22 @@ class CommandArgumentParser:
|
|||||||
if tldr_examples:
|
if tldr_examples:
|
||||||
self.add_tldr_examples(tldr_examples)
|
self.add_tldr_examples(tldr_examples)
|
||||||
self.options_manager: OptionsManager = options_manager or OptionsManager()
|
self.options_manager: OptionsManager = options_manager or OptionsManager()
|
||||||
|
self._is_runner_mode: bool = False
|
||||||
|
|
||||||
def mark_as_help_command(self) -> None:
|
def mark_as_help_command(self) -> None:
|
||||||
"""Mark this parser as the help command parser."""
|
"""Mark this parser as the help command parser."""
|
||||||
self._is_help_command = True
|
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:
|
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||||
"""Set the options manager for the parser."""
|
"""Set the options manager for the parser."""
|
||||||
if not isinstance(options_manager, OptionsManager):
|
if not isinstance(options_manager, OptionsManager):
|
||||||
@@ -238,7 +297,7 @@ class CommandArgumentParser:
|
|||||||
"""Register a destination as an execution argument."""
|
"""Register a destination as an execution argument."""
|
||||||
if dest in self._execution_dests:
|
if dest in self._execution_dests:
|
||||||
raise CommandArgumentError(
|
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)
|
self._execution_dests.add(dest)
|
||||||
|
|
||||||
@@ -288,9 +347,9 @@ class CommandArgumentParser:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid TLDR example format: {example}. "
|
f"invalid TLDR example format: {example}.",
|
||||||
"Examples must be either TLDRExample instances "
|
hint="examples must be either TLDRExample instances "
|
||||||
"or tuples of (usage, description)."
|
"or tuples of (usage, description).",
|
||||||
)
|
)
|
||||||
|
|
||||||
if "tldr" not in self._dest_set:
|
if "tldr" not in self._dest_set:
|
||||||
@@ -302,7 +361,7 @@ class CommandArgumentParser:
|
|||||||
description: str = "",
|
description: str = "",
|
||||||
) -> _GroupBuilder:
|
) -> _GroupBuilder:
|
||||||
if name in self._argument_groups:
|
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)
|
self._argument_groups[name] = ArgumentGroup(name=name, description=description)
|
||||||
return _GroupBuilder(self, group_name=name)
|
return _GroupBuilder(self, group_name=name)
|
||||||
|
|
||||||
@@ -314,7 +373,7 @@ class CommandArgumentParser:
|
|||||||
description: str = "",
|
description: str = "",
|
||||||
) -> _GroupBuilder:
|
) -> _GroupBuilder:
|
||||||
if name in self._mutex_groups:
|
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(
|
self._mutex_groups[name] = MutuallyExclusiveGroup(
|
||||||
name=name,
|
name=name,
|
||||||
required=required,
|
required=required,
|
||||||
@@ -329,7 +388,7 @@ class CommandArgumentParser:
|
|||||||
positional = True
|
positional = True
|
||||||
|
|
||||||
if positional and len(flags) > 1:
|
if positional and len(flags) > 1:
|
||||||
raise CommandArgumentError("Positional arguments cannot have multiple flags")
|
raise CommandArgumentError("positional arguments cannot have multiple flags")
|
||||||
return positional
|
return positional
|
||||||
|
|
||||||
def _validate_groups(
|
def _validate_groups(
|
||||||
@@ -342,22 +401,23 @@ class CommandArgumentParser:
|
|||||||
"""Validate that the specified groups exist and are compatible."""
|
"""Validate that the specified groups exist and are compatible."""
|
||||||
if group is not None:
|
if group is not None:
|
||||||
if group not in self._argument_groups:
|
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 is not None:
|
||||||
if mutex_group not in self._mutex_groups:
|
if mutex_group not in self._mutex_groups:
|
||||||
raise CommandArgumentError(
|
raise ArgumentGroupError(
|
||||||
f"Mutually exclusive group '{mutex_group}' does not exist"
|
f"mutually exclusive group '{mutex_group}' does not exist"
|
||||||
)
|
)
|
||||||
|
|
||||||
if positional and mutex_group is not None:
|
if positional and mutex_group is not None:
|
||||||
raise CommandArgumentError(
|
raise ArgumentGroupError(
|
||||||
"Positional arguments cannot belong to a mutually exclusive group"
|
"positional arguments cannot belong to a mutually exclusive group"
|
||||||
)
|
)
|
||||||
|
|
||||||
if required and mutex_group is not None:
|
if required and mutex_group is not None:
|
||||||
raise CommandArgumentError(
|
raise ArgumentGroupError(
|
||||||
"Arguments inside a mutually exclusive group should not be individually required; "
|
"arguments inside a mutually exclusive group cannot be individually required",
|
||||||
"make the group required instead."
|
hint="make the group required instead",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
|
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
|
||||||
@@ -365,27 +425,31 @@ class CommandArgumentParser:
|
|||||||
if dest:
|
if dest:
|
||||||
if not dest.replace("_", "").isalnum():
|
if not dest.replace("_", "").isalnum():
|
||||||
raise CommandArgumentError(
|
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():
|
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
|
return dest
|
||||||
dest = None
|
dest = None
|
||||||
for flag in flags:
|
for flag in flags:
|
||||||
if flag.startswith("--"):
|
if flag.startswith("--"):
|
||||||
dest = flag.lstrip("-").replace("-", "_").lower()
|
dest = flag.lstrip("-").replace("-", "_")
|
||||||
break
|
break
|
||||||
elif flag.startswith("-"):
|
elif flag.startswith("-"):
|
||||||
dest = flag.lstrip("-").replace("-", "_").lower()
|
dest = flag.lstrip("-").replace("-", "_")
|
||||||
else:
|
else:
|
||||||
dest = flag.replace("-", "_").lower()
|
dest = flag.replace("-", "_")
|
||||||
assert dest is not None, "dest should not be None"
|
assert dest is not None, "dest should not be None"
|
||||||
if not dest.replace("_", "").isalnum():
|
if not dest.replace("_", "").isalnum():
|
||||||
raise CommandArgumentError(
|
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():
|
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
|
return dest
|
||||||
|
|
||||||
def _determine_required(
|
def _determine_required(
|
||||||
@@ -405,7 +469,7 @@ class CommandArgumentParser:
|
|||||||
ArgumentAction.TLDR,
|
ArgumentAction.TLDR,
|
||||||
):
|
):
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Argument with action {action} cannot be required"
|
f"argument with action '{action}' cannot be required"
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
if positional:
|
if positional:
|
||||||
@@ -441,7 +505,7 @@ class CommandArgumentParser:
|
|||||||
):
|
):
|
||||||
if nargs is not None:
|
if nargs is not None:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"nargs cannot be specified for {action} actions"
|
f"nargs cannot be specified for '{action}' actions"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
if nargs is None:
|
if nargs is None:
|
||||||
@@ -452,7 +516,7 @@ class CommandArgumentParser:
|
|||||||
raise CommandArgumentError("nargs must be a positive integer")
|
raise CommandArgumentError("nargs must be a positive integer")
|
||||||
elif isinstance(nargs, str):
|
elif isinstance(nargs, str):
|
||||||
if nargs not in allowed_nargs:
|
if nargs not in allowed_nargs:
|
||||||
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
|
raise CommandArgumentError(f"invalid nargs value: {nargs}")
|
||||||
else:
|
else:
|
||||||
raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
|
raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
|
||||||
return nargs
|
return nargs
|
||||||
@@ -461,14 +525,16 @@ class CommandArgumentParser:
|
|||||||
self, choices: Iterable | None, expected_type: Any, action: ArgumentAction
|
self, choices: Iterable | None, expected_type: Any, action: ArgumentAction
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
"""Normalize and validate choices for the argument."""
|
"""Normalize and validate choices for the argument."""
|
||||||
if choices is not None:
|
if choices is None:
|
||||||
|
choices = []
|
||||||
|
else:
|
||||||
if action in (
|
if action in (
|
||||||
ArgumentAction.STORE_TRUE,
|
ArgumentAction.STORE_TRUE,
|
||||||
ArgumentAction.STORE_FALSE,
|
ArgumentAction.STORE_FALSE,
|
||||||
ArgumentAction.STORE_BOOL_OPTIONAL,
|
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
):
|
):
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"choices cannot be specified for {action} actions"
|
f"choices cannot be specified for '{action}' actions"
|
||||||
)
|
)
|
||||||
if isinstance(choices, dict):
|
if isinstance(choices, dict):
|
||||||
raise CommandArgumentError("choices cannot be a dict")
|
raise CommandArgumentError("choices cannot be a dict")
|
||||||
@@ -478,14 +544,13 @@ class CommandArgumentParser:
|
|||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
"choices must be iterable (like list, tuple, or set)"
|
"choices must be iterable (like list, tuple, or set)"
|
||||||
) from error
|
) from error
|
||||||
else:
|
|
||||||
choices = []
|
|
||||||
for choice in choices:
|
for choice in choices:
|
||||||
try:
|
try:
|
||||||
coerce_value(choice, expected_type)
|
coerce_value(choice, expected_type)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
type_name = get_type_name(expected_type)
|
||||||
raise CommandArgumentError(
|
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
|
) from error
|
||||||
return choices
|
return choices
|
||||||
|
|
||||||
@@ -493,25 +558,29 @@ class CommandArgumentParser:
|
|||||||
self, default: Any, expected_type: type, dest: str
|
self, default: Any, expected_type: type, dest: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate the default value type."""
|
"""Validate the default value type."""
|
||||||
if default is not None:
|
if default is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
coerce_value(default, expected_type)
|
coerce_value(default, expected_type)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
type_name = get_type_name(expected_type)
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
|
f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
|
||||||
) from error
|
) from error
|
||||||
|
|
||||||
def _validate_default_list_type(
|
def _validate_default_list_type(
|
||||||
self, default: list[Any], expected_type: type, dest: str
|
self, default: list[Any], expected_type: type, dest: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate the default value type for a list."""
|
"""Validate the default value type for a list."""
|
||||||
if isinstance(default, list):
|
if not isinstance(default, list):
|
||||||
|
return None
|
||||||
for item in default:
|
for item in default:
|
||||||
try:
|
try:
|
||||||
coerce_value(item, expected_type)
|
coerce_value(item, expected_type)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
type_name = get_type_name(expected_type)
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
|
f"invalid default list value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
|
||||||
) from error
|
) from error
|
||||||
|
|
||||||
def _validate_resolver(
|
def _validate_resolver(
|
||||||
@@ -524,7 +593,7 @@ class CommandArgumentParser:
|
|||||||
raise CommandArgumentError("resolver must be provided for ACTION action")
|
raise CommandArgumentError("resolver must be provided for ACTION action")
|
||||||
elif action != ArgumentAction.ACTION and resolver is not None:
|
elif action != ArgumentAction.ACTION and resolver is not None:
|
||||||
raise CommandArgumentError(
|
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):
|
if not isinstance(resolver, BaseAction):
|
||||||
@@ -540,7 +609,8 @@ class CommandArgumentParser:
|
|||||||
action = ArgumentAction(action)
|
action = ArgumentAction(action)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise CommandArgumentError(
|
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
|
) from error
|
||||||
if action in (
|
if action in (
|
||||||
ArgumentAction.STORE_TRUE,
|
ArgumentAction.STORE_TRUE,
|
||||||
@@ -552,7 +622,7 @@ class CommandArgumentParser:
|
|||||||
):
|
):
|
||||||
if positional:
|
if positional:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Action '{action}' cannot be used with positional arguments"
|
f"action '{action}' cannot be used with positional arguments"
|
||||||
)
|
)
|
||||||
|
|
||||||
return action
|
return action
|
||||||
@@ -579,49 +649,55 @@ class CommandArgumentParser:
|
|||||||
return []
|
return []
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
elif action in (
|
elif action is ArgumentAction.STORE_TRUE and default is not False:
|
||||||
ArgumentAction.STORE_TRUE,
|
|
||||||
ArgumentAction.STORE_FALSE,
|
|
||||||
ArgumentAction.STORE_BOOL_OPTIONAL,
|
|
||||||
):
|
|
||||||
raise CommandArgumentError(
|
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):
|
elif action in (ArgumentAction.HELP, ArgumentAction.TLDR, ArgumentAction.COUNT):
|
||||||
raise CommandArgumentError(
|
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(
|
if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance(
|
||||||
default, list
|
default, list
|
||||||
):
|
):
|
||||||
|
type_name = get_type_name(default)
|
||||||
raise CommandArgumentError(
|
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 isinstance(nargs, int) and nargs == 1:
|
||||||
if not isinstance(default, list):
|
if not isinstance(default, list):
|
||||||
default = [default]
|
default = [default]
|
||||||
if isinstance(nargs, int) or nargs in ("*", "+"):
|
if isinstance(nargs, int) or nargs in ("*", "+"):
|
||||||
if not isinstance(default, list):
|
if not isinstance(default, list):
|
||||||
|
type_name = get_type_name(default)
|
||||||
raise CommandArgumentError(
|
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
|
return default
|
||||||
|
|
||||||
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
||||||
"""Validate the flags provided for the argument."""
|
"""Validate the flags provided for the argument."""
|
||||||
if not flags:
|
if not flags:
|
||||||
raise CommandArgumentError("No flags provided")
|
raise CommandArgumentError("no flags provided for argument")
|
||||||
for flag in flags:
|
for flag in flags:
|
||||||
if not isinstance(flag, str):
|
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:
|
if flag.startswith("--") and len(flag) < 3:
|
||||||
raise CommandArgumentError(
|
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:
|
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
|
||||||
raise CommandArgumentError(
|
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(
|
def _register_store_bool_optional(
|
||||||
@@ -654,7 +730,6 @@ class CommandArgumentParser:
|
|||||||
group=group,
|
group=group,
|
||||||
mutex_group=mutex_group,
|
mutex_group=mutex_group,
|
||||||
)
|
)
|
||||||
|
|
||||||
negated_argument = Argument(
|
negated_argument = Argument(
|
||||||
flags=(negated_flag,),
|
flags=(negated_flag,),
|
||||||
dest=dest,
|
dest=dest,
|
||||||
@@ -677,7 +752,7 @@ class CommandArgumentParser:
|
|||||||
if flag in self._flag_map and not bypass_validation:
|
if flag in self._flag_map and not bypass_validation:
|
||||||
existing = self._flag_map[flag]
|
existing = self._flag_map[flag]
|
||||||
raise CommandArgumentError(
|
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:
|
for flag in argument.flags:
|
||||||
@@ -719,8 +794,7 @@ class CommandArgumentParser:
|
|||||||
group: str | None = None,
|
group: str | None = None,
|
||||||
mutex_group: str | None = None,
|
mutex_group: str | None = 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,
|
Supports positional and flagged arguments, type coercion, default values,
|
||||||
validation rules, and optional resolution via `BaseAction`.
|
validation rules, and optional resolution via `BaseAction`.
|
||||||
@@ -741,19 +815,18 @@ class CommandArgumentParser:
|
|||||||
group (str | None): Optional argument group name for help organization.
|
group (str | None): Optional argument group name for help organization.
|
||||||
mutex_group (str | None): Optional mutually exclusive group name.
|
mutex_group (str | None): Optional mutually exclusive group name.
|
||||||
"""
|
"""
|
||||||
expected_type = type
|
|
||||||
self._validate_flags(flags)
|
self._validate_flags(flags)
|
||||||
positional = self._is_positional(flags)
|
positional = self._is_positional(flags)
|
||||||
dest = self._get_dest_from_flags(flags, dest)
|
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:
|
if dest in self.RESERVED_DESTS:
|
||||||
raise CommandArgumentError(
|
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)
|
self._validate_groups(group, mutex_group, positional, required)
|
||||||
@@ -768,38 +841,45 @@ class CommandArgumentParser:
|
|||||||
and default is not None
|
and default is not None
|
||||||
):
|
):
|
||||||
if isinstance(default, list):
|
if isinstance(default, list):
|
||||||
self._validate_default_list_type(default, expected_type, dest)
|
self._validate_default_list_type(default, type, dest)
|
||||||
else:
|
else:
|
||||||
self._validate_default_type(default, expected_type, dest)
|
self._validate_default_type(default, type, dest)
|
||||||
choices = self._normalize_choices(choices, expected_type, action)
|
choices = self._normalize_choices(choices, type, action)
|
||||||
if default is not None and choices:
|
if default is not None and choices:
|
||||||
|
choices_str = ", ".join((str(choice) for choice in choices))
|
||||||
if isinstance(default, list):
|
if isinstance(default, list):
|
||||||
if not all(choice in choices for choice in default):
|
if not all(choice in choices for choice in default):
|
||||||
raise CommandArgumentError(
|
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:
|
elif default not in choices:
|
||||||
# If default is not in choices, raise an error
|
# If default is not in choices, raise an error
|
||||||
raise CommandArgumentError(
|
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)
|
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(
|
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):
|
if not isinstance(lazy_resolver, bool):
|
||||||
|
type_name = get_type_name(lazy_resolver)
|
||||||
raise CommandArgumentError(
|
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:
|
if action == ArgumentAction.STORE_BOOL_OPTIONAL:
|
||||||
self._register_store_bool_optional(flags, dest, help, group, mutex_group)
|
self._register_store_bool_optional(flags, dest, help, group, mutex_group)
|
||||||
else:
|
return None
|
||||||
argument = Argument(
|
argument = Argument(
|
||||||
flags=flags,
|
flags=flags,
|
||||||
dest=dest,
|
dest=dest,
|
||||||
action=action,
|
action=action,
|
||||||
type=expected_type,
|
type=type,
|
||||||
default=default,
|
default=default,
|
||||||
choices=choices,
|
choices=choices,
|
||||||
required=required,
|
required=required,
|
||||||
@@ -871,8 +951,9 @@ class CommandArgumentParser:
|
|||||||
return None
|
return None
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
arg_states[spec.dest].has_invalid_choice = True
|
arg_states[spec.dest].has_invalid_choice = True
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(
|
||||||
f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}"
|
dest=spec.dest,
|
||||||
|
choices=spec.choices,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _raise_remaining_args_error(
|
def _raise_remaining_args_error(
|
||||||
@@ -888,14 +969,7 @@ class CommandArgumentParser:
|
|||||||
if arg.dest not in consumed_dests and flag.startswith(token)
|
if arg.dest not in consumed_dests and flag.startswith(token)
|
||||||
]
|
]
|
||||||
|
|
||||||
if remaining_flags:
|
raise UnrecognizedOptionError(token=token, remaining_flags=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."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _consume_nargs(
|
def _consume_nargs(
|
||||||
self, args: list[str], index: int, spec: Argument
|
self, args: list[str], index: int, spec: Argument
|
||||||
@@ -910,13 +984,19 @@ class CommandArgumentParser:
|
|||||||
values = []
|
values = []
|
||||||
if isinstance(spec.nargs, int):
|
if isinstance(spec.nargs, int):
|
||||||
if index + spec.nargs > len(args):
|
if index + spec.nargs > len(args):
|
||||||
raise CommandArgumentError(
|
raise MissingValueError(
|
||||||
f"Expected {spec.nargs} value(s) for '{spec.dest}' but got {len(args) - index}"
|
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]
|
values = args[index : index + spec.nargs]
|
||||||
return values, index + spec.nargs
|
return values, index + spec.nargs
|
||||||
elif spec.nargs == "+":
|
elif spec.nargs == "+":
|
||||||
if index >= len(args):
|
if index >= len(args):
|
||||||
|
raise MissingValueError(spec.dest, expected_count=1)
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Expected at least one value for '{spec.dest}'"
|
f"Expected at least one value for '{spec.dest}'"
|
||||||
)
|
)
|
||||||
@@ -1002,21 +1082,28 @@ class CommandArgumentParser:
|
|||||||
else:
|
else:
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
arg_states[spec.dest].has_invalid_choice = True
|
arg_states[spec.dest].has_invalid_choice = True
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||||
f"Invalid value for '{spec.dest}': {error}"
|
|
||||||
) from error
|
|
||||||
if spec.action == ArgumentAction.ACTION:
|
if spec.action == ArgumentAction.ACTION:
|
||||||
assert isinstance(
|
assert isinstance(
|
||||||
spec.resolver, BaseAction
|
spec.resolver, BaseAction
|
||||||
), "resolver should be an instance of BaseAction"
|
), "resolver should be an instance of BaseAction"
|
||||||
if spec.nargs == "+" and len(typed) == 0:
|
if spec.nargs == "+" and len(typed) == 0:
|
||||||
raise CommandArgumentError(
|
raise MissingValueError(
|
||||||
f"Argument '{spec.dest}' requires at least one value"
|
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:
|
if isinstance(spec.nargs, int) and len(typed) != spec.nargs:
|
||||||
raise CommandArgumentError(
|
raise MissingValueError(
|
||||||
f"Argument '{spec.dest}' requires exactly {spec.nargs} value(s)"
|
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:
|
if not spec.lazy_resolver or not from_validate:
|
||||||
try:
|
try:
|
||||||
result[spec.dest] = await spec.resolver(*typed)
|
result[spec.dest] = await spec.resolver(*typed)
|
||||||
@@ -1094,7 +1181,7 @@ class CommandArgumentParser:
|
|||||||
flag = f"-{char}"
|
flag = f"-{char}"
|
||||||
arg = self._flag_map.get(flag)
|
arg = self._flag_map.get(flag)
|
||||||
if not arg:
|
if not arg:
|
||||||
raise CommandArgumentError(f"Unrecognized option: {flag}")
|
raise UnrecognizedOptionError(flag)
|
||||||
expanded.append(flag)
|
expanded.append(flag)
|
||||||
else:
|
else:
|
||||||
return token
|
return token
|
||||||
@@ -1129,8 +1216,9 @@ class CommandArgumentParser:
|
|||||||
)
|
)
|
||||||
elif spec.nargs is None:
|
elif spec.nargs is None:
|
||||||
try:
|
try:
|
||||||
|
type_name = get_type_name(spec.type)
|
||||||
raise CommandArgumentError(
|
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:
|
except AttributeError as error:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
@@ -1161,7 +1249,7 @@ class CommandArgumentParser:
|
|||||||
|
|
||||||
if action == ArgumentAction.HELP:
|
if action == ArgumentAction.HELP:
|
||||||
if not from_validate:
|
if not from_validate:
|
||||||
self.render_help(invocation_context=invocation_context)
|
self.render_help(invocation_context)
|
||||||
arg_states[spec.dest].set_consumed()
|
arg_states[spec.dest].set_consumed()
|
||||||
raise HelpSignal()
|
raise HelpSignal()
|
||||||
elif action == ArgumentAction.TLDR:
|
elif action == ArgumentAction.TLDR:
|
||||||
@@ -1171,7 +1259,7 @@ class CommandArgumentParser:
|
|||||||
consumed_indices.add(index)
|
consumed_indices.add(index)
|
||||||
index += 1
|
index += 1
|
||||||
elif not from_validate:
|
elif not from_validate:
|
||||||
self.render_tldr(invocation_context=invocation_context)
|
self.render_tldr(invocation_context)
|
||||||
arg_states[spec.dest].set_consumed()
|
arg_states[spec.dest].set_consumed()
|
||||||
raise HelpSignal()
|
raise HelpSignal()
|
||||||
else:
|
else:
|
||||||
@@ -1187,9 +1275,7 @@ class CommandArgumentParser:
|
|||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
arg_states[spec.dest].has_invalid_choice = True
|
arg_states[spec.dest].has_invalid_choice = True
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||||
f"Invalid value for '{spec.dest}': {error}"
|
|
||||||
) from error
|
|
||||||
if not spec.lazy_resolver or not from_validate:
|
if not spec.lazy_resolver or not from_validate:
|
||||||
try:
|
try:
|
||||||
result[spec.dest] = await spec.resolver(*typed_values)
|
result[spec.dest] = await spec.resolver(*typed_values)
|
||||||
@@ -1228,9 +1314,7 @@ class CommandArgumentParser:
|
|||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
arg_states[spec.dest].has_invalid_choice = True
|
arg_states[spec.dest].has_invalid_choice = True
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||||
f"Invalid value for '{spec.dest}': {error}"
|
|
||||||
) from error
|
|
||||||
if not typed_values:
|
if not typed_values:
|
||||||
self._raise_suggestion_error(spec)
|
self._raise_suggestion_error(spec)
|
||||||
if spec.nargs is None:
|
if spec.nargs is None:
|
||||||
@@ -1247,9 +1331,7 @@ class CommandArgumentParser:
|
|||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
arg_states[spec.dest].has_invalid_choice = True
|
arg_states[spec.dest].has_invalid_choice = True
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||||
f"Invalid value for '{spec.dest}': {error}"
|
|
||||||
) from error
|
|
||||||
result[spec.dest].extend(typed_values)
|
result[spec.dest].extend(typed_values)
|
||||||
consumed_indices.update(range(index, new_index))
|
consumed_indices.update(range(index, new_index))
|
||||||
index = new_index
|
index = new_index
|
||||||
@@ -1260,9 +1342,7 @@ class CommandArgumentParser:
|
|||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
arg_states[spec.dest].has_invalid_choice = True
|
arg_states[spec.dest].has_invalid_choice = True
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(dest=spec.dest, error=error) from error
|
||||||
f"Invalid value for '{spec.dest}': {error}"
|
|
||||||
) from error
|
|
||||||
if not typed_values and spec.nargs not in ("*", "?"):
|
if not typed_values and spec.nargs not in ("*", "?"):
|
||||||
self._raise_suggestion_error(spec)
|
self._raise_suggestion_error(spec)
|
||||||
if spec.nargs in (None, 1, "?"):
|
if spec.nargs in (None, 1, "?"):
|
||||||
@@ -1497,26 +1577,29 @@ class CommandArgumentParser:
|
|||||||
if isinstance(spec.nargs, int) and spec.nargs > 1:
|
if isinstance(spec.nargs, int) and spec.nargs > 1:
|
||||||
assert isinstance(
|
assert isinstance(
|
||||||
result.get(spec.dest), list
|
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:
|
if not result[spec.dest] and not spec.required:
|
||||||
continue
|
continue
|
||||||
if spec.action == ArgumentAction.APPEND:
|
if spec.action == ArgumentAction.APPEND:
|
||||||
for group in result[spec.dest]:
|
for group in result[spec.dest]:
|
||||||
if len(group) % spec.nargs != 0:
|
if len(group) % spec.nargs != 0:
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(
|
||||||
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
|
dest=spec.dest,
|
||||||
|
error=f"invalid number of values: expected a multiple of {spec.nargs}",
|
||||||
)
|
)
|
||||||
elif spec.action == ArgumentAction.EXTEND:
|
elif spec.action == ArgumentAction.EXTEND:
|
||||||
if len(result[spec.dest]) % spec.nargs != 0:
|
if len(result[spec.dest]) % spec.nargs != 0:
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(
|
||||||
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
|
dest=spec.dest,
|
||||||
|
error=f"invalid number of values: expected a multiple of {spec.nargs}",
|
||||||
)
|
)
|
||||||
elif len(result[spec.dest]) != spec.nargs:
|
elif len(result[spec.dest]) != spec.nargs:
|
||||||
arg_states[spec.dest].reset()
|
arg_states[spec.dest].reset()
|
||||||
raise CommandArgumentError(
|
raise InvalidValueError(
|
||||||
f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}"
|
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 == "+":
|
if isinstance(spec.nargs, str) and spec.nargs == "+":
|
||||||
@@ -2047,6 +2130,8 @@ class CommandArgumentParser:
|
|||||||
program_style = (
|
program_style = (
|
||||||
self.options_manager.get("program_style") or self.command_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}"
|
return f"[{program_style}]{program}[/{program_style}] {command_keys}"
|
||||||
|
|
||||||
if invocation_context.is_cli_mode:
|
if invocation_context.is_cli_mode:
|
||||||
@@ -2067,6 +2152,19 @@ class CommandArgumentParser:
|
|||||||
options_text = self.get_options_text()
|
options_text = self.get_options_text()
|
||||||
return f"{prefix} {options_text}".strip() if options_text else prefix
|
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(
|
def _iter_keyword_help_sections(
|
||||||
self,
|
self,
|
||||||
) -> Generator[tuple[str, str, list[Argument]], None, None]:
|
) -> Generator[tuple[str, str, list[Argument]], None, None]:
|
||||||
@@ -2093,7 +2191,6 @@ class CommandArgumentParser:
|
|||||||
|
|
||||||
def render_help(
|
def render_help(
|
||||||
self,
|
self,
|
||||||
*,
|
|
||||||
invocation_context: InvocationContext | None = None,
|
invocation_context: InvocationContext | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Render full help output for the command.
|
"""Render full help output for the command.
|
||||||
@@ -2112,15 +2209,14 @@ class CommandArgumentParser:
|
|||||||
- Supports argument grouping and mutually exclusive groups
|
- Supports argument grouping and mutually exclusive groups
|
||||||
- Applies styling based on configured command style
|
- Applies styling based on configured command style
|
||||||
"""
|
"""
|
||||||
usage = self.get_usage(invocation_context)
|
self.render_usage(invocation_context)
|
||||||
self.console.print(f"[bold]usage: {usage}[/bold]\n")
|
|
||||||
|
|
||||||
if self.help_text:
|
if self.help_text:
|
||||||
self.console.print(self.help_text + "\n")
|
self.console.print(f"\n{self.help_text}")
|
||||||
|
|
||||||
if self._arguments:
|
if self._arguments:
|
||||||
if self._positional:
|
if self._positional:
|
||||||
self.console.print("[bold]positional:[/bold]")
|
self.console.print("\n[bold]positional:[/bold]")
|
||||||
for arg in self._positional.values():
|
for arg in self._positional.values():
|
||||||
flags = arg.get_positional_text()
|
flags = arg.get_positional_text()
|
||||||
arg_line = f" {flags:<30} "
|
arg_line = f" {flags:<30} "
|
||||||
@@ -2167,7 +2263,7 @@ class CommandArgumentParser:
|
|||||||
if self.help_epilog:
|
if self.help_epilog:
|
||||||
self.console.print("\n" + self.help_epilog, style="dim")
|
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.
|
"""Render concise example usage (TLDR) for the command.
|
||||||
|
|
||||||
This method displays a minimal, example-driven view of how to invoke
|
This method displays a minimal, example-driven view of how to invoke
|
||||||
@@ -2185,13 +2281,12 @@ class CommandArgumentParser:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
prefix = self._get_invocation_prefix(invocation_context)
|
prefix = self._get_invocation_prefix(invocation_context)
|
||||||
usage = self.get_usage(invocation_context)
|
self.render_usage(invocation_context)
|
||||||
self.console.print(f"[bold]usage:[/] {usage}\n")
|
|
||||||
|
|
||||||
if self.help_text:
|
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:
|
for example in self._tldr_examples:
|
||||||
usage = f"{prefix} {example.usage.strip()}"
|
usage = f"{prefix} {example.usage.strip()}"
|
||||||
description = example.description.strip()
|
description = example.description.strip()
|
||||||
|
|||||||
@@ -1,179 +1,650 @@
|
|||||||
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
|
# 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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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.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)
|
@dataclass(slots=True)
|
||||||
class RootOptions:
|
class Option:
|
||||||
"""Container for root-level Falyx session flags.
|
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
|
def format_for_help(self) -> str:
|
||||||
boundary before namespace routing and command-local parsing begin. These
|
"""Return a formatted string of the option's flags for help output."""
|
||||||
values represent session-scoped behavior that applies to the overall Falyx
|
return ", ".join(self.flags)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class FalyxParser:
|
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
|
def __init__(self, flx: Falyx) -> None:
|
||||||
and command-local argument parsing begin. Its job is to inspect only the
|
self._flx = flx
|
||||||
leading session-scoped flags in argv, determine the initial application
|
self._options_by_dest: dict[str, Option] = {}
|
||||||
mode, and return a normalized `RootParseResult`.
|
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:
|
def get_flags(self) -> list[str]:
|
||||||
- Parse only root/session flags such as verbose logging, help, TLDR,
|
"""Return a list of the first flag for the registered options."""
|
||||||
and prompt suppression.
|
return [option.flags[0] for option in self._options]
|
||||||
- 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`.
|
|
||||||
|
|
||||||
Design Notes:
|
def get_options(self) -> list[Option]:
|
||||||
- This parser does not resolve commands or namespaces.
|
"""Return a list of registered options."""
|
||||||
- This parser does not parse command-specific arguments.
|
return self._options
|
||||||
- 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.
|
|
||||||
|
|
||||||
Typical Usage:
|
def _add_tldr(self):
|
||||||
`Falyx.run()` or another top-level entrypoint passes raw argv into
|
"""Add TLDR argument to the parser."""
|
||||||
`FalyxParser.parse()`, applies the returned session options, and then
|
if "tldr" in self._dest_set:
|
||||||
forwards the untouched remaining argv into the routed Falyx execution
|
return None
|
||||||
flow.
|
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
|
||||||
|
|
||||||
Attributes:
|
def add_tldr_example(
|
||||||
ROOT_FLAG_ALIASES: Mapping of recognized root CLI flags to
|
self,
|
||||||
`RootOptions` attribute names.
|
*,
|
||||||
|
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()
|
||||||
|
|
||||||
ROOT_FLAG_ALIASES: dict[str, str] = {
|
def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None:
|
||||||
"-n": "never_prompt",
|
"""Register multiple namespace-level TLDR examples.
|
||||||
"--never-prompt": "never_prompt",
|
|
||||||
"-v": "verbose",
|
Supports either `FalyxTLDRExample` objects or shorthand tuples of
|
||||||
"--verbose": "verbose",
|
`(entry_key, usage, description)`.
|
||||||
"-d": "debug_hooks",
|
|
||||||
"--debug-hooks": "debug_hooks",
|
Args:
|
||||||
"?": "help",
|
examples (list[FalyxTLDRInput]): Example definitions to validate and append.
|
||||||
"-h": "help",
|
|
||||||
"--help": "help",
|
Raises:
|
||||||
"-T": "tldr",
|
FalyxError: If an example has an unsupported shape.
|
||||||
"--tldr": "tldr",
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]:
|
||||||
def _parse_root_options(
|
"""Expand POSIX-style bundled arguments into separate arguments."""
|
||||||
cls,
|
expanded: list[str] = []
|
||||||
argv: list[str],
|
for token in tokens:
|
||||||
) -> tuple[RootOptions, list[str]]:
|
if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
|
||||||
"""Parse only root/session flags from the start of argv.
|
expanded.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
Parsing stops at the first token that is not a recognized root flag.
|
bundle = [f"-{char}" for char in token[1:]]
|
||||||
Remaining tokens are returned untouched for later routing.
|
|
||||||
|
|
||||||
Examples:
|
if (
|
||||||
["--verbose", "deploy", "--env", "prod"]
|
all(
|
||||||
-> (RootOptions(verbose=True), ["deploy", "--env", "prod"])
|
flag in self._options_by_dest
|
||||||
|
and self._can_bundle_option(self._options_by_dest[flag])
|
||||||
["deploy", "--verbose"]
|
for flag in bundle[:-1]
|
||||||
-> (RootOptions(), ["deploy", "--verbose"])
|
)
|
||||||
"""
|
and bundle[-1] in self._options_by_dest
|
||||||
options = RootOptions()
|
):
|
||||||
remaining_start = 0
|
expanded.extend(bundle)
|
||||||
|
|
||||||
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:
|
else:
|
||||||
remaining_start = len(argv)
|
expanded.append(token)
|
||||||
|
return expanded
|
||||||
|
|
||||||
remaining = argv[remaining_start:]
|
def _default_values(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
return options, remaining
|
values: dict[str, Any] = {}
|
||||||
|
root_values: dict[str, Any] = {}
|
||||||
|
|
||||||
@classmethod
|
for option in self._options:
|
||||||
def parse(cls, argv: list[str] | None = None) -> RootParseResult:
|
if option.scope == OptionScope.ROOT:
|
||||||
argv = argv or []
|
root_values[option.dest] = option.default
|
||||||
root, remaining = cls._parse_root_options(argv)
|
elif option.scope == OptionScope.NAMESPACE:
|
||||||
|
values.setdefault(option.dest, option.default)
|
||||||
|
else:
|
||||||
|
assert False, f"unhandled option scope: {option.scope}"
|
||||||
|
|
||||||
if root.help or root.tldr:
|
return values, root_values
|
||||||
return RootParseResult(
|
|
||||||
mode=FalyxMode.HELP,
|
def _consume_option(
|
||||||
raw_argv=argv,
|
self,
|
||||||
never_prompt=root.never_prompt,
|
option: Option,
|
||||||
verbose=root.verbose,
|
argv: list[str],
|
||||||
debug_hooks=root.debug_hooks,
|
index: int,
|
||||||
tldr_requested=root.tldr,
|
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}}}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return RootParseResult(
|
values[option.dest] = value
|
||||||
mode=FalyxMode.COMMAND,
|
return index + 2
|
||||||
raw_argv=argv,
|
|
||||||
verbose=root.verbose,
|
raise FalyxOptionError(f"unsupported option action: {option.action}")
|
||||||
debug_hooks=root.debug_hooks,
|
|
||||||
never_prompt=root.never_prompt,
|
def parse_args(
|
||||||
remaining_argv=remaining,
|
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
|
# 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.
|
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
|
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
|
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.
|
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.
|
does not perform parsing, validation, or execution itself.
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from falyx.mode import FalyxMode
|
from falyx.mode import FalyxMode
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class RootParseResult:
|
class ParseResult:
|
||||||
"""Represents the normalized result of root-level Falyx argument parsing.
|
"""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 application boundary. It separates session-level runtime settings from
|
||||||
the remaining argv that should continue into namespace routing and
|
the remaining argv that should continue into namespace routing and
|
||||||
command-local parsing.
|
command-local parsing.
|
||||||
@@ -37,18 +38,27 @@ class RootParseResult:
|
|||||||
Attributes:
|
Attributes:
|
||||||
mode: Top-level runtime mode selected from the root parse.
|
mode: Top-level runtime mode selected from the root parse.
|
||||||
raw_argv: Original argv passed into the root parser.
|
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.
|
verbose: Whether verbose logging should be enabled for the session.
|
||||||
debug_hooks: Whether hook execution should be logged in detail.
|
debug_hooks: Whether hook execution should be logged in detail.
|
||||||
never_prompt: Whether prompts should be suppressed for the session.
|
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
|
mode: FalyxMode
|
||||||
raw_argv: list[str] = field(default_factory=list)
|
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
|
verbose: bool = False
|
||||||
debug_hooks: bool = False
|
debug_hooks: bool = False
|
||||||
never_prompt: 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
|
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:
|
def coerce_bool(value: str) -> bool:
|
||||||
"""Convert a string to a boolean.
|
"""Convert a string to a boolean.
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Includes:
|
|||||||
- `should_prompt_user()` for conditional prompt logic.
|
- `should_prompt_user()` for conditional prompt logic.
|
||||||
- `confirm_async()` for interactive yes/no confirmation.
|
- `confirm_async()` for interactive yes/no confirmation.
|
||||||
"""
|
"""
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.formatted_text import (
|
from prompt_toolkit.formatted_text import (
|
||||||
@@ -24,11 +26,25 @@ from falyx.themes import OneColors
|
|||||||
from falyx.validators import yes_no_validator
|
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(
|
def should_prompt_user(
|
||||||
*,
|
*,
|
||||||
confirm: bool,
|
confirm: bool,
|
||||||
options: OptionsManager,
|
options: OptionsManager,
|
||||||
namespace: str = "default",
|
namespace: str = "root",
|
||||||
override_namespace: str = "execution",
|
override_namespace: str = "execution",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Determine whether to prompt the user for confirmation.
|
"""Determine whether to prompt the user for confirmation.
|
||||||
@@ -41,7 +57,7 @@ def should_prompt_user(
|
|||||||
Args:
|
Args:
|
||||||
confirm (bool): The initial confirmation flag (e.g., from a command argument).
|
confirm (bool): The initial confirmation flag (e.g., from a command argument).
|
||||||
options (OptionsManager): The options manager to check for override flags.
|
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").
|
override_namespace (str): The secondary namespace for overrides (default: "execution").
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ class RouteResult:
|
|||||||
specific nested namespace.
|
specific nested namespace.
|
||||||
leaf_argv: Remaining argv that should be delegated to the resolved
|
leaf_argv: Remaining argv that should be delegated to the resolved
|
||||||
command's local parser.
|
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.
|
suggestions: Suggested entry names for unresolved input.
|
||||||
is_preview: Whether the routed invocation is in preview mode.
|
is_preview: Whether the routed invocation is in preview mode.
|
||||||
"""
|
"""
|
||||||
@@ -88,5 +90,6 @@ class RouteResult:
|
|||||||
command: "Command | None" = None
|
command: "Command | None" = None
|
||||||
namespace_entry: FalyxNamespace | None = None
|
namespace_entry: FalyxNamespace | None = None
|
||||||
leaf_argv: list[str] = field(default_factory=list)
|
leaf_argv: list[str] = field(default_factory=list)
|
||||||
|
current_head: str = ""
|
||||||
suggestions: list[str] = field(default_factory=list)
|
suggestions: list[str] = field(default_factory=list)
|
||||||
is_preview: bool = False
|
is_preview: bool = False
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from rich.markup import escape
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from falyx.console import console
|
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.themes import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict, chunks
|
from falyx.utils import CaseInsensitiveDict, chunks
|
||||||
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
from falyx.validators import MultiIndexValidator, MultiKeyValidator
|
||||||
@@ -292,7 +292,19 @@ async def prompt_for_index(
|
|||||||
if show_table:
|
if show_table:
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
selection = await prompt_session.prompt_async(
|
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),
|
message=rich_text_to_prompt_text(prompt_message),
|
||||||
validator=MultiIndexValidator(
|
validator=MultiIndexValidator(
|
||||||
min_index,
|
min_index,
|
||||||
@@ -303,6 +315,7 @@ async def prompt_for_index(
|
|||||||
cancel_key,
|
cancel_key,
|
||||||
),
|
),
|
||||||
default=default_selection,
|
default=default_selection,
|
||||||
|
placeholder=placeholder,
|
||||||
)
|
)
|
||||||
|
|
||||||
if selection.strip() == cancel_key:
|
if selection.strip() == cancel_key:
|
||||||
@@ -331,12 +344,25 @@ async def prompt_for_selection(
|
|||||||
if show_table:
|
if show_table:
|
||||||
console.print(table, justify="center")
|
console.print(table, justify="center")
|
||||||
|
|
||||||
selected = await prompt_session.prompt_async(
|
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),
|
message=rich_text_to_prompt_text(prompt_message),
|
||||||
validator=MultiKeyValidator(
|
validator=MultiKeyValidator(
|
||||||
keys, number_selections, separator, allow_duplicates, cancel_key
|
keys, number_selections, separator, allow_duplicates, cancel_key
|
||||||
),
|
),
|
||||||
default=default_selection,
|
default=default_selection,
|
||||||
|
placeholder=placeholder,
|
||||||
)
|
)
|
||||||
|
|
||||||
if selected.strip() == cancel_key:
|
if selected.strip() == cancel_key:
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ class CommandValidator(Validator):
|
|||||||
message=self.error_message,
|
message=self.error_message,
|
||||||
cursor_position=len(text),
|
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
|
return None
|
||||||
if route.kind in {
|
if route.kind in {
|
||||||
RouteKind.NAMESPACE_MENU,
|
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
|
# test_command.py
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from falyx.action import Action, BaseIOAction, ChainedAction
|
from falyx.action import Action, BaseIOAction, ChainedAction
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
@@ -172,3 +173,15 @@ def test_command_bad_action():
|
|||||||
with pytest.raises(TypeError) as exc_info:
|
with pytest.raises(TypeError) as exc_info:
|
||||||
Command(key="TEST", description="Test Command", action="not_callable")
|
Command(key="TEST", description="Test Command", action="not_callable")
|
||||||
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"
|
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))
|
results = list(completer.get_completions(Document("OPS -"), None))
|
||||||
texts = completion_texts(results)
|
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 "-h" in texts
|
||||||
assert "--help" in texts
|
assert "--help" in texts
|
||||||
assert "-T" 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 world") == '"hello world"'
|
||||||
assert completer._ensure_quote("hello") == "hello"
|
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 rich.text import Text
|
||||||
|
|
||||||
from falyx import Falyx
|
from falyx import Falyx
|
||||||
from falyx.console import console
|
from falyx.exceptions import CommandArgumentError
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -82,17 +82,14 @@ async def test_help_command_by_tag(capsys):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_help_command_empty_tags(capsys):
|
async def test_help_command_bad_argument(capsys):
|
||||||
flx = Falyx()
|
flx = Falyx()
|
||||||
|
|
||||||
async def untagged_command(falyx: Falyx):
|
async def untagged_command(falyx: Falyx):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
flx.add_command(
|
flx.add_command("U", "Untagged Command", untagged_command)
|
||||||
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
|
with pytest.raises(
|
||||||
)
|
CommandArgumentError, match="Unexpected positional argument: nonexistent_tag"
|
||||||
|
):
|
||||||
await flx.execute_command("H nonexistent_tag")
|
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
|
|
||||||
|
|||||||
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 sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from falyx import Falyx
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -14,3 +78,178 @@ async def test_run_basic(capsys):
|
|||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Show this help menu." in captured.out
|
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
|
import pytest
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from falyx.action import Action
|
||||||
from falyx.console import console as falyx_console
|
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.parser import ArgumentAction, CommandArgumentParser
|
||||||
from falyx.signals import HelpSignal
|
from falyx.signals import HelpSignal
|
||||||
|
|
||||||
@@ -835,3 +837,175 @@ async def test_render_help():
|
|||||||
assert "Foo help" in output
|
assert "Foo help" in output
|
||||||
assert "--bar" in output
|
assert "--bar" in output
|
||||||
assert "Bar help" 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
|
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():
|
def test_enable_execution_options_registers_confirm_flags():
|
||||||
parser = CommandArgumentParser()
|
parser = CommandArgumentParser()
|
||||||
parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM}))
|
parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM}))
|
||||||
@@ -48,12 +63,12 @@ def test_register_execution_dest_rejects_duplicates():
|
|||||||
parser = CommandArgumentParser()
|
parser = CommandArgumentParser()
|
||||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
):
|
):
|
||||||
parser.add_argument("--summary", action="store_true")
|
parser.add_argument("--summary", action="store_true")
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
):
|
):
|
||||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
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 = CommandArgumentParser()
|
||||||
parser.add_argument("--summary", action="store_true", help="A conflicting argument.")
|
parser.add_argument("--summary", action="store_true", help="A conflicting argument.")
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
):
|
):
|
||||||
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
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"],
|
execution_options=["summary"],
|
||||||
)
|
)
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
CommandArgumentError, match="Destination 'summary' is already defined"
|
CommandArgumentError, match="destination 'summary' is already defined"
|
||||||
):
|
):
|
||||||
command.arg_parser.add_argument(
|
command.arg_parser.add_argument(
|
||||||
"--summary", action="store_true", help="A conflicting argument."
|
"--summary", action="store_true", help="A conflicting argument."
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(
|
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}))
|
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import pytest
|
|||||||
|
|
||||||
from falyx.exceptions import CommandArgumentError
|
from falyx.exceptions import CommandArgumentError
|
||||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||||
|
from falyx.parser.parser_types import TLDRExample
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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[0].description == "This is the first example."
|
||||||
assert parser._tldr_examples[1].usage == "example2"
|
assert parser._tldr_examples[1].usage == "example2"
|
||||||
assert parser._tldr_examples[1].description == "This is the second example."
|
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 import Command
|
||||||
from falyx.command_runner import CommandRunner
|
from falyx.command_runner import CommandRunner
|
||||||
from falyx.console import console as falyx_console
|
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.hook_manager import HookManager, HookType
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
||||||
@@ -123,8 +129,10 @@ async def test_command_runner_initialization(
|
|||||||
command_with_no_parser,
|
command_with_no_parser,
|
||||||
command_with_custom_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.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.options, OptionsManager)
|
||||||
assert isinstance(runner.runner_hooks, HookManager)
|
assert isinstance(runner.runner_hooks, HookManager)
|
||||||
assert runner.console == falyx_console
|
assert runner.console == falyx_console
|
||||||
@@ -133,7 +141,6 @@ async def test_command_runner_initialization(
|
|||||||
assert runner.command.options_manager == runner.options
|
assert runner.command.options_manager == runner.options
|
||||||
assert runner.executor.options == runner.options
|
assert runner.executor.options == runner.options
|
||||||
assert runner.executor.hooks == runner.runner_hooks
|
assert runner.executor.hooks == runner.runner_hooks
|
||||||
assert runner.executor.console == runner.console
|
|
||||||
assert runner.options.get("summary", namespace_name="execution") is None
|
assert runner.options.get("summary", namespace_name="execution") is None
|
||||||
|
|
||||||
runner_no_parser = CommandRunner(command_with_no_parser)
|
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()
|
custom_console = Console()
|
||||||
runner = CommandRunner(command_with_parser, console=custom_console)
|
runner = CommandRunner(command_with_parser, console=custom_console)
|
||||||
assert runner.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):
|
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,
|
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(
|
CommandRunner(
|
||||||
command_with_parser,
|
command_with_parser,
|
||||||
runner_hooks=custom_hooks,
|
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"):
|
with pytest.raises(FalyxError, match="boom"):
|
||||||
await runner.run("--foo 42", wrap_errors=True)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_command_runner_debug_statement(command_with_parser, caplog):
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_command_runner_run_from_command_build_with_all_execution_options(
|
async def test_command_runner_run_from_command_build_with_all_execution_options(
|
||||||
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")
|
CommandRunner.from_command("Not a Command")
|
||||||
|
|
||||||
with pytest.raises(
|
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(
|
CommandRunner.from_command(
|
||||||
Command(
|
Command(
|
||||||
@@ -360,7 +382,7 @@ async def test_command_runner_build_with_bad_execution_options():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_command_runner_build_with_bad_runner_hooks():
|
async def test_command_runner_build_with_bad_runner_hooks():
|
||||||
with pytest.raises(
|
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(
|
CommandRunner.build(
|
||||||
key="T",
|
key="T",
|
||||||
@@ -438,7 +460,7 @@ async def test_command_runner_cli_with_failing_action(command_with_failing_actio
|
|||||||
await runner.cli(["--help"])
|
await runner.cli(["--help"])
|
||||||
captured = Text.from_ansi(capture.get()).plain
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
|
|
||||||
assert "usage: falyx T" in captured
|
assert "usage: falyx" in captured
|
||||||
assert "--foo" in captured
|
assert "--foo" in captured
|
||||||
assert "summary" in captured
|
assert "summary" in captured
|
||||||
assert "retries" 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"):
|
with pytest.raises(SystemExit, match="0"):
|
||||||
await runner.cli(["--help"])
|
await runner.cli(["--help"])
|
||||||
captured = Text.from_ansi(capture.get()).plain
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
assert "falyx E [--help]" in captured
|
assert "falyx [--help]" in captured
|
||||||
assert "usage:" in captured
|
assert "usage:" in captured
|
||||||
assert "positional:" in captured
|
assert "positional:" in captured
|
||||||
assert "options:" in captured
|
assert "options:" in captured
|
||||||
assert "❌" not in captured
|
|
||||||
|
|
||||||
with falyx_console.capture() as capture:
|
with falyx_console.capture() as capture:
|
||||||
with pytest.raises(SystemExit, match="2"):
|
with pytest.raises(SystemExit, match="2"):
|
||||||
await runner.cli(["--not-an-arg"])
|
await runner.cli(["--not-an-arg"])
|
||||||
captured = Text.from_ansi(capture.get()).plain
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
assert "falyx E [--help]" in captured
|
assert "falyx [--help]" in captured
|
||||||
assert "usage:" in captured
|
assert "usage:" in captured
|
||||||
assert "positional:" in captured
|
assert "positional:" in captured
|
||||||
assert "options:" in captured
|
assert "options:" in captured
|
||||||
assert "❌" in captured
|
|
||||||
falyx_console.clear()
|
falyx_console.clear()
|
||||||
|
|
||||||
with falyx_console.capture() as capture:
|
with error_console.capture() as capture:
|
||||||
with pytest.raises(SystemExit, match="1"):
|
with pytest.raises(SystemExit, match="1"):
|
||||||
await runner.cli(["FalyxError"])
|
await runner.cli(["FalyxError"])
|
||||||
captured = Text.from_ansi(capture.get()).plain
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
assert "This is a FalyxError." in captured
|
assert "This is a FalyxError." in captured
|
||||||
assert "❌ Error:" in captured
|
assert "error:" in captured
|
||||||
falyx_console.clear()
|
falyx_console.clear()
|
||||||
|
|
||||||
with falyx_console.capture() as capture:
|
with falyx_console.capture() as capture:
|
||||||
with pytest.raises(SystemExit, match="130"):
|
with pytest.raises(SystemExit, match="130"):
|
||||||
await runner.cli(["QuitSignal"])
|
await runner.cli(["QuitSignal"])
|
||||||
captured = Text.from_ansi(capture.get()).plain
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
assert "❌" not in captured
|
|
||||||
|
|
||||||
with falyx_console.capture() as capture:
|
with falyx_console.capture() as capture:
|
||||||
with pytest.raises(SystemExit, match="1"):
|
with pytest.raises(SystemExit, match="1"):
|
||||||
await runner.cli(["BackSignal"])
|
await runner.cli(["BackSignal"])
|
||||||
captured = Text.from_ansi(capture.get()).plain
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
assert "❌" not in captured
|
|
||||||
|
|
||||||
with falyx_console.capture() as capture:
|
with falyx_console.capture() as capture:
|
||||||
with pytest.raises(SystemExit, match="1"):
|
with pytest.raises(SystemExit, match="1"):
|
||||||
await runner.cli(["CancelSignal"])
|
await runner.cli(["CancelSignal"])
|
||||||
captured = Text.from_ansi(capture.get()).plain
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
assert "❌" not in captured
|
|
||||||
|
|
||||||
with falyx_console.capture() as capture:
|
with falyx_console.capture() as capture:
|
||||||
with pytest.raises(SystemExit, match="1"):
|
with pytest.raises(SystemExit, match="1"):
|
||||||
await runner.cli(["Other"])
|
await runner.cli(["Other"])
|
||||||
captured = Text.from_ansi(capture.get()).plain
|
captured = Text.from_ansi(capture.get()).plain
|
||||||
assert "❌" not in captured
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 "Action executed with args:" in captured
|
||||||
assert "and kwargs:" in captured
|
assert "and kwargs:" in captured
|
||||||
assert "{'foo': 42}" 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_falyx = AsyncMock()
|
||||||
fake_route = SimpleNamespace()
|
fake_route = SimpleNamespace()
|
||||||
fake_route.is_preview = True
|
fake_route.is_preview = True
|
||||||
|
fake_route.command = SimpleNamespace()
|
||||||
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
|
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
|
||||||
validator = CommandValidator(fake_falyx, "Invalid!")
|
validator = CommandValidator(fake_falyx, "Invalid!")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user