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:
2026-05-10 13:48:06 -04:00
parent cce92cca09
commit 8db7a9e6dc
47 changed files with 2886 additions and 1089 deletions

View File

@@ -7,7 +7,7 @@ maintaining a mutable list of named actions—such as adding, removing, or retri
actions by name—without duplicating logic across composite action types.
"""
from typing import Sequence
from typing import Any, Sequence
from falyx.action.base_action import BaseAction

View File

@@ -115,6 +115,7 @@ class ChainedAction(BaseAction, ActionListMixin):
name: str,
actions: (
Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]]
| Any
| None
) = None,
*,

View File

@@ -185,6 +185,7 @@ class LoadFileAction(BaseAction):
except Exception as error:
logger.error("Failed to parse %s: %s", self.file_path.name, error)
raise
return value
async def _run(self, *args, **kwargs) -> Any:
@@ -241,7 +242,7 @@ class LoadFileAction(BaseAction):
for line in preview_lines:
content_tree.add(f"[dim]{line}[/]")
elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}:
raw = self.load_file()
raw = await self.load_file()
if raw is not None:
preview_str = (
json.dumps(raw, indent=2)

View File

@@ -88,7 +88,12 @@ class SelectionAction(BaseAction):
allow_duplicates (bool): Whether duplicate selections are allowed.
inject_last_result (bool): If True, attempts to inject the last result as default.
inject_into (str): The keyword name for injected value (default: "last_result").
return_type (SelectionReturnType | str): The type of result to return.
return_type (SelectionReturnType | str): The type of result to return. Options:
- KEY: Return the selected key(s) only.
- VALUE: Return the value(s) associated with the selected key(s).
- DESCRIPTION: Return the description(s) of the selected item(s).
- DESCRIPTION_VALUE: Return a dict of {description: value} pairs.
- ITEMS: Return full `SelectionOption` objects as a dict {key: SelectionOption}.
prompt_session (PromptSession | None): Reused or customized prompt_toolkit session.
never_prompt (bool): If True, skips prompting and uses default_selection or last_result.
show_table (bool): Whether to render the selection table before prompting.

View File

@@ -46,6 +46,7 @@ from typing import Any, Awaitable, Callable
from prompt_toolkit.formatted_text import FormattedText
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.style import Style
from rich.tree import Tree
from falyx.action.action import Action
@@ -53,7 +54,7 @@ from falyx.action.base_action import BaseAction
from falyx.console import console
from falyx.context import ExecutionContext, InvocationContext
from falyx.debug import register_debug_hooks
from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.exceptions import CommandArgumentError, InvalidHookError, NotAFalyxError
from falyx.execution_option import ExecutionOption
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
@@ -121,14 +122,14 @@ class Command(BaseModel):
aliases (list[str], optional): Alternate names for invocation.
help_text (str): Help description shown in CLI/menu.
help_epilog (str): Additional help content.
style (str): Rich style used for rendering.
style (Style | str): Rich style used for rendering.
confirm (bool): Whether confirmation is required before execution.
confirm_message (str): Confirmation prompt text.
preview_before_confirm (bool): Whether to preview before confirmation.
spinner (bool): Enable spinner during execution.
spinner_message (str): Spinner message text.
spinner_type (str): Rich Spinner animation type (e.g., dots, line, etc.).
spinner_style (str): Rich style for the spinner.
spinner_style (Style | str): Rich style for the spinner.
spinner_speed (float): Spinner speed multiplier.
hooks (HookManager | None): Hook manager for lifecycle events.
tags (list[str], optional): Tags for grouping and filtering.
@@ -150,6 +151,8 @@ class Command(BaseModel):
Override help rendering.
custom_tldr (Callable[[], str | None] | None):
Override TLDR rendering.
custom_usage (Callable[[], str | None] | None):
Override usage rendering.
auto_args (bool): Auto-generate arguments from action signature.
arg_metadata (dict[str, Any], optional): Metadata for arguments.
simple_help_signature (bool): Use simplified help formatting.
@@ -179,14 +182,14 @@ class Command(BaseModel):
aliases: list[str] = Field(default_factory=list)
help_text: str = ""
help_epilog: str = ""
style: str = OneColors.WHITE
style: Style | str = OneColors.WHITE
confirm: bool = False
confirm_message: str = "Are you sure?"
preview_before_confirm: bool = True
spinner: bool = False
spinner_message: str = "Processing..."
spinner_type: str = "dots"
spinner_style: str = OneColors.CYAN
spinner_style: Style | str = OneColors.CYAN
spinner_speed: float = 1.0
hooks: "HookManager" = Field(default_factory=HookManager)
retry: bool = False
@@ -200,8 +203,9 @@ class Command(BaseModel):
arguments: list[dict[str, Any]] = Field(default_factory=list)
argument_config: Callable[[CommandArgumentParser], None] | None = None
custom_parser: ArgParserProtocol | None = None
custom_help: Callable[[], None] | None = None
custom_tldr: Callable[[], None] | None = None
custom_help: Callable[[], str | None] | None = None
custom_tldr: Callable[[], str | None] | None = None
custom_usage: Callable[[], str | None] | None = None
auto_args: bool = True
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
simple_help_signature: bool = False
@@ -482,6 +486,13 @@ class Command(BaseModel):
return FormattedText(prompt)
@property
def primary_alias(self) -> str:
"""Get the primary alias for the command, used in help displays."""
if self.aliases:
return self.aliases[0].lower()
return self.key
@property
def usage(self) -> str:
"""Generate a help string for the command arguments."""
@@ -527,7 +538,7 @@ class Command(BaseModel):
- Formatting may vary depending on CLI vs menu mode.
"""
if self.arg_parser and not self.simple_help_signature:
usage = self.arg_parser.get_usage(invocation_context=invocation_context)
usage = self.arg_parser.get_usage(invocation_context)
description = f"[dim]{self.help_text or self.description}[/dim]"
if self.tags:
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
@@ -549,6 +560,18 @@ class Command(BaseModel):
if self._context:
self._context.log_summary()
def render_usage(self, invocation_context: InvocationContext | None = None) -> None:
"""Render the usage information for the command."""
if callable(self.custom_usage):
output = self.custom_usage()
if output:
console.print(output)
return
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_usage(invocation_context)
else:
console.print(f"[bold]usage:[/] {self.key}")
def render_help(self, invocation_context: InvocationContext | None = None) -> bool:
"""Display the help message for the command."""
if callable(self.custom_help):
@@ -557,7 +580,7 @@ class Command(BaseModel):
console.print(output)
return True
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_help(invocation_context=invocation_context)
self.arg_parser.render_help(invocation_context)
return True
return False
@@ -569,7 +592,7 @@ class Command(BaseModel):
console.print(output)
return True
if isinstance(self.arg_parser, CommandArgumentParser):
self.arg_parser.render_tldr(invocation_context=invocation_context)
self.arg_parser.render_tldr(invocation_context)
return True
return False
@@ -617,14 +640,14 @@ class Command(BaseModel):
aliases: list[str] | None = None,
help_text: str = "",
help_epilog: str = "",
style: str = OneColors.WHITE,
style: Style | str = OneColors.WHITE,
confirm: bool = False,
confirm_message: str = "Are you sure?",
preview_before_confirm: bool = True,
spinner: bool = False,
spinner_message: str = "Processing...",
spinner_type: str = "dots",
spinner_style: str = OneColors.CYAN,
spinner_style: Style | str = OneColors.CYAN,
spinner_speed: float = 1.0,
options_manager: OptionsManager | None = None,
hooks: HookManager | None = None,
@@ -645,6 +668,7 @@ class Command(BaseModel):
custom_parser: ArgParserProtocol | None = None,
custom_help: Callable[[], str | None] | None = None,
custom_tldr: Callable[[], str | None] | None = None,
custom_usage: Callable[[], str | None] | None = None,
auto_args: bool = True,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool = False,
@@ -679,14 +703,14 @@ class Command(BaseModel):
aliases (list[str] | None): Optional alternate names for invocation.
help_text (str): Help text shown in command help output.
help_epilog (str): Additional help text shown after the main help body.
style (str): Rich style used when rendering the command.
style (Style | str): Rich style used when rendering the command.
confirm (bool): Whether confirmation is required before execution.
confirm_message (str): Confirmation prompt text.
preview_before_confirm (bool): Whether to preview before confirmation.
spinner (bool): Whether to enable spinner lifecycle hooks.
spinner_message (str): Spinner message text.
spinner_type (str): Spinner animation type.
spinner_style (str): Spinner style.
spinner_style (Style | str): Spinner style.
spinner_speed (float): Spinner speed multiplier.
options_manager (OptionsManager | None): Shared options manager for the
command and its parser.
@@ -721,6 +745,8 @@ class Command(BaseModel):
renderer.
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
renderer.
custom_usage (Callable[[], str | None] | None): Optional custom usage
renderer.
auto_args (bool): Whether to infer arguments automatically from the action
signature when explicit definitions are not provided.
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional metadata
@@ -735,8 +761,8 @@ class Command(BaseModel):
Raises:
NotAFalyxError: If `arg_parser` is provided but is not a
`CommandArgumentParser` instance, or if `hooks` is provided but is not
a `HookManager` instance.
`CommandArgumentParser` instance.
InvalidHookError: If `hooks` is provided but is not a `HookManager` instance.
Notes:
- Execution options supplied as strings are converted to
@@ -757,7 +783,7 @@ class Command(BaseModel):
options_manager = options_manager or OptionsManager()
if hooks and not isinstance(hooks, HookManager):
raise NotAFalyxError("hooks must be an instance of HookManager.")
raise InvalidHookError("hooks must be an instance of HookManager.")
hooks = hooks or HookManager()
if retry_policy and not isinstance(retry_policy, RetryPolicy):
@@ -805,6 +831,7 @@ class Command(BaseModel):
custom_parser=custom_parser,
custom_help=custom_help,
custom_tldr=custom_tldr,
custom_usage=custom_usage,
auto_args=auto_args,
arg_metadata=arg_metadata or {},
simple_help_signature=simple_help_signature,

View File

@@ -39,7 +39,7 @@ Design Notes:
duplication across Falyx runtime entrypoints.
Typical Usage:
executor = CommandExecutor(options=options, hooks=hooks, console=console)
executor = CommandExecutor(options=options, hooks=hooks)
result = await executor.execute(
command=command,
args=args,
@@ -51,8 +51,6 @@ from __future__ import annotations
from typing import Any
from rich.console import Console
from falyx.action import Action
from falyx.command import Command
from falyx.context import ExecutionContext
@@ -61,7 +59,6 @@ from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
class CommandExecutor:
@@ -81,15 +78,13 @@ class CommandExecutor:
- Apply scoped runtime overrides using `OptionsManager`
- Trigger executor-level hooks before and after command execution
- Create and manage an executor-level `ExecutionContext`
- Render execution errors to the configured console
- Control whether errors are raised, wrapped, or suppressed
- Control whether errors are raised or wrapped
- Emit optional execution summaries
Attributes:
options (OptionsManager): Shared options manager used to apply scoped
execution overrides.
hooks (HookManager): Hook manager for executor-level lifecycle hooks.
console (Console): Rich console used for user-facing error output.
"""
def __init__(
@@ -97,11 +92,9 @@ class CommandExecutor:
*,
options: OptionsManager,
hooks: HookManager,
console: Console,
) -> None:
self.options = options
self.hooks = hooks
self.console = console
def _debug_hooks(self, command: Command) -> None:
"""Log executor-level and command-level hook registrations for debugging.
@@ -112,7 +105,7 @@ class CommandExecutor:
Args:
command (Command): The command about to be executed.
"""
logger.debug("Executor hooks:\n%s", str(self.hooks))
logger.debug("executor hooks:\n%s", str(self.hooks))
logger.debug("['%s'] hooks:\n%s", command.key, str(command.hooks))
def _apply_retry_overrides(
@@ -164,7 +157,7 @@ class CommandExecutor:
else:
logger.warning(
"[%s] Retry requested, but action is not an Action instance.",
command.description,
command.key,
)
def _execution_option_overrides(
@@ -189,30 +182,6 @@ class CommandExecutor:
"skip_confirm": execution_args.get("skip_confirm", False),
}
async def _handle_action_error(
self, selected_command: Command, error: Exception
) -> None:
"""Render and log a command execution error.
This helper logs the full exception details for debugging and prints a
user-facing error message to the configured console.
Args:
selected_command (Command): The command that failed.
error (Exception): The exception raised during command execution.
"""
logger.debug(
"[%s] '%s' failed with error: %s",
selected_command.key,
selected_command.description,
error,
exc_info=True,
)
self.console.print(
f"[{OneColors.DARK_RED}]An error occurred while executing "
f"{selected_command.description}:[/] {error}"
)
async def execute(
self,
*,
@@ -277,6 +246,11 @@ class CommandExecutor:
- Summary output is only emitted when the `summary` execution option is
present in `execution_args`.
"""
if not (raise_on_error or wrap_errors):
raise FalyxError(
"CommandExecutor.execute() requires either raise_on_error=True "
"or wrap_errors=True."
)
self._debug_hooks(command)
self._apply_retry_overrides(command, execution_args)
overrides = self._execution_option_overrides(execution_args)
@@ -307,23 +281,24 @@ class CommandExecutor:
except (KeyboardInterrupt, EOFError) as error:
logger.info(
"[execute] '%s' interrupted by user.",
command.description,
command.key,
)
if wrap_errors:
raise FalyxError(
f"[execute] ⚠️ '{command.description}' interrupted by user."
f"[execute] '{command.key}' interrupted by user."
) from error
if raise_on_error:
raise error
except Exception as error:
logger.debug(
"[execute] '%s' failed: %s",
command.key,
error,
exc_info=True,
)
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
await self._handle_action_error(command, error)
if wrap_errors:
raise FalyxError(
f"[execute] '{command.description}' failed: {error}"
) from error
if raise_on_error:
raise FalyxError(f"[execute] '{command.key}' failed: {error}") from error
raise error
finally:
context.stop_timer()

View File

@@ -57,7 +57,13 @@ from falyx.action import BaseAction
from falyx.command import Command
from falyx.command_executor import CommandExecutor
from falyx.console import console as falyx_console
from falyx.exceptions import CommandArgumentError, FalyxError, NotAFalyxError
from falyx.console import error_console, print_error
from falyx.exceptions import (
CommandArgumentError,
FalyxError,
InvalidHookError,
NotAFalyxError,
)
from falyx.execution_option import ExecutionOption
from falyx.hook_manager import HookManager
from falyx.logger import logger
@@ -85,6 +91,7 @@ class CommandRunner:
Attributes:
command (Command): The command executed by this runner.
program (str): Program name used in CLI usage text and help output.
options (OptionsManager): Shared options manager used by the command,
parser, and executor.
runner_hooks (HookManager): Executor-level hooks used during execution.
@@ -97,6 +104,7 @@ class CommandRunner:
self,
command: Command,
*,
program: str | None = None,
options: OptionsManager | None = None,
runner_hooks: HookManager | None = None,
console: Console | None = None,
@@ -109,6 +117,9 @@ class CommandRunner:
Args:
command (Command): The command to execute.
program (str | None): Program name used in CLI usage text, invocation-path
rendering, and built-in help output. If `None`, an empty program name is
used.
options (OptionsManager | None): Optional shared options manager. If
omitted, a new `OptionsManager` is created.
runner_hooks (HookManager | None): Optional executor-level hook manager. If
@@ -117,16 +128,22 @@ class CommandRunner:
the default Falyx console is used.
"""
self.command = command
self.program = program or ""
self.options = self._get_options(options)
self.runner_hooks = self._get_hooks(runner_hooks)
self.console = self._get_console(console)
self.error_console = error_console
self.command.options_manager = self.options
if program:
self.command.program = program
if isinstance(self.command.arg_parser, CommandArgumentParser):
self.command.arg_parser.set_options_manager(self.options)
self.command.arg_parser.is_runner_mode = True
if program:
self.command.arg_parser.program = program
self.executor = CommandExecutor(
options=self.options,
hooks=self.runner_hooks,
console=self.console,
)
self.options.from_mapping(values={}, namespace_name="execution")
@@ -152,7 +169,7 @@ class CommandRunner:
elif isinstance(hooks, HookManager):
return hooks
else:
raise NotAFalyxError("hooks must be an instance of HookManager or None.")
raise InvalidHookError("hooks must be an instance of HookManager or None.")
async def run(
self,
@@ -253,10 +270,10 @@ class CommandRunner:
sys.exit(0)
except CommandArgumentError as error:
self.command.render_help()
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{self.command.key}'] {error}")
print_error(message=error)
sys.exit(2)
except FalyxError as error:
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
print_error(message=error)
sys.exit(1)
except QuitSignal:
logger.info("[QuitSignal]. <- Exiting run.")
@@ -276,6 +293,7 @@ class CommandRunner:
cls,
command: Command,
*,
program: str | None = None,
runner_hooks: HookManager | None = None,
options: OptionsManager | None = None,
console: Console | None = None,
@@ -288,6 +306,9 @@ class CommandRunner:
Args:
command (Command): Existing command instance to wrap.
program (str | None): Program name used in CLI usage text, invocation-path
rendering, and built-in help output. If `None`, an empty program name is
used.
runner_hooks (HookManager | None): Optional executor-level hook manager
for the runner.
options (OptionsManager | None): Optional shared options manager.
@@ -303,9 +324,10 @@ class CommandRunner:
if not isinstance(command, Command):
raise NotAFalyxError("command must be an instance of Command.")
if runner_hooks and not isinstance(runner_hooks, HookManager):
raise NotAFalyxError("runner_hooks must be an instance of HookManager.")
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
return cls(
command=command,
program=program,
options=options,
runner_hooks=runner_hooks,
console=console,
@@ -318,6 +340,7 @@ class CommandRunner:
description: str,
action: BaseAction | Callable[..., Any],
*,
program: str | None = None,
runner_hooks: HookManager | None = None,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
@@ -352,6 +375,8 @@ class CommandRunner:
execution_options: list[ExecutionOption | str] | None = None,
custom_parser: ArgParserProtocol | None = None,
custom_help: Callable[[], str | None] | None = None,
custom_tldr: Callable[[], str | None] | None = None,
custom_usage: Callable[[], str | None] | None = None,
auto_args: bool = True,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool = False,
@@ -369,6 +394,9 @@ class CommandRunner:
description (str): Short description of the command.
action (BaseAction | Callable[..., Any]): Underlying execution logic for
the command.
program (str | None): Program name used in CLI usage text, invocation-path
rendering, and built-in help output. If `None`, an empty program name is
used.
runner_hooks (HookManager | None): Optional executor-level hooks for the
runner.
args (tuple): Static positional arguments applied to the command.
@@ -418,6 +446,10 @@ class CommandRunner:
implementation.
custom_help (Callable[[], str | None] | None): Optional custom help
renderer.
custom_tldr (Callable[[], str | None] | None): Optional custom TLDR
renderer.
custom_usage (Callable[[], str | None] | None): Optional custom usage
renderer.
auto_args (bool): Whether to infer arguments automatically from the
action signature.
arg_metadata (dict[str, str | dict[str, Any]] | None): Optional
@@ -432,8 +464,9 @@ class CommandRunner:
CommandRunner: A runner wrapping the newly built command.
Raises:
NotAFalyxError: If `runner_hooks` is provided but is not a
`HookManager` instance.
NotAFalyxError: If `arg_parser` is provided but is not a
`CommandArgumentParser` instance.
InvalidHookError: If `runner_hooks` is provided but is not a `HookManager`
Notes:
- This method is intended as a standalone convenience factory.
@@ -445,6 +478,7 @@ class CommandRunner:
key=key,
description=description,
action=action,
program=program,
args=args,
kwargs=kwargs,
hidden=hidden,
@@ -478,6 +512,8 @@ class CommandRunner:
argument_config=argument_config,
custom_parser=custom_parser,
custom_help=custom_help,
custom_tldr=custom_tldr,
custom_usage=custom_usage,
auto_args=auto_args,
arg_metadata=arg_metadata,
simple_help_signature=simple_help_signature,
@@ -485,7 +521,7 @@ class CommandRunner:
)
if runner_hooks and not isinstance(runner_hooks, HookManager):
raise NotAFalyxError("runner_hooks must be an instance of HookManager.")
raise InvalidHookError("runner_hooks must be an instance of HookManager.")
return cls(
command=command,

View File

@@ -140,12 +140,11 @@ class FalyxCompleter(Completer):
suggestions = self._suggest_namespace_entries(route.namespace, route.stub)
# Only here should namespace-level help/TLDR be suggested.
if not route.command and (not route.stub or route.stub.startswith("-")):
suggestions.extend(
flag
for flag in ("-h", "--help", "-T", "--tldr")
if flag.startswith(route.stub)
)
# TODO: better completer in FalyxParser
if not route.command: # and (not route.stub or route.stub.startswith("-")):
for flag in route.namespace.parser._options_by_dest:
if flag.startswith(route.stub):
suggestions.append(flag)
if route.is_preview:
suggestions = [f"?{s}" for s in suggestions]

View File

@@ -2,6 +2,17 @@
"""Global console instance for Falyx CLI applications."""
from rich.console import Console
from falyx.themes import get_nord_theme
from falyx.themes import OneColors, get_nord_theme
console = Console(color_system="truecolor", theme=get_nord_theme())
error_console = Console(color_system="truecolor", theme=get_nord_theme(), stderr=True)
def print_error(
message: str | Exception,
*,
hint: str | None = None,
) -> None:
error_console.print(f"[{OneColors.DARK_RED}]error:[/] {message}")
if hint:
error_console.print(f"[{OneColors.LIGHT_YELLOW}]hint:[/] {hint}")

View File

@@ -10,8 +10,6 @@ It provides:
- `SharedContext` for transient shared state across grouped or chained
actions, including propagated results, indexed errors, and arbitrary
shared data.
- `InvocationSegment` for representing a single styled token within a
rendered invocation path.
- `InvocationContext` for capturing the current routed command path as an
immutable value object that supports both plain-text and Rich-markup
rendering.
@@ -30,8 +28,10 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from rich.console import Console
from rich.markup import escape
from rich.style import Style
from falyx.console import console
from falyx.display_types import StyledSegment
from falyx.mode import FalyxMode
@@ -292,24 +292,6 @@ class SharedContext(BaseModel):
)
class InvocationSegment(BaseModel):
"""Styled path segment used to build an invocation display path.
`InvocationSegment` represents a single token within an `InvocationContext`,
such as a namespace key, command key, or alias. It stores the raw display
text and an optional Rich style so invocation paths can be rendered either
as plain text or styled markup.
Attributes:
text (str): Display text for this path segment.
style (str | None): Optional Rich style applied when rendering this
segment in markup output.
"""
text: str
style: str | None = None
class InvocationContext(BaseModel):
"""Immutable invocation-path context for routed Falyx help and execution.
@@ -324,11 +306,11 @@ class InvocationContext(BaseModel):
Attributes:
program (str): Root program name used in CLI-mode help and usage output.
program_style (str): Rich style applied to the program name when rendering
program_style (Style | str): Rich style applied to the program name when rendering
`markup_path`.
typed_path (list[str]): Raw invocation tokens collected during routing,
excluding the root program name.
segments (list[InvocationSegment]): Styled path segments used to render the
segments (list[StyledSegment]): Styled path segments used to render the
invocation path with Rich markup.
mode (FalyxMode): Active Falyx mode for this invocation context. This is
used to determine whether the path should include the program name.
@@ -337,12 +319,14 @@ class InvocationContext(BaseModel):
"""
program: str = ""
program_style: str = ""
program_style: Style | str = ""
typed_path: list[str] = Field(default_factory=list)
segments: list[InvocationSegment] = Field(default_factory=list)
segments: list[StyledSegment] = Field(default_factory=list)
mode: FalyxMode = FalyxMode.MENU
is_preview: bool = False
model_config = ConfigDict(arbitrary_types_allowed=True)
@property
def is_cli_mode(self) -> bool:
"""Whether this context should render using CLI path semantics.
@@ -357,7 +341,7 @@ class InvocationContext(BaseModel):
self,
token: str,
*,
style: str | None = None,
style: Style | str | None = None,
) -> InvocationContext:
"""Return a new context with one additional path segment appended.
@@ -377,7 +361,7 @@ class InvocationContext(BaseModel):
program=self.program,
program_style=self.program_style,
typed_path=[*self.typed_path, token],
segments=[*self.segments, InvocationSegment(text=token, style=style)],
segments=[*self.segments, StyledSegment(text=token, style=style)],
mode=self.mode,
is_preview=self.is_preview,
)
@@ -427,7 +411,7 @@ class InvocationContext(BaseModel):
In CLI mode, the root program name is included and styled with
`program_style` when provided. Each path segment is escaped and styled
using its associated `InvocationSegment.style` value when present.
using its associated `StyledSegment.style` value when present.
Returns:
str: Rich-markup invocation path suitable for help and usage rendering.

33
falyx/display_types.py Normal file
View 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)

View File

@@ -17,7 +17,8 @@ Exception Hierarchy:
├── EmptyChainError
├── EmptyGroupError
├── EmptyPoolError
── CommandArgumentError
── CommandArgumentError
└── EntryNotFoundError
These are raised internally throughout the Falyx system to signal user-facing or
developer-facing problems that should be caught and reported.
@@ -25,7 +26,16 @@ developer-facing problems that should be caught and reported.
class FalyxError(Exception):
"""Custom exception for the Falyx class."""
"""Base exception class for all Falyx CLI framework errors."""
def __init__(
self,
message: str | None = None,
hint: str | None = None,
):
if message:
super().__init__(message)
self.hint = hint
class CommandAlreadyExistsError(FalyxError):
@@ -60,5 +70,152 @@ class EmptyPoolError(FalyxError):
"""Exception raised when the pool is empty."""
class CommandArgumentError(FalyxError):
class UsageError(FalyxError):
"""Exception raised when there is an error in the command usage."""
def __init__(
self,
message: str | None = None,
hint: str | None = None,
show_short_usage: bool = True,
):
super().__init__(message, hint)
self.show_short_usage = show_short_usage
class FalyxOptionError(UsageError):
"""Exception raised when there is an error in the Falyx option parser."""
class CommandArgumentError(UsageError):
"""Exception raised when there is an error in the command argument parser."""
class ArgumentGroupError(CommandArgumentError):
"""Exception raised when there is an error in the argument group."""
class ArgumentParsingError(CommandArgumentError):
"""Exception raised when there is an error during argument parsing."""
def __init__(
self,
message: str | None = None,
hint: str | None = None,
show_short_usage: bool = True,
command_key: str | None = None,
dest: str | None = None,
token: str | None = None,
):
self.command_key = command_key
self.dest = dest
self.token = token
super().__init__(message, hint, show_short_usage)
class EntryNotFoundError(UsageError):
"""Exception raised when a routing entry is not found."""
def __init__(
self,
unknown_name: str,
suggestions: list[str] | None = None,
message_context: str = "",
show_short_usage: bool = True,
):
self.unknown_name = unknown_name
self.suggestions = suggestions
self.message_context = message_context
super().__init__(
self.build_message(),
self.build_hint(),
show_short_usage,
)
def build_message(self) -> str:
prefix = f"{self.message_context}: " if self.message_context else ""
return f"{prefix}unknown command or namespace '{self.unknown_name}'."
def build_hint(self) -> str | None:
if self.suggestions:
return f"did you mean: {', '.join(self.suggestions[:10])}?"
else:
return None
class UnrecognizedOptionError(ArgumentParsingError):
def __init__(
self,
token: str,
remaining_flags: list[str] | None = None,
show_short_usage: bool = True,
):
self.remaining_flags = remaining_flags
self.token = token
super().__init__(
self.build_message(),
self.build_hint(),
show_short_usage=show_short_usage,
token=token,
)
def build_message(self) -> str:
return f"unrecognized option '{self.token}'"
def build_hint(self) -> str:
if self.remaining_flags:
return f"did you mean one of: {', '.join(self.remaining_flags)}?"
return "use --help to see available options"
class InvalidValueError(ArgumentParsingError):
def __init__(
self,
dest: str | None = None,
choices: list[str] | None = None,
expected: str | None = None,
error: Exception | str | None = None,
show_short_usage: bool = True,
):
self.choices = choices
self.expected = expected
self.error = error
self.dest = dest
super().__init__(
self.build_message(),
self.build_hint(),
show_short_usage=show_short_usage,
dest=dest,
)
def build_message(self) -> str:
if self.dest and self.choices:
return f"invalid value for '{self.dest}'"
elif self.dest and self.error:
return f"invalid value for '{self.dest}': {self.error}"
elif self.dest and self.expected:
return f"invalid value for '{self.dest}': expected {self.expected}"
else:
return "invalid command argument value."
def build_hint(self) -> str | None:
if self.dest and self.choices:
return f"the value for '{self.dest}' must be one of {{{', '.join(self.choices)}}}."
else:
return None
class MissingValueError(ArgumentParsingError):
def __init__(
self,
dest: str,
expected_count: int | None = None,
actual_count: int | None = None,
):
self.expected_count = expected_count
self.actual_count = actual_count
self.dest = dest
class TokenizationError(UsageError):
raw_input: str | None = None

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,9 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from rich.style import StyleType
from falyx.context import InvocationContext
from falyx.themes import OneColors
if TYPE_CHECKING:
@@ -35,15 +38,15 @@ class FalyxNamespace:
resolution, completion, help output, and menu rendering.
Attributes:
key: Primary identifier used to enter the namespace.
description: User-facing description of the namespace.
namespace: Nested `Falyx` instance activated when this namespace is
key (str): Primary identifier used to enter the namespace.
description (str): User-facing namespace description.
namespace (Falyx): Nested `Falyx` instance activated when this namespace is
selected.
aliases: Optional alternate names that may also resolve to the same
aliases (list[str]): Optional alternate names that may also resolve to the same
namespace.
help_text: Optional short help text used in listings or help output.
style: Rich style used when rendering the namespace key or aliases.
hidden: Whether the namespace should be omitted from visible menus and
help_text (str): Optional short help text used in listings or help output.
style (StyleType): Rich style used when rendering the namespace key or aliases.
hidden (bool): Whether the namespace should be omitted from visible menus and
help listings.
"""
@@ -52,5 +55,14 @@ class FalyxNamespace:
namespace: Falyx
aliases: list[str] = field(default_factory=list)
help_text: str = ""
style: str = OneColors.CYAN
style: StyleType = OneColors.CYAN
hidden: bool = False
def get_help_signature(
self, invocation_context: InvocationContext
) -> tuple[str, str, str | None]:
"""Returns the usage signature for this namespace, used in help rendering."""
usage = f"{self.key} {self.namespace._get_usage_fragment(invocation_context)}"
if self.aliases:
usage += f" (aliases: {', '.join(self.aliases)})"
return usage, self.description, self.help_text

View File

@@ -1,32 +1,39 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Manages global or scoped CLI options across namespaces for Falyx commands.
"""Option state management for Falyx CLI runtimes.
The `OptionsManager` provides a centralized interface for retrieving, setting, toggling,
and introspecting options defined in `argparse.Namespace` objects. It is used internally
by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc.
This module defines `OptionsManager`, a small utility responsible for
storing, retrieving, and temporarily overriding runtime option values across
named namespaces.
Each option is stored under a namespace key (e.g., "default", "user_config") to
support multiple sources of configuration.
Falyx uses this manager to hold global session- and execution-scoped flags such
as verbosity, prompt suppression, confirmation behavior, and other mutable
runtime settings. Options are stored in isolated namespace dictionaries so
different layers of the runtime can share one manager without clobbering each
other's state.
Key Features:
- Safe getter/setter for typed option resolution
- Toggle support for boolean options (used by bottom bar toggles, etc.)
- Callable getter/toggler wrappers for dynamic UI bindings
- Namespace merging via `from_namespace`
In addition to basic get/set operations, the manager provides helpers for:
Typical Usage:
- toggling boolean flags
- exposing option access as zero-argument callables for UI bindings
- temporarily overriding a namespace within a context manager
- holding a shared `SpinnerManager` for spinner lifecycle integration
Typical usage:
```
options = OptionsManager()
options.from_namespace(args, namespace_name="default")
options.from_mapping({"verbose": True})
if options.get("verbose"):
...
options.toggle("force_confirm")
value_fn = options.get_value_getter("dry_run")
toggle_fn = options.get_toggle_function("debug")
Used by:
- Falyx CLI runtime configuration
- Bottom bar toggles
- Dynamic flag injection into commands and actions
with options.override_namespace({"skip_confirm": True}, "execution"):
...
```
Attributes:
options (defaultdict[str, dict[str, Any]]): Mapping of namespace names to
option dictionaries.
spinners (SpinnerManager): Shared spinner manager available to runtime
components that need coordinated spinner rendering.
"""
from collections import defaultdict
from contextlib import contextmanager
@@ -37,17 +44,40 @@ from falyx.spinner_manager import SpinnerManager
class OptionsManager:
"""Manages CLI option state across multiple argparse namespaces.
"""Manage mutable option values across named runtime namespaces.
Allows dynamic retrieval, setting, toggling, and introspection of command-line
options. Supports named namespaces (e.g., "default") and is used throughout
Falyx for runtime configuration and bottom bar toggle integration.
`OptionsManager` is the central store for Falyx runtime flags. Each option
is stored under a namespace name such as `"default"` or `"execution"`,
allowing global settings and temporary execution-scoped overrides to
coexist in one shared object.
The manager supports direct reads and writes, boolean toggling, namespace
snapshots, and temporary override contexts. It also exposes small callable
wrappers that are useful when integrating option reads or toggles into UI
components such as bottom-bar controls or key bindings.
Args:
namespaces (list[tuple[str, dict[str, Any]]] | None): Optional initial
namespace/value pairs to preload into the manager.
Attributes:
options (defaultdict[str, dict[str, Any]]): Internal namespace-to-option
mapping.
spinners (SpinnerManager): Shared spinner manager used by other Falyx
runtime components.
"""
def __init__(
self,
namespaces: list[tuple[str, dict[str, Any]]] | None = None,
) -> None:
"""Initialize the option manager.
Args:
namespaces (list[tuple[str, dict[str, Any]]] | None): Optional list
of `(namespace_name, values)` pairs to load during
initialization.
"""
self.options: defaultdict = defaultdict(dict)
self.spinners = SpinnerManager()
if namespaces:
@@ -59,7 +89,16 @@ class OptionsManager:
values: Mapping[str, Any],
namespace_name: str = "default",
) -> None:
"""Load options from a mapping, optionally with a prefix for namespacing."""
"""Merge option values into a namespace.
Existing keys in the target namespace are updated in place. Missing
namespaces are created automatically.
Args:
values (Mapping[str, Any]): Mapping of option names to values.
namespace_name (str): Target namespace to update. Defaults to
`"default"`.
"""
self.options[namespace_name].update(dict(values))
def get(
@@ -68,7 +107,18 @@ class OptionsManager:
default: Any = None,
namespace_name: str = "default",
) -> Any:
"""Get the value of an option."""
"""Return an option value from a namespace.
Args:
option_name (str): Name of the option to retrieve.
default (Any): Value to return when the option is not present.
Defaults to `None`.
namespace_name (str): Namespace to read from. Defaults to
`"default"`.
Returns:
Any: The stored option value if present, otherwise `default`.
"""
return self.options[namespace_name].get(option_name, default)
def set(
@@ -77,7 +127,13 @@ class OptionsManager:
value: Any,
namespace_name: str = "default",
) -> None:
"""Set the value of an option."""
"""Store an option value in a namespace.
Args:
option_name (str): Name of the option to set.
value (Any): Value to store.
namespace_name (str): Namespace to update. Defaults to `"default"`.
"""
self.options[namespace_name][option_name] = value
def has_option(
@@ -85,7 +141,16 @@ class OptionsManager:
option_name: str,
namespace_name: str = "default",
) -> bool:
"""Check if an option exists in the namespace."""
"""Return whether an option exists in a namespace.
Args:
option_name (str): Name of the option to check.
namespace_name (str): Namespace to inspect. Defaults to `"default"`.
Returns:
bool: `True` if the option exists in the namespace, otherwise
`False`.
"""
return option_name in self.options[namespace_name]
def toggle(
@@ -93,7 +158,16 @@ class OptionsManager:
option_name: str,
namespace_name: str = "default",
) -> None:
"""Toggle a boolean option."""
"""Invert a boolean option in place.
Args:
option_name (str): Name of the option to toggle.
namespace_name (str): Namespace containing the option. Defaults to
`"default"`.
Raises:
TypeError: If the target option is missing or is not a boolean.
"""
current = self.get(option_name, namespace_name=namespace_name)
if not isinstance(current, bool):
raise TypeError(
@@ -109,7 +183,20 @@ class OptionsManager:
option_name: str,
namespace_name: str = "default",
) -> Callable[[], Any]:
"""Get the value of an option as a getter function."""
"""Return a zero-argument callable that reads an option value.
This is useful for UI integrations that expect a callback instead of an
eagerly evaluated value.
Args:
option_name (str): Name of the option to read.
namespace_name (str): Namespace to read from. Defaults to
`"default"`.
Returns:
Callable[[], Any]: Function that returns the current option value
when called.
"""
def _getter() -> Any:
return self.get(option_name, namespace_name=namespace_name)
@@ -121,7 +208,19 @@ class OptionsManager:
option_name: str,
namespace_name: str = "default",
) -> Callable[[], None]:
"""Get the toggle function for a boolean option."""
"""Return a zero-argument callable that toggles a boolean option.
This is useful for key bindings, bottom-bar toggles, or other UI hooks
that need a callable action.
Args:
option_name (str): Name of the boolean option to toggle.
namespace_name (str): Namespace containing the option. Defaults to
`"default"`.
Returns:
Callable[[], None]: Function that toggles the option when called.
"""
def _toggle() -> None:
self.toggle(option_name, namespace_name=namespace_name)
@@ -129,7 +228,17 @@ class OptionsManager:
return _toggle
def get_namespace_dict(self, namespace_name: str) -> dict[str, Any]:
"""Return all options in a namespace as a dictionary."""
"""Return a shallow copy of one namespace's option dictionary.
Args:
namespace_name (str): Namespace to snapshot.
Returns:
dict[str, Any]: Copy of the namespace's stored options.
Raises:
ValueError: If the requested namespace does not exist.
"""
if namespace_name not in self.options:
raise ValueError(f"Namespace '{namespace_name}' not found.")
return dict(self.options[namespace_name])
@@ -140,7 +249,24 @@ class OptionsManager:
overrides: Mapping[str, Any],
namespace_name: str = "execution",
) -> Iterator[None]:
"""Temporarily override options in a namespace within a context."""
"""Temporarily apply option overrides within a namespace.
The current namespace contents are copied before the overrides are
applied. When the context exits, the original namespace state is
restored, even if an exception is raised inside the context block.
Args:
overrides (Mapping[str, Any]): Temporary option values to merge into
the namespace.
namespace_name (str): Namespace to override. Defaults to
`"execution"`.
Yields:
None: Control is yielded to the wrapped context block.
Raises:
ValueError: If the namespace does not already exist.
"""
original = self.get_namespace_dict(namespace_name)
try:
self.from_mapping(values=overrides, namespace_name=namespace_name)

View File

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

View File

@@ -1,5 +1,5 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""CommandArgumentParser implementation for the Falyx CLI framework.
"""CommandArgumentParser for the Falyx CLI framework.
This module provides a structured, extensible argument parsing system designed
specifically for Falyx commands. It replaces traditional argparse usage with a
@@ -49,17 +49,26 @@ from __future__ import annotations
from collections import Counter, defaultdict
from copy import deepcopy
from pathlib import Path
from typing import Any, Generator, Iterable, Sequence
from typing import Any, Generator, Iterable
from rich.console import Console
from rich.markup import escape
from rich.padding import Padding
from rich.panel import Panel
from rich.style import StyleType
from falyx.action.base_action import BaseAction
from falyx.console import console
from falyx.context import InvocationContext
from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.exceptions import (
ArgumentGroupError,
ArgumentParsingError,
CommandArgumentError,
InvalidValueError,
MissingValueError,
NotAFalyxError,
UnrecognizedOptionError,
)
from falyx.execution_option import ExecutionOption
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager
@@ -73,9 +82,11 @@ from falyx.parser.parser_types import (
false_none,
true_none,
)
from falyx.parser.utils import coerce_value
from falyx.parser.utils import coerce_value, get_type_name
from falyx.signals import HelpSignal
builtin_type = type
class _GroupBuilder:
"""Helper for assigning arguments to a named group or mutex group.
@@ -99,15 +110,52 @@ class _GroupBuilder:
self.parser = parser
self.group_name = group_name
self.mutex_name = mutex_name
if group_name and mutex_name:
raise ArgumentGroupError("cannot specify both group_name and mutex_name")
if not group_name and not mutex_name:
raise ArgumentGroupError("must specify either group_name or mutex_name")
def add_argument(self, *flags, **kwargs) -> None:
def add_argument(
self,
*flags,
action: str | ArgumentAction = "store",
nargs: int | str | None = None,
default: Any = None,
type: Any = str,
choices: Iterable | None = None,
required: bool = False,
help: str = "",
dest: str | None = None,
resolver: BaseAction | None = None,
lazy_resolver: bool = True,
suggestions: list[str] | None = None,
) -> None:
self.parser.add_argument(
*flags,
action=action,
nargs=nargs,
default=default,
type=type,
choices=choices,
required=required,
help=help,
dest=dest,
resolver=resolver,
lazy_resolver=lazy_resolver,
suggestions=suggestions,
group=self.group_name,
mutex_group=self.mutex_name,
**kwargs,
)
def __str__(self) -> str:
if self.group_name:
return f"GroupBuilder(group='{self.group_name}')"
elif self.mutex_name:
return f"GroupBuilder(mutex_group='{self.mutex_name}')"
assert (
False
), "Invalid GroupBuilder state: neither group_name nor mutex_name is set"
class CommandArgumentParser:
"""
@@ -136,7 +184,7 @@ class CommandArgumentParser:
self,
command_key: str = "",
command_description: str = "",
command_style: str = "bold",
command_style: StyleType = "bold",
help_text: str = "",
help_epilog: str = "",
aliases: list[str] | None = None,
@@ -148,7 +196,7 @@ class CommandArgumentParser:
self.console: Console = console
self.command_key: str = command_key
self.command_description: str = command_description
self.command_style: str = command_style
self.command_style: StyleType = command_style
self.help_text: str = help_text
self.help_epilog: str = help_epilog
self.aliases: list[str] = aliases or []
@@ -172,11 +220,22 @@ class CommandArgumentParser:
if tldr_examples:
self.add_tldr_examples(tldr_examples)
self.options_manager: OptionsManager = options_manager or OptionsManager()
self._is_runner_mode: bool = False
def mark_as_help_command(self) -> None:
"""Mark this parser as the help command parser."""
self._is_help_command = True
@property
def is_runner_mode(self) -> bool:
"""Check if the parser is being used in a CommandRunner context."""
return self._is_runner_mode
@is_runner_mode.setter
def is_runner_mode(self, is_runner_mode: bool) -> None:
"""Set whether the parser is being used in a CommandRunner context."""
self._is_runner_mode = is_runner_mode
def set_options_manager(self, options_manager: OptionsManager) -> None:
"""Set the options manager for the parser."""
if not isinstance(options_manager, OptionsManager):
@@ -238,7 +297,7 @@ class CommandArgumentParser:
"""Register a destination as an execution argument."""
if dest in self._execution_dests:
raise CommandArgumentError(
f"Destination '{dest}' is already registered as an execution argument"
f"destination '{dest}' is already registered as an execution argument"
)
self._execution_dests.add(dest)
@@ -288,9 +347,9 @@ class CommandArgumentParser:
)
else:
raise CommandArgumentError(
f"Invalid TLDR example format: {example}. "
"Examples must be either TLDRExample instances "
"or tuples of (usage, description)."
f"invalid TLDR example format: {example}.",
hint="examples must be either TLDRExample instances "
"or tuples of (usage, description).",
)
if "tldr" not in self._dest_set:
@@ -302,7 +361,7 @@ class CommandArgumentParser:
description: str = "",
) -> _GroupBuilder:
if name in self._argument_groups:
raise CommandArgumentError(f"Argument group '{name}' already exists")
raise ArgumentGroupError(f"argument group '{name}' already exists")
self._argument_groups[name] = ArgumentGroup(name=name, description=description)
return _GroupBuilder(self, group_name=name)
@@ -314,7 +373,7 @@ class CommandArgumentParser:
description: str = "",
) -> _GroupBuilder:
if name in self._mutex_groups:
raise CommandArgumentError(f"Mutex group '{name}' already exists")
raise ArgumentGroupError(f"mutex group '{name}' already exists")
self._mutex_groups[name] = MutuallyExclusiveGroup(
name=name,
required=required,
@@ -329,7 +388,7 @@ class CommandArgumentParser:
positional = True
if positional and len(flags) > 1:
raise CommandArgumentError("Positional arguments cannot have multiple flags")
raise CommandArgumentError("positional arguments cannot have multiple flags")
return positional
def _validate_groups(
@@ -342,22 +401,23 @@ class CommandArgumentParser:
"""Validate that the specified groups exist and are compatible."""
if group is not None:
if group not in self._argument_groups:
raise CommandArgumentError(f"Argument group '{group}' does not exist")
raise ArgumentGroupError(f"argument group '{group}' does not exist")
if mutex_group is not None:
if mutex_group not in self._mutex_groups:
raise CommandArgumentError(
f"Mutually exclusive group '{mutex_group}' does not exist"
raise ArgumentGroupError(
f"mutually exclusive group '{mutex_group}' does not exist"
)
if positional and mutex_group is not None:
raise CommandArgumentError(
"Positional arguments cannot belong to a mutually exclusive group"
raise ArgumentGroupError(
"positional arguments cannot belong to a mutually exclusive group"
)
if required and mutex_group is not None:
raise CommandArgumentError(
"Arguments inside a mutually exclusive group should not be individually required; "
"make the group required instead."
raise ArgumentGroupError(
"arguments inside a mutually exclusive group cannot be individually required",
hint="make the group required instead",
)
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
@@ -365,27 +425,31 @@ class CommandArgumentParser:
if dest:
if not dest.replace("_", "").isalnum():
raise CommandArgumentError(
"dest must be a valid identifier (letters, digits, and underscores only)"
f"invalid dest '{dest}' must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise CommandArgumentError("dest must not start with a digit")
raise CommandArgumentError(
f"invalid dest '{dest}': cannot start with a digit"
)
return dest
dest = None
for flag in flags:
if flag.startswith("--"):
dest = flag.lstrip("-").replace("-", "_").lower()
dest = flag.lstrip("-").replace("-", "_")
break
elif flag.startswith("-"):
dest = flag.lstrip("-").replace("-", "_").lower()
dest = flag.lstrip("-").replace("-", "_")
else:
dest = flag.replace("-", "_").lower()
dest = flag.replace("-", "_")
assert dest is not None, "dest should not be None"
if not dest.replace("_", "").isalnum():
raise CommandArgumentError(
"dest must be a valid identifier (letters, digits, and underscores only)"
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise CommandArgumentError("dest must not start with a digit")
raise CommandArgumentError(
f"invalid dest '{dest}': cannot start with a digit"
)
return dest
def _determine_required(
@@ -405,7 +469,7 @@ class CommandArgumentParser:
ArgumentAction.TLDR,
):
raise CommandArgumentError(
f"Argument with action {action} cannot be required"
f"argument with action '{action}' cannot be required"
)
return True
if positional:
@@ -441,7 +505,7 @@ class CommandArgumentParser:
):
if nargs is not None:
raise CommandArgumentError(
f"nargs cannot be specified for {action} actions"
f"nargs cannot be specified for '{action}' actions"
)
return None
if nargs is None:
@@ -452,7 +516,7 @@ class CommandArgumentParser:
raise CommandArgumentError("nargs must be a positive integer")
elif isinstance(nargs, str):
if nargs not in allowed_nargs:
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
raise CommandArgumentError(f"invalid nargs value: {nargs}")
else:
raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
return nargs
@@ -461,14 +525,16 @@ class CommandArgumentParser:
self, choices: Iterable | None, expected_type: Any, action: ArgumentAction
) -> list[Any]:
"""Normalize and validate choices for the argument."""
if choices is not None:
if choices is None:
choices = []
else:
if action in (
ArgumentAction.STORE_TRUE,
ArgumentAction.STORE_FALSE,
ArgumentAction.STORE_BOOL_OPTIONAL,
):
raise CommandArgumentError(
f"choices cannot be specified for {action} actions"
f"choices cannot be specified for '{action}' actions"
)
if isinstance(choices, dict):
raise CommandArgumentError("choices cannot be a dict")
@@ -478,14 +544,13 @@ class CommandArgumentParser:
raise CommandArgumentError(
"choices must be iterable (like list, tuple, or set)"
) from error
else:
choices = []
for choice in choices:
try:
coerce_value(choice, expected_type)
except Exception as error:
type_name = get_type_name(expected_type)
raise CommandArgumentError(
f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}"
f"invalid choice {choice!r}: cannot be coerced to {type_name} error: {error}"
) from error
return choices
@@ -493,25 +558,29 @@ class CommandArgumentParser:
self, default: Any, expected_type: type, dest: str
) -> None:
"""Validate the default value type."""
if default is not 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 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
def _validate_default_list_type(
self, default: list[Any], expected_type: type, dest: str
) -> None:
"""Validate the default value type for a list."""
if isinstance(default, list):
if not isinstance(default, list):
return None
for item in default:
try:
coerce_value(item, expected_type)
except Exception as error:
type_name = get_type_name(expected_type)
raise CommandArgumentError(
f"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
def _validate_resolver(
@@ -524,7 +593,7 @@ class CommandArgumentParser:
raise CommandArgumentError("resolver must be provided for ACTION action")
elif action != ArgumentAction.ACTION and resolver is not None:
raise CommandArgumentError(
f"resolver should not be provided for action {action}"
f"resolver should not be provided for action '{action}'"
)
if not isinstance(resolver, BaseAction):
@@ -540,7 +609,8 @@ class CommandArgumentParser:
action = ArgumentAction(action)
except ValueError as error:
raise CommandArgumentError(
f"Invalid action '{action}' is not a valid ArgumentAction"
f"invalid action '{action}' is not a valid ArgumentAction",
hint=f"valid actions are: {', '.join([a.value for a in ArgumentAction])}",
) from error
if action in (
ArgumentAction.STORE_TRUE,
@@ -552,7 +622,7 @@ class CommandArgumentParser:
):
if positional:
raise CommandArgumentError(
f"Action '{action}' cannot be used with positional arguments"
f"action '{action}' cannot be used with positional arguments"
)
return action
@@ -579,49 +649,55 @@ class CommandArgumentParser:
return []
else:
return None
elif action in (
ArgumentAction.STORE_TRUE,
ArgumentAction.STORE_FALSE,
ArgumentAction.STORE_BOOL_OPTIONAL,
):
elif action is ArgumentAction.STORE_TRUE and default is not False:
raise CommandArgumentError(
f"Default value cannot be set for action {action}. It is a boolean flag."
f"default value for '{action}' action must be False or None, got {default!r}"
)
elif action is ArgumentAction.STORE_FALSE and default is not True:
raise CommandArgumentError(
f"default value for '{action}' action must be True or None, got {default!r}"
)
elif action is ArgumentAction.STORE_BOOL_OPTIONAL:
raise CommandArgumentError(
f"default value for '{action}' action must be None, got {default!r}"
)
elif action in (ArgumentAction.HELP, ArgumentAction.TLDR, ArgumentAction.COUNT):
raise CommandArgumentError(
f"Default value cannot be set for action {action}."
f"default value cannot be set for action '{action}'."
)
if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance(
default, list
):
type_name = get_type_name(default)
raise CommandArgumentError(
f"Default value for action {action} must be a list, got {type(default).__name__}"
f"default value for action '{action}' must be a list, got {type_name}"
)
if isinstance(nargs, int) and nargs == 1:
if not isinstance(default, list):
default = [default]
if isinstance(nargs, int) or nargs in ("*", "+"):
if not isinstance(default, list):
type_name = get_type_name(default)
raise CommandArgumentError(
f"Default value for action {action} with nargs {nargs} must be a list, got {type(default).__name__}"
f"default value for action '{action}' with nargs {nargs} must be a list, got {type_name}"
)
return default
def _validate_flags(self, flags: tuple[str, ...]) -> None:
"""Validate the flags provided for the argument."""
if not flags:
raise CommandArgumentError("No flags provided")
raise CommandArgumentError("no flags provided for argument")
for flag in flags:
if not isinstance(flag, str):
raise CommandArgumentError(f"Flag '{flag}' must be a string")
raise CommandArgumentError(f"invalid flag '{flag}' must be a string")
if flag.startswith("--") and len(flag) < 3:
raise CommandArgumentError(
f"Flag '{flag}' must be at least 3 characters long"
f"invalid flag '{flag}': long flags must have at least one character after '--'"
)
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
raise CommandArgumentError(
f"Flag '{flag}' must be a single character or start with '--'"
f"invalid flag '{flag}': short flags must be a single character"
)
def _register_store_bool_optional(
@@ -654,7 +730,6 @@ class CommandArgumentParser:
group=group,
mutex_group=mutex_group,
)
negated_argument = Argument(
flags=(negated_flag,),
dest=dest,
@@ -677,7 +752,7 @@ class CommandArgumentParser:
if flag in self._flag_map and not bypass_validation:
existing = self._flag_map[flag]
raise CommandArgumentError(
f"Flag '{flag}' is already used by argument '{existing.dest}'"
f"flag '{flag}' is already used by argument '{existing.dest}'"
)
for flag in argument.flags:
@@ -719,8 +794,7 @@ class CommandArgumentParser:
group: str | None = None,
mutex_group: str | None = None,
) -> None:
"""
Define a new argument for the parser.
"""Define a new argument for the parser.
Supports positional and flagged arguments, type coercion, default values,
validation rules, and optional resolution via `BaseAction`.
@@ -741,19 +815,18 @@ class CommandArgumentParser:
group (str | None): Optional argument group name for help organization.
mutex_group (str | None): Optional mutually exclusive group name.
"""
expected_type = type
self._validate_flags(flags)
positional = self._is_positional(flags)
dest = self._get_dest_from_flags(flags, dest)
if dest in self._dest_set:
raise CommandArgumentError(
f"Destination '{dest}' is already defined.\n"
"Merging multiple arguments into the same dest (e.g. positional + flagged) "
"is not supported. Define a unique 'dest' for each argument."
)
if dest in self.RESERVED_DESTS:
raise CommandArgumentError(
f"Destination '{dest}' is reserved and cannot be used."
f"invalid dest '{dest}': '{dest}' is reserved and cannot be used."
)
if dest in self._dest_set:
raise CommandArgumentError(
f"destination '{dest}' is already defined.",
hint="merging multiple arguments into the same dest (e.g. positional + flagged) "
"is not supported. Define a unique 'dest' for each argument.",
)
self._validate_groups(group, mutex_group, positional, required)
@@ -768,38 +841,45 @@ class CommandArgumentParser:
and default is not None
):
if isinstance(default, list):
self._validate_default_list_type(default, expected_type, dest)
self._validate_default_list_type(default, type, dest)
else:
self._validate_default_type(default, expected_type, dest)
choices = self._normalize_choices(choices, expected_type, action)
self._validate_default_type(default, type, dest)
choices = self._normalize_choices(choices, type, action)
if default is not None and choices:
choices_str = ", ".join((str(choice) for choice in choices))
if isinstance(default, list):
if not all(choice in choices for choice in default):
raise CommandArgumentError(
f"Default list value {default!r} for '{dest}' must be a subset of choices: {choices}"
f"default list value {default!r} for '{dest}' must be a subset of choices: {choices_str}"
)
elif default not in choices:
# If default is not in choices, raise an error
raise CommandArgumentError(
f"Default value '{default}' not in allowed choices: {choices}"
f"default value '{default}' not in allowed choices: {choices_str}"
)
required = self._determine_required(required, positional, nargs, action)
if not isinstance(suggestions, Sequence) and suggestions is not None:
if suggestions is not None and not isinstance(suggestions, list):
type_name = get_type_name(suggestions)
raise CommandArgumentError(
f"suggestions must be a list or None, got {type(suggestions)}"
f"suggestions must be a list or None, got {type_name}"
)
if isinstance(suggestions, list) and not all(
isinstance(suggestion, str) for suggestion in suggestions
):
raise CommandArgumentError("suggestions must be a list of strings")
if not isinstance(lazy_resolver, bool):
type_name = get_type_name(lazy_resolver)
raise CommandArgumentError(
f"lazy_resolver must be a boolean, got {type(lazy_resolver)}"
f"lazy_resolver must be a boolean, got {type_name}"
)
if action == ArgumentAction.STORE_BOOL_OPTIONAL:
self._register_store_bool_optional(flags, dest, help, group, mutex_group)
else:
return None
argument = Argument(
flags=flags,
dest=dest,
action=action,
type=expected_type,
type=type,
default=default,
choices=choices,
required=required,
@@ -871,8 +951,9 @@ class CommandArgumentParser:
return None
arg_states[spec.dest].reset()
arg_states[spec.dest].has_invalid_choice = True
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}"
raise InvalidValueError(
dest=spec.dest,
choices=spec.choices,
)
def _raise_remaining_args_error(
@@ -888,14 +969,7 @@ class CommandArgumentParser:
if arg.dest not in consumed_dests and flag.startswith(token)
]
if remaining_flags:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(remaining_flags)}?"
)
else:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Use --help to see available options."
)
raise UnrecognizedOptionError(token=token, remaining_flags=remaining_flags)
def _consume_nargs(
self, args: list[str], index: int, spec: Argument
@@ -910,13 +984,19 @@ class CommandArgumentParser:
values = []
if isinstance(spec.nargs, int):
if index + spec.nargs > len(args):
raise CommandArgumentError(
f"Expected {spec.nargs} value(s) for '{spec.dest}' but got {len(args) - index}"
raise MissingValueError(
spec.dest,
expected_count=spec.nargs,
actual_count=len(args) - index,
)
# raise CommandArgumentError(
# f"Expected {spec.nargs} value(s) for '{spec.dest}' but got {len(args) - index}"
# )
values = args[index : index + spec.nargs]
return values, index + spec.nargs
elif spec.nargs == "+":
if index >= len(args):
raise MissingValueError(spec.dest, expected_count=1)
raise CommandArgumentError(
f"Expected at least one value for '{spec.dest}'"
)
@@ -1002,21 +1082,28 @@ class CommandArgumentParser:
else:
arg_states[spec.dest].reset()
arg_states[spec.dest].has_invalid_choice = True
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
raise InvalidValueError(dest=spec.dest, error=error) from error
if spec.action == ArgumentAction.ACTION:
assert isinstance(
spec.resolver, BaseAction
), "resolver should be an instance of BaseAction"
if spec.nargs == "+" and len(typed) == 0:
raise CommandArgumentError(
f"Argument '{spec.dest}' requires at least one value"
raise MissingValueError(
dest=spec.dest,
expected_count=1,
)
# raise CommandArgumentError(
# f"Argument '{spec.dest}' requires at least one value"
# )
if isinstance(spec.nargs, int) and len(typed) != spec.nargs:
raise CommandArgumentError(
f"Argument '{spec.dest}' requires exactly {spec.nargs} value(s)"
raise MissingValueError(
spec.dest,
expected_count=spec.nargs,
actual_count=len(typed),
)
# raise CommandArgumentError(
# f"Argument '{spec.dest}' requires exactly {spec.nargs} value(s)"
# )
if not spec.lazy_resolver or not from_validate:
try:
result[spec.dest] = await spec.resolver(*typed)
@@ -1094,7 +1181,7 @@ class CommandArgumentParser:
flag = f"-{char}"
arg = self._flag_map.get(flag)
if not arg:
raise CommandArgumentError(f"Unrecognized option: {flag}")
raise UnrecognizedOptionError(flag)
expanded.append(flag)
else:
return token
@@ -1129,8 +1216,9 @@ class CommandArgumentParser:
)
elif spec.nargs is None:
try:
type_name = get_type_name(spec.type)
raise CommandArgumentError(
f"Enter a {spec.type.__name__} value for '{spec.dest}'. {help_text}"
f"Enter a {type_name} value for '{spec.dest}'. {help_text}"
)
except AttributeError as error:
raise CommandArgumentError(
@@ -1161,7 +1249,7 @@ class CommandArgumentParser:
if action == ArgumentAction.HELP:
if not from_validate:
self.render_help(invocation_context=invocation_context)
self.render_help(invocation_context)
arg_states[spec.dest].set_consumed()
raise HelpSignal()
elif action == ArgumentAction.TLDR:
@@ -1171,7 +1259,7 @@ class CommandArgumentParser:
consumed_indices.add(index)
index += 1
elif not from_validate:
self.render_tldr(invocation_context=invocation_context)
self.render_tldr(invocation_context)
arg_states[spec.dest].set_consumed()
raise HelpSignal()
else:
@@ -1187,9 +1275,7 @@ class CommandArgumentParser:
except ValueError as error:
arg_states[spec.dest].reset()
arg_states[spec.dest].has_invalid_choice = True
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
raise InvalidValueError(dest=spec.dest, error=error) from error
if not spec.lazy_resolver or not from_validate:
try:
result[spec.dest] = await spec.resolver(*typed_values)
@@ -1228,9 +1314,7 @@ class CommandArgumentParser:
except ValueError as error:
arg_states[spec.dest].reset()
arg_states[spec.dest].has_invalid_choice = True
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
raise InvalidValueError(dest=spec.dest, error=error) from error
if not typed_values:
self._raise_suggestion_error(spec)
if spec.nargs is None:
@@ -1247,9 +1331,7 @@ class CommandArgumentParser:
except ValueError as error:
arg_states[spec.dest].reset()
arg_states[spec.dest].has_invalid_choice = True
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
raise InvalidValueError(dest=spec.dest, error=error) from error
result[spec.dest].extend(typed_values)
consumed_indices.update(range(index, new_index))
index = new_index
@@ -1260,9 +1342,7 @@ class CommandArgumentParser:
except ValueError as error:
arg_states[spec.dest].reset()
arg_states[spec.dest].has_invalid_choice = True
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
raise InvalidValueError(dest=spec.dest, error=error) from error
if not typed_values and spec.nargs not in ("*", "?"):
self._raise_suggestion_error(spec)
if spec.nargs in (None, 1, "?"):
@@ -1497,26 +1577,29 @@ class CommandArgumentParser:
if isinstance(spec.nargs, int) and spec.nargs > 1:
assert isinstance(
result.get(spec.dest), list
), f"Invalid value for '{spec.dest}': expected a list"
), f"invalid value for '{spec.dest}': expected a list"
if not result[spec.dest] and not spec.required:
continue
if spec.action == ArgumentAction.APPEND:
for group in result[spec.dest]:
if len(group) % spec.nargs != 0:
arg_states[spec.dest].reset()
raise CommandArgumentError(
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
raise InvalidValueError(
dest=spec.dest,
error=f"invalid number of values: expected a multiple of {spec.nargs}",
)
elif spec.action == ArgumentAction.EXTEND:
if len(result[spec.dest]) % spec.nargs != 0:
arg_states[spec.dest].reset()
raise CommandArgumentError(
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
raise InvalidValueError(
dest=spec.dest,
error=f"invalid number of values: expected a multiple of {spec.nargs}",
)
elif len(result[spec.dest]) != spec.nargs:
arg_states[spec.dest].reset()
raise CommandArgumentError(
f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}"
raise InvalidValueError(
dest=spec.dest,
error=f"invalid number of values: expected {spec.nargs}, got {len(result[spec.dest])}",
)
if isinstance(spec.nargs, str) and spec.nargs == "+":
@@ -2047,6 +2130,8 @@ class CommandArgumentParser:
program_style = (
self.options_manager.get("program_style") or self.command_style
)
if self.is_runner_mode:
return f"[{program_style}]{program}[/{program_style}]"
return f"[{program_style}]{program}[/{program_style}] {command_keys}"
if invocation_context.is_cli_mode:
@@ -2067,6 +2152,19 @@ class CommandArgumentParser:
options_text = self.get_options_text()
return f"{prefix} {options_text}".strip() if options_text else prefix
def render_usage(
self,
invocation_context: InvocationContext | None = None,
) -> None:
"""Render the usage string for this parser.
Args:
invocation_context (InvocationContext | None): Optional routed invocation
context used to scope the rendered usage path.
"""
usage = self.get_usage(invocation_context)
self.console.print(f"[bold]usage:[/bold] {usage}")
def _iter_keyword_help_sections(
self,
) -> Generator[tuple[str, str, list[Argument]], None, None]:
@@ -2093,7 +2191,6 @@ class CommandArgumentParser:
def render_help(
self,
*,
invocation_context: InvocationContext | None = None,
) -> None:
"""Render full help output for the command.
@@ -2112,15 +2209,14 @@ class CommandArgumentParser:
- Supports argument grouping and mutually exclusive groups
- Applies styling based on configured command style
"""
usage = self.get_usage(invocation_context)
self.console.print(f"[bold]usage: {usage}[/bold]\n")
self.render_usage(invocation_context)
if self.help_text:
self.console.print(self.help_text + "\n")
self.console.print(f"\n{self.help_text}")
if self._arguments:
if self._positional:
self.console.print("[bold]positional:[/bold]")
self.console.print("\n[bold]positional:[/bold]")
for arg in self._positional.values():
flags = arg.get_positional_text()
arg_line = f" {flags:<30} "
@@ -2167,7 +2263,7 @@ class CommandArgumentParser:
if self.help_epilog:
self.console.print("\n" + self.help_epilog, style="dim")
def render_tldr(self, *, invocation_context: InvocationContext | None = None) -> None:
def render_tldr(self, invocation_context: InvocationContext | None = None) -> None:
"""Render concise example usage (TLDR) for the command.
This method displays a minimal, example-driven view of how to invoke
@@ -2185,13 +2281,12 @@ class CommandArgumentParser:
)
return
prefix = self._get_invocation_prefix(invocation_context)
usage = self.get_usage(invocation_context)
self.console.print(f"[bold]usage:[/] {usage}\n")
self.render_usage(invocation_context)
if self.help_text:
self.console.print(f"{self.help_text}\n")
self.console.print(f"\n{self.help_text}")
self.console.print("[bold]examples:[/bold]")
self.console.print("\n[bold]examples:[/bold]")
for example in self._tldr_examples:
usage = f"{prefix} {example.usage.strip()}"
description = example.description.strip()

View File

@@ -1,179 +1,650 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Root parsing models and helpers for the Falyx CLI runtime.
This module defines the minimal parsing layer used before namespace routing and
command-local argument parsing begin.
It provides:
- `RootOptions`, a lightweight container for session-scoped flags such as
verbose logging, help, TLDR, and prompt suppression.
- `FalyxParser`, a small root parser that consumes only leading global options
from argv and leaves the remaining tokens untouched for downstream routing.
Unlike `CommandArgumentParser`, this module does not parse command-specific
arguments or attempt to resolve leaf-command inputs. Its responsibility is
intentionally narrow: identify root-level flags, determine the initial
application mode, and normalize the result into a `RootParseResult`.
Parsing behavior is prefix-based. Root flags are consumed only from the start of
argv, and parsing stops at the first non-root token or an explicit `--`
separator. This allows the remaining arguments to be preserved exactly for later
namespace resolution and command-local parsing.
Typical flow:
1. Raw argv is passed to `FalyxParser.parse()`.
2. Leading root/session flags are extracted into `RootOptions`.
3. A `RootParseResult` is returned with either:
- `FalyxMode.HELP` when root help or TLDR was requested, or
- `FalyxMode.COMMAND` when normal routed execution should continue.
4. Remaining argv is forwarded unchanged to the main Falyx routing layer.
This module serves as the root-entry parsing boundary for Falyx applications.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any
from falyx.console import console
from falyx.exceptions import EntryNotFoundError, FalyxOptionError
from falyx.mode import FalyxMode
from falyx.parser.parse_result import RootParseResult
from falyx.options_manager import OptionsManager
from falyx.parser.parse_result import ParseResult
from falyx.parser.parser_types import (
FalyxTLDRExample,
FalyxTLDRInput,
false_none,
true_none,
)
from falyx.parser.utils import coerce_value, get_type_name
if TYPE_CHECKING:
from falyx.falyx import Falyx
builtin_type = type
class OptionAction(Enum):
STORE = "store"
STORE_TRUE = "store_true"
STORE_FALSE = "store_false"
STORE_BOOL_OPTIONAL = "store_bool_optional"
COUNT = "count"
HELP = "help"
TLDR = "tldr"
@classmethod
def choices(cls) -> list[OptionAction]:
"""Return a list of all argument actions."""
return list(cls)
@classmethod
def _get_alias(cls, value: str) -> str:
aliases = {
"optional": "store_bool_optional",
"true": "store_true",
"false": "store_false",
}
return aliases.get(value, value)
@classmethod
def _missing_(cls, value: object) -> OptionAction:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized)
for member in cls:
if member.value == alias:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
def __str__(self) -> str:
"""Return the string representation of the argument action."""
return self.value
class OptionScope(Enum):
ROOT = "root"
NAMESPACE = "namespace"
@classmethod
def _missing_(cls, value: object) -> OptionScope:
if not isinstance(value, str):
raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
for member in cls:
if member.value == normalized:
return member
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}")
@dataclass(slots=True)
class RootOptions:
"""Container for root-level Falyx session flags.
class Option:
flags: tuple[str, ...]
dest: str
action: OptionAction = OptionAction.STORE
type: Any = str
default: Any = None
choices: list[str] | None = None
help: str = ""
suggestions: list[str] | None = None
scope: OptionScope = OptionScope.NAMESPACE
`RootOptions` stores the boolean flags recognized at the application
boundary before namespace routing and command-local parsing begin. These
values represent session-scoped behavior that applies to the overall Falyx
runtime rather than to any individual command.
The model is intentionally small and lightweight. It is produced by
`FalyxParser._parse_root_options()` and then translated into a
`RootParseResult` that drives the initial execution mode and runtime
configuration.
Attributes:
verbose: Whether verbose logging should be enabled for the session.
debug_hooks: Whether hook execution should be logged in detail.
never_prompt: Whether prompts should be suppressed for the session.
help: Whether root help output was requested.
tldr: Whether root TLDR output was requested.
"""
verbose: bool = False
debug_hooks: bool = False
never_prompt: bool = False
help: bool = False
tldr: bool = False
def format_for_help(self) -> str:
"""Return a formatted string of the option's flags for help output."""
return ", ".join(self.flags)
class FalyxParser:
"""Parse root-level Falyx CLI flags into an initial runtime result.
RESERVED_DESTS: set[str] = {"help", "tldr"}
`FalyxParser` is the narrow, top-level parser used before namespace routing
and command-local argument parsing begin. Its job is to inspect only the
leading session-scoped flags in argv, determine the initial application
mode, and return a normalized `RootParseResult`.
def __init__(self, flx: Falyx) -> None:
self._flx = flx
self._options_by_dest: dict[str, Option] = {}
self._options: list[Option] = []
self._dest_set: set[str] = set()
self._tldr_examples: list[FalyxTLDRExample] = []
self._add_reserved_options()
self.help_option: Option | None = None
self.tldr_option: Option | None = None
Responsibilities:
- Parse only root/session flags such as verbose logging, help, TLDR,
and prompt suppression.
- Stop parsing at the first non-root token or explicit `--` separator.
- Preserve the remaining argv exactly for downstream routing.
- Translate root help or TLDR requests into `FalyxMode.HELP`.
- Translate normal execution into `FalyxMode.COMMAND`.
def get_flags(self) -> list[str]:
"""Return a list of the first flag for the registered options."""
return [option.flags[0] for option in self._options]
Design Notes:
- This parser does not resolve commands or namespaces.
- This parser does not parse command-specific arguments.
- Command-local parsing is delegated later to `CommandArgumentParser`
after Falyx routing has identified a leaf command.
- Root parsing is intentionally prefix-only so session flags apply at
the application boundary without mutating command-local argv.
def get_options(self) -> list[Option]:
"""Return a list of registered options."""
return self._options
Typical Usage:
`Falyx.run()` or another top-level entrypoint passes raw argv into
`FalyxParser.parse()`, applies the returned session options, and then
forwards the untouched remaining argv into the routed Falyx execution
flow.
def _add_tldr(self):
"""Add TLDR argument to the parser."""
if "tldr" in self._dest_set:
return None
tldr = Option(
flags=("--tldr", "-T"),
action=OptionAction.TLDR,
help="Show quick usage examples.",
dest="tldr",
default=False,
)
self._register_option(tldr)
self.tldr_option = tldr
Attributes:
ROOT_FLAG_ALIASES: Mapping of recognized root CLI flags to
`RootOptions` attribute names.
def add_tldr_example(
self,
*,
entry_key: str,
usage: str,
description: str,
) -> None:
"""Register a single namespace-level TLDR example.
The referenced entry must resolve to a known command or namespace in the
current `Falyx` instance. Unknown entries are reported to the console and
are not added.
Args:
entry_key (str): Command or namespace key the example is associated with.
usage (str): Example usage fragment shown after the resolved invocation path.
description (str): Short explanation displayed alongside the example.
Raises:
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
namespace in this `Falyx` instance.
"""
entry, suggestions = self._flx.resolve_entry(entry_key)
if not entry:
raise EntryNotFoundError(
unknown_name=entry_key,
suggestions=suggestions,
message_context="TLDR example",
)
self._tldr_examples.append(
FalyxTLDRExample(entry_key=entry_key, usage=usage, description=description)
)
self._add_tldr()
ROOT_FLAG_ALIASES: dict[str, str] = {
"-n": "never_prompt",
"--never-prompt": "never_prompt",
"-v": "verbose",
"--verbose": "verbose",
"-d": "debug_hooks",
"--debug-hooks": "debug_hooks",
"?": "help",
"-h": "help",
"--help": "help",
"-T": "tldr",
"--tldr": "tldr",
def add_tldr_examples(self, examples: list[FalyxTLDRInput]) -> None:
"""Register multiple namespace-level TLDR examples.
Supports either `FalyxTLDRExample` objects or shorthand tuples of
`(entry_key, usage, description)`.
Args:
examples (list[FalyxTLDRInput]): Example definitions to validate and append.
Raises:
FalyxError: If an example has an unsupported shape.
EntryNotFoundError: If `entry_key` cannot be resolved to a known command or
namespace in this `Falyx` instance.
"""
for example in examples:
if isinstance(example, FalyxTLDRExample):
entry, suggestions = self._flx.resolve_entry(example.entry_key)
if not entry:
raise EntryNotFoundError(
unknown_name=example.entry_key,
suggestions=suggestions,
message_context="TLDR example",
)
self._tldr_examples.append(example)
self._add_tldr()
elif len(example) == 3:
entry_key, usage, description = example
self.add_tldr_example(
entry_key=entry_key,
usage=usage,
description=description,
)
self._add_tldr()
else:
raise FalyxOptionError(
f"invalid TLDR example format: {example}.\n"
"examples must be either FalyxTLDRExample instances "
"or tuples of (entry_key, usage, description).",
)
def _add_reserved_options(self) -> None:
help = Option(
flags=("-h", "--help", "?"),
dest="help",
action=OptionAction.HELP,
help="Show root-level help output and exit.",
default=False,
)
self._register_option(help)
self.help_option = help
if not self._flx.disable_verbose_option:
verbose = Option(
flags=("-v", "--verbose"),
dest="verbose",
action=OptionAction.STORE_TRUE,
help="Enable verbose logging for the session.",
default=False,
scope=OptionScope.ROOT,
)
self._register_option(verbose)
if not self._flx.disable_debug_hooks_option:
debug_hooks = Option(
flags=("-d", "--debug-hooks"),
dest="debug_hooks",
action=OptionAction.STORE_TRUE,
help="Log hook execution in detail for the session.",
default=False,
scope=OptionScope.ROOT,
)
self._register_option(debug_hooks)
if not self._flx.disable_never_prompt_option:
never_prompt = Option(
flags=("-n", "--never-prompt"),
dest="never_prompt",
action=OptionAction.STORE_TRUE,
help="Suppress all prompts for the session.",
default=False,
scope=OptionScope.ROOT,
)
self._register_option(never_prompt)
def _register_store_bool_optional(
self,
flags: tuple[str, ...],
dest: str,
help: str,
) -> None:
"""Register a store_bool_optional action with the parser."""
if len(flags) != 1:
raise FalyxOptionError(
"store_bool_optional action can only have a single flag"
)
if not flags[0].startswith("--"):
raise FalyxOptionError(
"store_bool_optional action must use a long flag (e.g. --flag)"
)
base_flag = flags[0]
negated_flag = f"--no-{base_flag.lstrip('-')}"
argument = Option(
flags=flags,
dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL,
type=true_none,
default=None,
help=help,
)
negated_argument = Option(
flags=(negated_flag,),
dest=dest,
action=OptionAction.STORE_BOOL_OPTIONAL,
type=false_none,
default=None,
help=help,
)
self._register_option(argument)
self._register_option(negated_argument, bypass_validation=True)
def _register_option(self, option: Option, bypass_validation: bool = False) -> None:
self._dest_set.add(option.dest)
self._options.append(option)
for flag in option.flags:
if flag in self._options and not bypass_validation:
existing = self._options_by_dest[flag]
raise FalyxOptionError(
f"flag '{flag}' is already used by argument '{existing.dest}'"
)
self._options_by_dest[flag] = option
def _validate_flags(self, flags: tuple[str, ...]) -> None:
if not flags:
raise FalyxOptionError("no flags provided for option")
for flag in flags:
if not isinstance(flag, str):
raise FalyxOptionError(f"invalid flag '{flag}': must be a string")
if not flag.startswith("-"):
raise FalyxOptionError(f"invalid flag '{flag}': must start with '-'")
if flag.startswith("--") and len(flag) < 3:
raise FalyxOptionError(
f"invalid flag '{flag}': long flags must have at least one character after '--'"
)
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
raise FalyxOptionError(
f"invalid flag '{flag}': short flags must be a single character"
)
if flag in self._options_by_dest:
existing = self._options_by_dest[flag]
raise FalyxOptionError(
f"flag '{flag}' is already used by argument '{existing.dest}'"
)
def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
if dest:
if not dest.replace("_", "").isalnum():
raise FalyxOptionError(
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise FalyxOptionError(
f"invalid dest '{dest}': cannot start with a digit"
)
return dest
dest = None
for flag in flags:
cleaned = flag.lstrip("-").replace("-", "_").lower()
dest = cleaned
if flag.startswith("--"):
break
assert dest is not None, "dest should not be None"
if not dest.replace("_", "").isalnum():
raise FalyxOptionError(
f"invalid dest '{dest}': must be a valid identifier (letters, digits, and underscores only)"
)
if dest[0].isdigit():
raise FalyxOptionError(f"invalid dest '{dest}': cannot start with a digit")
return dest
def _validate_action(self, action: str | OptionAction) -> OptionAction:
if isinstance(action, OptionAction):
return action
try:
return OptionAction(action)
except ValueError as error:
raise FalyxOptionError(
f"invalid option action '{action}' is not a valid OptionAction",
hint=f"valid actions are: {', '.join(a.value for a in OptionAction)}",
) from error
def _resolve_default(
self,
default: Any,
action: OptionAction,
) -> Any:
if default is None:
if action == OptionAction.STORE_TRUE:
return False
elif action == OptionAction.STORE_FALSE:
return True
elif action == OptionAction.STORE_BOOL_OPTIONAL:
return None
elif action == OptionAction.COUNT:
return 0
elif action is OptionAction.STORE_TRUE and default is not False:
raise FalyxOptionError(
f"default value for '{action}' action must be False or None, got {default!r}"
)
elif action is OptionAction.STORE_FALSE and default is not True:
raise FalyxOptionError(
f"default value for '{action}' action must be True or None, got {default!r}"
)
elif action is OptionAction.STORE_BOOL_OPTIONAL:
raise FalyxOptionError(
f"default value for '{action}' action must be None, got {default!r}"
)
elif action in (OptionAction.HELP, OptionAction.TLDR, OptionAction.COUNT):
raise FalyxOptionError(f"default value cannot be set for action '{action}'.")
return default
def _validate_default_type(
self,
default: Any,
expected_type: Any,
dest: str,
) -> None:
if default is None:
return None
try:
coerce_value(default, expected_type)
except Exception as error:
type_name = get_type_name(expected_type)
raise FalyxOptionError(
f"invalid default value {default!r} for '{dest}' cannot be coerced to {type_name} error: {error}"
) from error
def _normalize_choices(
self,
choices: list[str] | None,
expected_type: type,
action: OptionAction,
) -> list[Any]:
if choices is None:
choices = []
else:
if action in (
OptionAction.STORE_TRUE,
OptionAction.STORE_FALSE,
OptionAction.STORE_BOOL_OPTIONAL,
):
raise FalyxOptionError(
f"choices cannot be specified for '{action}' actions"
)
if isinstance(choices, dict):
raise FalyxOptionError("choices cannot be a dict")
try:
choices = list(choices)
except TypeError as error:
raise FalyxOptionError(
"choices must be iterable (like list, tuple, or set)"
) from error
for choice in choices:
try:
coerce_value(choice, expected_type)
except Exception as error:
type_name = get_type_name(expected_type)
raise FalyxOptionError(
f"invalid choice {choice!r} cannot be coerced to {type_name} error: {error}"
) from error
return choices
def add_option(
self,
flags: tuple[str, ...],
dest: str,
action: str | OptionAction = "store",
type: type = str,
default: Any = None,
choices: list[str] | None = None,
help: str = "",
suggestions: list[str] | None = None,
) -> None:
self._validate_flags(flags)
dest = self._get_dest_from_flags(flags, dest)
if dest in self.RESERVED_DESTS:
raise FalyxOptionError(
f"invalid dest '{dest}': '{dest}' is reserved and cannot be used as an option dest"
)
if dest in self._dest_set:
raise FalyxOptionError(f"duplicate option dest '{dest}'")
action = self._validate_action(action)
default = self._resolve_default(default, action)
self._validate_default_type(default, type, dest)
choices = self._normalize_choices(choices, type, action)
if default is not None and choices and default not in choices:
choices_str = ", ".join((str(choice) for choice in choices))
raise FalyxOptionError(
f"default value {default!r} is not in allowed choices: {choices_str}"
)
if suggestions is not None and not isinstance(suggestions, list):
type_name = get_type_name(suggestions)
raise FalyxOptionError(f"suggestions must be a list or None, got {type_name}")
if isinstance(suggestions, list) and not all(
isinstance(suggestion, str) for suggestion in suggestions
):
raise FalyxOptionError("suggestions must be a list of strings")
if action is OptionAction.STORE_BOOL_OPTIONAL:
self._register_store_bool_optional(flags, dest, help)
return None
option = Option(
flags=flags,
dest=dest,
action=action,
type=type,
default=default,
choices=choices,
help=help,
suggestions=suggestions,
)
self._register_option(option)
def apply_to_options(
self,
parse_result: ParseResult,
options: OptionsManager,
) -> None:
for dest, value in parse_result.options.items():
options.set(dest, value, namespace_name=self_flx.namespace_name)
for dest, value in parse_result.root_options.items():
options.set(dest, value, namespace_name="root")
def _can_bundle_option(self, option: Option) -> bool:
return option.action in {
OptionAction.STORE_TRUE,
OptionAction.STORE_FALSE,
OptionAction.COUNT,
OptionAction.HELP,
OptionAction.TLDR,
}
@classmethod
def _parse_root_options(
cls,
argv: list[str],
) -> tuple[RootOptions, list[str]]:
"""Parse only root/session flags from the start of argv.
def _resolve_posix_bundling(self, tokens: list[str]) -> list[str]:
"""Expand POSIX-style bundled arguments into separate arguments."""
expanded: list[str] = []
for token in tokens:
if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
expanded.append(token)
continue
Parsing stops at the first token that is not a recognized root flag.
Remaining tokens are returned untouched for later routing.
bundle = [f"-{char}" for char in token[1:]]
Examples:
["--verbose", "deploy", "--env", "prod"]
-> (RootOptions(verbose=True), ["deploy", "--env", "prod"])
["deploy", "--verbose"]
-> (RootOptions(), ["deploy", "--verbose"])
"""
options = RootOptions()
remaining_start = 0
for index, token in enumerate(argv):
if token == "--":
remaining_start = index + 1
break
attr = cls.ROOT_FLAG_ALIASES.get(token)
if attr is None:
remaining_start = index
break
setattr(options, attr, True)
if (
all(
flag in self._options_by_dest
and self._can_bundle_option(self._options_by_dest[flag])
for flag in bundle[:-1]
)
and bundle[-1] in self._options_by_dest
):
expanded.extend(bundle)
else:
remaining_start = len(argv)
expanded.append(token)
return expanded
remaining = argv[remaining_start:]
return options, remaining
def _default_values(self) -> tuple[dict[str, Any], dict[str, Any]]:
values: dict[str, Any] = {}
root_values: dict[str, Any] = {}
@classmethod
def parse(cls, argv: list[str] | None = None) -> RootParseResult:
argv = argv or []
root, remaining = cls._parse_root_options(argv)
for option in self._options:
if option.scope == OptionScope.ROOT:
root_values[option.dest] = option.default
elif option.scope == OptionScope.NAMESPACE:
values.setdefault(option.dest, option.default)
else:
assert False, f"unhandled option scope: {option.scope}"
if root.help or root.tldr:
return RootParseResult(
mode=FalyxMode.HELP,
raw_argv=argv,
never_prompt=root.never_prompt,
verbose=root.verbose,
debug_hooks=root.debug_hooks,
tldr_requested=root.tldr,
return values, root_values
def _consume_option(
self,
option: Option,
argv: list[str],
index: int,
values: dict[str, Any],
) -> int:
match option.action:
case OptionAction.STORE_TRUE:
values[option.dest] = True
return index + 1
case OptionAction.STORE_FALSE:
values[option.dest] = False
return index + 1
case OptionAction.STORE_BOOL_OPTIONAL:
values[option.dest] = option.type(None)
return index + 1
case OptionAction.COUNT:
values[option.dest] = int(values.get(option.dest) or 0) + 1
return index + 1
case OptionAction.HELP:
values[option.dest] = True
return index + 1
case OptionAction.TLDR:
values[option.dest] = True
return index + 1
case OptionAction.STORE:
value_index = index + 1
if value_index >= len(argv):
raise FalyxOptionError(f"option '{argv[index]}' expected a value")
raw_value = argv[value_index]
try:
value = coerce_value(raw_value, option.type)
except Exception as error:
raise FalyxOptionError(
f"invalid value for '{argv[index]}': {error}"
) from error
if option.choices and value not in option.choices:
choices = ", ".join(str(choice) for choice in option.choices)
raise FalyxOptionError(
f"invalid value for '{argv[index]}': expected one of {{{choices}}}"
)
return RootParseResult(
mode=FalyxMode.COMMAND,
raw_argv=argv,
verbose=root.verbose,
debug_hooks=root.debug_hooks,
never_prompt=root.never_prompt,
remaining_argv=remaining,
values[option.dest] = value
return index + 2
raise FalyxOptionError(f"unsupported option action: {option.action}")
def parse_args(
self,
argv: list[str] | None = None,
) -> ParseResult:
raw_argv = argv or []
arguments = self._resolve_posix_bundling(raw_argv)
values, root_values = self._default_values()
index = 0
while index < len(arguments):
token = arguments[index]
# Explicit option terminator. Everything after belongs to routing/command.
if token == "--":
index += 1
break
# First non-option is the route boundary.
if not token.startswith("-"):
break
# Unknown leading option is an error at this scope.
# This is what keeps root/namespace options honest.
option = self._options_by_dest.get(token)
if option is None:
raise FalyxOptionError(
f"unknown option '{token}' for '{self._flx.program or self._flx.title}'"
)
target_values = root_values if option.scope == OptionScope.ROOT else values
index = self._consume_option(option, arguments, index, target_values)
remaining_argv = arguments[index:]
help_requested = values.get("help", False) or values.get("tldr", False)
return ParseResult(
mode=FalyxMode.HELP if help_requested else FalyxMode.COMMAND,
raw_argv=raw_argv,
options=values,
root_options=root_values,
remaining_argv=remaining_argv,
help=values.get("help", False),
tldr=values.get("tldr", False),
current_head=remaining_argv[0] if remaining_argv else "",
)

View File

@@ -1,10 +1,10 @@
# Falyx CLI Framework — (c) 2026 rtj.dev LLC — MIT Licensed
"""Root parse result model for the Falyx CLI runtime.
"""Parse result model for the Falyx CLI runtime.
This module defines `RootParseResult`, the normalized output produced by the
This module defines `ParseResult`, the normalized output produced by the
root-level Falyx parsing stage.
`RootParseResult` captures the session-scoped state derived from the initial
`ParseResult` captures the session-scoped state derived from the initial
CLI parse before namespace routing or command-local argument parsing begins. It
records the selected top-level mode, the original argv, root option flags, and
any remaining argv that should be forwarded into the routed execution layer.
@@ -17,15 +17,16 @@ The dataclass is intentionally lightweight and focused on root parsing only. It
does not perform parsing, validation, or execution itself.
"""
from dataclasses import dataclass, field
from typing import Any
from falyx.mode import FalyxMode
@dataclass(slots=True)
class RootParseResult:
class ParseResult:
"""Represents the normalized result of root-level Falyx argument parsing.
`RootParseResult` stores the outcome of the initial CLI parse that occurs at
`ParseResult` stores the outcome of the initial CLI parse that occurs at
the application boundary. It separates session-level runtime settings from
the remaining argv that should continue into namespace routing and
command-local parsing.
@@ -37,18 +38,27 @@ class RootParseResult:
Attributes:
mode: Top-level runtime mode selected from the root parse.
raw_argv: Original argv passed into the root parser.
options: Dictionary of parsed root-level options and their values.
root_options: Dictionary of parsed root-level options that should be
applied at the root level for all namespaces.
remaining_argv: Unconsumed argv that should be forwarded to routed
command resolution.
current_head: The current head token being processed (for error reporting).
help: Whether help output was requested at the root level.
tldr: Whether TLDR output was requested at the root level.
verbose: Whether verbose logging should be enabled for the session.
debug_hooks: Whether hook execution should be logged in detail.
never_prompt: Whether prompts should be suppressed for the session.
remaining_argv: Unconsumed argv that should be forwarded to routed
command resolution.
tldr_requested: Whether root TLDR output was requested.
"""
mode: FalyxMode
raw_argv: list[str] = field(default_factory=list)
options: dict[str, Any] = field(default_factory=dict)
root_options: dict[str, Any] = field(default_factory=dict)
remaining_argv: list[str] = field(default_factory=list)
current_head: str = ""
help: bool = False
tldr: bool = False
verbose: bool = False
debug_hooks: bool = False
never_prompt: bool = False
remaining_argv: list[str] = field(default_factory=list)
tldr_requested: bool = False

View File

@@ -23,6 +23,16 @@ from falyx.logger import logger
from falyx.parser.signature import infer_args_from_func
def get_type_name(type_: Any) -> str:
if hasattr(type_, "__name__"):
return type_.__name__
elif not isinstance(type_, type):
parent_type = type(type_)
if hasattr(parent_type, "__name__"):
return parent_type.__name__
return str(type_)
def coerce_bool(value: str) -> bool:
"""Convert a string to a boolean.

View File

@@ -8,6 +8,8 @@ Includes:
- `should_prompt_user()` for conditional prompt logic.
- `confirm_async()` for interactive yes/no confirmation.
"""
from contextlib import contextmanager
from typing import Iterator
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (
@@ -24,11 +26,25 @@ from falyx.themes import OneColors
from falyx.validators import yes_no_validator
@contextmanager
def prompt_session_context(session: PromptSession) -> Iterator[PromptSession]:
"""Temporary override for prompt session management"""
message = session.message
validator = session.validator
placeholder = session.placeholder
try:
yield session
finally:
session.message = message
session.validator = validator
session.placeholder = placeholder
def should_prompt_user(
*,
confirm: bool,
options: OptionsManager,
namespace: str = "default",
namespace: str = "root",
override_namespace: str = "execution",
) -> bool:
"""Determine whether to prompt the user for confirmation.
@@ -41,7 +57,7 @@ def should_prompt_user(
Args:
confirm (bool): The initial confirmation flag (e.g., from a command argument).
options (OptionsManager): The options manager to check for override flags.
namespace (str): The primary namespace to check for options (default: "default").
namespace (str): The primary namespace to check for options (default: "root").
override_namespace (str): The secondary namespace for overrides (default: "execution").
Returns:

View File

@@ -78,6 +78,8 @@ class RouteResult:
specific nested namespace.
leaf_argv: Remaining argv that should be delegated to the resolved
command's local parser.
current_head: The current head token that routing is evaluating, used for
generating suggestions.
suggestions: Suggested entry names for unresolved input.
is_preview: Whether the routed invocation is in preview mode.
"""
@@ -88,5 +90,6 @@ class RouteResult:
command: "Command | None" = None
namespace_entry: FalyxNamespace | None = None
leaf_argv: list[str] = field(default_factory=list)
current_head: str = ""
suggestions: list[str] = field(default_factory=list)
is_preview: bool = False

View File

@@ -20,7 +20,7 @@ from rich.markup import escape
from rich.table import Table
from falyx.console import console
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.prompt_utils import prompt_session_context, rich_text_to_prompt_text
from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import MultiIndexValidator, MultiKeyValidator
@@ -292,7 +292,19 @@ async def prompt_for_index(
if show_table:
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),
validator=MultiIndexValidator(
min_index,
@@ -303,6 +315,7 @@ async def prompt_for_index(
cancel_key,
),
default=default_selection,
placeholder=placeholder,
)
if selection.strip() == cancel_key:
@@ -331,12 +344,25 @@ async def prompt_for_selection(
if show_table:
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),
validator=MultiKeyValidator(
keys, number_selections, separator, allow_duplicates, cancel_key
),
default=default_selection,
placeholder=placeholder,
)
if selected.strip() == cancel_key:

View File

@@ -55,7 +55,12 @@ class CommandValidator(Validator):
message=self.error_message,
cursor_position=len(text),
)
if route.is_preview:
if route.is_preview and route.command is None:
raise ValidationError(
message=self.error_message,
cursor_position=len(text),
)
elif route.is_preview:
return None
if route.kind in {
RouteKind.NAMESPACE_MENU,

View 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

View File

@@ -1,5 +1,6 @@
# test_command.py
import pytest
from pydantic import ValidationError
from falyx.action import Action, BaseIOAction, ChainedAction
from falyx.command import Command
@@ -172,3 +173,15 @@ def test_command_bad_action():
with pytest.raises(TypeError) as exc_info:
Command(key="TEST", description="Test Command", action="not_callable")
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"
def test_command_bad_options_manager():
"""Test if Command raises an exception when options_manager is not a dict or callable."""
with pytest.raises(ValidationError) as exc_info:
Command(
key="TEST",
description="Test Command",
action=dummy_action,
options_manager="not_a_dict_or_callable",
)
assert "Input should be an instance of OptionsManager" in str(exc_info.value)

View File

@@ -118,6 +118,19 @@ def test_get_completions_namespace_boundary_suggests_help_flags(falyx):
results = list(completer.get_completions(Document("OPS -"), None))
texts = completion_texts(results)
assert "-h" in texts
assert "--help" in texts
assert "-T" not in texts
assert "--tldr" not in texts
falyx.add_tldr_example(
entry_key="R",
usage="",
description="This is a TLDR example for the R command.",
)
results = list(completer.get_completions(Document("-"), None))
texts = completion_texts(results)
assert "-h" in texts
assert "--help" in texts
assert "-T" in texts
@@ -247,3 +260,46 @@ def test_ensure_quote_wraps_whitespace(falyx):
assert completer._ensure_quote("hello world") == '"hello world"'
assert completer._ensure_quote("hello") == "hello"
def test_command_suggestions_are_case_insensitive(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("r"), None))
texts = completion_texts(results)
assert "r" in texts
assert "run" in texts
results = list(completer.get_completions(Document("R"), None))
texts = completion_texts(results)
assert "R" in texts
assert "RUN" in texts
def test_namespace_suggestions_are_case_insensitive(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("op"), None))
texts = completion_texts(results)
assert "ops" in texts
assert "operations" in texts
results = list(completer.get_completions(Document("OP"), None))
texts = completion_texts(results)
assert "OPS" in texts
assert "OPERATIONS" in texts
def test_command_completions_after_namespace(falyx):
completer = FalyxCompleter(falyx)
results = list(completer.get_completions(Document("OPS D --"), None))
texts = completion_texts(results)
assert "--target" in texts
assert "--region" in texts
assert "--help" in texts

View File

@@ -2,7 +2,7 @@ import pytest
from rich.text import Text
from falyx import Falyx
from falyx.console import console
from falyx.exceptions import CommandArgumentError
@pytest.mark.asyncio
@@ -82,17 +82,14 @@ async def test_help_command_by_tag(capsys):
@pytest.mark.asyncio
async def test_help_command_empty_tags(capsys):
async def test_help_command_bad_argument(capsys):
flx = Falyx()
async def untagged_command(falyx: Falyx):
pass
flx.add_command(
"U", "Untagged Command", untagged_command, help_text="This command has no tags."
)
flx.add_command("U", "Untagged Command", untagged_command)
with pytest.raises(
CommandArgumentError, match="Unexpected positional argument: nonexistent_tag"
):
await flx.execute_command("H nonexistent_tag")
captured = capsys.readouterr()
text = Text.from_ansi(captured.out)
assert "Unexpected positional argument: nonexistent_tag" in text.plain

View File

View File

@@ -1,8 +1,72 @@
import asyncio
import sys
import pytest
from rich.text import Text
from falyx import Falyx
from falyx.console import console as falyx_console
from falyx.exceptions import FalyxError
from falyx.parser import ParseResult
from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
async def throw_error_action(error: str):
if error == "QuitSignal":
raise QuitSignal("Quit signal triggered.")
elif error == "BackSignal":
raise BackSignal("Back signal triggered.")
elif error == "CancelSignal":
raise CancelSignal("Cancel signal triggered.")
elif error == "ValueError":
raise ValueError("This is a ValueError.")
elif error == "HelpSignal":
raise HelpSignal("Help signal triggered.")
elif error == "FalyxError":
raise FalyxError("This is a FalyxError.")
elif error == "FlowSignal":
raise FlowSignal("Flow signal triggered.")
else:
raise asyncio.CancelledError("An error occurred in the action.")
@pytest.fixture
def flx() -> Falyx:
sys.argv = ["falyx", "T"]
flx = Falyx()
flx.add_command(
"T",
"Test",
action=lambda: "hello",
)
flx.add_tldr_example(
entry_key="T",
usage="",
description="This is a TLDR example for the T command.",
)
return flx
@pytest.fixture
def flx_with_submenu() -> Falyx:
flx = Falyx()
submenu = Falyx("Submenu")
submenu.add_command(
"T",
"Test",
action=lambda: "hello from submenu",
)
submenu.add_tldr_example(
entry_key="T",
usage="",
description="This is a TLDR example for the T command in the submenu.",
)
flx.add_submenu(
"S",
"Submenu",
submenu=submenu,
)
return flx
@pytest.mark.asyncio
@@ -14,3 +78,178 @@ async def test_run_basic(capsys):
captured = capsys.readouterr()
assert "Show this help menu." in captured.out
@pytest.mark.asyncio
async def test_run_default_to_menu(flx):
sys.argv = ["falyx", "T"]
flx.default_to_menu = False
with pytest.raises(SystemExit):
await flx.run()
await flx.run(always_start_menu=True)
@pytest.mark.asyncio
async def test_run_default_to_menu_help(flx):
sys.argv = ["falyx"]
flx.default_to_menu = False
with pytest.raises(SystemExit, match="0"):
with falyx_console.capture() as capture:
await flx.run()
captured = Text.from_ansi(capture.get()).plain
assert "Show this help menu." in captured
@pytest.mark.asyncio
async def test_run_debug_hooks(flx):
sys.argv = ["falyx", "--debug-hooks", "T"]
assert flx.options.get("debug_hooks") is False
with pytest.raises(SystemExit):
await flx.run()
assert flx.options.get("debug_hooks") is True
@pytest.mark.asyncio
async def test_run_never_prompt(flx):
sys.argv = ["falyx", "--never-prompt", "T"]
assert flx.options.get("never_prompt") is False
with pytest.raises(SystemExit):
await flx.run()
falyx_console.print(flx.options.get_namespace_dict("default"))
assert flx.options.get("debug_hooks") is False
assert flx.options.get("never_prompt") is True
@pytest.mark.asyncio
async def test_run_bad_args(flx):
sys.argv = ["falyx", "T", "--unknown-arg"]
with pytest.raises(SystemExit, match="2"):
await flx.run()
@pytest.mark.asyncio
async def test_run_help(flx):
sys.argv = ["falyx", "T", "--help"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
sys.argv = ["falyx", "--help"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
sys.argv = ["falyx", "-h"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
sys.argv = ["falyx", "--tldr"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
sys.argv = ["falyx", "-T"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
@pytest.mark.asyncio
async def test_run_entry_not_found(flx):
sys.argv = ["falyx", "UNKNOWN_COMMAND"]
with pytest.raises(SystemExit, match="2"):
await flx.run()
@pytest.mark.asyncio
async def test_run_test_exceptions(flx):
flx.add_command(
"E",
"Throw Error",
action=throw_error_action,
)
sys.argv = ["falyx", "E", "ValueError"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "QuitSignal"]
with pytest.raises(SystemExit, match="130"):
await flx.run()
sys.argv = ["falyx", "E", "BackSignal"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "CancelSignal"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "HelpSignal"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "FlowSignal"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "--verbose", "E", "FalyxError"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
sys.argv = ["falyx", "E", "UnknownError"]
with pytest.raises(SystemExit, match="1"):
await flx.run()
@pytest.mark.asyncio
async def test_run_no_args(flx):
sys.argv = ["falyx"]
with pytest.raises(SystemExit, match="0"):
await flx.run()
@pytest.mark.asyncio
async def test_run_submenu(flx_with_submenu):
sys.argv = ["falyx", "S", "T"]
with pytest.raises(SystemExit, match="0"):
await flx_with_submenu.run()
@pytest.mark.asyncio
async def test_run_submenu_help(flx_with_submenu):
sys.argv = ["falyx", "S", "--help"]
with pytest.raises(SystemExit, match="0"):
await flx_with_submenu.run()
@pytest.mark.asyncio
async def test_run_submenu_tldr(flx_with_submenu):
sys.argv = ["falyx", "S", "--tldr"]
with pytest.raises(SystemExit, match="0"):
await flx_with_submenu.run()
@pytest.mark.asyncio
async def test_run_preview(flx):
sys.argv = ["falyx", "preview", "T"]
with pytest.raises(SystemExit, match="0"):
with falyx_console.capture() as capture:
await flx.run()
captured = Text.from_ansi(capture.get()).plain
assert "Command: 'T'" in captured
assert "Would call: <lambda>(args=(), kwargs={})" in captured

View File

@@ -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"]

View File

@@ -1,8 +1,10 @@
import pytest
from rich.text import Text
from falyx.action import Action
from falyx.console import console as falyx_console
from falyx.exceptions import CommandArgumentError
from falyx.exceptions import CommandArgumentError, NotAFalyxError
from falyx.options_manager import OptionsManager
from falyx.parser import ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal
@@ -835,3 +837,175 @@ async def test_render_help():
assert "Foo help" in output
assert "--bar" in output
assert "Bar help" in output
def test_command_argument_parser_set_options_manager_invalid():
parser = CommandArgumentParser()
with pytest.raises(NotAFalyxError):
parser.set_options_manager("not_a_options_manager")
with pytest.raises(NotAFalyxError):
parser.set_options_manager(123)
with pytest.raises(NotAFalyxError):
parser.set_options_manager(None)
def test_command_argument_parser_set_options_manager_valid():
parser = CommandArgumentParser()
options_manager = OptionsManager([("new_namespace", {"foo": "bar"})])
parser.set_options_manager(options_manager)
assert parser.options_manager == options_manager
assert parser.options_manager.get("foo", namespace_name="new_namespace") == "bar"
def test_add_argument_invalid_required():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action=ArgumentAction.STORE_TRUE, required=True)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action=ArgumentAction.STORE_FALSE, required=True)
with pytest.raises(CommandArgumentError):
parser.add_argument(
"--foo", action=ArgumentAction.STORE_BOOL_OPTIONAL, required=True
)
def test_add_argument_invalid_choices():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_true", choices="not_a_list")
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", choices=123)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", choices={"a": 1, "b": 2})
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", choices=["a", "b"], type=int)
def test_add_argument_resolver_invalid():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", resolver=lambda x: x)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", resolver=123)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="action", resolver="not_a_function")
def test_add_argument_resolver_valid():
parser = CommandArgumentParser()
parser.add_argument(
"--foo", action="action", resolver=Action("test", lambda x: x.upper())
)
def test_add_argument_resolve_invalid_default():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_true", default="any value")
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_false", default=False)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_true", default=True)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="store_bool_optional", default=False)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="count", default=500)
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="append", default="not a list")
with pytest.raises(CommandArgumentError):
parser.add_argument("--foo", action="extend", default="not a list")
with pytest.raises(CommandArgumentError):
parser.add_argument("--count", action="count", default=0)
@pytest.mark.asyncio
async def test_add_argument_resolve_valid_default():
parser = CommandArgumentParser()
parser.add_argument("--foo", action="store_true", default=False)
parser.add_argument("--bar", action="store_false", default=True)
parser.add_argument("--baz", action="store_bool_optional", default=None)
parser.add_argument("--items", action="append", default=[])
parser.add_argument("--values", action="extend", default=[])
parser.add_argument("--number", action="store", nargs=1, type=int, default=0)
result = await parser.parse_args(["--number", "5"])
assert result["foo"] is False
assert result["bar"] is True
assert result["baz"] is None
assert result["items"] == []
assert result["values"] == []
assert result["number"] == 5
def test_add_argument_in_reserved_dests():
parser = CommandArgumentParser()
with pytest.raises(
CommandArgumentError,
match="invalid dest .*'help' is reserved and cannot be used.",
):
parser.add_argument("--help")
with pytest.raises(
CommandArgumentError,
match="invalid dest .*'tldr' is reserved and cannot be used.",
):
parser.add_argument("--tldr")
def test_add_argument_in_reserved_dests_positional():
parser = CommandArgumentParser()
with pytest.raises(
CommandArgumentError,
match="invalid dest .*'help' is reserved and cannot be used.",
):
parser.add_argument("help")
with pytest.raises(
CommandArgumentError,
match="invalid dest .*'tldr' is reserved and cannot be used.",
):
parser.add_argument("tldr")
def test_add_argument_invalid_suggestions():
parser = CommandArgumentParser()
with pytest.raises(
CommandArgumentError, match="suggestions must be a list or None, got int"
):
parser.add_argument("--valid", suggestions=112445)
def test_add_argument_invalid_lazy_resolver():
parser = CommandArgumentParser()
with pytest.raises(
CommandArgumentError, match="lazy_resolver must be a boolean, got int"
):
parser.add_argument("--valid", lazy_resolver=123)

View File

@@ -31,6 +31,21 @@ def test_enable_execution_options_registers_retry_flags():
assert "retry_backoff" in parser._execution_dests
def test_enable_execution_options_invalid_double_registration_raises():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
with pytest.raises(
CommandArgumentError, match="destination 'summary' is already defined"
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
with pytest.raises(
CommandArgumentError,
match="destination 'summary' is already registered as an execution argument",
):
parser._register_execution_dest("summary")
def test_enable_execution_options_registers_confirm_flags():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.CONFIRM}))
@@ -48,12 +63,12 @@ def test_register_execution_dest_rejects_duplicates():
parser = CommandArgumentParser()
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
with pytest.raises(
CommandArgumentError, match="Destination 'summary' is already defined"
CommandArgumentError, match="destination 'summary' is already defined"
):
parser.add_argument("--summary", action="store_true")
with pytest.raises(
CommandArgumentError, match="Destination 'summary' is already defined"
CommandArgumentError, match="destination 'summary' is already defined"
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))
@@ -138,6 +153,6 @@ async def test_parse_args_split_with_conflicting_execution_option_raises():
parser = CommandArgumentParser()
parser.add_argument("--summary", action="store_true", help="A conflicting argument.")
with pytest.raises(
CommandArgumentError, match="Destination 'summary' is already defined"
CommandArgumentError, match="destination 'summary' is already defined"
):
parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))

View 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,
)

View File

@@ -69,14 +69,14 @@ async def test_resolve_args_raises_on_conflicting_execution_option():
execution_options=["summary"],
)
with pytest.raises(
CommandArgumentError, match="Destination 'summary' is already defined"
CommandArgumentError, match="destination 'summary' is already defined"
):
command.arg_parser.add_argument(
"--summary", action="store_true", help="A conflicting argument."
)
with pytest.raises(
CommandArgumentError, match="Destination 'summary' is already defined"
CommandArgumentError, match="destination 'summary' is already defined"
):
command.arg_parser.enable_execution_options(frozenset({ExecutionOption.SUMMARY}))

View File

@@ -2,6 +2,7 @@ import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.parser.parser_types import TLDRExample
@pytest.mark.asyncio
@@ -45,3 +46,27 @@ async def test_add_tldr_examples_in_init():
assert parser._tldr_examples[0].description == "This is the first example."
assert parser._tldr_examples[1].usage == "example2"
assert parser._tldr_examples[1].description == "This is the second example."
def test_add_tldr_example():
parser = CommandArgumentParser()
parser.add_tldr_example("example1", "This is the first example.")
assert len(parser._tldr_examples) == 1
assert parser._tldr_examples[0].usage == "example1"
assert parser._tldr_examples[0].description == "This is the first example."
def test_add_tldr_example_bad_args():
parser = CommandArgumentParser()
with pytest.raises(TypeError):
parser.add_tldr_example("example1", "This is the first example.", "extra_arg")
def test_add_tldr_examples_with_tldr_example_objects():
parser = CommandArgumentParser()
example1 = TLDRExample(usage="example1", description="This is the first example.")
example2 = TLDRExample(usage="example2", description="This is the second example.")
parser.add_tldr_examples([example1, example2])
assert len(parser._tldr_examples) == 2
assert parser._tldr_examples[0] == example1
assert parser._tldr_examples[1] == example2

View File

@@ -9,7 +9,13 @@ from falyx.action import Action
from falyx.command import Command
from falyx.command_runner import CommandRunner
from falyx.console import console as falyx_console
from falyx.exceptions import CommandArgumentError, FalyxError, NotAFalyxError
from falyx.console import error_console
from falyx.exceptions import (
CommandArgumentError,
FalyxError,
InvalidHookError,
NotAFalyxError,
)
from falyx.hook_manager import HookManager, HookType
from falyx.options_manager import OptionsManager
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
@@ -123,8 +129,10 @@ async def test_command_runner_initialization(
command_with_no_parser,
command_with_custom_parser,
):
runner = CommandRunner(command_with_parser)
runner = CommandRunner(command_with_parser, program="test_program")
assert runner.command == command_with_parser
assert runner.program == "test_program"
assert runner.command.arg_parser.program == "test_program"
assert isinstance(runner.options, OptionsManager)
assert isinstance(runner.runner_hooks, HookManager)
assert runner.console == falyx_console
@@ -133,7 +141,6 @@ async def test_command_runner_initialization(
assert runner.command.options_manager == runner.options
assert runner.executor.options == runner.options
assert runner.executor.hooks == runner.runner_hooks
assert runner.executor.console == runner.console
assert runner.options.get("summary", namespace_name="execution") is None
runner_no_parser = CommandRunner(command_with_no_parser)
@@ -166,7 +173,6 @@ def test_command_runner_initialization_with_custom_console(command_with_parser):
custom_console = Console()
runner = CommandRunner(command_with_parser, console=custom_console)
assert runner.console == custom_console
assert runner.executor.console == custom_console
def test_command_runner_initialization_with_custom_hooks(command_with_parser):
@@ -199,7 +205,9 @@ def test_command_runner_initialization_with_all_bad_components(command_with_pars
console=custom_console,
)
with pytest.raises(NotAFalyxError, match="hooks must be an instance of HookManager"):
with pytest.raises(
InvalidHookError, match="hooks must be an instance of HookManager"
):
CommandRunner(
command_with_parser,
runner_hooks=custom_hooks,
@@ -236,8 +244,6 @@ async def test_command_runner_run_with_failing_action(command_with_failing_actio
with pytest.raises(FalyxError, match="boom"):
await runner.run("--foo 42", wrap_errors=True)
assert await runner.run("--foo 42", wrap_errors=False, raise_on_error=False) is None
@pytest.mark.asyncio
async def test_command_runner_debug_statement(command_with_parser, caplog):
@@ -276,6 +282,22 @@ async def test_command_runner_run_with_retries_with_action(
assert "[throw_error] All 2 retries failed." in caplog.text
@pytest.mark.asyncio
async def test_command_runner_run_with_retries_delay_with_action(
command_throwing_error, caplog
):
runner = CommandRunner(command_throwing_error)
with pytest.raises(asyncio.CancelledError, match="An error occurred in the action."):
await runner.run("Other")
with pytest.raises(ValueError, match="This is a ValueError."):
await runner.run("ValueError --retries 2 --retry-delay 1.0 --retry-backoff 2.0")
assert "[throw_error] Retry attempt 1/2 failed due to 'ValueError'." in caplog.text
assert "[throw_error] Retry attempt 2/2 failed due to 'ValueError'." in caplog.text
assert "[throw_error] All 2 retries failed." in caplog.text
@pytest.mark.asyncio
async def test_command_runner_run_from_command_build_with_all_execution_options(
command_build_with_all_execution_options,
@@ -313,7 +335,7 @@ async def test_command_runner_from_command_bad_command():
CommandRunner.from_command("Not a Command")
with pytest.raises(
NotAFalyxError, match="runner_hooks must be an instance of HookManager"
InvalidHookError, match="runner_hooks must be an instance of HookManager"
):
CommandRunner.from_command(
Command(
@@ -360,7 +382,7 @@ async def test_command_runner_build_with_bad_execution_options():
@pytest.mark.asyncio
async def test_command_runner_build_with_bad_runner_hooks():
with pytest.raises(
NotAFalyxError, match="runner_hooks must be an instance of HookManager"
InvalidHookError, match="runner_hooks must be an instance of HookManager"
):
CommandRunner.build(
key="T",
@@ -438,7 +460,7 @@ async def test_command_runner_cli_with_failing_action(command_with_failing_actio
await runner.cli(["--help"])
captured = Text.from_ansi(capture.get()).plain
assert "usage: falyx T" in captured
assert "usage: falyx" in captured
assert "--foo" in captured
assert "summary" in captured
assert "retries" in captured
@@ -453,54 +475,48 @@ async def test_command_runner_cli_exceptions(command_throwing_error):
with pytest.raises(SystemExit, match="0"):
await runner.cli(["--help"])
captured = Text.from_ansi(capture.get()).plain
assert "falyx E [--help]" in captured
assert "falyx [--help]" in captured
assert "usage:" in captured
assert "positional:" in captured
assert "options:" in captured
assert "" not in captured
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="2"):
await runner.cli(["--not-an-arg"])
captured = Text.from_ansi(capture.get()).plain
assert "falyx E [--help]" in captured
assert "falyx [--help]" in captured
assert "usage:" in captured
assert "positional:" in captured
assert "options:" in captured
assert "" in captured
falyx_console.clear()
with falyx_console.capture() as capture:
with error_console.capture() as capture:
with pytest.raises(SystemExit, match="1"):
await runner.cli(["FalyxError"])
captured = Text.from_ansi(capture.get()).plain
assert "This is a FalyxError." in captured
assert "❌ Error:" in captured
assert "error:" in captured
falyx_console.clear()
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="130"):
await runner.cli(["QuitSignal"])
captured = Text.from_ansi(capture.get()).plain
assert "" not in captured
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="1"):
await runner.cli(["BackSignal"])
captured = Text.from_ansi(capture.get()).plain
assert "" not in captured
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="1"):
await runner.cli(["CancelSignal"])
captured = Text.from_ansi(capture.get()).plain
assert "" not in captured
with falyx_console.capture() as capture:
with pytest.raises(SystemExit, match="1"):
await runner.cli(["Other"])
captured = Text.from_ansi(capture.get()).plain
assert "" not in captured
@pytest.mark.asyncio
@@ -514,3 +530,12 @@ async def test_command_runner_cli_uses_sys_argv(command_with_parser, monkeypatch
assert "Action executed with args:" in captured
assert "and kwargs:" in captured
assert "{'foo': 42}" in captured
@pytest.mark.asyncio
async def test_command_runner_run_error(command_with_parser):
runner = CommandRunner(command_with_parser)
with pytest.raises(FalyxError, match="requires either"):
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=False)
await runner.run(["--foo", "42"], raise_on_error=False, wrap_errors=True)
await runner.run(["--foo", "42"], raise_on_error=True, wrap_errors=False)

View File

@@ -40,6 +40,7 @@ async def test_command_validator_is_preview():
fake_falyx = AsyncMock()
fake_route = SimpleNamespace()
fake_route.is_preview = True
fake_route.command = SimpleNamespace()
fake_falyx.prepare_route.return_value = (fake_route, (), {}, {})
validator = CommandValidator(fake_falyx, "Invalid!")